<%= if assigns[:mfa_banner] do %>
{Phoenix.LiveView.TagEngine.component(
@@ -629,10 +717,10 @@
+ <.sandbox_settings_banner
+ :if={@sandbox?}
+ id="sandbox-banner-vcs"
+ variant={:local}
+ />
<%= if assigns[:github_banner] do %>
{Phoenix.LiveView.TagEngine.component(
@@ -764,6 +857,11 @@
permissions_message="data storage settings."
can_perform_action={@can_edit_data_retention}
/>
+ <.sandbox_settings_banner
+ :if={@sandbox?}
+ id="sandbox-banner-data-storage"
+ variant={:local}
+ />
<.form
:let={f}
@@ -952,6 +1050,11 @@
permissions_message="history exports."
can_perform_action={true}
/>
+ <.sandbox_settings_banner
+ :if={@sandbox?}
+ id="sandbox-banner-history-exports"
+ variant={:local}
+ />
- <.errors field={@confirm_form[:name]} />
<.modal_footer>
<.button
diff --git a/test/lightning/sandboxes_test.exs b/test/lightning/sandboxes_test.exs
index 351a81abe3a..4eb7a8eedb6 100644
--- a/test/lightning/sandboxes_test.exs
+++ b/test/lightning/sandboxes_test.exs
@@ -834,6 +834,118 @@ defmodule Lightning.Projects.SandboxesTest do
end
end
+ # These tests document that the merge pipeline does not propagate
+ # project-level Local or Inherited fields from sandbox to parent. They
+ # guard against future changes to MergeProjects or Provisioner that
+ # could accidentally start syncing these fields.
+ describe "merge/4 does not propagate Local/Inherited fields" do
+ setup do
+ actor = insert(:user)
+
+ parent =
+ insert(:project,
+ name: "parent",
+ description: "parent description",
+ requires_mfa: false,
+ concurrency: 5,
+ retention_policy: :retain_all,
+ history_retention_period: 30,
+ dataclip_retention_period: 30
+ )
+
+ ensure_member!(parent, actor, :owner)
+ insert(:simple_workflow, project: parent)
+
+ sandbox =
+ insert(:project,
+ name: "sandbox",
+ description: "sandbox description",
+ parent: parent,
+ requires_mfa: true,
+ concurrency: 1,
+ retention_policy: :erase_all,
+ history_retention_period: 7,
+ dataclip_retention_period: nil,
+ project_users: [%{user: actor, role: :owner}]
+ )
+
+ insert(:simple_workflow, project: sandbox)
+ {:ok, actor: actor, parent: parent, sandbox: sandbox}
+ end
+
+ test "requires_mfa stays at parent's value", %{
+ actor: actor,
+ parent: parent,
+ sandbox: sandbox
+ } do
+ {:ok, _} = Sandboxes.merge(sandbox, parent, actor)
+ assert Repo.reload(parent).requires_mfa == false
+ end
+
+ test "concurrency stays at parent's value", %{
+ actor: actor,
+ parent: parent,
+ sandbox: sandbox
+ } do
+ {:ok, _} = Sandboxes.merge(sandbox, parent, actor)
+ assert Repo.reload(parent).concurrency == 5
+ end
+
+ test "retention settings stay at parent's values", %{
+ actor: actor,
+ parent: parent,
+ sandbox: sandbox
+ } do
+ {:ok, _} = Sandboxes.merge(sandbox, parent, actor)
+ reloaded = Repo.reload(parent)
+ assert reloaded.retention_policy == :retain_all
+ assert reloaded.history_retention_period == 30
+ assert reloaded.dataclip_retention_period == 30
+ end
+
+ test "name and description stay at parent's values", %{
+ actor: actor,
+ parent: parent,
+ sandbox: sandbox
+ } do
+ {:ok, _} = Sandboxes.merge(sandbox, parent, actor)
+ reloaded = Repo.reload(parent)
+ assert reloaded.name == "parent"
+ assert reloaded.description == "parent description"
+ end
+
+ test "collaborators are not synced from sandbox to parent", %{
+ actor: actor,
+ parent: parent,
+ sandbox: sandbox
+ } do
+ sandbox_only_user = insert(:user)
+
+ insert(:project_user,
+ project: sandbox,
+ user: sandbox_only_user,
+ role: :editor
+ )
+
+ parent_user_ids_before =
+ parent.id
+ |> Lightning.Projects.get_project_users!()
+ |> Enum.map(& &1.user_id)
+
+ {:ok, _} = Sandboxes.merge(sandbox, parent, actor)
+
+ parent_user_ids_after =
+ parent.id
+ |> Lightning.Projects.get_project_users!()
+ |> Enum.map(& &1.user_id)
+
+ refute sandbox_only_user.id in parent_user_ids_after
+
+ assert Enum.sort(parent_user_ids_before) ==
+ Enum.sort(parent_user_ids_after)
+ end
+ end
+
describe "keychains" do
test "clones only used keychains and rewires jobs to cloned keychains" do
%{
diff --git a/test/lightning_web/live/project_live/sandbox_settings_test.exs b/test/lightning_web/live/project_live/sandbox_settings_test.exs
new file mode 100644
index 00000000000..26b680f346d
--- /dev/null
+++ b/test/lightning_web/live/project_live/sandbox_settings_test.exs
@@ -0,0 +1,419 @@
+defmodule LightningWeb.ProjectLive.SandboxSettingsTest do
+ use LightningWeb.ConnCase, async: true
+ use Mimic
+
+ import Phoenix.LiveViewTest
+ import Lightning.Factories
+
+ alias Lightning.Projects
+ alias Lightning.Projects.Sandboxes
+
+ setup :stub_usage_limiter_ok
+ setup :register_and_log_in_user
+
+ setup do
+ Mimic.copy(Lightning.Projects.Sandboxes)
+ :ok
+ end
+
+ defp setup_parent_and_sandbox(%{user: user}) do
+ parent =
+ insert(:project,
+ name: "parent-project",
+ project_users: [%{user: user, role: :owner}]
+ )
+
+ sandbox =
+ insert(:project,
+ name: "sandbox-test",
+ parent: parent,
+ project_users: [%{user: user, role: :owner}]
+ )
+
+ {:ok, parent: parent, sandbox: sandbox}
+ end
+
+ describe "non-sandbox project (parent project)" do
+ setup [:setup_parent_and_sandbox]
+
+ test "does not show any sandbox banners", %{conn: conn, parent: parent} do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{parent.id}/settings")
+
+ refute html =~ "sandbox-banner-"
+ end
+
+ test "shows 'Project Identity' header on project tab", %{
+ conn: conn,
+ parent: parent
+ } do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{parent.id}/settings")
+ assert html =~ "Project Identity"
+ refute html =~ "Sandbox Identity"
+ end
+
+ test "shows the danger zone delete button", %{conn: conn, parent: parent} do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{parent.id}/settings")
+ assert html =~ "The danger zone"
+ assert html =~ "Delete project"
+ end
+
+ test "shows webhook auth methods table on webhook_security tab", %{
+ conn: conn,
+ parent: parent
+ } do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{parent.id}/settings")
+ refute html =~ "Webhook authentication is managed in the parent project"
+ end
+ end
+
+ describe "sandbox project" do
+ setup [:setup_parent_and_sandbox]
+
+ test "shows Editable banner on credentials and collections tabs", %{
+ conn: conn,
+ sandbox: sandbox
+ } do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{sandbox.id}/settings")
+
+ assert html =~ ~s(id="sandbox-banner-credentials")
+ assert html =~ ~s(id="sandbox-banner-collections")
+
+ assert html =~
+ "Changes you make here will sync to the parent project on merge."
+ end
+
+ test "shows Local banner on project, collaboration, vcs, data-storage, history-exports tabs",
+ %{conn: conn, sandbox: sandbox} do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{sandbox.id}/settings")
+
+ for tab <- ~w(project collaboration vcs data-storage history-exports) do
+ assert html =~ ~s(id="sandbox-banner-#{tab}")
+ end
+
+ assert html =~
+ "Changes you make here only apply to this sandbox and do not sync"
+ end
+
+ test "shows Inherited banner on security tab", %{
+ conn: conn,
+ sandbox: sandbox
+ } do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{sandbox.id}/settings")
+
+ assert html =~ ~s(id="sandbox-banner-security")
+ assert html =~ "These settings are inherited from the parent project"
+ end
+
+ test "shows 'Sandbox Identity' header instead of 'Project Identity'", %{
+ conn: conn,
+ sandbox: sandbox
+ } do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{sandbox.id}/settings")
+
+ assert html =~ "Sandbox Identity"
+ assert html =~ "Sandbox setup"
+ assert html =~ "Identifies this sandbox within its parent:"
+ assert html =~ "parent-project"
+ end
+
+ test "shows the danger zone delete button", %{conn: conn, sandbox: sandbox} do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{sandbox.id}/settings")
+
+ assert html =~ "The danger zone"
+ assert html =~ "Delete sandbox"
+ refute html =~ "Delete project"
+ end
+
+ test "delete sandbox flow calls delete_sandbox and redirects to root project",
+ %{conn: conn, sandbox: sandbox, parent: parent} do
+ {:ok, view, _html} =
+ live(conn, ~p"/projects/#{sandbox.id}/settings/delete")
+
+ view
+ |> form("#confirm-delete-sandbox form",
+ confirm: %{name: sandbox.name}
+ )
+ |> render_submit()
+
+ flash = assert_redirected(view, ~p"/projects/#{parent.id}/w")
+ assert flash["info"] =~ "and all its associated descendants deleted"
+ assert is_nil(Lightning.Projects.get_project(sandbox.id))
+ end
+
+ test "confirm-delete-validate marks the form invalid for a wrong name",
+ %{conn: conn, sandbox: sandbox} do
+ {:ok, view, _html} =
+ live(conn, ~p"/projects/#{sandbox.id}/settings/delete")
+
+ html =
+ view
+ |> form("#confirm-delete-sandbox form", confirm: %{name: "wrong"})
+ |> render_change()
+
+ assert html =~ "does not match the sandbox name"
+ # Sandbox was not deleted
+ assert Lightning.Projects.get_project(sandbox.id)
+ end
+
+ test "submitting the confirm form with a wrong name does not delete and re-renders errors",
+ %{conn: conn, sandbox: sandbox} do
+ {:ok, view, _html} =
+ live(conn, ~p"/projects/#{sandbox.id}/settings/delete")
+
+ html =
+ view
+ |> form("#confirm-delete-sandbox form", confirm: %{name: "wrong"})
+ |> render_submit()
+
+ assert html =~ "does not match the sandbox name"
+ assert Lightning.Projects.get_project(sandbox.id)
+ end
+
+ test "close button navigates back to the settings index",
+ %{conn: conn, sandbox: sandbox} do
+ {:ok, view, _html} =
+ live(conn, ~p"/projects/#{sandbox.id}/settings/delete")
+
+ view
+ |> element("#confirm-delete-sandbox button[aria-label='Close']")
+ |> render_click()
+
+ assert_redirected(view, ~p"/projects/#{sandbox.id}/settings")
+ end
+
+ test "unauthorized sandbox delete surfaces an error and returns to settings",
+ %{conn: conn, sandbox: sandbox} do
+ {:ok, view, _html} =
+ live(conn, ~p"/projects/#{sandbox.id}/settings/delete")
+
+ # Simulate an authorization mismatch by stubbing the sandbox delete call
+ # to return :unauthorized while the settings page's own can_delete_project
+ # gate allowed us through.
+ Mimic.expect(Lightning.Projects.Sandboxes, :delete_sandbox, fn _, _ ->
+ {:error, :unauthorized}
+ end)
+
+ view
+ |> form("#confirm-delete-sandbox form",
+ confirm: %{name: sandbox.name}
+ )
+ |> render_submit()
+
+ flash = assert_redirected(view, ~p"/projects/#{sandbox.id}/settings")
+ assert flash["error"] =~ "permission to delete this sandbox"
+ assert Lightning.Projects.get_project(sandbox.id)
+ end
+
+ test "unexpected sandbox delete error surfaces a generic error",
+ %{conn: conn, sandbox: sandbox} do
+ {:ok, view, _html} =
+ live(conn, ~p"/projects/#{sandbox.id}/settings/delete")
+
+ Mimic.expect(Lightning.Projects.Sandboxes, :delete_sandbox, fn _, _ ->
+ {:error, :boom}
+ end)
+
+ view
+ |> form("#confirm-delete-sandbox form",
+ confirm: %{name: sandbox.name}
+ )
+ |> render_submit()
+
+ flash = assert_redirected(view, ~p"/projects/#{sandbox.id}/settings")
+ assert flash["error"] =~ "Could not delete sandbox"
+ assert Lightning.Projects.get_project(sandbox.id)
+ end
+
+ test "shows webhook security explanatory message instead of auth methods",
+ %{conn: conn, sandbox: sandbox} do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{sandbox.id}/settings")
+
+ assert html =~ "Webhook authentication is managed in the parent project"
+ end
+
+ test "MFA toggle is disabled in sandbox", %{conn: conn, sandbox: sandbox} do
+ {:ok, view, _html} = live(conn, ~p"/projects/#{sandbox.id}/settings")
+ html = render(view)
+
+ assert html =~ ~s(id="toggle-mfa-switch")
+ assert html =~ ~s(disabled)
+ assert html =~ "cursor-not-allowed"
+ end
+
+ test "does not show the role permissions message on webhook_security or security tabs",
+ %{conn: conn, sandbox: sandbox} do
+ {:ok, _view, html} = live(conn, ~p"/projects/#{sandbox.id}/settings")
+
+ refute html =~ "Role based permissions: You cannot modify"
+ end
+ end
+
+ describe "Sandboxes.parent_admin?/2" do
+ test "returns true when user is admin on direct parent" do
+ user = insert(:user)
+ parent = insert(:project, project_users: [%{user: user, role: :admin}])
+ sandbox = insert(:project, parent: parent, project_users: [])
+
+ assert Sandboxes.parent_admin?(sandbox, user)
+ end
+
+ test "returns true when user is owner on direct parent" do
+ user = insert(:user)
+ parent = insert(:project, project_users: [%{user: user, role: :owner}])
+ sandbox = insert(:project, parent: parent, project_users: [])
+
+ assert Sandboxes.parent_admin?(sandbox, user)
+ end
+
+ test "returns false when user is editor on parent" do
+ user = insert(:user)
+ parent = insert(:project, project_users: [%{user: user, role: :editor}])
+ sandbox = insert(:project, parent: parent, project_users: [])
+
+ refute Sandboxes.parent_admin?(sandbox, user)
+ end
+
+ test "returns false when user has no role on parent" do
+ user = insert(:user)
+ parent = insert(:project, project_users: [])
+ sandbox = insert(:project, parent: parent, project_users: [])
+
+ refute Sandboxes.parent_admin?(sandbox, user)
+ end
+
+ test "walks the chain — admin on grandparent counts" do
+ user = insert(:user)
+
+ grandparent =
+ insert(:project, project_users: [%{user: user, role: :admin}])
+
+ parent = insert(:project, parent: grandparent, project_users: [])
+ sandbox = insert(:project, parent: parent, project_users: [])
+
+ assert Sandboxes.parent_admin?(sandbox, user)
+ end
+
+ test "returns false for projects with no parent" do
+ user = insert(:user)
+ project = insert(:project, project_users: [%{user: user, role: :admin}])
+
+ refute Sandboxes.parent_admin?(project, user)
+ end
+
+ test "returns false when parent_id points to a deleted project" do
+ # Race condition: in-memory sandbox struct has a parent_id that
+ # was nilified in the database after we loaded the struct (because
+ # the parent project was deleted). The function should treat the
+ # missing ancestor as no ancestor, not crash.
+ user = insert(:user)
+ sandbox = insert(:project)
+ stale_parent_id = Ecto.UUID.generate()
+ stale_sandbox = %{sandbox | parent_id: stale_parent_id}
+
+ refute Sandboxes.parent_admin?(stale_sandbox, user)
+ end
+ end
+
+ describe "delete_project_user! parent admin protection" do
+ test "raises when removing a parent admin from a sandbox" do
+ admin = insert(:user)
+ other = insert(:user)
+
+ parent =
+ insert(:project,
+ project_users: [
+ %{user: admin, role: :admin},
+ %{user: other, role: :owner}
+ ]
+ )
+
+ sandbox =
+ insert(:project,
+ parent: parent,
+ project_users: [
+ %{user: admin, role: :editor},
+ %{user: other, role: :owner}
+ ]
+ )
+
+ sandbox_pu = Projects.get_project_user(sandbox, admin)
+
+ assert_raise ArgumentError,
+ ~r/Cannot remove a parent project admin/,
+ fn ->
+ Projects.delete_project_user!(sandbox_pu)
+ end
+ end
+
+ test "allows removing a non-parent-admin from a sandbox" do
+ regular = insert(:user)
+ owner = insert(:user)
+
+ parent =
+ insert(:project, project_users: [%{user: owner, role: :owner}])
+
+ sandbox =
+ insert(:project,
+ parent: parent,
+ project_users: [
+ %{user: regular, role: :editor},
+ %{user: owner, role: :owner}
+ ]
+ )
+
+ sandbox_pu = Projects.get_project_user(sandbox, regular)
+
+ assert %Lightning.Projects.ProjectUser{} =
+ Projects.delete_project_user!(sandbox_pu)
+ end
+
+ test "allows removing any user from a non-sandbox project" do
+ admin = insert(:user)
+ other = insert(:user)
+
+ project =
+ insert(:project,
+ project_users: [
+ %{user: admin, role: :admin},
+ %{user: other, role: :owner}
+ ]
+ )
+
+ pu = Projects.get_project_user(project, admin)
+
+ assert %Lightning.Projects.ProjectUser{} =
+ Projects.delete_project_user!(pu)
+ end
+ end
+
+ describe "Remove Collaborator UI guard for parent admins" do
+ test "Remove button is disabled for a parent admin in sandbox", %{
+ conn: conn,
+ user: user
+ } do
+ parent_admin = insert(:user, email: "parent-admin@example.com")
+
+ parent =
+ insert(:project,
+ project_users: [
+ %{user: user, role: :owner},
+ %{user: parent_admin, role: :admin}
+ ]
+ )
+
+ sandbox =
+ insert(:project,
+ parent: parent,
+ project_users: [
+ %{user: user, role: :owner},
+ %{user: parent_admin, role: :editor}
+ ]
+ )
+
+ {:ok, _view, html} = live(conn, ~p"/projects/#{sandbox.id}/settings")
+
+ assert html =~
+ "Cannot remove a user who is admin or owner on the parent project"
+ end
+ end
+end