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