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.Helpers.SystemActor alias Mv.Membership setup do system_actor = SystemActor.get_system_actor() %{actor: system_actor} end defp create_linked_member_for_user(user, _actor) do admin = Mv.Fixtures.user_with_role_fixture("admin") {: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 = Mv.Fixtures.user_with_role_fixture("admin") {: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 = Mv.Fixtures.user_with_role_fixture("normal_user") 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 = Mv.Fixtures.user_with_role_fixture("normal_user") 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 = Mv.Fixtures.user_with_role_fixture("own_data") linked_member = create_linked_member_for_user(user_a, actor) normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user") 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 = Mv.Fixtures.user_with_role_fixture("own_data") linked_member = create_linked_member_for_user(user_a, actor) admin = Mv.Fixtures.user_with_role_fixture("admin") 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 = Mv.Fixtures.user_with_role_fixture("own_data") 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 = Mv.Fixtures.user_with_role_fixture("normal_user") 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 = Mv.Fixtures.user_with_role_fixture("own_data") linked_member = create_linked_member_for_user(user_a, actor) normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user") 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 = Mv.Fixtures.user_with_role_fixture("own_data") linked_member = create_linked_member_for_user(user_a, actor) admin = Mv.Fixtures.user_with_role_fixture("admin") 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 = Mv.Fixtures.user_with_role_fixture("read_only") 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