defmodule Mv.Membership.MemberEmailValidationTest do @moduledoc """ Tests for Member email-change permission validation. When a member is linked to a user, only admins or the linked user may change that member's email. Unlinked members and non-email updates are unaffected. """ use Mv.DataCase, async: false alias Mv.Accounts alias Mv.Authorization alias Mv.Helpers.SystemActor alias Mv.Membership setup do system_actor = SystemActor.get_system_actor() %{actor: system_actor} end defp create_role_with_permission_set(permission_set_name, actor) do role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}" case Authorization.create_role( %{ name: role_name, description: "Test role for #{permission_set_name}", permission_set_name: permission_set_name }, actor: actor ) do {:ok, role} -> role {:error, error} -> raise "Failed to create role: #{inspect(error)}" end end defp create_user_with_permission_set(permission_set_name, actor) do role = create_role_with_permission_set(permission_set_name, actor) {:ok, user} = Accounts.User |> Ash.Changeset.for_create(:register_with_password, %{ email: "user#{System.unique_integer([:positive])}@example.com", password: "testpassword123" }) |> Ash.create(actor: actor) {:ok, user} = user |> Ash.Changeset.for_update(:update, %{}) |> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove) |> Ash.update(actor: actor) {:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor) user_with_role end defp create_admin_user(actor) do create_user_with_permission_set("admin", actor) end defp create_linked_member_for_user(user, actor) do admin = create_admin_user(actor) {:ok, member} = Membership.create_member( %{ first_name: "Linked", last_name: "Member", email: "linked#{System.unique_integer([:positive])}@example.com" }, actor: admin ) user |> Ash.Changeset.for_update(:update, %{}) |> Ash.Changeset.force_change_attribute(:member_id, member.id) |> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false) member end defp create_unlinked_member(actor) do admin = create_admin_user(actor) {:ok, member} = Membership.create_member( %{ first_name: "Unlinked", last_name: "Member", email: "unlinked#{System.unique_integer([:positive])}@example.com" }, actor: admin ) member end describe "unlinked member" do test "normal_user can update email of unlinked member", %{actor: actor} do normal_user = create_user_with_permission_set("normal_user", actor) unlinked_member = create_unlinked_member(actor) new_email = "new#{System.unique_integer([:positive])}@example.com" assert {:ok, updated} = Membership.update_member(unlinked_member, %{email: new_email}, actor: normal_user) assert updated.email == new_email end test "validation does not block when member has no linked user", %{actor: actor} do normal_user = create_user_with_permission_set("normal_user", actor) unlinked_member = create_unlinked_member(actor) new_email = "other#{System.unique_integer([:positive])}@example.com" assert {:ok, _} = Membership.update_member(unlinked_member, %{email: new_email}, actor: normal_user) end end describe "linked member – another user's member" do test "normal_user cannot update email of another user's linked member", %{actor: actor} do user_a = create_user_with_permission_set("own_data", actor) linked_member = create_linked_member_for_user(user_a, actor) normal_user_b = create_user_with_permission_set("normal_user", actor) new_email = "other#{System.unique_integer([:positive])}@example.com" assert {:error, %Ash.Error.Invalid{} = error} = Membership.update_member(linked_member, %{email: new_email}, actor: normal_user_b) assert Enum.any?(error.errors, &(&1.field == :email)), "expected an error for field :email, got: #{inspect(error.errors)}" end test "admin can update email of linked member", %{actor: actor} do user_a = create_user_with_permission_set("own_data", actor) linked_member = create_linked_member_for_user(user_a, actor) admin = create_admin_user(actor) new_email = "admin_changed#{System.unique_integer([:positive])}@example.com" assert {:ok, updated} = Membership.update_member(linked_member, %{email: new_email}, actor: admin) assert updated.email == new_email end end describe "linked member – own member" do test "own_data user can update email of their own linked member", %{actor: actor} do own_data_user = create_user_with_permission_set("own_data", actor) linked_member = create_linked_member_for_user(own_data_user, actor) {:ok, own_data_user} = Ash.get(Accounts.User, own_data_user.id, domain: Mv.Accounts, load: [:role], actor: actor) {:ok, own_data_user} = Ash.load(own_data_user, :member, domain: Mv.Accounts, actor: actor) new_email = "own_updated#{System.unique_integer([:positive])}@example.com" assert {:ok, updated} = Membership.update_member(linked_member, %{email: new_email}, actor: own_data_user) assert updated.email == new_email end test "normal_user with linked member can update email of that same member", %{actor: actor} do normal_user = create_user_with_permission_set("normal_user", actor) linked_member = create_linked_member_for_user(normal_user, actor) {:ok, normal_user} = Ash.get(Accounts.User, normal_user.id, domain: Mv.Accounts, load: [:role], actor: actor) {:ok, normal_user} = Ash.load(normal_user, :member, domain: Mv.Accounts, actor: actor) new_email = "normal_own#{System.unique_integer([:positive])}@example.com" assert {:ok, updated} = Membership.update_member(linked_member, %{email: new_email}, actor: normal_user) assert updated.email == new_email end end describe "no-op / other fields" do test "updating only other attributes on linked member as normal_user does not trigger validation error", %{actor: actor} do user_a = create_user_with_permission_set("own_data", actor) linked_member = create_linked_member_for_user(user_a, actor) normal_user_b = create_user_with_permission_set("normal_user", actor) assert {:ok, updated} = Membership.update_member(linked_member, %{first_name: "UpdatedName"}, actor: normal_user_b ) assert updated.first_name == "UpdatedName" assert updated.email == linked_member.email end test "updating email of linked member as admin succeeds", %{actor: actor} do user_a = create_user_with_permission_set("own_data", actor) linked_member = create_linked_member_for_user(user_a, actor) admin = create_admin_user(actor) new_email = "admin_ok#{System.unique_integer([:positive])}@example.com" assert {:ok, updated} = Membership.update_member(linked_member, %{email: new_email}, actor: admin) assert updated.email == new_email end end describe "read_only" do test "read_only cannot update any member (policy rejects before validation)", %{actor: actor} do read_only_user = create_user_with_permission_set("read_only", actor) linked_member = create_linked_member_for_user(read_only_user, actor) {:ok, read_only_user} = Ash.get(Accounts.User, read_only_user.id, domain: Mv.Accounts, load: [:role], actor: actor ) assert {:error, %Ash.Error.Forbidden{}} = Membership.update_member(linked_member, %{email: "changed@example.com"}, actor: read_only_user ) end end end