diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index bcaf506..4015aaa 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -86,7 +86,13 @@ defmodule Mv.Accounts.User do # - :create_user (for manual user creation with optional member link) # - :register_with_password (for password-based registration) # - :register_with_rauthy (for OIDC-based registration) - defaults [:read, :destroy] + defaults [:read] + + destroy :destroy do + primary? true + # Required because custom validation (system actor protection) cannot run atomically + require_atomic? false + end # Primary generic update action: # - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix @@ -169,6 +175,13 @@ defmodule Mv.Accounts.User do end end + # Internal update used only by SystemActor/bootstrap and tests to assign role to system user. + # Not protected by system-user validation so bootstrap can run. + update :update_internal do + accept [] + require_atomic? false + end + # Admin action for direct password changes in admin panel # Uses the official Ash Authentication HashPasswordChange with correct context update :admin_set_password do @@ -181,6 +194,11 @@ defmodule Mv.Accounts.User do # Use the official Ash Authentication password change change AshAuthentication.Strategy.Password.HashPasswordChange + + # Sync email changes to linked member when email is changed (e.g. form changes both) + change Mv.EmailSync.Changes.SyncUserEmailToMember do + where [changing(:email)] + end end # Action to link an OIDC account to an existing password-only user @@ -359,6 +377,21 @@ defmodule Mv.Accounts.User do :ok end end + + # Prevent modification of the system actor user (required for internal operations). + # Block update/destroy on UI-exposed actions only; :update_internal is used by bootstrap/tests. + validate fn changeset, _context -> + if Mv.Helpers.SystemActor.system_user?(changeset.data) do + {:error, + field: :email, + message: + "Cannot modify system actor user. This user is required for internal operations."} + else + :ok + end + end, + on: [:update, :destroy], + where: [action_is([:update, :update_user, :admin_set_password, :destroy])] end def validate_oidc_id_present(changeset, _context) do diff --git a/lib/mv/helpers/system_actor.ex b/lib/mv/helpers/system_actor.ex index 565c2ef..8cd93d2 100644 --- a/lib/mv/helpers/system_actor.ex +++ b/lib/mv/helpers/system_actor.ex @@ -172,6 +172,31 @@ defmodule Mv.Helpers.SystemActor do end end + @doc """ + Returns whether the given user is the system actor user (case-insensitive email match). + + Use this instead of ad-hoc `to_string(user.email) == system_user_email()` so + comparisons are consistent and case-insensitive everywhere. + + ## Returns + + - `boolean()` - true if user's email matches system user email (case-insensitive) + + ## Examples + + iex> Mv.Helpers.SystemActor.system_user?(user_with_system_email) + true + iex> Mv.Helpers.SystemActor.system_user?(other_user) + false + + """ + @spec system_user?(Mv.Accounts.User.t() | map() | nil) :: boolean() + def system_user?(%{email: email}) when not is_nil(email) do + normalized_email(to_string(email)) == normalized_system_user_email() + end + + def system_user?(_), do: false + @doc """ Returns the email address of the system user. @@ -191,6 +216,11 @@ defmodule Mv.Helpers.SystemActor do @spec system_user_email() :: String.t() def system_user_email, do: system_user_email_config() + # Case-insensitive normalized form for comparisons + defp normalized_system_user_email, do: normalized_email(system_user_email_config()) + + defp normalized_email(email) when is_binary(email), do: String.downcase(email) + # Returns the system user email from environment variable or default # This allows configuration via SYSTEM_ACTOR_EMAIL env var @spec system_user_email_config() :: String.t() @@ -368,7 +398,7 @@ defmodule Mv.Helpers.SystemActor do upsert_identity: :unique_email, authorize?: false ) - |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.update!(authorize?: false) |> Ash.load!(:role, domain: Mv.Accounts, authorize?: false) diff --git a/lib/mv_web/error_helpers.ex b/lib/mv_web/error_helpers.ex new file mode 100644 index 0000000..dbbc89c --- /dev/null +++ b/lib/mv_web/error_helpers.ex @@ -0,0 +1,25 @@ +defmodule MvWeb.ErrorHelpers do + @moduledoc """ + Shared helpers for formatting errors in the web layer. + + Use `format_ash_error/1` for Ash errors so behaviour stays consistent + (e.g. handling Invalid errors whose entries may lack a `:message` field). + """ + + @doc """ + Formats an Ash error for display to the user. + + Handles `Ash.Error.Invalid` by joining error messages; entries without + a `:message` field are inspected to avoid FunctionClauseError. + Other errors are inspected. + """ + @spec format_ash_error(Ash.Error.t() | term()) :: String.t() + def format_ash_error(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do + Enum.map_join(errors, ", ", fn + %{message: message} when is_binary(message) -> message + other -> inspect(other) + end) + end + + def format_ash_error(error), do: inspect(error) +end diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 6cf3f0f..0a286c9 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -264,12 +264,31 @@ defmodule MvWeb.UserLive.Form do def mount(params, _session, socket) do actor = current_actor(socket) - user = - case params["id"] do - nil -> nil - id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor) - end + case load_user_or_redirect(params["id"], actor, socket) do + {:redirect, socket} -> + {:ok, socket} + {:ok, user} -> + mount_continue(user, params, socket) + end + end + + defp load_user_or_redirect(nil, _actor, _socket), do: {:ok, nil} + + defp load_user_or_redirect(id, actor, socket) do + user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor) + + if Mv.Helpers.SystemActor.system_user?(user) do + {:redirect, + socket + |> put_flash(:error, gettext("This user cannot be edited.")) + |> push_navigate(to: ~p"/users")} + else + {:ok, user} + end + end + + defp mount_continue(user, params, socket) do action = if is_nil(user), do: gettext("New"), else: gettext("Edit") page_title = action <> " " <> gettext("User") diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex index 2062ae6..4526e4c 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -25,10 +25,18 @@ defmodule MvWeb.UserLive.Index do import MvWeb.LiveHelpers, only: [current_actor: 1] + require Ash.Query + import MvWeb.ErrorHelpers, only: [format_ash_error: 1] + @impl true def mount(_params, _session, socket) do actor = current_actor(socket) - users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member], actor: actor) + + users = + Mv.Accounts.User + |> Ash.Query.filter(email != ^Mv.Helpers.SystemActor.system_user_email()) + |> Ash.read!(domain: Mv.Accounts, load: [:member], actor: actor) + sorted = Enum.sort_by(users, & &1.email) {:ok, @@ -64,7 +72,7 @@ defmodule MvWeb.UserLive.Index do )} {:error, error} -> - {:noreply, put_flash(socket, :error, format_error(error))} + {:noreply, put_flash(socket, :error, format_ash_error(error))} end {:error, %Ash.Error.Query.NotFound{}} -> @@ -75,7 +83,7 @@ defmodule MvWeb.UserLive.Index do put_flash(socket, :error, gettext("You do not have permission to access this user"))} {:error, error} -> - {:noreply, put_flash(socket, :error, format_error(error))} + {:noreply, put_flash(socket, :error, format_ash_error(error))} end end @@ -137,12 +145,4 @@ defmodule MvWeb.UserLive.Index do defp toggle_order(:desc), do: :asc defp sort_fun(:asc), do: &<=/2 defp sort_fun(:desc), do: &>=/2 - - defp format_error(%Ash.Error.Invalid{errors: errors}) do - Enum.map_join(errors, ", ", fn %{message: message} -> message end) - end - - defp format_error(error) do - inspect(error) - end end diff --git a/lib/mv_web/live/user_live/show.ex b/lib/mv_web/live/user_live/show.ex index 641e091..e961d84 100644 --- a/lib/mv_web/live/user_live/show.ex +++ b/lib/mv_web/live/user_live/show.ex @@ -75,9 +75,16 @@ defmodule MvWeb.UserLive.Show do actor = current_actor(socket) user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member], actor: actor) - {:ok, - socket - |> assign(:page_title, gettext("Show User")) - |> assign(:user, user)} + if Mv.Helpers.SystemActor.system_user?(user) do + {:ok, + socket + |> put_flash(:error, gettext("This user cannot be viewed.")) + |> push_navigate(to: ~p"/users")} + else + {:ok, + socket + |> assign(:page_title, gettext("Show User")) + |> assign(:user, user)} + end end end diff --git a/mix.exs b/mix.exs index 99f4507..5271b6b 100644 --- a/mix.exs +++ b/mix.exs @@ -76,7 +76,7 @@ defmodule Mv.MixProject do {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, {:sobelow, "~> 0.14", only: [:dev, :test], runtime: false}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, - {:picosat_elixir, "~> 0.1", only: [:dev, :test]}, + {:picosat_elixir, "~> 0.1"}, {:ecto_commons, "~> 0.3"}, {:slugify, "~> 1.3"}, {:nimble_csv, "~> 1.0"} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index cf83765..e97950d 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2252,3 +2252,13 @@ msgid "Total: %{count} member" msgid_plural "Total: %{count} members" msgstr[0] "Insgesamt: %{count} Mitglied" msgstr[1] "Insgesamt: %{count} Mitglieder" + +#: lib/mv_web/live/user_live/form.ex +#, elixir-autogen, elixir-format +msgid "This user cannot be edited." +msgstr "Dieser Benutzer kann nicht bearbeitet werden." + +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "This user cannot be viewed." +msgstr "Dieser Benutzer kann nicht angezeigt werden." diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a77a013..b59085f 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2253,3 +2253,13 @@ msgid "Total: %{count} member" msgid_plural "Total: %{count} members" msgstr[0] "" msgstr[1] "" + +#: lib/mv_web/live/user_live/form.ex +#, elixir-autogen, elixir-format +msgid "This user cannot be edited." +msgstr "" + +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "This user cannot be viewed." +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index b5a060b..a85d2d7 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2253,3 +2253,13 @@ msgid "Total: %{count} member" msgid_plural "Total: %{count} members" msgstr[0] "" msgstr[1] "" + +#: lib/mv_web/live/user_live/form.ex +#, elixir-autogen, elixir-format +msgid "This user cannot be edited." +msgstr "" + +#: lib/mv_web/live/user_live/show.ex +#, elixir-autogen, elixir-format +msgid "This user cannot be viewed." +msgstr "" diff --git a/priv/repo/migrations/20260127124937_ensure_system_actor_user_exists.exs b/priv/repo/migrations/20260127124937_ensure_system_actor_user_exists.exs new file mode 100644 index 0000000..8856bb2 --- /dev/null +++ b/priv/repo/migrations/20260127124937_ensure_system_actor_user_exists.exs @@ -0,0 +1,62 @@ +defmodule Mv.Repo.Migrations.EnsureSystemActorUserExists do + @moduledoc """ + Ensures the system actor user always exists. + + The system actor is used for systemic operations (email sync, cycle generation, + background jobs). It is created by seeds in development; in production seeds + may not run, so this migration guarantees the user exists. + + Creates a user with email "system@mila.local" (default from Mv.Helpers.SystemActor) + and the Admin role. The user has no password and no OIDC ID, so it cannot log in. + """ + use Ecto.Migration + import Ecto.Query + + @system_user_email "system@mila.local" + + def up do + admin_role_id = ensure_admin_role_exists() + ensure_system_actor_user_exists(admin_role_id) + end + + def down do + # Not reversible - do not delete system user on rollback + :ok + end + + defp ensure_admin_role_exists do + case repo().one(from(r in "roles", where: r.name == "Admin", select: r.id)) do + nil -> + execute(""" + INSERT INTO roles (id, name, description, permission_set_name, is_system_role, inserted_at, updated_at) + VALUES (uuid_generate_v7(), 'Admin', 'Administrator with full access', 'admin', false, (now() AT TIME ZONE 'utc'), (now() AT TIME ZONE 'utc')) + """) + + role_id = repo().one(from(r in "roles", where: r.name == "Admin", select: r.id)) + IO.puts("✅ Created 'Admin' role (was missing)") + role_id + + role_id -> + role_id + end + end + + defp ensure_system_actor_user_exists(_admin_role_id) do + case repo().one(from(u in "users", where: u.email == ^@system_user_email, select: u.id)) do + nil -> + # Use subquery for role_id to avoid nil/empty-string UUID (CI can lag after role insert) + execute(""" + INSERT INTO users (id, email, hashed_password, oidc_id, member_id, role_id) + SELECT uuid_generate_v7(), '#{@system_user_email}', NULL, NULL, NULL, r.id + FROM roles r + WHERE r.name = 'Admin' + LIMIT 1 + """) + + IO.puts("✅ Created system actor user (#{@system_user_email})") + + _ -> + :ok + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 1a1f80f..43ffcaf 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -268,9 +268,9 @@ case Accounts.User |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do {:ok, existing_system_user} when not is_nil(existing_system_user) -> # System user already exists - ensure it has admin role - # Use authorize?: false for bootstrap + # Use authorize?: false for bootstrap; :update_internal bypasses system-user modification block existing_system_user - |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.update!(authorize?: false) @@ -287,7 +287,7 @@ case Accounts.User upsert_identity: :unique_email, authorize?: false ) - |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.update!(authorize?: false) diff --git a/test/accounts/user_email_sync_test.exs b/test/accounts/user_email_sync_test.exs index d324783..eab4e38 100644 --- a/test/accounts/user_email_sync_test.exs +++ b/test/accounts/user_email_sync_test.exs @@ -120,6 +120,43 @@ defmodule Mv.Accounts.UserEmailSyncTest do {:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id, actor: actor) assert member_after_unlink.email == "user@example.com" end + + test "admin_set_password with email change syncs to linked member", %{actor: actor} do + # Create member and user linked to it (with password so admin_set_password applies) + {:ok, member} = Membership.create_member(@valid_member_attrs, actor: actor) + + {:ok, user} = + Mv.Accounts.User + |> Ash.Changeset.for_create(:register_with_password, %{ + email: "user@example.com", + password: "initialpass123" + }) + |> Ash.create(actor: actor) + + {:ok, user} = + user + |> Ash.Changeset.for_update(:update_user, %{member: %{id: member.id}}) + |> Ash.update(actor: actor) + + assert user.member_id == member.id + {:ok, m} = Ash.get(Mv.Membership.Member, member.id, actor: actor) + assert m.email == "user@example.com" + + # Change both email and password via admin_set_password (e.g. user form "Change Password") + {:ok, updated_user} = + user + |> Ash.Changeset.for_update(:admin_set_password, %{ + email: "newemail@example.com", + password: "newpassword123" + }) + |> Ash.update(actor: actor) + + assert to_string(updated_user.email) == "newemail@example.com" + + # Member email must be synced (Option A: SyncUserEmailToMember on admin_set_password) + {:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id, actor: actor) + assert synced_member.email == "newemail@example.com" + end end describe "AshAuthentication compatibility" do diff --git a/test/mv/helpers/system_actor_test.exs b/test/mv/helpers/system_actor_test.exs index af28443..c2715ae 100644 --- a/test/mv/helpers/system_actor_test.exs +++ b/test/mv/helpers/system_actor_test.exs @@ -10,6 +10,14 @@ defmodule Mv.Helpers.SystemActorTest do require Ash.Query + # Deletes a user row directly via SQL, bypassing Ash validations. + # Use only in tests when setting up "no system user" / "no users" scenarios; + # Ash.destroy! forbids deleting the system actor user. + defp delete_user_bypass_ash(user) do + id = Ecto.UUID.dump!(user.id) + Ecto.Adapters.SQL.query!(Mv.Repo, "DELETE FROM users WHERE id = $1", [id]) + end + # Helper function to ensure admin role exists defp ensure_admin_role do case Authorization.list_roles() do @@ -49,7 +57,7 @@ defmodule Mv.Helpers.SystemActorTest do |> Ash.read_one(domain: Mv.Accounts, authorize?: false) do {:ok, user} when not is_nil(user) -> user - |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.update!(authorize?: false) |> Ash.load!(:role, domain: Mv.Accounts, authorize?: false) @@ -60,7 +68,7 @@ defmodule Mv.Helpers.SystemActorTest do upsert_identity: :unique_email, authorize?: false ) - |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove) |> Ash.update!(authorize?: false) |> Ash.load!(:role, domain: Mv.Accounts, authorize?: false) @@ -124,7 +132,7 @@ defmodule Mv.Helpers.SystemActorTest do |> Ash.Query.filter(email == ^"system@mila.local") |> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do {:ok, user} when not is_nil(user) -> - Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor) + delete_user_bypass_ash(user) _ -> :ok @@ -163,7 +171,7 @@ defmodule Mv.Helpers.SystemActorTest do |> Ash.Query.filter(email == ^"system@mila.local") |> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do {:ok, user} when not is_nil(user) -> - Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor) + delete_user_bypass_ash(user) _ -> :ok @@ -177,7 +185,7 @@ defmodule Mv.Helpers.SystemActorTest do |> Ash.Query.filter(email == ^admin_email) |> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do {:ok, user} when not is_nil(user) -> - Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor) + delete_user_bypass_ash(user) _ -> :ok @@ -227,7 +235,7 @@ defmodule Mv.Helpers.SystemActorTest do |> Ash.Query.filter(email == ^"system@mila.local") |> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do {:ok, user} when not is_nil(user) -> - Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor) + delete_user_bypass_ash(user) _ -> :ok @@ -241,7 +249,7 @@ defmodule Mv.Helpers.SystemActorTest do |> Ash.Query.filter(email == ^admin_email) |> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do {:ok, user} when not is_nil(user) -> - Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor) + delete_user_bypass_ash(user) _ -> :ok @@ -275,7 +283,7 @@ defmodule Mv.Helpers.SystemActorTest do |> Ash.Query.filter(email == ^"system@mila.local") |> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do {:ok, user} when not is_nil(user) -> - Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor) + delete_user_bypass_ash(user) _ -> :ok @@ -314,7 +322,7 @@ defmodule Mv.Helpers.SystemActorTest do |> Ash.Query.filter(email == ^"system@mila.local") |> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do {:ok, user} when not is_nil(user) -> - Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor) + delete_user_bypass_ash(user) _ -> :ok @@ -328,7 +336,7 @@ defmodule Mv.Helpers.SystemActorTest do |> Ash.Query.filter(email == ^admin_email) |> Ash.read_one(domain: Mv.Accounts, actor: system_actor) do {:ok, user} when not is_nil(user) -> - Ash.destroy!(user, domain: Mv.Accounts, actor: system_actor) + delete_user_bypass_ash(user) _ -> :ok @@ -365,9 +373,9 @@ defmodule Mv.Helpers.SystemActorTest do system_actor = SystemActor.get_system_actor() - # Assign wrong role to system user + # Assign wrong role to system user (use :update_internal so bootstrap-style update is allowed) system_user - |> Ash.Changeset.for_update(:update, %{}) + |> Ash.Changeset.for_update(:update_internal, %{}) |> Ash.Changeset.manage_relationship(:role, read_only_role, type: :append_and_remove) |> Ash.update!(actor: system_actor) diff --git a/test/mv_web/live/user_live/show_test.exs b/test/mv_web/live/user_live/show_test.exs index 3551fdf..9518106 100644 --- a/test/mv_web/live/user_live/show_test.exs +++ b/test/mv_web/live/user_live/show_test.exs @@ -154,4 +154,14 @@ defmodule MvWeb.UserLive.ShowTest do assert html =~ gettext("Show User") || html =~ to_string(user.email) end end + + describe "system actor user" do + test "redirects to user list when viewing system actor user", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + conn = conn_with_oidc_user(conn) + + assert {:error, {:live_redirect, %{to: "/users"}}} = + live(conn, ~p"/users/#{system_actor.id}") + end + end end diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs index ed309fb..4b76c19 100644 --- a/test/mv_web/user_live/form_test.exs +++ b/test/mv_web/user_live/form_test.exs @@ -420,4 +420,14 @@ defmodule MvWeb.UserLive.FormTest do assert is_nil(updated_user.member) end end + + describe "system actor user" do + test "redirects to user list when editing system actor user", %{conn: conn} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + conn = conn_with_oidc_user(conn, %{email: "admin@example.com"}) + + assert {:error, {:live_redirect, %{to: "/users"}}} = + live(conn, "/users/#{system_actor.id}/edit") + end + end end diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index 41c198d..a1a02ea 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -405,6 +405,27 @@ defmodule MvWeb.UserLive.IndexTest do end end + describe "system actor user" do + test "does not show system actor user in list", %{conn: conn} do + # Ensure system actor exists (e.g. via get_system_actor in conn_with_oidc_user) + _system_actor = Mv.Helpers.SystemActor.get_system_actor() + system_email = Mv.Helpers.SystemActor.system_user_email() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/users") + + refute html =~ system_email, + "System actor user (#{system_email}) must not appear in the user list" + end + + test "destroying system actor user returns error", %{current_user: current_user} do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + assert {:error, %Ash.Error.Invalid{}} = + Ash.destroy(system_actor, domain: Mv.Accounts, actor: current_user) + end + end + describe "member linking display" do test "displays linked member name in user list", %{conn: conn} do system_actor = Mv.Helpers.SystemActor.get_system_actor()