defmodule Mv.Membership.MemberPoliciesTest do @moduledoc """ Tests for Member resource authorization policies. Tests all 4 permission sets (own_data, read_only, normal_user, admin) and verifies that policies correctly enforce access control based on user roles and permission sets. """ # async: false because we need database commits to be visible across queries # in the same test (especially for unlinked members) use Mv.DataCase, async: false alias Mv.Membership alias Mv.Accounts require Ash.Query setup do system_actor = Mv.Helpers.SystemActor.get_system_actor() %{actor: system_actor} end # Helper to create a member linked to a user defp create_linked_member_for_user(user, _actor) do admin = Mv.Fixtures.user_with_role_fixture("admin") # Create member # NOTE: We need to ensure the member is actually persisted to the database # before we try to link it. Ash may delay writes, so we explicitly return the struct. {:ok, member} = Membership.create_member( %{ first_name: "Linked", last_name: "Member", email: "linked#{System.unique_integer([:positive])}@example.com" }, actor: admin ) # Link member to user (User.member_id = member.id) # We use force_change_attribute because the member already exists and we just # need to set the foreign key. This avoids the issue where manage_relationship # tries to query the member without the actor context. result = 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) {:ok, _user} = result # Return the member struct directly - no need to reload since we just created it # and we're in the same transaction/sandbox member end # Helper to create an unlinked member (no user relationship) 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 "own_data permission set (Mitglied)" do setup %{actor: actor} do user = Mv.Fixtures.user_with_role_fixture("own_data") linked_member = create_linked_member_for_user(user, actor) unlinked_member = create_unlinked_member(actor) # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) {:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor) %{user: user, linked_member: linked_member, unlinked_member: unlinked_member} end test "can read linked member", %{user: user, linked_member: linked_member} do {:ok, member} = Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership) assert member.id == linked_member.id end test "can update linked member", %{user: user, linked_member: linked_member} do # Update is allowed via HasPermission check with :linked scope (not via special case) # The special case policy only applies to :read actions {:ok, updated_member} = Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user) assert updated_member.first_name == "Updated" end test "cannot read unlinked member (returns forbidden)", %{ user: user, unlinked_member: unlinked_member } do # Note: With auto_filter policies, when a user tries to read a member that doesn't # match the filter (id == actor.member_id), Ash returns NotFound, not Forbidden. # This is the expected behavior - the filter makes the record "invisible" to the user. assert_raise Ash.Error.Invalid, fn -> Ash.get!(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership) end end test "cannot update unlinked member (returns forbidden)", %{ user: user, unlinked_member: unlinked_member } do assert {:error, %Ash.Error.Forbidden{}} = Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user) end test "list members returns only linked member", %{ user: user, linked_member: linked_member } do {:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership) # Should only return the linked member (scope :linked filters) assert length(members) == 1 assert hd(members).id == linked_member.id end test "cannot create member (returns forbidden)", %{user: user} do assert {:error, %Ash.Error.Forbidden{}} = Membership.create_member( %{ first_name: "New", last_name: "Member", email: "new#{System.unique_integer([:positive])}@example.com" }, actor: user ) end test "cannot destroy member (returns forbidden)", %{ user: user, linked_member: linked_member } do assert_raise Ash.Error.Forbidden, fn -> Ash.destroy!(linked_member, actor: user) end end end describe "read_only permission set (Vorstand/Buchhaltung)" do setup %{actor: actor} do user = Mv.Fixtures.user_with_role_fixture("read_only") linked_member = create_linked_member_for_user(user, actor) unlinked_member = create_unlinked_member(actor) # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) %{user: user, linked_member: linked_member, unlinked_member: unlinked_member} end test "can read all members", %{ user: user, linked_member: linked_member, unlinked_member: unlinked_member } do {:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership) # Should return all members (scope :all) member_ids = Enum.map(members, & &1.id) assert linked_member.id in member_ids assert unlinked_member.id in member_ids end test "can read individual member", %{ user: user, unlinked_member: unlinked_member } do {:ok, member} = Ash.get(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership) assert member.id == unlinked_member.id end test "cannot create member (returns forbidden)", %{user: user} do assert {:error, %Ash.Error.Forbidden{}} = Membership.create_member( %{ first_name: "New", last_name: "Member", email: "new#{System.unique_integer([:positive])}@example.com" }, actor: user ) end test "cannot update any member (returns forbidden)", %{ user: user, linked_member: linked_member } do assert {:error, %Ash.Error.Forbidden{}} = Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user) end test "cannot destroy any member (returns forbidden)", %{ user: user, unlinked_member: unlinked_member } do assert_raise Ash.Error.Forbidden, fn -> Ash.destroy!(unlinked_member, actor: user) end end end describe "normal_user permission set (Kassenwart)" do setup %{actor: actor} do user = Mv.Fixtures.user_with_role_fixture("normal_user") linked_member = create_linked_member_for_user(user, actor) unlinked_member = create_unlinked_member(actor) # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) %{user: user, linked_member: linked_member, unlinked_member: unlinked_member} end test "can read all members", %{ user: user, linked_member: linked_member, unlinked_member: unlinked_member } do {:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership) # Should return all members (scope :all) member_ids = Enum.map(members, & &1.id) assert linked_member.id in member_ids assert unlinked_member.id in member_ids end test "can create member", %{user: user} do {:ok, member} = Membership.create_member( %{ first_name: "New", last_name: "Member", email: "new#{System.unique_integer([:positive])}@example.com" }, actor: user ) assert member.first_name == "New" end test "can update any member", %{user: user, unlinked_member: unlinked_member} do {:ok, updated_member} = Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user) assert updated_member.first_name == "Updated" end test "cannot destroy member (safety - not in permission set)", %{ user: user, unlinked_member: unlinked_member } do assert_raise Ash.Error.Forbidden, fn -> Ash.destroy!(unlinked_member, actor: user) end end end describe "admin permission set" do setup %{actor: actor} do user = Mv.Fixtures.user_with_role_fixture("admin") linked_member = create_linked_member_for_user(user, actor) unlinked_member = create_unlinked_member(actor) # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) %{user: user, linked_member: linked_member, unlinked_member: unlinked_member} end test "can read all members", %{ user: user, linked_member: linked_member, unlinked_member: unlinked_member } do {:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership) # Should return all members (scope :all) member_ids = Enum.map(members, & &1.id) assert linked_member.id in member_ids assert unlinked_member.id in member_ids end test "can create member", %{user: user} do {:ok, member} = Membership.create_member( %{ first_name: "New", last_name: "Member", email: "new#{System.unique_integer([:positive])}@example.com" }, actor: user ) assert member.first_name == "New" end test "can update any member", %{user: user, unlinked_member: unlinked_member} do {:ok, updated_member} = Membership.update_member(unlinked_member, %{first_name: "Updated"}, actor: user) assert updated_member.first_name == "Updated" end test "can destroy any member", %{ user: user, unlinked_member: unlinked_member } do :ok = Ash.destroy(unlinked_member, actor: user) # Verify member is deleted assert {:error, _} = Ash.get(Membership.Member, unlinked_member.id, domain: Mv.Membership) end end describe "special case: user can always READ linked member" do setup %{actor: _actor} do # Note: The special case policy only applies to :read actions. # Updates are handled by HasPermission with :linked scope (if permission exists). :ok end test "read_only user can read linked member (via special case bypass)", %{actor: actor} do # read_only has Member.read scope :all, but the special case ensures # users can ALWAYS read their linked member, even if they had no read permission. # This test verifies the special case works independently of permission sets. user = Mv.Fixtures.user_with_role_fixture("read_only") linked_member = create_linked_member_for_user(user, actor) # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) {:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor) # Should succeed (special case bypass policy for :read takes precedence) {:ok, member} = Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership) assert member.id == linked_member.id end test "own_data user can read linked member (via special case bypass)", %{actor: actor} do # own_data has Member.read scope :linked, but the special case ensures # users can ALWAYS read their linked member regardless of permission set. user = Mv.Fixtures.user_with_role_fixture("own_data") linked_member = create_linked_member_for_user(user, actor) # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) {:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor) # Should succeed (special case bypass policy for :read takes precedence) {:ok, member} = Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership) assert member.id == linked_member.id end test "own_data user can update linked member (via HasPermission :linked scope)", %{ actor: actor } do # Update is NOT handled by special case - it's handled by HasPermission # with :linked scope. own_data has Member.update scope :linked. user = Mv.Fixtures.user_with_role_fixture("own_data") linked_member = create_linked_member_for_user(user, actor) # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) {:ok, user} = Ash.load(user, :member, domain: Mv.Accounts, actor: actor) # Should succeed via HasPermission check (not special case) {:ok, updated_member} = Membership.update_member(linked_member, %{first_name: "Updated"}, actor: user) assert updated_member.first_name == "Updated" end end describe "member user link - only admin may set or change user link" do test "normal_user can create member without :user argument", %{actor: _actor} do normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) {:ok, member} = Membership.create_member( %{ first_name: "NoLink", last_name: "Member", email: "nolink#{System.unique_integer([:positive])}@example.com" }, actor: normal_user ) assert member.first_name == "NoLink" # Member has_one :user (FK on User side); ensure no user is linked {:ok, member} = Ash.load(member, :user, domain: Mv.Membership) assert is_nil(member.user) end test "normal_user cannot create member with :user argument (forbidden)", %{actor: _actor} do normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) # Another user to try to link to other_user = Mv.Fixtures.user_with_role_fixture("read_only") other_user = Mv.Authorization.Actor.ensure_loaded(other_user) attrs = %{ first_name: "Linked", last_name: "Member", email: "linked#{System.unique_integer([:positive])}@example.com", user: %{id: other_user.id} } assert {:error, %Ash.Error.Forbidden{}} = Membership.create_member(attrs, actor: normal_user) end test "normal_user can update member without :user argument", %{actor: actor} do normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) unlinked_member = create_unlinked_member(actor) {:ok, updated} = Membership.update_member(unlinked_member, %{first_name: "UpdatedByNormal"}, actor: normal_user ) assert updated.first_name == "UpdatedByNormal" end test "normal_user cannot update member with :user argument (forbidden)", %{actor: actor} do normal_user = Mv.Fixtures.user_with_role_fixture("normal_user") normal_user = Mv.Authorization.Actor.ensure_loaded(normal_user) other_user = Mv.Fixtures.user_with_role_fixture("own_data") other_user = Mv.Authorization.Actor.ensure_loaded(other_user) unlinked_member = create_unlinked_member(actor) # Passing :user in params tries to link member to other_user - only admin may do that params = %{first_name: unlinked_member.first_name, user: %{id: other_user.id}} assert {:error, %Ash.Error.Forbidden{}} = Membership.update_member(unlinked_member, params, actor: normal_user) end test "admin can create member with :user argument", %{actor: _actor} do admin = Mv.Fixtures.user_with_role_fixture("admin") admin = Mv.Authorization.Actor.ensure_loaded(admin) link_target = Mv.Fixtures.user_with_role_fixture("own_data") link_target = Mv.Authorization.Actor.ensure_loaded(link_target) attrs = %{ first_name: "AdminLinked", last_name: "Member", email: "adminlinked#{System.unique_integer([:positive])}@example.com", user: %{id: link_target.id} } {:ok, member} = Membership.create_member(attrs, actor: admin) assert member.first_name == "AdminLinked" # Reload link_target to see the new member_id set by manage_relationship {:ok, link_target} = Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin) assert link_target.member_id == member.id end test "admin can update member with :user argument (link)", %{actor: actor} do admin = Mv.Fixtures.user_with_role_fixture("admin") admin = Mv.Authorization.Actor.ensure_loaded(admin) unlinked_member = create_unlinked_member(actor) link_target = Mv.Fixtures.user_with_role_fixture("read_only") link_target = Mv.Authorization.Actor.ensure_loaded(link_target) {:ok, updated} = Membership.update_member( unlinked_member, %{user: %{id: link_target.id}}, actor: admin ) assert updated.id == unlinked_member.id # Member should now be linked to link_target (user.member_id points to this member) {:ok, reloaded_user} = Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, load: [:member], actor: admin ) assert reloaded_user.member_id == updated.id end end end