diff --git a/CHANGELOG.md b/CHANGELOG.md index b7b9a0f67e7..440e92fd4be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -105,6 +105,14 @@ and this project adheres to action as new sandbox creation, and the Restore button in the sandbox list is disabled (with the limiter's tooltip) when the active sandbox count is already at the limit. +- Sandboxes no longer appear in the sandboxes list, the project picker, or via + the sandbox URL unless the user has access + [#4762](https://github.com/OpenFn/lightning/issues/4762) +- The Merge button on a sandbox now requires admin or owner on the source + sandbox (or admin/owner on the root project) + [#4762](https://github.com/OpenFn/lightning/issues/4762) +- Sandbox policies no longer treat `User.role: :superuser` as a project-access + bypass [#4762](https://github.com/OpenFn/lightning/issues/4762) - `Cmd/Ctrl+Enter` now runs the workflow directly; `Cmd/Ctrl+Shift+Enter` opens "run with custom input". When a retryable run is loaded, the primary action switches to retry. [#4736](https://github.com/OpenFn/lightning/issues/4736) diff --git a/assets/js/picker/Picker.tsx b/assets/js/picker/Picker.tsx index ea326679676..4e08c7a1db2 100644 --- a/assets/js/picker/Picker.tsx +++ b/assets/js/picker/Picker.tsx @@ -33,9 +33,16 @@ export interface PickerItem { /** * Optional hero icon class for the row (e.g. `hero-credit-card`). * When absent, falls back to the project-style default: - * `hero-folder` at depth 0 and `hero-beaker` for nested items. + * `hero-folder` for root projects and `hero-beaker` for sandboxes. */ icon?: string; + /** + * Whether the underlying project is structurally a sandbox (has a + * real `parent_id` in the database). Drives the default icon + * independently of the row's display depth, so a sandbox surfaced as + * a user's access root still renders with the sandbox icon. + */ + isSandbox?: boolean; } interface PickerProps { @@ -203,7 +210,15 @@ export function Picker(props: PickerProps) { }, [openPicker, openEvent]); const go = (href: string, sameSection = false) => { - window.location.href = href + (sameSection ? window.location.hash : ''); + const fullHref = href + (sameSection ? window.location.hash : ''); + const a = document.createElement('a'); + a.href = fullHref; + a.setAttribute('data-phx-link', 'redirect'); + a.setAttribute('data-phx-link-state', 'push'); + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); }; const handleInputKeyDown = useCallback( @@ -316,7 +331,7 @@ export function Picker(props: PickerProps) { const isNested = item.depth > 0; const indentPx = item.depth * 10; const itemIcon = - item.icon ?? (isNested ? 'hero-beaker' : 'hero-folder'); + item.icon ?? (item.isSandbox ? 'hero-beaker' : 'hero-folder'); return (
  • { e.preventDefault(); @@ -56,7 +56,7 @@ export function PickerButton(props: PickerButtonProps) { 'text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 hover:border-gray-400', isSandbox && 'text-white border border-transparent hover:opacity-90' )} - style={isSandbox && color ? { backgroundColor: color } : undefined} + style={isSandbox ? { backgroundColor: color } : undefined} > { }); describe('Picker navigation', () => { - let hrefSetter: ReturnType; - let originalLocation: Location; + let clickedAnchors: HTMLAnchorElement[]; + let clickSpy: ReturnType; beforeEach(() => { - hrefSetter = vi.fn(); - originalLocation = window.location; - delete (window as unknown as { location?: Location }).location; - (window as unknown as { location: unknown }).location = { - pathname: '/', - search: '', - hash: '', - get href() { - return ''; - }, - set href(value: string) { - hrefSetter(value); - }, - }; + clickedAnchors = []; + clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, 'click') + .mockImplementation(function (this: HTMLAnchorElement) { + clickedAnchors.push(this); + }); }); afterEach(() => { - (window as unknown as { location: Location }).location = originalLocation; + clickSpy.mockRestore(); closePicker(); }); + function lastNavigation() { + expect(clickedAnchors).toHaveLength(1); + const a = clickedAnchors[0]; + return { + href: a.getAttribute('href'), + phxLink: a.getAttribute('data-phx-link'), + phxLinkState: a.getAttribute('data-phx-link-state'), + }; + } + test('navigates to the server-provided href on click', () => { mountPicker([item('target', 'target', 0, { href: '/projects/target/w' })]); openPicker(); @@ -201,7 +203,11 @@ describe('Picker navigation', () => { targetOption.click(); }); - expect(hrefSetter).toHaveBeenCalledWith('/projects/target/w'); + expect(lastNavigation()).toEqual({ + href: '/projects/target/w', + phxLink: 'redirect', + phxLinkState: 'push', + }); }); test('uses data-view-all-href for the view-all row', () => { @@ -221,12 +227,11 @@ describe('Picker navigation', () => { viewAll.click(); }); - expect(hrefSetter).toHaveBeenCalledWith('/somewhere'); + expect(lastNavigation().href).toBe('/somewhere'); }); test('preserves the current hash when item has sameSection=true', () => { - (window as unknown as { location: { hash: string } }).location.hash = - '#credentials'; + window.location.hash = '#credentials'; mountPicker([ item('target', 'target', 0, { @@ -243,14 +248,11 @@ describe('Picker navigation', () => { option.click(); }); - expect(hrefSetter).toHaveBeenCalledWith( - '/projects/target/settings#credentials' - ); + expect(lastNavigation().href).toBe('/projects/target/settings#credentials'); }); test('drops the hash when item has sameSection=false', () => { - (window as unknown as { location: { hash: string } }).location.hash = - '#some-anchor'; + window.location.hash = '#some-anchor'; mountPicker([ item('target', 'target', 0, { @@ -267,12 +269,11 @@ describe('Picker navigation', () => { option.click(); }); - expect(hrefSetter).toHaveBeenCalledWith('/projects/target/history'); + expect(lastNavigation().href).toBe('/projects/target/history'); }); test('does not preserve hash for the view-all row', () => { - (window as unknown as { location: { hash: string } }).location.hash = - '#credentials'; + window.location.hash = '#credentials'; render( { viewAll.click(); }); - expect(hrefSetter).toHaveBeenCalledWith('/projects'); + expect(lastNavigation().href).toBe('/projects'); }); }); diff --git a/lib/lightning/policies/sandboxes.ex b/lib/lightning/policies/sandboxes.ex index 4afefadc50e..0cf42f5adfd 100644 --- a/lib/lightning/policies/sandboxes.ex +++ b/lib/lightning/policies/sandboxes.ex @@ -2,12 +2,23 @@ defmodule Lightning.Policies.Sandboxes do @moduledoc """ The Bodyguard Policy module for sandbox project operations. - Sandboxes have different authorization rules than regular projects: + Sandbox authorization mirrors regular projects: access is decided by the + acting user's role on the project they're acting on (or the workspace + root, where the cascade applies). `User.role` (`:user` / `:superuser`) + is a user-type for global user-management screens; it is not a + project-access bypass and has no effect on sandbox policy decisions, in + line with `Lightning.Policies.ProjectUsers`. + - Sandbox owners/admins can manage their own sandboxes - Root project owners/admins can manage any sandbox in their workspace - - Editors (and above) on the root project can merge sandboxes - Editors (and above) on the parent project can provision sandboxes - - Superusers can manage any sandbox anywhere + + Destructive actions on a sandbox (delete, update, merge) are scoped to + admin/owner on the sandbox itself (or the root cascade above). This + matches the rest of Lightning, where destructive actions are admin/owner + scoped, and it keeps the merge button on the sandboxes list aligned with + the cleanup step that runs after merge submission (which calls + `:delete_sandbox` and so requires admin/owner on the source). """ @behaviour Bodyguard.Policy @@ -22,24 +33,27 @@ defmodule Lightning.Policies.Sandboxes do | :merge_sandbox @doc """ - Authorize sandbox operations based on user role and project hierarchy. + Authorize sandbox operations based on the user's role on the project + involved. ## Authorization Rules ### `:delete_sandbox` and `:update_sandbox` - User can perform these actions if they are: - - Superuser (can manage any sandbox) + User must be one of: - Owner/admin of the sandbox itself - Owner/admin of the root project (workspace) ### `:provision_sandbox` - User can create sandboxes if they are: - - Editor/admin/owner of the parent project they're creating the sandbox under + User must be editor/admin/owner of the parent project they're creating + the sandbox under. ### `:merge_sandbox` - User can merge a sandbox into a target project if they are: - - Editor/admin/owner on the target project - - Superuser + This check authorises the **target side** of a merge: the user must be + editor/admin/owner on the target project (the project being merged + *into*). The merge flow also requires admin/owner on the **source + sandbox** itself, enforced by `check_manage_permissions/3` (button + gate) and by the post-merge cleanup, which calls `:delete_sandbox` + to retire the source and so requires admin/owner there. ## Parameters - `action` - The action being attempted @@ -50,84 +64,60 @@ defmodule Lightning.Policies.Sandboxes do @spec authorize(actions(), User.t(), Project.t()) :: boolean def authorize(:provision_sandbox, %User{} = user, %Project{} = parent_project) do - case Projects.get_project_user_role(user, parent_project) do - role when role in [:owner, :admin, :editor] -> true - _ -> user.role == :superuser - end + Projects.get_project_user_role(user, parent_project) in [ + :owner, + :admin, + :editor + ] end def authorize(:merge_sandbox, %User{} = user, %Project{} = target_project) do - case Projects.get_project_user_role(user, target_project) do - role when role in [:owner, :admin, :editor] -> true - _ -> user.role == :superuser - end + Projects.get_project_user_role(user, target_project) in [ + :owner, + :admin, + :editor + ] end def authorize(action, %User{} = user, %Project{} = sandbox) when action in [:delete_sandbox, :update_sandbox] do - cond do - user.role == :superuser -> - true - - Projects.get_project_user_role(user, sandbox) in [:owner, :admin] -> - true - - has_root_project_permission?(sandbox, user) -> - true - - true -> - false - end + Projects.get_project_user_role(user, sandbox) in [:owner, :admin] or + has_root_project_permission?(sandbox, user) end def authorize(_action, _user, _project), do: false @doc """ - Bulk permission check for multiple sandboxes to avoid N+1 queries. + Bulk manage check for multiple sandboxes, avoiding N+1 queries. - Returns a map: sandbox_id => %{update: boolean, delete: boolean, merge: boolean} + Returns a map `sandbox_id => boolean()` where `true` means the user can + perform any of the destructive sandbox actions (update, delete, merge) + on that sandbox. The boolean is `true` when the user is an owner/admin + on the sandbox itself, or an owner/admin on the root project (cascade). Assumes `root_project.project_users` and each `sandbox.project_users` are preloaded (as ensured by `Projects.list_workspace_projects/2`). """ @spec check_manage_permissions([Project.t()], User.t(), Project.t()) :: - %{ - binary() => %{update: boolean(), delete: boolean(), merge: boolean()} - } + %{binary() => boolean()} def check_manage_permissions(sandboxes, %User{} = user, root_project) do - is_superuser = user.role == :superuser - is_root_owner_or_admin = Enum.any?( root_project.project_users, &(&1.user_id == user.id and &1.role in [:owner, :admin]) ) - is_root_editor_plus = - is_root_owner_or_admin or - Enum.any?( - root_project.project_users, - &(&1.user_id == user.id and &1.role == :editor) - ) - - has_full_privileges = is_superuser or is_root_owner_or_admin - - if has_full_privileges do - Map.new(sandboxes, &{&1.id, %{update: true, delete: true, merge: true}}) + if is_root_owner_or_admin do + Map.new(sandboxes, &{&1.id, true}) else Map.new(sandboxes, fn sandbox -> - is_owner_or_admin_here? = + can_manage? = Enum.any?( sandbox.project_users, &(&1.user_id == user.id and &1.role in [:owner, :admin]) ) - {sandbox.id, - %{ - update: is_owner_or_admin_here?, - delete: is_owner_or_admin_here?, - merge: is_root_editor_plus - }} + {sandbox.id, can_manage?} end) end end diff --git a/lib/lightning/projects.ex b/lib/lightning/projects.ex index e293ec9e504..d98d52568f8 100644 --- a/lib/lightning/projects.ex +++ b/lib/lightning/projects.ex @@ -71,6 +71,28 @@ defmodule Lightning.Projects do ] end + defmodule ProjectTreeItem do + @moduledoc """ + A node in a user-visible project tree. Carries only the fields needed + to render the tree, with `parent_id` shaped for display (`nil` at the + user's access roots, the nearest visible ancestor for descendants). + `sandbox?` reflects the underlying DB shape (true when the project has + a real `parent_id` in the database, regardless of where the user's + access root sits) so the picker can pick the right icon for a sandbox + surfaced as an access root. Not a persistable record. + """ + + @type t :: %__MODULE__{ + id: Ecto.UUID.t(), + name: String.t(), + color: String.t() | nil, + parent_id: Ecto.UUID.t() | nil, + sandbox?: boolean() + } + + defstruct [:id, :name, :color, :parent_id, :sandbox?] + end + defdelegate subscribe, to: Events def get_projects_overview(user, opts \\ []) @@ -279,18 +301,28 @@ defmodule Lightning.Projects do end @doc """ - Returns the **root ancestor** of a project by walking up `parent_id` links. + Returns the **root ancestor** of a project. - Supports arbitrarily deep nesting. (Assumes the parent chain is well-formed.) + Uses `preload_ancestors/1` (one recursive CTE) and then walks the loaded + `:parent` chain in memory, so the cost is one round trip regardless of + how deep `project` sits in its workspace. The returned root carries + `parent: nil` (it has no parent in the database); intermediate ancestors + remain on the chain in case the caller wants them. """ @spec root_of(Project.t()) :: Project.t() - def root_of(%Project{} = p) do - case p.parent_id do - nil -> p - pid -> root_of(Repo.get!(Project, pid)) - end + def root_of(%Project{parent_id: nil} = project), do: project + + def root_of(%Project{} = project) do + project + |> preload_ancestors() + |> walk_to_root() end + defp walk_to_root(%Project{parent: %Project{} = parent}), + do: walk_to_root(parent) + + defp walk_to_root(%Project{} = project), do: project + @doc """ Preloads the full ancestor chain on a project's `:parent` association, so that `Project.display_name/1` can walk to the root. @@ -851,6 +883,22 @@ defmodule Lightning.Projects do descendants_query(project_ids) |> Repo.all() end + @doc """ + Returns every descendant project of `project_id`, ordered by name. + + Unlike `list_workspace_projects/2`, the input is treated as the subtree + root: only its descendants are returned, not the absolute root of the + workspace. + """ + @spec list_descendants(Ecto.UUID.t()) :: [Project.t()] + def list_descendants(project_id) when is_binary(project_id) do + from(p in Project, + where: p.id in subquery(descendants_query([project_id])), + order_by: [asc: p.name] + ) + |> Repo.all() + end + @doc """ Returns an `%Ecto.Changeset{}` for tracking project changes. @@ -925,32 +973,355 @@ defmodule Lightning.Projects do end @doc """ - Returns all projects the user can access, including sandboxes at any depth. - - Root projects are fetched via the user's project memberships, then all - descendants are included using a recursive CTE via `descendant_ids/1`. + Returns the user-visible project tree as a list of `ProjectTreeItem`s + ready for a hierarchical render (walk by grouping on `parent_id`). + + The user's *access roots* are the topmost projects they can reach: every + project they hold a `project_users` row on (any depth), plus, for support + users, every workspace root flagged `allow_support_access`. A membership + on a deep sandbox without membership on its ancestors is therefore a + legitimate access root. + + Descendants are filtered by the same per-project rule: each descendant + is included only when the user has a `project_users` row on it or is a + support user and that descendant carries `allow_support_access: true`. + Authority does not cascade from a parent project to its descendants. + + `parent_id` on the returned items is the *visible* parent: `nil` at each + access root, and the nearest visible ancestor for descendants whose real + parent is hidden. The result is not a list of persistable records; see + `ProjectTreeItem`. """ - @spec get_project_tree_for_user(User.t()) :: [Project.t()] + @spec get_project_tree_for_user(User.t()) :: [ProjectTreeItem.t()] def get_project_tree_for_user(%User{} = user) do - roots = get_projects_for_user(user) + case roots_for_user_tree(user) do + [] -> + [] + + roots -> + descendants = load_active_descendants(roots, user) + visible = filter_descendants_for_user(descendants, user) + shape_for_picker(roots, visible, descendants) + end + end + + defp load_active_descendants(roots, %User{} = user) do root_ids = Enum.map(roots, & &1.id) - case descendant_ids(root_ids) do + case Repo.all(active_descendants_query(root_ids)) do [] -> - roots + [] desc_ids -> - descendants = - from(p in Project, - where: p.id in ^desc_ids and is_nil(p.scheduled_deletion), - order_by: [asc: p.name] - ) - |> Repo.all() + pu_for_user = project_users_for_user_query(user) - roots ++ descendants + from(p in Project, + where: p.id in ^desc_ids, + preload: [project_users: ^pu_for_user], + order_by: [asc: p.name] + ) + |> Repo.all() end end + defp shape_for_picker(roots, visible_descendants, all_descendants) do + Enum.map(roots, &as_access_root_item/1) ++ + descendant_tree_items(visible_descendants, roots, all_descendants) + end + + defp project_users_for_user_query(%User{id: user_id}) do + from(pu in ProjectUser, where: pu.user_id == ^user_id) + end + + defp as_access_root_item(%Project{} = project) do + %ProjectTreeItem{ + id: project.id, + name: project.name, + color: project.color, + parent_id: nil, + sandbox?: not is_nil(project.parent_id) + } + end + + defp descendant_tree_items(visible_descendants, roots, all_descendants) do + project_map = Map.new(roots ++ all_descendants, &{&1.id, &1}) + + visible_id_set = + roots + |> Enum.concat(visible_descendants) + |> MapSet.new(& &1.id) + + Enum.map(visible_descendants, fn p -> + %ProjectTreeItem{ + id: p.id, + name: p.name, + color: p.color, + parent_id: + nearest_visible_ancestor_id(p.parent_id, project_map, visible_id_set), + sandbox?: true + } + end) + end + + defp nearest_visible_ancestor_id(parent_id, project_map, visible_ids) do + if MapSet.member?(visible_ids, parent_id) do + parent_id + else + # Parent chain is fully in project_map by the active-descendants CTE; fetch! fails loud if that ever breaks. + parent = Map.fetch!(project_map, parent_id) + nearest_visible_ancestor_id(parent.parent_id, project_map, visible_ids) + end + end + + defp active_descendants_query(root_ids) do + max_depth = max_project_tree_depth() + + direct_children = + from(p in Project, + where: p.parent_id in ^root_ids and is_nil(p.scheduled_deletion), + select: %{id: p.id, depth: 0} + ) + + next_level_down = + from(p in Project, + join: d in "active_project_descendants", + on: p.parent_id == d.id, + where: d.depth < ^max_depth and is_nil(p.scheduled_deletion), + select: %{id: p.id, depth: d.depth + 1} + ) + + "active_project_descendants" + |> recursive_ctes(true) + |> with_cte("active_project_descendants", + as: ^union_all(direct_children, ^next_level_down) + ) + |> select([d], type(d.id, Ecto.UUID)) + end + + defp roots_for_user_tree(%User{} = user) do + candidates = load_candidate_roots(user) + candidate_id_set = MapSet.new(candidates, & &1.id) + ancestors_by_candidate = ancestor_ids_by_starting_id(candidate_id_set) + + Enum.reject( + candidates, + &shadowed_by_candidate_ancestor?( + &1, + ancestors_by_candidate, + candidate_id_set + ) + ) + end + + defp load_candidate_roots(%User{} = user) do + pu_for_user = project_users_for_user_query(user) + + user + |> candidate_roots_query() + |> Repo.all() + |> Repo.preload(project_users: pu_for_user) + |> Enum.sort_by(& &1.name) + end + + defp shadowed_by_candidate_ancestor?( + %Project{id: id}, + ancestors_by_candidate, + candidate_id_set + ) do + ancestors = Map.get(ancestors_by_candidate, id, MapSet.new()) + not MapSet.disjoint?(ancestors, candidate_id_set) + end + + defp candidate_roots_query(%User{support_user: true} = user) do + support_roots = + from(p in Project, + where: + p.allow_support_access and + is_nil(p.scheduled_deletion) and + is_nil(p.parent_id) + ) + + membership_projects = + from(p in Project, + join: pu in assoc(p, :project_users), + where: pu.user_id == ^user.id and is_nil(p.scheduled_deletion) + ) + + support_roots |> union(^membership_projects) + end + + defp candidate_roots_query(%User{} = user) do + from(p in Project, + join: pu in assoc(p, :project_users), + where: pu.user_id == ^user.id and is_nil(p.scheduled_deletion) + ) + end + + defp ancestor_ids_by_starting_id(starting_ids) do + if MapSet.size(starting_ids) == 0 do + %{} + else + starting_ids + |> MapSet.to_list() + |> ancestor_pairs_query() + |> Repo.all() + |> group_ancestors_by_starting_id() + end + end + + defp ancestor_pairs_query(starting_ids) do + max_depth = max_project_tree_depth() + + starting_rows = + from(p in Project, + where: p.id in ^starting_ids, + select: %{starting_id: p.id, ancestor_id: p.parent_id, depth: 0} + ) + + next_ancestor_up = + from(p in Project, + join: a in "ancestor_walk", + on: a.ancestor_id == p.id, + where: not is_nil(a.ancestor_id) and a.depth < ^max_depth, + select: %{ + starting_id: a.starting_id, + ancestor_id: p.parent_id, + depth: a.depth + 1 + } + ) + + "ancestor_walk" + |> recursive_ctes(true) + |> with_cte("ancestor_walk", + as: ^union_all(starting_rows, ^next_ancestor_up) + ) + |> where([a], not is_nil(a.ancestor_id)) + |> select( + [a], + {type(a.starting_id, Ecto.UUID), type(a.ancestor_id, Ecto.UUID)} + ) + end + + defp group_ancestors_by_starting_id(pairs) do + pairs + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + |> Map.new(fn {id, ancestors} -> {id, MapSet.new(ancestors)} end) + end + + @doc """ + Returns the subset of `sandboxes` that `user` is allowed to see. + + A sandbox is visible when the user has a `project_users` row on that + sandbox, or is a support user on a sandbox flagged + `allow_support_access`. Visibility does not cascade from a parent + project; each sandbox is an independent project with its own + membership list (seeded from the parent at provision time). + + Assumes each `sandbox.project_users` is preloaded; raises + `ArgumentError` otherwise. + """ + @spec visible_sandboxes([Project.t()], User.t()) :: [Project.t()] + def visible_sandboxes(sandboxes, %User{} = user) do + Enum.each(sandboxes, &assert_project_users_loaded!(&1, "sandbox")) + Enum.filter(sandboxes, &accessible?(&1, user)) + end + + defp assert_project_users_loaded!( + %Project{project_users: %Ecto.Association.NotLoaded{}}, + label + ) do + raise ArgumentError, + "visible_sandboxes/2 requires :project_users to be preloaded on " <> + "the #{label}; see the function docstring" + end + + defp assert_project_users_loaded!(%Project{}, _label), do: :ok + + @doc "Topmost ancestor of `project` that `user` can see; falls back to `project`." + @spec access_root_for_user(Project.t(), User.t()) :: Project.t() + def access_root_for_user(%Project{parent_id: nil} = project, %User{}), + do: project + + def access_root_for_user(%Project{} = project, %User{} = user) do + project.id + |> ancestor_chain_with_user_membership(user.id) + |> Enum.find(project, &accessible?(&1, user)) + end + + defp ancestor_chain_with_user_membership(project_id, user_id) do + max_depth = max_project_tree_depth() + + seed = + from(p in Project, + where: p.id == ^project_id, + select: %{id: p.id, depth: 0} + ) + + step_up = + from(p in Project, + join: walked in "ancestor_walk", + on: walked.id == p.id, + where: walked.depth < ^max_depth and not is_nil(p.parent_id), + select: %{id: p.parent_id, depth: walked.depth + 1} + ) + + from(p in Project, + join: a in "ancestor_walk", + on: a.id == p.id, + left_join: pu in ProjectUser, + on: pu.project_id == p.id and pu.user_id == ^user_id, + order_by: [desc: a.depth], + preload: [project_users: pu] + ) + |> with_cte("ancestor_walk", as: ^union_all(seed, ^step_up)) + |> recursive_ctes(true) + |> Repo.all() + end + + @doc "Display name from `access_root` down to `project`, joined by `/`." + @spec display_name_within_access_root(Project.t(), Project.t()) :: String.t() + def display_name_within_access_root( + %Project{} = project, + %Project{id: access_root_id} + ) do + project + |> preload_ancestors() + |> truncate_parent_chain(access_root_id) + |> Project.display_name() + end + + defp truncate_parent_chain(%Project{id: id} = project, id), + do: %{project | parent: nil} + + defp truncate_parent_chain( + %Project{parent: %Project{} = parent} = project, + access_root_id + ), + do: %{project | parent: truncate_parent_chain(parent, access_root_id)} + + defp truncate_parent_chain(%Project{} = project, _access_root_id), + do: project + + defp accessible?(%Project{} = project, %User{} = user) do + member?(project, user) or support_access?(project, user) + end + + defp support_access?( + %Project{allow_support_access: true}, + %User{support_user: true} + ), + do: true + + defp support_access?(_project, _user), do: false + + defp member?(%Project{project_users: pus}, %User{id: user_id}) do + Enum.any?(pus, &(&1.user_id == user_id)) + end + + defp filter_descendants_for_user(descendants, %User{} = user) do + Enum.filter(descendants, &accessible?(&1, user)) + end + defp project_user_role_query(%User{id: user_id}, %Project{id: project_id}) do from(p in Project, join: pu in assoc(p, :project_users), @@ -1504,8 +1875,11 @@ defmodule Lightning.Projects do recursive walker used by `Lightning.Extensions.ProjectHook.handle_delete_project/1` to cascade hard-deletes through the subtree at purge time. Filtering would skip scheduled descendants and (because the parent FK is `:nilify_all`) leave - them as orphan root projects in the database. User-facing surfaces should use - `list_workspace_projects/2`, which filters scheduled rows out. + them as orphan root projects in the database. User-facing surfaces should + use `list_workspace_projects/2`, which returns the full workspace and lets + the caller decide what to display: the sandboxes list shows scheduled rows + in a separate "Recently Deleted" section, while the picker filters them out + at the SQL level in `get_project_tree_for_user/1`'s active-descendants CTE. """ @spec list_sandboxes(Ecto.UUID.t()) :: [Project.t()] def list_sandboxes(parent_id) when is_binary(parent_id) do diff --git a/lib/lightning/projects/sandboxes.ex b/lib/lightning/projects/sandboxes.ex index 2d8a667d3c4..8675fc0e042 100644 --- a/lib/lightning/projects/sandboxes.ex +++ b/lib/lightning/projects/sandboxes.ex @@ -29,10 +29,10 @@ defmodule Lightning.Projects.Sandboxes do ## Authorization - * **Provisioning**: Requires `:editor`, `:admin`, or `:owner` role on the parent project, or superuser - * **Merge**: Requires `:editor`, `:admin`, or `:owner` role on the target project, or superuser + * **Provisioning**: Requires `:editor`, `:admin`, or `:owner` role on the parent project + * **Merge**: Requires `:editor`, `:admin`, or `:owner` role on the target project * **Updates/Deletion**: Requires `:owner` or `:admin` role on the sandbox itself, - or `:owner` or `:admin` on the root project, or superuser + or `:owner` or `:admin` on the root project ## Transaction safety diff --git a/lib/lightning_web/components/layout_components.ex b/lib/lightning_web/components/layout_components.ex index 1b089323203..e4351604fe4 100644 --- a/lib/lightning_web/components/layout_components.ex +++ b/lib/lightning_web/components/layout_components.ex @@ -305,7 +305,7 @@ defmodule LightningWeb.LayoutComponents do ## Example <.breadcrumbs> - <.breadcrumb_project_picker project={@project} /> + <.breadcrumb_project_picker project={@project} current_user={@current_user} access_root={@access_root} /> <.breadcrumb_items items={[{"History", "/projects/\#{@project}/history"}]} /> <.breadcrumb> <:label>{@page_title} @@ -356,18 +356,33 @@ defmodule LightningWeb.LayoutComponents do LiveView pages and the collaborative editor. """ attr :project, Lightning.Projects.Project, required: true + attr :current_user, Lightning.Accounts.User, default: nil + attr :access_root, Lightning.Projects.Project, default: nil def breadcrumb_project_picker(assigns) do alias Lightning.Projects alias Lightning.Projects.Project - project = Projects.preload_ancestors(assigns.project) + access_root = + case {assigns[:access_root], assigns[:current_user]} do + {%Project{} = ar, _} -> + ar + + {nil, %Lightning.Accounts.User{} = user} -> + Projects.access_root_for_user(assigns.project, user) + + _ -> + assigns.project + end assigns = assigns - |> assign(:label, Project.display_name(project)) - |> assign(:is_sandbox, to_string(Project.sandbox?(project))) - |> assign(:color, project.color) + |> assign( + :label, + Projects.display_name_within_access_root(assigns.project, access_root) + ) + |> assign(:is_sandbox, to_string(Project.sandbox?(assigns.project))) + |> assign(:color, assigns.project.color) ~H"""
  • @@ -490,7 +505,8 @@ defmodule LightningWeb.LayoutComponents do depth: depth, color: project.color, href: href, - sameSection: same_section + sameSection: same_section, + isSandbox: project.sandbox? } walk_project_tree(project.id, by_parent, depth + 1, label, path, [ diff --git a/lib/lightning_web/hooks.ex b/lib/lightning_web/hooks.ex index 193be11ceb1..e83368163e6 100644 --- a/lib/lightning_web/hooks.ex +++ b/lib/lightning_web/hooks.ex @@ -55,11 +55,15 @@ defmodule LightningWeb.Hooks do {:halt, redirect(socket, to: ~p"/mfa_required")} can_access_project -> + access_root = + Lightning.Projects.access_root_for_user(project, current_user) + {:cont, socket |> assign(:side_menu_theme, "primary-theme") |> assign(:project_user, project_user) |> assign(:project, project) + |> assign(:access_root, access_root) |> assign(:projects, projects)} true -> diff --git a/lib/lightning_web/live/channel_live/index.ex b/lib/lightning_web/live/channel_live/index.ex index e6483b215b4..5c3a136fc0e 100644 --- a/lib/lightning_web/live/channel_live/index.ex +++ b/lib/lightning_web/live/channel_live/index.ex @@ -28,7 +28,11 @@ defmodule LightningWeb.ChannelLive.Index do <:breadcrumbs> - + <:label>{@page_title} diff --git a/lib/lightning_web/live/channel_request_live/show.ex b/lib/lightning_web/live/channel_request_live/show.ex index 4ab4bdfec08..ec8de681092 100644 --- a/lib/lightning_web/live/channel_request_live/show.ex +++ b/lib/lightning_web/live/channel_request_live/show.ex @@ -55,7 +55,11 @@ defmodule LightningWeb.ChannelRequestLive.Show do <:breadcrumbs> - + <:breadcrumbs> - + <:label> {@page_title} diff --git a/lib/lightning_web/live/run_live/index.html.heex b/lib/lightning_web/live/run_live/index.html.heex index d3414c9b5c0..1325dec924e 100644 --- a/lib/lightning_web/live/run_live/index.html.heex +++ b/lib/lightning_web/live/run_live/index.html.heex @@ -10,7 +10,11 @@ <:breadcrumbs> - + <:label>{@page_title} diff --git a/lib/lightning_web/live/run_live/show.ex b/lib/lightning_web/live/run_live/show.ex index 6f198f317a9..b25d8623a50 100644 --- a/lib/lightning_web/live/run_live/show.ex +++ b/lib/lightning_web/live/run_live/show.ex @@ -64,7 +64,11 @@ defmodule LightningWeb.RunLive.Show do <:breadcrumbs> - + diff --git a/lib/lightning_web/live/sandbox_live/components.ex b/lib/lightning_web/live/sandbox_live/components.ex index d722916056b..c68084b8240 100644 --- a/lib/lightning_web/live/sandbox_live/components.ex +++ b/lib/lightning_web/live/sandbox_live/components.ex @@ -82,12 +82,14 @@ defmodule LightningWeb.SandboxLive.Components do ~H"""
    -
    + <%= if is_nil(@root_project.parent_id) do %> <.root_project_card root_project={@root_project} is_current={@current_project.id == @root_project.id} /> -
    + <% else %> + <.sandbox_card sandbox={@root_project} /> + <% end %>
    <%= if Enum.empty?(@active_sandboxes) and Enum.empty?(@scheduled_sandboxes) do %>
    @@ -132,10 +134,13 @@ defmodule LightningWeb.SandboxLive.Components do attr :sandbox, Project, required: true attr :changeset, :any, required: true attr :root_project, Project, required: true + attr :descendants, :list, default: [] def confirm_delete_modal(assigns) do assigns = - assign(assigns, :confirm_form, to_form(assigns.changeset, as: :confirm)) + assigns + |> assign(:confirm_form, to_form(assigns.changeset, as: :confirm)) + |> assign(:descendant_count, length(assigns.descendants)) ~H""" <.modal @@ -163,7 +168,13 @@ defmodule LightningWeb.SandboxLive.Components do

    - Deleting a sandbox removes it (along with its descendants) from OpenFn. + Deleting a sandbox removes it from OpenFn. + + Its child sandbox will also be deleted. + + 1}> + Its {@descendant_count} child sandboxes will also be deleted. +

    @@ -389,8 +400,7 @@ defmodule LightningWeb.SandboxLive.Components do <:message> It can be restored from the sandbox list for {grace_period_label()}, then permanently removed.

    - Child sandbox {List.first(@descendants).name} - will also be deleted. + Its child sandbox will also be deleted.
    1} class="mt-2"> Its {@descendant_count} child sandboxes will also be deleted. @@ -497,6 +507,10 @@ defmodule LightningWeb.SandboxLive.Components do {@root_project.name} <.badge + :if={ + has_environment?(@root_project) or + not Project.sandbox?(@root_project) + } id={"env-badge-#{@root_project.id}"} env={ if has_environment?(@root_project), @@ -532,7 +546,7 @@ defmodule LightningWeb.SandboxLive.Components do
    @@ -576,7 +590,7 @@ defmodule LightningWeb.SandboxLive.Components do
    diff --git a/lib/lightning_web/live/sandbox_live/index.ex b/lib/lightning_web/live/sandbox_live/index.ex index 0fb707473b4..fd58fe33210 100644 --- a/lib/lightning_web/live/sandbox_live/index.ex +++ b/lib/lightning_web/live/sandbox_live/index.ex @@ -40,16 +40,10 @@ defmodule LightningWeb.SandboxLive.Index do def handle_params( %{"id" => id}, _uri, - %{ - assigns: %{ - sandboxes: sandboxes, - project: project, - live_action: live_action - } - } = socket + %{assigns: %{project: project, live_action: live_action}} = socket ) when live_action == :edit do - case Enum.find(sandboxes, &(&1.id == id)) do + case Enum.find(socket.assigns.workspace_tree, &(&1.id == id)) do nil -> {:noreply, put_flash(socket, :error, "Sandbox not found")} @@ -104,7 +98,7 @@ defmodule LightningWeb.SandboxLive.Index do @impl true def handle_event("open-delete-modal", %{"id" => sandbox_id}, socket) do - case Enum.find(socket.assigns.sandboxes, &(&1.id == sandbox_id)) do + case Enum.find(socket.assigns.workspace_tree, &(&1.id == sandbox_id)) do nil -> {:noreply, put_flash(socket, :error, "Sandbox not found")} @@ -114,6 +108,7 @@ defmodule LightningWeb.SandboxLive.Index do socket |> assign(:confirm_delete_open?, true) |> assign(:confirm_delete_sandbox, sandbox) + |> assign(:confirm_delete_descendants, active_descendants(sandbox.id)) |> assign(:confirm_delete_input, "") |> assign(:confirm_changeset, confirm_changeset(sandbox))} else @@ -185,7 +180,7 @@ defmodule LightningWeb.SandboxLive.Index do %{"id" => sandbox_id}, %{assigns: %{current_user: current_user}} = socket ) do - case Enum.find(socket.assigns.sandboxes, &(&1.id == sandbox_id)) do + case Enum.find(socket.assigns.workspace_tree, &(&1.id == sandbox_id)) do nil -> {:noreply, put_flash(socket, :error, "Sandbox not found")} @@ -207,7 +202,7 @@ defmodule LightningWeb.SandboxLive.Index do @impl true def handle_event("open-merge-modal", %{"id" => sandbox_id}, socket) do - case Enum.find(socket.assigns.sandboxes, &(&1.id == sandbox_id)) do + case Enum.find(socket.assigns.workspace_tree, &(&1.id == sandbox_id)) do nil -> {:noreply, put_flash(socket, :error, "Sandbox not found")} @@ -218,8 +213,7 @@ defmodule LightningWeb.SandboxLive.Index do default_target = Enum.find(target_options, &(&1.value == sandbox.parent_id)) - descendants = - get_all_descendants(sandbox, socket.assigns.workspace_projects) + descendants = active_descendants(sandbox.id) merge_changeset = merge_changeset(%{ @@ -440,7 +434,11 @@ defmodule LightningWeb.SandboxLive.Index do <:breadcrumbs> - + <:label>Sandboxes @@ -488,6 +486,7 @@ defmodule LightningWeb.SandboxLive.Index do sandbox={@confirm_delete_sandbox} changeset={@confirm_changeset} root_project={@root_project} + descendants={@confirm_delete_descendants} /> Projects.root_of() |> Repo.preload(:project_users) + + descendants = + access_root.id + |> Projects.list_descendants() + |> Repo.preload([:parent, :project_users]) + |> Projects.visible_sandboxes(current_user) + can_create_sandbox = Permissions.can?( :sandboxes, @@ -548,44 +555,55 @@ defmodule LightningWeb.SandboxLive.Index do manage_permissions = Lightning.Policies.Sandboxes.check_manage_permissions( - descendants, + [access_root | descendants], current_user, - root_project + workspace_root ) - sandboxes = - Enum.map(descendants, fn sandbox -> - perms = - Map.get(manage_permissions, sandbox.id, %{ - update: false, - delete: false, - merge: false - }) - - scheduled? = not is_nil(sandbox.scheduled_deletion) - - {restore_blocked_by_limit?, restore_blocked_message} = - restore_block_state(scheduled?, limit_new_sandbox) - - sandbox - |> Map.put(:can_edit, perms.update and not scheduled?) - |> Map.put(:can_delete, perms.delete and not scheduled?) - |> Map.put(:can_merge, perms.merge and not scheduled?) - |> Map.put(:can_cancel_deletion, perms.delete and scheduled?) - |> Map.put(:restore_blocked_by_limit?, restore_blocked_by_limit?) - |> Map.put(:restore_blocked_message, restore_blocked_message) - |> Map.put(:scheduled_for_deletion?, scheduled?) - |> Map.put(:is_current, project.id == sandbox.id) - end) + decorate = + &decorate_for_render(&1, manage_permissions, project, limit_new_sandbox) + + decorated_root = decorate.(access_root) + decorated_sandboxes = Enum.map(descendants, decorate) socket - |> assign(:workspace_projects, [root_project | descendants]) - |> assign(:root_project, root_project) - |> assign(:sandboxes, sandboxes) + |> assign(:workspace_projects, [access_root | descendants]) + |> assign(:workspace_tree, [decorated_root | decorated_sandboxes]) + |> assign(:root_project, decorated_root) + |> assign(:sandboxes, decorated_sandboxes) |> assign(:can_create_sandbox, can_create_sandbox) |> assign(:nesting_at_limit, nesting_at_limit) end + defp active_descendants(sandbox_id) do + sandbox_id + |> Projects.list_descendants() + |> Enum.filter(&is_nil(&1.scheduled_deletion)) + end + + defp decorate_for_render( + sandbox, + manage_permissions, + current_project, + limit_new_sandbox + ) do + can_manage? = Map.get(manage_permissions, sandbox.id, false) + scheduled? = not is_nil(sandbox.scheduled_deletion) + + {restore_blocked_by_limit?, restore_blocked_message} = + restore_block_state(scheduled?, limit_new_sandbox) + + sandbox + |> Map.put(:can_edit, can_manage? and not scheduled?) + |> Map.put(:can_delete, can_manage? and not scheduled?) + |> Map.put(:can_merge, can_manage? and not scheduled?) + |> Map.put(:can_cancel_deletion, can_manage? and scheduled?) + |> Map.put(:restore_blocked_by_limit?, restore_blocked_by_limit?) + |> Map.put(:restore_blocked_message, restore_blocked_message) + |> Map.put(:scheduled_for_deletion?, scheduled?) + |> Map.put(:is_current, current_project.id == sandbox.id) + end + defp restore_block_state(true, {:error, _reason, %{text: text}}), do: {true, text} @@ -595,6 +613,7 @@ defmodule LightningWeb.SandboxLive.Index do socket |> assign(:confirm_delete_open?, false) |> assign(:confirm_delete_sandbox, nil) + |> assign(:confirm_delete_descendants, []) |> assign(:confirm_delete_input, "") |> assign(:confirm_changeset, empty_confirm_changeset()) end @@ -723,12 +742,12 @@ defmodule LightningWeb.SandboxLive.Index do socket.assigns.workspace_projects |> Enum.reject(fn potential_target -> - potential_target.id == source_sandbox.id or + not is_nil(potential_target.scheduled_deletion) or + potential_target.id == source_sandbox.id or Projects.descendant_of?(potential_target, source_sandbox, root_project) end) |> Enum.filter(fn project -> - user_role_on_project(project, current_user) in [:owner, :admin, :editor] or - current_user.role == :superuser + user_role_on_project(project, current_user) in [:owner, :admin, :editor] end) |> Enum.map(fn project -> %{ @@ -745,32 +764,6 @@ defmodule LightningWeb.SandboxLive.Index do end end - defp get_all_descendants(sandbox, workspace_projects) do - project_map = Map.new(workspace_projects, &{&1.id, &1}) - - workspace_projects - |> Enum.filter(fn project -> - descendant_of?(project.parent_id, sandbox.id, project_map) - end) - |> Enum.sort_by(& &1.name) - end - - defp descendant_of?(nil, _ancestor_id, _project_map), do: false - - defp descendant_of?(parent_id, ancestor_id, _project_map) - when parent_id == ancestor_id, - do: true - - defp descendant_of?(parent_id, ancestor_id, project_map) do - case Map.get(project_map, parent_id) do - nil -> - false - - parent -> - descendant_of?(parent.parent_id, ancestor_id, project_map) - end - end - defp find_target_project(workspace_projects, target_id) do Enum.find(workspace_projects, fn project -> project.id == target_id end) end diff --git a/lib/lightning_web/live/workflow_live/collaborate.ex b/lib/lightning_web/live/workflow_live/collaborate.ex index 05b2eeef4eb..f13b42ecec5 100644 --- a/lib/lightning_web/live/workflow_live/collaborate.ex +++ b/lib/lightning_web/live/workflow_live/collaborate.ex @@ -12,23 +12,36 @@ defmodule LightningWeb.WorkflowLive.Collaborate do alias Lightning.AiAssistant alias Lightning.Policies.Permissions + alias Lightning.Projects + alias Lightning.Projects.Project alias Lightning.Workflows alias Lightning.Workflows.WebhookAuthMethod alias Lightning.Workflows.Workflow alias LightningWeb.Channels.WorkflowJSON on_mount({LightningWeb.Hooks, :project_scope}) - on_mount {LightningWeb.Hooks, :check_limits} - on_mount {LightningWeb.Hooks, :check_legacy_preference} + on_mount({LightningWeb.Hooks, :check_limits}) + on_mount({LightningWeb.Hooks, :check_legacy_preference}) @impl true - def mount(params, _session, %{assigns: %{project: project}} = socket) do + def mount( + params, + _session, + %{assigns: %{project: project, access_root: access_root}} = socket + ) do + is_sandbox? = Project.sandbox?(project) + {:ok, socket |> assign(workflow_assigns(params, project)) |> assign( active_menu_item: :overview, project: project, + project_display_name: + Projects.display_name_within_access_root(project, access_root), + project_is_sandbox: is_sandbox?, + root_project_id: if(is_sandbox?, do: access_root.id), + root_project_name: if(is_sandbox?, do: access_root.name), show_credential_modal: false, credential_schema: nil, credential_to_edit: nil, @@ -181,25 +194,11 @@ defmodule LightningWeb.WorkflowLive.Collaborate do data-workflow-name={@workflow.name} data-project-id={@workflow.project_id} data-project-name={@project.name} - data-project-display-name={ - Lightning.Projects.Project.display_name( - Lightning.Projects.preload_ancestors(@project) - ) - } - data-project-is-sandbox={ - to_string(Lightning.Projects.Project.sandbox?(@project)) - } + data-project-display-name={@project_display_name} + data-project-is-sandbox={to_string(@project_is_sandbox)} data-project-color={@project.color} - data-root-project-id={ - if Lightning.Projects.Project.sandbox?(@project), - do: Lightning.Projects.root_of(@project).id, - else: nil - } - data-root-project-name={ - if Lightning.Projects.Project.sandbox?(@project), - do: Lightning.Projects.root_of(@project).name, - else: nil - } + data-root-project-id={@root_project_id} + data-root-project-name={@root_project_name} data-project-env={@project.env} data-is-new-workflow={if @is_new_workflow, do: "true", else: nil} data-ai-assistant-enabled={if @ai_assistant_enabled, do: "true", else: "false"} diff --git a/lib/lightning_web/live/workflow_live/edit.ex b/lib/lightning_web/live/workflow_live/edit.ex index c9b90187099..2326f20bc27 100644 --- a/lib/lightning_web/live/workflow_live/edit.ex +++ b/lib/lightning_web/live/workflow_live/edit.ex @@ -95,7 +95,11 @@ defmodule LightningWeb.WorkflowLive.Edit do <:breadcrumbs> - + diff --git a/lib/lightning_web/live/workflow_live/index.ex b/lib/lightning_web/live/workflow_live/index.ex index f332f086b10..5c3f48706be 100644 --- a/lib/lightning_web/live/workflow_live/index.ex +++ b/lib/lightning_web/live/workflow_live/index.ex @@ -39,7 +39,11 @@ defmodule LightningWeb.WorkflowLive.Index do <:breadcrumbs> - + <:label>{@page_title} diff --git a/test/lightning/policies/sandbox_permissions_test.exs b/test/lightning/policies/sandbox_permissions_test.exs index 89e0c59672f..72ea88554c3 100644 --- a/test/lightning/policies/sandbox_permissions_test.exs +++ b/test/lightning/policies/sandbox_permissions_test.exs @@ -95,15 +95,15 @@ defmodule Lightning.Policies.SandboxesTest do |> Permissions.can?(:provision_sandbox, user, root_project) end - test "superusers can provision sandboxes in any workspace", %{ + test "superusers without a project role cannot provision sandboxes", %{ superuser: superuser, root_project: root_project, other_root_project: other_root_project } do - assert Sandboxes + refute Sandboxes |> Permissions.can?(:provision_sandbox, superuser, root_project) - assert Sandboxes + refute Sandboxes |> Permissions.can?( :provision_sandbox, superuser, @@ -138,18 +138,18 @@ defmodule Lightning.Policies.SandboxesTest do end describe "delete_sandbox permissions" do - test "superusers can delete any sandbox", %{ + test "superusers without a project role cannot delete sandboxes", %{ superuser: superuser, sandbox: sandbox, sandbox_with_owner: sandbox_with_owner, other_sandbox: other_sandbox } do - assert Sandboxes |> Permissions.can?(:delete_sandbox, superuser, sandbox) + refute Sandboxes |> Permissions.can?(:delete_sandbox, superuser, sandbox) - assert Sandboxes + refute Sandboxes |> Permissions.can?(:delete_sandbox, superuser, sandbox_with_owner) - assert Sandboxes + refute Sandboxes |> Permissions.can?(:delete_sandbox, superuser, other_sandbox) end @@ -238,18 +238,18 @@ defmodule Lightning.Policies.SandboxesTest do end describe "update_sandbox permissions" do - test "superusers can update any sandbox", %{ + test "superusers without a project role cannot update sandboxes", %{ superuser: superuser, sandbox: sandbox, sandbox_with_owner: sandbox_with_owner, other_sandbox: other_sandbox } do - assert Sandboxes |> Permissions.can?(:update_sandbox, superuser, sandbox) + refute Sandboxes |> Permissions.can?(:update_sandbox, superuser, sandbox) - assert Sandboxes + refute Sandboxes |> Permissions.can?(:update_sandbox, superuser, sandbox_with_owner) - assert Sandboxes + refute Sandboxes |> Permissions.can?(:update_sandbox, superuser, other_sandbox) end @@ -393,15 +393,15 @@ defmodule Lightning.Policies.SandboxesTest do |> Permissions.can?(:merge_sandbox, user, root_project) end - test "superusers can merge sandboxes into any project", %{ + test "superusers without a project role cannot merge sandboxes", %{ superuser: superuser, root_project: root_project, other_root_project: other_root_project } do - assert Sandboxes + refute Sandboxes |> Permissions.can?(:merge_sandbox, superuser, root_project) - assert Sandboxes + refute Sandboxes |> Permissions.can?( :merge_sandbox, superuser, @@ -426,7 +426,7 @@ defmodule Lightning.Policies.SandboxesTest do %{sandboxes: sandboxes} end - test "superuser gets full permissions on all sandboxes", %{ + test "superuser without a project role gets no manage rights", %{ superuser: superuser, root_project: root_project, sandboxes: sandboxes @@ -437,7 +437,7 @@ defmodule Lightning.Policies.SandboxesTest do assert map_size(permissions) == 4 for sandbox <- sandboxes do - assert %{update: true, delete: true} = permissions[sandbox.id] + assert permissions[sandbox.id] == false end end @@ -453,7 +453,7 @@ defmodule Lightning.Policies.SandboxesTest do assert map_size(permissions) == 4 for sandbox <- sandboxes do - assert %{update: true, delete: true} = permissions[sandbox.id] + assert permissions[sandbox.id] == true end end @@ -474,7 +474,7 @@ defmodule Lightning.Policies.SandboxesTest do assert map_size(permissions) == 4 for sandbox <- sandboxes do - assert %{update: true, delete: true} = permissions[sandbox.id] + assert permissions[sandbox.id] == true end end @@ -491,9 +491,9 @@ defmodule Lightning.Policies.SandboxesTest do for sandbox <- sandboxes do if sandbox.id == owned_sandbox.id do - assert %{update: true, delete: true} = permissions[sandbox.id] + assert permissions[sandbox.id] == true else - assert %{update: false, delete: false} = permissions[sandbox.id] + assert permissions[sandbox.id] == false end end end @@ -511,9 +511,9 @@ defmodule Lightning.Policies.SandboxesTest do for sandbox <- sandboxes do if sandbox.id == admin_sandbox.id do - assert %{update: true, delete: true} = permissions[sandbox.id] + assert permissions[sandbox.id] == true else - assert %{update: false, delete: false} = permissions[sandbox.id] + assert permissions[sandbox.id] == false end end end @@ -529,12 +529,11 @@ defmodule Lightning.Policies.SandboxesTest do assert map_size(permissions) == 4 for sandbox <- sandboxes do - assert %{update: false, delete: false, merge: false} = - permissions[sandbox.id] + assert permissions[sandbox.id] == false end end - test "root project editor gets merge but not update/delete on all sandboxes", + test "root project editor gets no manage rights without an admin/owner row on the sandbox", %{ root_project: root_project, sandboxes: sandboxes, @@ -551,8 +550,7 @@ defmodule Lightning.Policies.SandboxesTest do assert map_size(permissions) == 4 for sandbox <- sandboxes do - assert %{update: false, delete: false, merge: true} = - permissions[sandbox.id] + assert permissions[sandbox.id] == false end end end @@ -632,10 +630,10 @@ defmodule Lightning.Policies.SandboxesTest do permissions = Sandboxes.check_manage_permissions(sandboxes, user, root_project) - # Editor on root gets merge but not update/delete + # Editor on root, editor on sandbox: no manage rights without admin/owner + # on the sandbox itself (or root cascade). for sandbox <- sandboxes do - assert %{update: false, delete: false, merge: true} = - permissions[sandbox.id] + assert permissions[sandbox.id] == false end end end diff --git a/test/lightning/projects_test.exs b/test/lightning/projects_test.exs index 8b23fbc60b6..7719ff3f21b 100644 --- a/test/lightning/projects_test.exs +++ b/test/lightning/projects_test.exs @@ -489,6 +489,316 @@ defmodule Lightning.ProjectsTest do assert [project_1] == Projects.get_projects_for_user(other_user) end + test "get_project_tree_for_user/1 returns empty for a user with no memberships" do + user = user_fixture() + _other_project = project_fixture() + + assert Projects.get_project_tree_for_user(user) == [] + end + + test "get_project_tree_for_user/1 does not cascade visibility from a root owner to descendants the owner has no row on" do + owner = user_fixture() + + root = + project_fixture(project_users: [%{user_id: owner.id, role: :owner}]) + + _sandbox = insert(:project, parent: root) + _nested = insert(:project, parent: insert(:project, parent: root)) + + ids = + owner + |> Projects.get_project_tree_for_user() + |> Enum.map(& &1.id) + + assert ids == [root.id] + end + + test "get_project_tree_for_user/1 hides descendants the user has no project_users row on" do + editor = user_fixture() + + root = + project_fixture(project_users: [%{user_id: editor.id, role: :editor}]) + + visible_sandbox = + insert(:project, + parent: root, + project_users: [%{user: editor, role: :viewer}] + ) + + _hidden_sandbox = insert(:project, parent: root) + + ids = + editor + |> Projects.get_project_tree_for_user() + |> Enum.map(& &1.id) + |> Enum.sort() + + assert ids == Enum.sort([root.id, visible_sandbox.id]) + end + + test "get_project_tree_for_user/1 ignores superuser role and shows only projects with direct membership" do + superuser = insert(:user, role: :superuser) + + root = + project_fixture(project_users: [%{user_id: superuser.id, role: :viewer}]) + + _sandbox = insert(:project, parent: root) + + ids = + superuser + |> Projects.get_project_tree_for_user() + |> Enum.map(& &1.id) + + assert ids == [root.id] + end + + test "get_project_tree_for_user/1 shows support users only the projects whose own allow_support_access is true" do + support_user = insert(:user, support_user: true) + + root = insert(:project, allow_support_access: true) + + flagged_sandbox = + insert(:project, parent: root, allow_support_access: true) + + _unflagged_sandbox = + insert(:project, parent: root, allow_support_access: false) + + ids = + support_user + |> Projects.get_project_tree_for_user() + |> Enum.map(& &1.id) + |> Enum.sort() + + assert ids == Enum.sort([root.id, flagged_sandbox.id]) + end + + test "get_project_tree_for_user/1 prunes an active descendant whose intermediate ancestor was scheduled for deletion outside the cascade" do + user = user_fixture() + + root = + project_fixture(project_users: [%{user_id: user.id, role: :owner}]) + + middle = insert(:project, parent: root) + + active_leaf = + insert(:project, + parent: middle, + project_users: [%{user: user, role: :viewer}] + ) + + Repo.update_all( + from(p in Project, where: p.id == ^middle.id), + set: [ + scheduled_deletion: DateTime.utc_now() |> DateTime.truncate(:second) + ] + ) + + ids = + user + |> Projects.get_project_tree_for_user() + |> Enum.map(& &1.id) + |> Enum.sort() + + assert ids == [root.id] + refute middle.id in ids + refute active_leaf.id in ids + end + + test "get_project_tree_for_user/1 prunes the subtree under a scheduled ancestor" do + user = user_fixture() + + root = + project_fixture(project_users: [%{user_id: user.id, role: :owner}]) + + now = DateTime.utc_now() |> DateTime.truncate(:second) + + scheduled_branch = + insert(:project, parent: root, scheduled_deletion: now) + + _scheduled_leaf = + insert(:project, + parent: scheduled_branch, + scheduled_deletion: now + ) + + active_branch = + insert(:project, + parent: root, + project_users: [%{user: user, role: :viewer}] + ) + + ids = + user + |> Projects.get_project_tree_for_user() + |> Enum.map(& &1.id) + |> Enum.sort() + + assert ids == Enum.sort([root.id, active_branch.id]) + end + + test "get_project_tree_for_user/1 surfaces a sandbox the user is a direct member of, even with no role on its absolute root" do + user = user_fixture() + + root = insert(:project, name: "absolute-root") + _middle = insert(:project, name: "middle", parent: root) + + sandbox = + insert(:project, + name: "deep-sandbox", + parent: root, + project_users: [%{user: user, role: :owner}] + ) + + leaf = + insert(:project, + name: "deep-leaf", + parent: sandbox, + project_users: [%{user: user, role: :viewer}] + ) + + ids = + user + |> Projects.get_project_tree_for_user() + |> Enum.map(& &1.id) + |> Enum.sort() + + assert ids == Enum.sort([sandbox.id, leaf.id]) + refute root.id in ids + end + + test "get_project_tree_for_user/1 reparents a visible descendant onto its nearest visible ancestor when intermediates are hidden" do + user = user_fixture() + + root = + project_fixture(project_users: [%{user_id: user.id, role: :editor}]) + + hidden_middle = insert(:project, parent: root) + + nested_member = + insert(:project, + parent: hidden_middle, + project_users: [%{user: user, role: :viewer}] + ) + + projects = Projects.get_project_tree_for_user(user) + by_id = Map.new(projects, &{&1.id, &1}) + + assert Map.has_key?(by_id, root.id) + assert Map.has_key?(by_id, nested_member.id) + refute Map.has_key?(by_id, hidden_middle.id) + + assert by_id[root.id].parent_id == nil + assert by_id[nested_member.id].parent_id == root.id + end + + test "get_project_tree_for_user/1 reparents a directly-visible grandchild onto the root when the intermediate is hidden" do + user = user_fixture() + + root = + project_fixture(project_users: [%{user_id: user.id, role: :owner}]) + + middle = insert(:project, parent: root) + + grandchild = + insert(:project, + parent: middle, + project_users: [%{user: user, role: :viewer}] + ) + + projects = Projects.get_project_tree_for_user(user) + ids = Enum.map(projects, & &1.id) + by_id = Map.new(projects, &{&1.id, &1}) + + assert Enum.count(ids, &(&1 == root.id)) == 1 + assert Enum.count(ids, &(&1 == grandchild.id)) == 1 + refute Map.has_key?(by_id, middle.id) + + assert by_id[root.id].parent_id == nil + assert by_id[grandchild.id].parent_id == root.id + end + + test "get_project_tree_for_user/1 reparents past two consecutive hidden intermediates" do + user = user_fixture() + + root = + project_fixture(project_users: [%{user_id: user.id, role: :owner}]) + + hidden_a = insert(:project, parent: root) + hidden_b = insert(:project, parent: hidden_a) + + deep_visible = + insert(:project, + parent: hidden_b, + project_users: [%{user: user, role: :viewer}] + ) + + projects = Projects.get_project_tree_for_user(user) + ids = Enum.map(projects, & &1.id) + by_id = Map.new(projects, &{&1.id, &1}) + + assert Enum.count(ids, &(&1 == root.id)) == 1 + assert Enum.count(ids, &(&1 == deep_visible.id)) == 1 + refute Map.has_key?(by_id, hidden_a.id) + refute Map.has_key?(by_id, hidden_b.id) + + assert by_id[deep_visible.id].parent_id == root.id + end + + test "get_project_tree_for_user/1 shadows a sandbox membership under a support-access root for a support user" do + support_user = insert(:user, support_user: true) + + root = insert(:project, allow_support_access: true) + + sandbox = + insert(:project, + parent: root, + project_users: [%{user: support_user, role: :viewer}] + ) + + projects = Projects.get_project_tree_for_user(support_user) + ids = Enum.map(projects, & &1.id) + by_id = Map.new(projects, &{&1.id, &1}) + + assert Enum.count(ids, &(&1 == root.id)) == 1 + assert Enum.count(ids, &(&1 == sandbox.id)) == 1 + + assert by_id[root.id].parent_id == nil + assert by_id[sandbox.id].parent_id == root.id + end + + test "get_project_tree_for_user/1 surfaces only directly-accessible projects across multiple workspaces" do + user = user_fixture() + + admin_root = + project_fixture(project_users: [%{user_id: user.id, role: :admin}]) + + _admin_sandbox = insert(:project, parent: admin_root) + + editor_root = + project_fixture(project_users: [%{user_id: user.id, role: :editor}]) + + editor_visible = + insert(:project, + parent: editor_root, + project_users: [%{user: user, role: :viewer}] + ) + + _editor_hidden = insert(:project, parent: editor_root) + + ids = + user + |> Projects.get_project_tree_for_user() + |> Enum.map(& &1.id) + |> Enum.sort() + + assert ids == + Enum.sort([ + admin_root.id, + editor_root.id, + editor_visible.id + ]) + end + test "get_project_user_role/2" do user_1 = user_fixture() user_2 = user_fixture() @@ -615,6 +925,184 @@ defmodule Lightning.ProjectsTest do end end + describe "visible_sandboxes/3" do + setup do + superuser = insert(:user, role: :superuser) + user = insert(:user) + other_user = insert(:user) + + root_project = insert(:project) + root_project_owner = insert(:user) + + insert(:project_user, + user: root_project_owner, + project: root_project, + role: :owner + ) + + sandbox = insert(:project, parent: root_project) + + sandbox_with_owner = insert(:project, parent: root_project) + sandbox_owner = insert(:user) + + insert(:project_user, + user: sandbox_owner, + project: sandbox_with_owner, + role: :owner + ) + + sandbox_with_admin = insert(:project, parent: root_project) + sandbox_admin = insert(:user) + + insert(:project_user, + user: sandbox_admin, + project: sandbox_with_admin, + role: :admin + ) + + root_project = Repo.preload(root_project, :project_users) + sandbox = Repo.preload(sandbox, :project_users) + sandbox_with_owner = Repo.preload(sandbox_with_owner, :project_users) + sandbox_with_admin = Repo.preload(sandbox_with_admin, :project_users) + + %{ + superuser: superuser, + user: user, + other_user: other_user, + root_project: root_project, + root_project_owner: root_project_owner, + sandbox: sandbox, + sandbox_with_owner: sandbox_with_owner, + sandbox_owner: sandbox_owner, + sandbox_with_admin: sandbox_with_admin, + sandbox_admin: sandbox_admin + } + end + + test "superuser role alone returns no sandboxes", %{ + superuser: superuser, + sandbox: sandbox, + sandbox_with_owner: sandbox_with_owner, + sandbox_with_admin: sandbox_with_admin + } do + sandboxes = [sandbox, sandbox_with_owner, sandbox_with_admin] + + assert Projects.visible_sandboxes(sandboxes, superuser) == [] + end + + test "root project owner with no row on a sandbox does not see it", %{ + root_project_owner: owner, + sandbox: sandbox, + sandbox_with_owner: sandbox_with_owner, + sandbox_with_admin: sandbox_with_admin + } do + sandboxes = [sandbox, sandbox_with_owner, sandbox_with_admin] + + assert Projects.visible_sandboxes(sandboxes, owner) == [] + end + + test "root project admin with no row on a sandbox does not see it", %{ + user: user, + root_project: root_project, + sandbox: sandbox, + sandbox_with_owner: sandbox_with_owner, + sandbox_with_admin: sandbox_with_admin + } do + insert(:project_user, user: user, project: root_project, role: :admin) + sandboxes = [sandbox, sandbox_with_owner, sandbox_with_admin] + + assert Projects.visible_sandboxes(sandboxes, user) == [] + end + + test "root project editor only sees sandboxes they are a member of", %{ + user: user, + root_project: root_project, + sandbox: sandbox, + sandbox_with_owner: sandbox_with_owner, + sandbox_with_admin: sandbox_with_admin + } do + insert(:project_user, user: user, project: root_project, role: :editor) + insert(:project_user, user: user, project: sandbox, role: :viewer) + + sandbox = Repo.preload(sandbox, :project_users, force: true) + + visible = + Projects.visible_sandboxes( + [sandbox, sandbox_with_owner, sandbox_with_admin], + user + ) + + assert Enum.map(visible, & &1.id) == [sandbox.id] + end + + test "user with no role on the root sees only sandboxes they belong to", + %{ + sandbox_owner: sandbox_owner, + sandbox: sandbox, + sandbox_with_owner: sandbox_with_owner, + sandbox_with_admin: sandbox_with_admin + } do + visible = + Projects.visible_sandboxes( + [sandbox, sandbox_with_owner, sandbox_with_admin], + sandbox_owner + ) + + assert Enum.map(visible, & &1.id) == [sandbox_with_owner.id] + end + + test "user with no role anywhere sees no sandboxes", %{ + other_user: other_user, + sandbox: sandbox, + sandbox_with_owner: sandbox_with_owner, + sandbox_with_admin: sandbox_with_admin + } do + assert Projects.visible_sandboxes( + [sandbox, sandbox_with_owner, sandbox_with_admin], + other_user + ) == [] + end + + test "support user sees a sandbox whose own allow_support_access is true" do + support_user = insert(:user, support_user: true) + root = insert(:project, allow_support_access: true) + + sandbox_a = + insert(:project, parent: root, allow_support_access: true) + + sandbox_b = + insert(:project, parent: root, allow_support_access: true) + + sandbox_a = Repo.preload(sandbox_a, :project_users) + sandbox_b = Repo.preload(sandbox_b, :project_users) + + assert Projects.visible_sandboxes([sandbox_a, sandbox_b], support_user) == + [sandbox_a, sandbox_b] + end + + test "support user does not see a sandbox whose own allow_support_access is false, even when the root allows it" do + support_user = insert(:user, support_user: true) + root = insert(:project, allow_support_access: true) + + sandbox = + insert(:project, parent: root, allow_support_access: false) + + sandbox = Repo.preload(sandbox, :project_users) + + assert Projects.visible_sandboxes([sandbox], support_user) == [] + end + + test "raises ArgumentError when a sandbox's project_users are not preloaded" do + user = insert(:user) + root = insert(:project) + sandbox = insert(:project, parent: root) + + assert_raise ArgumentError, ~r/project_users.*preloaded.*sandbox/, fn -> + Projects.visible_sandboxes([sandbox], user) + end + end + end + describe "export_project/2 as yaml:" do test "works on project with no workflows" do project = project_fixture(name: "newly-created-project") @@ -3067,6 +3555,46 @@ defmodule Lightning.ProjectsTest do end end + describe "list_descendants/1" do + test "returns [] for a project with no children" do + project = insert(:project) + assert Projects.list_descendants(project.id) == [] + end + + test "returns every descendant of the subtree root, ordered by name" do + root = insert(:project, name: "root") + child = insert(:project, name: "alpha", parent: root) + grandchild = insert(:project, name: "bravo", parent: child) + + assert Projects.list_descendants(root.id) |> Enum.map(& &1.id) == + [child.id, grandchild.id] + end + + test "does not include the subtree root itself" do + root = insert(:project) + _child = insert(:project, parent: root) + + refute root.id in Enum.map(Projects.list_descendants(root.id), & &1.id) + end + + test "returns scheduled-for-deletion descendants alongside active ones" do + root = insert(:project) + active = insert(:project, name: "active", parent: root) + + scheduled = + insert(:project, + name: "scheduled", + parent: root, + scheduled_deletion: DateTime.utc_now() |> DateTime.truncate(:second) + ) + + ids = + Projects.list_descendants(root.id) |> Enum.map(& &1.id) |> Enum.sort() + + assert ids == Enum.sort([active.id, scheduled.id]) + end + end + describe "depth_of/1" do test "returns 0 for a root project" do root = insert(:project) @@ -3397,6 +3925,139 @@ defmodule Lightning.ProjectsTest do end end + describe "access_root_for_user/2" do + test "returns the project itself when the user has direct membership on it" do + user = insert(:user) + project = insert(:project, project_users: [%{user: user, role: :owner}]) + + assert Projects.access_root_for_user(project, user).id == project.id + end + + test "returns the topmost accessible ancestor when the user is on the root and on a descendant" do + user = insert(:user) + root = insert(:project, project_users: [%{user: user, role: :owner}]) + middle = insert(:project, parent: root) + + leaf = + insert(:project, + parent: middle, + project_users: [%{user: user, role: :viewer}] + ) + + assert Projects.access_root_for_user(leaf, user).id == root.id + end + + test "returns the deepest ancestor the user has access to when intermediates are hidden" do + user = insert(:user) + root = insert(:project) + + middle = + insert(:project, + parent: root, + project_users: [%{user: user, role: :admin}] + ) + + leaf = + insert(:project, + parent: middle, + project_users: [%{user: user, role: :viewer}] + ) + + assert Projects.access_root_for_user(leaf, user).id == middle.id + end + + test "falls back to the project itself when no ancestor is accessible" do + user = insert(:user) + root = insert(:project) + middle = insert(:project, parent: root) + + leaf = + insert(:project, + parent: middle, + project_users: [%{user: user, role: :admin}] + ) + + assert Projects.access_root_for_user(leaf, user).id == leaf.id + end + + test "honors support users via per-project allow_support_access" do + support_user = insert(:user, support_user: true) + root = insert(:project, allow_support_access: true) + + leaf = + insert(:project, + parent: root, + allow_support_access: true + ) + + assert Projects.access_root_for_user(leaf, support_user).id == root.id + end + + test "does not surface an ancestor whose allow_support_access is false to a support user" do + support_user = insert(:user, support_user: true) + root = insert(:project, allow_support_access: false) + + leaf = + insert(:project, + parent: root, + allow_support_access: true + ) + + assert Projects.access_root_for_user(leaf, support_user).id == leaf.id + end + end + + describe "display_name_within_access_root/2" do + test "returns the project name alone when access_root is the project itself" do + project = insert(:project, name: "acme-workspace") + + assert Projects.display_name_within_access_root(project, project) == + "acme-workspace" + end + + test "joins names from the access root down to the project, stopping at the root" do + root = insert(:project, name: "acme-workspace") + middle = insert(:project, name: "acme-staging", parent: root) + leaf = insert(:project, name: "acme-staging-dev", parent: middle) + + assert Projects.display_name_within_access_root(leaf, root) == + "acme-workspace/acme-staging/acme-staging-dev" + end + + test "truncates at a deeper access root, hiding ancestors above it" do + root = insert(:project, name: "hidden-root") + access = insert(:project, name: "user-access-root", parent: root) + leaf = insert(:project, name: "leaf", parent: access) + + assert Projects.display_name_within_access_root(leaf, access) == + "user-access-root/leaf" + end + + test "falls back to the full ancestor chain when access_root is not in the project's chain" do + root = insert(:project, name: "acme-workspace") + project = insert(:project, name: "acme-staging", parent: root) + unrelated = insert(:project, name: "beta-workspace") + + assert Projects.display_name_within_access_root(project, unrelated) == + "acme-workspace/acme-staging" + end + end + + describe "subscribe/0" do + test "delivers project lifecycle events to the calling process" do + assert :ok = Projects.subscribe() + + project = insert(:project) + Lightning.Projects.Events.project_created(project) + + assert_receive %Lightning.Projects.Events.ProjectCreated{project: ^project} + + Lightning.Projects.Events.project_deleted(project) + + assert_receive %Lightning.Projects.Events.ProjectDeleted{project: ^project} + end + end + defp build_parent_chain(project) do case project.parent do nil -> diff --git a/test/lightning/sandboxes_test.exs b/test/lightning/sandboxes_test.exs index 8b3c0e2a0de..b669a22f217 100644 --- a/test/lightning/sandboxes_test.exs +++ b/test/lightning/sandboxes_test.exs @@ -1457,36 +1457,15 @@ defmodule Lightning.Projects.SandboxesTest do ) == 1 end - test "superuser actor who is not on the parent becomes sandbox owner" do + test "superuser actor who is not on the parent cannot provision a sandbox" do superuser = insert(:user, role: :superuser) parent_owner = insert(:user) - editor = insert(:user) parent = insert(:project) ensure_member!(parent, parent_owner, :owner) - ensure_member!(parent, editor, :editor) - - {:ok, sandbox} = - Sandboxes.provision(parent, superuser, %{name: "sb-superuser"}) - - sandbox = Repo.preload(sandbox, :project_users) - - assert Enum.any?( - sandbox.project_users, - &(&1.user_id == superuser.id and &1.role == :owner) - ) - - assert Enum.any?( - sandbox.project_users, - &(&1.user_id == parent_owner.id and &1.role == :admin) - ) - assert Enum.any?( - sandbox.project_users, - &(&1.user_id == editor.id and &1.role == :editor) - ) - - assert Enum.count(sandbox.project_users, &(&1.role == :owner)) == 1 + assert {:error, :unauthorized} = + Sandboxes.provision(parent, superuser, %{name: "sb-superuser"}) end test "still provisions cleanly when the parent has only the actor on it" do diff --git a/test/lightning_web/components/layout_components_test.exs b/test/lightning_web/components/layout_components_test.exs index e1eb59778ea..b48db2d0c1d 100644 --- a/test/lightning_web/components/layout_components_test.exs +++ b/test/lightning_web/components/layout_components_test.exs @@ -3,6 +3,7 @@ defmodule LightningWeb.LayoutComponentsTest do use LightningWeb.ConnCase import Phoenix.LiveViewTest + import Lightning.Factories alias LightningWeb.LayoutComponents alias LightningWeb.Components.Menu @@ -103,10 +104,41 @@ defmodule LightningWeb.LayoutComponentsTest do assert html =~ "breadcrumb-project-picker-trigger" assert html =~ ~s(data-react-name="PickerButton") - assert html =~ ~s(data-label="parent-project/my-sandbox") + assert html =~ ~s(data-label="my-sandbox") assert html =~ ~s(data-is-sandbox="true") assert html =~ ~s(data-color="#E33D63") end + + test "uses the access_root attr to truncate the displayed ancestor chain" do + root = insert(:project, name: "acme-workspace") + sandbox = insert(:project, name: "acme-staging", parent: root) + + html = + (&LayoutComponents.breadcrumb_project_picker/1) + |> render_component(%{project: sandbox, access_root: sandbox}) + + assert html =~ ~s(data-label="acme-staging") + refute html =~ "acme-workspace/acme-staging" + end + + test "derives access_root from current_user when access_root is not passed" do + user = insert(:user) + root = insert(:project, name: "acme-workspace") + + sandbox = + insert(:project, + name: "acme-staging", + parent: root, + project_users: [%{user: user, role: :admin}] + ) + + html = + (&LayoutComponents.breadcrumb_project_picker/1) + |> render_component(%{project: sandbox, current_user: user}) + + assert html =~ ~s(data-label="acme-staging") + refute html =~ "acme-workspace/acme-staging" + end end describe "global_project_picker/1" do @@ -117,6 +149,110 @@ defmodule LightningWeb.LayoutComponentsTest do refute html =~ "global-project-picker" end + + test "items nest a visible sandbox under its nearest visible ancestor when intermediates are hidden" do + user = insert(:user) + + root = + insert(:project, + name: "root", + project_users: [%{user: user, role: :editor}] + ) + + hidden_middle = + insert(:project, name: "hidden-middle", parent: root) + + nested_member = + insert(:project, + name: "nested-member", + parent: hidden_middle, + project_users: [%{user: user, role: :viewer}] + ) + + html = + (&LayoutComponents.global_project_picker/1) + |> render_component(%{current_user: user, current_path: "/projects"}) + + items = + html + |> Floki.parse_fragment!() + |> Floki.find("#global-project-picker") + |> Floki.attribute("data-items") + |> List.first() + |> Jason.decode!() + + ids = Enum.map(items, & &1["id"]) + depth_by_id = Map.new(items, &{&1["id"], &1["depth"]}) + + assert root.id in ids + assert nested_member.id in ids + refute hidden_middle.id in ids + + assert depth_by_id[root.id] == 0 + assert depth_by_id[nested_member.id] == 1 + end + + test "items surface a sandbox the user is a direct member of when the user has no role on its root" do + user = insert(:user) + absolute_root = insert(:project) + + sandbox = + insert(:project, + parent: absolute_root, + project_users: [%{user: user, role: :owner}] + ) + + html = + (&LayoutComponents.global_project_picker/1) + |> render_component(%{current_user: user, current_path: "/projects"}) + + items = + html + |> Floki.parse_fragment!() + |> Floki.find("#global-project-picker") + |> Floki.attribute("data-items") + |> List.first() + |> Jason.decode!() + + ids = Enum.map(items, & &1["id"]) + depth_by_id = Map.new(items, &{&1["id"], &1["depth"]}) + + assert ids == [sandbox.id] + assert depth_by_id[sandbox.id] == 0 + end + + test "items omit sandboxes the user has no access to" do + user = insert(:user) + + parent = + insert(:project, project_users: [%{user: user, role: :editor}]) + + visible_sandbox = + insert(:project, + parent: parent, + project_users: [%{user: user, role: :viewer}] + ) + + hidden_sandbox = insert(:project, parent: parent) + + html = + (&LayoutComponents.global_project_picker/1) + |> render_component(%{current_user: user, current_path: "/projects"}) + + items = + html + |> Floki.parse_fragment!() + |> Floki.find("#global-project-picker") + |> Floki.attribute("data-items") + |> List.first() + |> Jason.decode!() + + ids = Enum.map(items, & &1["id"]) + + assert parent.id in ids + assert visible_sandbox.id in ids + refute hidden_sandbox.id in ids + end end test "menu_item/1 renders custom menu items" do diff --git a/test/lightning_web/controllers/webhooks_controller_test.exs b/test/lightning_web/controllers/webhooks_controller_test.exs index 4d3aa91adae..8fc74482730 100644 --- a/test/lightning_web/controllers/webhooks_controller_test.exs +++ b/test/lightning_web/controllers/webhooks_controller_test.exs @@ -1,7 +1,6 @@ defmodule LightningWeb.WebhooksControllerTest do use LightningWeb.ConnCase, async: false - import Ecto.Query import Lightning.Factories import Mox diff --git a/test/lightning_web/live/sandbox_live/form_component_test.exs b/test/lightning_web/live/sandbox_live/form_component_test.exs index 503b32a2488..58f02304107 100644 --- a/test/lightning_web/live/sandbox_live/form_component_test.exs +++ b/test/lightning_web/live/sandbox_live/form_component_test.exs @@ -274,7 +274,13 @@ defmodule LightningWeb.SandboxLive.FormComponentTest do describe "edit modal" do setup %{conn: conn, user: user} do parent = insert(:project, project_users: [%{user: user, role: :owner}]) - sb = insert(:sandbox, parent: parent, name: "sb-1") + + sb = + insert(:sandbox, + parent: parent, + name: "sb-1", + project_users: [%{user: user, role: :owner}] + ) Mimic.stub( Lightning.Projects, @@ -355,9 +361,16 @@ defmodule LightningWeb.SandboxLive.FormComponentTest do test "color input displays existing sandbox color", %{ conn: conn, - parent: parent + parent: parent, + user: user } do - sb = insert(:sandbox, parent: parent, name: "sb-colored", color: "#ff0000") + sb = + insert(:sandbox, + parent: parent, + name: "sb-colored", + color: "#ff0000", + project_users: [%{user: user, role: :owner}] + ) {:ok, view, _} = live(conn, ~p"/projects/#{parent.id}/sandboxes/#{sb.id}/edit") diff --git a/test/lightning_web/live/sandbox_live/index_test.exs b/test/lightning_web/live/sandbox_live/index_test.exs index 4c4d9a6d06a..d0dd0e0f762 100644 --- a/test/lightning_web/live/sandbox_live/index_test.exs +++ b/test/lightning_web/live/sandbox_live/index_test.exs @@ -347,6 +347,69 @@ defmodule LightningWeb.SandboxLive.IndexTest do refute has_element?(view, "#confirm-delete-sandbox") end + test "delete modal shows singular descendant copy when the sandbox has one child", + %{conn: conn, parent: parent, sb1: sb1, user: user} do + _only_child = + insert(:project, + name: "only-child", + parent: sb1, + project_users: [%{user: user, role: :owner}] + ) + + {:ok, view, _} = live(conn, ~p"/projects/#{parent.id}/sandboxes") + + view |> element("#delete-sandbox-#{sb1.id} button") |> render_click() + + html = render(view) + assert html =~ "Its child sandbox will also be deleted." + refute html =~ "child sandboxes will also be deleted" + end + + test "delete modal shows plural descendant copy with count when the sandbox has multiple children", + %{conn: conn, parent: parent, sb1: sb1, user: user} do + for n <- 1..3 do + insert(:project, + name: "child-#{n}", + parent: sb1, + project_users: [%{user: user, role: :owner}] + ) + end + + {:ok, view, _} = live(conn, ~p"/projects/#{parent.id}/sandboxes") + + view |> element("#delete-sandbox-#{sb1.id} button") |> render_click() + + html = render(view) + assert html =~ "Its 3 child sandboxes will also be deleted." + refute html =~ "Its child sandbox will also be deleted." + end + + test "delete modal descendant count excludes children already scheduled for deletion", + %{conn: conn, parent: parent, sb1: sb1, user: user} do + _active_child = + insert(:project, + name: "active-child", + parent: sb1, + project_users: [%{user: user, role: :owner}] + ) + + _scheduled_child = + insert(:project, + name: "scheduled-child", + parent: sb1, + scheduled_deletion: DateTime.utc_now() |> DateTime.truncate(:second), + project_users: [%{user: user, role: :owner}] + ) + + {:ok, view, _} = live(conn, ~p"/projects/#{parent.id}/sandboxes") + + view |> element("#delete-sandbox-#{sb1.id} button") |> render_click() + + html = render(view) + assert html =~ "Its child sandbox will also be deleted." + refute html =~ "child sandboxes will also be deleted" + end + test "confirm-delete result paths: ok, unauthorized, not_found, generic error", %{conn: conn, parent: parent, sb1: sb1, sb2: sb2, user: user} do {:ok, view, _} = @@ -362,13 +425,11 @@ defmodule LightningWeb.SandboxLive.IndexTest do end ) - Mimic.expect(Lightning.Projects, :list_workspace_projects, fn id -> - assert id == parent.id + parent_id = parent.id - %{ - root: parent, - descendants: [sb2] - } + Mimic.stub(Lightning.Projects, :list_descendants, fn + ^parent_id -> [sb2] + _ -> [] end) Mimic.allow(Lightning.Projects, self(), view.pid) @@ -773,6 +834,135 @@ defmodule LightningWeb.SandboxLive.IndexTest do end end + describe "Sandbox visibility" do + setup :register_and_log_in_user + + test "root editor only sees sandboxes they are a project user on", %{ + conn: conn, + user: user + } do + parent = + insert(:project, + name: "parent", + project_users: [%{user: user, role: :editor}] + ) + + visible_sandbox = + insert(:project, + name: "visible-sandbox", + parent: parent, + project_users: [%{user: user, role: :viewer}] + ) + + hidden_sandbox = + insert(:project, + name: "hidden-sandbox", + parent: parent, + project_users: [] + ) + + {:ok, view, _html} = live(conn, ~p"/projects/#{parent.id}/sandboxes") + + assert has_element?(view, "#edit-sandbox-#{visible_sandbox.id}") + refute has_element?(view, "#edit-sandbox-#{hidden_sandbox.id}") + end + + test "root owner only sees sandboxes they have a direct row on", %{ + conn: conn, + user: user + } do + parent = + insert(:project, + name: "parent", + project_users: [%{user: user, role: :owner}] + ) + + sandbox_with_pu = + insert(:project, + name: "with-pu", + parent: parent, + project_users: [%{user: user, role: :owner}] + ) + + sandbox_without_pu = + insert(:project, + name: "without-pu", + parent: parent, + project_users: [] + ) + + {:ok, view, _html} = live(conn, ~p"/projects/#{parent.id}/sandboxes") + + assert has_element?(view, "#edit-sandbox-#{sandbox_with_pu.id}") + refute has_element?(view, "#edit-sandbox-#{sandbox_without_pu.id}") + end + + test "handlers reject a hidden sandbox id dispatched via a crafted event", + %{conn: conn, user: user} do + parent = + insert(:project, + name: "parent", + project_users: [%{user: user, role: :editor}] + ) + + hidden_sandbox = + insert(:project, + name: "hidden-sandbox", + parent: parent, + scheduled_deletion: DateTime.utc_now() |> DateTime.truncate(:second), + project_users: [] + ) + + {:ok, view, _html} = live(conn, ~p"/projects/#{parent.id}/sandboxes") + + for event <- ~w(open-delete-modal cancel-sandbox-deletion open-merge-modal) do + html = render_hook(view, event, %{"id" => hidden_sandbox.id}) + assert html =~ "Sandbox not found" + end + + assigns = :sys.get_state(view.pid).socket.assigns + refute assigns.confirm_delete_open? + refute assigns.merge_modal_open? + + {:ok, _edit_view, edit_html} = + live( + conn, + ~p"/projects/#{parent.id}/sandboxes/#{hidden_sandbox.id}/edit" + ) + + assert edit_html =~ "Sandbox not found" + end + + test "sandbox-only member sees their access root, not the absolute workspace root", + %{conn: conn, user: user} do + hidden_root = + insert(:project, + name: "hidden-workspace", + project_users: [] + ) + + access_root = + insert(:project, + name: "user-access-root", + parent: hidden_root, + project_users: [%{user: user, role: :admin}] + ) + + visible_leaf = + insert(:project, + name: "visible-leaf", + parent: access_root, + project_users: [%{user: user, role: :admin}] + ) + + {:ok, _view, html} = live(conn, ~p"/projects/#{access_root.id}/sandboxes") + + refute html =~ hidden_root.name + assert html =~ access_root.name + assert html =~ visible_leaf.name + end + end + describe "Delete sandbox with descendant checking" do setup :register_and_log_in_user @@ -866,9 +1056,11 @@ defmodule LightningWeb.SandboxLive.IndexTest do end ) - Mimic.expect(Lightning.Projects, :list_workspace_projects, fn id -> - assert id == parent.id - %{root: parent, descendants: []} + parent_id = parent.id + + Mimic.stub(Lightning.Projects, :list_descendants, fn + ^parent_id -> [] + _ -> [] end) Mimic.allow(Lightning.Projects, self(), view.pid) @@ -1027,6 +1219,11 @@ defmodule LightningWeb.SandboxLive.IndexTest do %{user_id: other_user.id, role: :viewer} ]) + _ = + Lightning.Projects.add_project_users(scheduled, [ + %{user_id: other_user.id, role: :viewer} + ]) + {:ok, view, _} = live(conn, ~p"/projects/#{parent.id}/sandboxes") assert has_element?( @@ -1424,6 +1621,26 @@ defmodule LightningWeb.SandboxLive.IndexTest do assert html =~ child1.name end + test "merge modal descendant count excludes children already scheduled for deletion", + %{conn: conn, root: root, child1: child1, grandchild1: grandchild1} do + Repo.update_all( + from(p in Project, where: p.id == ^grandchild1.id), + set: [ + scheduled_deletion: DateTime.utc_now() |> DateTime.truncate(:second) + ] + ) + + {:ok, view, _} = live(conn, ~p"/projects/#{root.id}/sandboxes") + + view + |> element("#branch-rewire-sandbox-#{child1.id} button") + |> render_click() + + html = render(view) + assert html =~ "Its child sandbox will also be deleted." + refute html =~ "Its 2 child sandboxes will also be deleted." + end + test "merge modal shows correct dropdown options", %{ conn: conn, root: root, @@ -1914,6 +2131,36 @@ defmodule LightningWeb.SandboxLive.IndexTest do descendant_ids = Enum.map(assigns.merge_descendants, & &1.id) refute root.id in descendant_ids end + + test "merge modal lists descendants the current viewer cannot otherwise see", + %{conn: conn, user: user, root: root, child1: child1} do + hidden_grandchild = + insert(:project, + name: "hidden-grandchild", + parent: child1, + project_users: [] + ) + + visible_grandchild = + insert(:project, + name: "visible-grandchild", + parent: child1, + project_users: [%{user: user, role: :viewer}] + ) + + {:ok, view, _} = live(conn, ~p"/projects/#{root.id}/sandboxes") + + view + |> element("#branch-rewire-sandbox-#{child1.id} button") + |> render_click() + + descendant_ids = + :sys.get_state(view.pid).socket.assigns.merge_descendants + |> Enum.map(& &1.id) + + assert visible_grandchild.id in descendant_ids + assert hidden_grandchild.id in descendant_ids + end end describe "collection sync on merge" do @@ -2306,7 +2553,12 @@ defmodule LightningWeb.SandboxLive.IndexTest do with_version(parent_workflow) # Create sandbox from parent (at this point, parent only has job1) - sandbox = insert(:project, name: "Sandbox", parent: parent) + sandbox = + insert(:project, + name: "Sandbox", + parent: parent, + project_users: [%{user: owner_user, role: :owner}] + ) sandbox_workflow = insert(:workflow, @@ -2405,11 +2657,12 @@ defmodule LightningWeb.SandboxLive.IndexTest do assert remaining_job.body == "job1_modified()" end - test "editor on root can see and use merge button", %{ - conn: conn, - parent: parent, - sandbox: sandbox - } do + test "editor on root and editor on sandbox cannot use the merge button (merge requires admin/owner on the source)", + %{ + conn: conn, + parent: parent, + sandbox: sandbox + } do editor_user = insert(:user) insert(:project_user, user: editor_user, project: parent, role: :editor) @@ -2425,7 +2678,7 @@ defmodule LightningWeb.SandboxLive.IndexTest do sandboxes = :sys.get_state(view.pid).socket.assigns.sandboxes test_sandbox = Enum.find(sandboxes, &(&1.id == sandbox.id)) - assert test_sandbox.can_merge == true + assert test_sandbox.can_merge == false assert test_sandbox.can_edit == false assert test_sandbox.can_delete == false end @@ -2449,15 +2702,16 @@ defmodule LightningWeb.SandboxLive.IndexTest do parent: parent, sandbox: sandbox } do - # A user who is editor on root but viewer on a specific target - # should be blocked at server-side enforcement - editor_user = insert(:user) - insert(:project_user, user: editor_user, project: parent, role: :editor) + # An admin on the source sandbox who is only a viewer on a specific + # target should be blocked at server-side enforcement when they try + # to merge into that target. + actor = insert(:user) + insert(:project_user, user: actor, project: parent, role: :editor) insert(:project_user, - user: editor_user, + user: actor, project: sandbox, - role: :editor + role: :admin ) # Create a target project where this user is only a viewer @@ -2466,11 +2720,11 @@ defmodule LightningWeb.SandboxLive.IndexTest do name: "restricted-target", parent: parent, project_users: [ - %{user: editor_user, role: :viewer} + %{user: actor, role: :viewer} ] ) - conn = log_in_user(conn, editor_user) + conn = log_in_user(conn, actor) {:ok, view, _html} = live(conn, ~p"/projects/#{parent.id}/sandboxes") # Open merge modal @@ -2493,13 +2747,13 @@ defmodule LightningWeb.SandboxLive.IndexTest do parent: parent, sandbox: sandbox } do - editor_user = insert(:user) - insert(:project_user, user: editor_user, project: parent, role: :editor) + actor = insert(:user) + insert(:project_user, user: actor, project: parent, role: :editor) insert(:project_user, - user: editor_user, + user: actor, project: sandbox, - role: :editor + role: :admin ) # Create another sandbox where user is only a viewer @@ -2508,11 +2762,11 @@ defmodule LightningWeb.SandboxLive.IndexTest do name: "viewer-only-sandbox", parent: parent, project_users: [ - %{user: editor_user, role: :viewer} + %{user: actor, role: :viewer} ] ) - # Create a sandbox where the editor has no membership at all + # Create a sandbox where the actor has no membership at all no_membership_sandbox = insert(:project, name: "no-membership-sandbox", @@ -2520,7 +2774,7 @@ defmodule LightningWeb.SandboxLive.IndexTest do project_users: [] ) - conn = log_in_user(conn, editor_user) + conn = log_in_user(conn, actor) {:ok, view, _html} = live(conn, ~p"/projects/#{parent.id}/sandboxes") # Open merge modal @@ -2541,6 +2795,42 @@ defmodule LightningWeb.SandboxLive.IndexTest do refute no_membership_sandbox.id in target_ids end + test "merge target options exclude sandboxes scheduled for deletion", %{ + conn: conn, + user: user, + parent: parent, + sandbox: sandbox + } do + sibling = + insert(:project, + name: "sibling-active", + parent: parent, + project_users: [%{user: user, role: :owner}] + ) + + scheduled_sibling = + insert(:project, + name: "sibling-scheduled", + parent: parent, + project_users: [%{user: user, role: :owner}], + scheduled_deletion: DateTime.utc_now() |> DateTime.truncate(:second) + ) + + {:ok, view, _html} = live(conn, ~p"/projects/#{parent.id}/sandboxes") + + view + |> element("#branch-rewire-sandbox-#{sandbox.id} button") + |> render_click() + + target_ids = + :sys.get_state(view.pid).socket.assigns.merge_target_options + |> Enum.map(& &1.value) + + assert parent.id in target_ids + assert sibling.id in target_ids + refute scheduled_sibling.id in target_ids + end + test "checks for divergence when opening merge modal with default target", %{ conn: conn, diff --git a/test/lightning_web/live/workflow_live/collaborate_new_test.exs b/test/lightning_web/live/workflow_live/collaborate_new_test.exs index 5cda912158c..3a9a81ffdf9 100644 --- a/test/lightning_web/live/workflow_live/collaborate_new_test.exs +++ b/test/lightning_web/live/workflow_live/collaborate_new_test.exs @@ -5,12 +5,15 @@ defmodule LightningWeb.WorkflowLive.CollaborateNewTest do import Phoenix.LiveViewTest describe "sandbox indicator banner data attributes" do - test "sets root project data attributes when creating workflow in sandbox", - %{ - conn: conn - } do + test "sets root project data attributes when creating workflow in a sandbox the user has full ancestor access to", + %{conn: conn} do user = insert(:user) - parent_project = insert(:project, name: "Production Project") + + parent_project = + insert(:project, + name: "Production Project", + project_users: [%{user_id: user.id, role: :owner}] + ) sandbox = insert(:sandbox, @@ -31,6 +34,31 @@ defmodule LightningWeb.WorkflowLive.CollaborateNewTest do assert html =~ "data-root-project-name=\"#{parent_project.name}\"" end + test "falls back to the sandbox itself when the user has no access to ancestors", + %{conn: conn} do + user = insert(:user) + parent_project = insert(:project, name: "Production Project") + + sandbox = + insert(:sandbox, + parent: parent_project, + name: "test-sandbox", + project_users: [%{user_id: user.id, role: :owner}] + ) + + conn = log_in_user(conn, user) + + {:ok, _view, html} = + live( + conn, + ~p"/projects/#{sandbox.id}/w/new?method=template" + ) + + refute html =~ parent_project.name + assert html =~ "data-root-project-id=\"#{sandbox.id}\"" + assert html =~ "data-root-project-name=\"#{sandbox.name}\"" + end + test "sets null root project data attributes when creating workflow in root project", %{conn: conn} do user = insert(:user) @@ -53,15 +81,21 @@ defmodule LightningWeb.WorkflowLive.CollaborateNewTest do refute html =~ "data-root-project-name=" end - test "sets correct root project when creating workflow in deeply nested sandbox", + test "sets correct root project when creating workflow in a deeply nested sandbox the user has full ancestor access to", %{conn: conn} do user = insert(:user) - root_project = insert(:project, name: "Root Project") + + root_project = + insert(:project, + name: "Root Project", + project_users: [%{user_id: user.id, role: :owner}] + ) sandbox_a = insert(:sandbox, parent: root_project, - name: "sandbox-a" + name: "sandbox-a", + project_users: [%{user_id: user.id, role: :owner}] ) sandbox_b = @@ -85,5 +119,40 @@ defmodule LightningWeb.WorkflowLive.CollaborateNewTest do assert html =~ "data-project-display-name=\"#{root_project.name}/#{sandbox_a.name}/#{sandbox_b.name}\"" end + + test "deeply nested sandbox truncates display name at the user's access root when creating workflow", + %{conn: conn} do + user = insert(:user) + root_project = insert(:project, name: "Root Project") + + sandbox_a = + insert(:sandbox, + parent: root_project, + name: "sandbox-a", + project_users: [%{user_id: user.id, role: :owner}] + ) + + sandbox_b = + insert(:sandbox, + parent: sandbox_a, + name: "sandbox-b", + project_users: [%{user_id: user.id, role: :owner}] + ) + + conn = log_in_user(conn, user) + + {:ok, _view, html} = + live( + conn, + ~p"/projects/#{sandbox_b.id}/w/new" + ) + + refute html =~ root_project.name + assert html =~ "data-root-project-id=\"#{sandbox_a.id}\"" + assert html =~ "data-root-project-name=\"#{sandbox_a.name}\"" + + assert html =~ + "data-project-display-name=\"#{sandbox_a.name}/#{sandbox_b.name}\"" + end end end diff --git a/test/lightning_web/live/workflow_live/collaborate_test.exs b/test/lightning_web/live/workflow_live/collaborate_test.exs index 4acb9fda264..a98816facbe 100644 --- a/test/lightning_web/live/workflow_live/collaborate_test.exs +++ b/test/lightning_web/live/workflow_live/collaborate_test.exs @@ -6,11 +6,15 @@ defmodule LightningWeb.WorkflowLive.CollaborateTest do import Phoenix.LiveViewTest describe "sandbox indicator banner data attributes" do - test "sets root project data attributes when in sandbox project", %{ - conn: conn - } do + test "sets root project data attributes to the user's access root in a sandbox project", + %{conn: conn} do user = insert(:user) - parent_project = insert(:project, name: "Production Project") + + parent_project = + insert(:project, + name: "Production Project", + project_users: [%{user_id: user.id, role: :owner}] + ) sandbox = insert(:sandbox, @@ -33,6 +37,33 @@ defmodule LightningWeb.WorkflowLive.CollaborateTest do assert html =~ "data-root-project-name=\"#{parent_project.name}\"" end + test "falls back to the sandbox itself when the user has no access to ancestors", + %{conn: conn} do + user = insert(:user) + parent_project = insert(:project, name: "Production Project") + + sandbox = + insert(:sandbox, + parent: parent_project, + name: "test-sandbox", + project_users: [%{user_id: user.id, role: :owner}] + ) + + workflow = workflow_fixture(project_id: sandbox.id) + + conn = log_in_user(conn, user) + + {:ok, _view, html} = + live( + conn, + ~p"/projects/#{sandbox.id}/w/#{workflow.id}" + ) + + refute html =~ parent_project.name + assert html =~ "data-root-project-id=\"#{sandbox.id}\"" + assert html =~ "data-root-project-name=\"#{sandbox.name}\"" + end + test "sets null root project data attributes when in root project", %{ conn: conn } do @@ -60,12 +91,18 @@ defmodule LightningWeb.WorkflowLive.CollaborateTest do test "sets correct root project in deeply nested sandbox", %{conn: conn} do user = insert(:user) - root_project = insert(:project, name: "Root Project") + + root_project = + insert(:project, + name: "Root Project", + project_users: [%{user_id: user.id, role: :owner}] + ) sandbox_a = insert(:sandbox, parent: root_project, - name: "sandbox-a" + name: "sandbox-a", + project_users: [%{user_id: user.id, role: :owner}] ) sandbox_b = @@ -91,6 +128,43 @@ defmodule LightningWeb.WorkflowLive.CollaborateTest do assert html =~ "data-project-display-name=\"#{root_project.name}/#{sandbox_a.name}/#{sandbox_b.name}\"" end + + test "deeply nested sandbox truncates display name at the user's access root", + %{conn: conn} do + user = insert(:user) + root_project = insert(:project, name: "Root Project") + + sandbox_a = + insert(:sandbox, + parent: root_project, + name: "sandbox-a", + project_users: [%{user_id: user.id, role: :owner}] + ) + + sandbox_b = + insert(:sandbox, + parent: sandbox_a, + name: "sandbox-b", + project_users: [%{user_id: user.id, role: :owner}] + ) + + workflow = workflow_fixture(project_id: sandbox_b.id) + + conn = log_in_user(conn, user) + + {:ok, _view, html} = + live( + conn, + ~p"/projects/#{sandbox_b.id}/w/#{workflow.id}" + ) + + refute html =~ root_project.name + assert html =~ "data-root-project-id=\"#{sandbox_a.id}\"" + assert html =~ "data-root-project-name=\"#{sandbox_a.name}\"" + + assert html =~ + "data-project-display-name=\"#{sandbox_a.name}/#{sandbox_b.name}\"" + end end describe "credential creation and broadcasting" do