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 alias Mv.Authorization require Ash.Query # Helper to create a role with a specific permission set defp create_role_with_permission_set(permission_set_name) 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 }) do {:ok, role} -> role {:error, error} -> raise "Failed to create role: #{inspect(error)}" end end # Helper to create a user with a specific permission set # Returns user with role preloaded (required for authorization) defp create_user_with_permission_set(permission_set_name) do # Create role with permission set role = create_role_with_permission_set(permission_set_name) # Create user {:ok, user} = Accounts.User |> Ash.Changeset.for_create(:register_with_password, %{ email: "user#{System.unique_integer([:positive])}@example.com", password: "testpassword123" }) |> Ash.create() # Assign role to user {:ok, user} = user |> Ash.Changeset.for_update(:update, %{}) |> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove) |> Ash.update() # Reload user with role preloaded (critical for authorization!) {:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts) user_with_role end # Helper to create an admin user (for creating test fixtures) defp create_admin_user do create_user_with_permission_set("admin") end # Helper to create a member linked to a user defp create_linked_member_for_user(user) do admin = create_admin_user() # 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.Member |> Ash.Changeset.for_create(:create_member, %{ first_name: "Linked", last_name: "Member", email: "linked#{System.unique_integer([:positive])}@example.com" }) |> Ash.create(actor: admin, return_notifications?: false) # 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 do admin = create_admin_user() {:ok, member} = Membership.Member |> Ash.Changeset.for_create(:create_member, %{ first_name: "Unlinked", last_name: "Member", email: "unlinked#{System.unique_integer([:positive])}@example.com" }) |> Ash.create(actor: admin) member end describe "own_data permission set (Mitglied)" do setup do user = create_user_with_permission_set("own_data") linked_member = create_linked_member_for_user(user) unlinked_member = create_unlinked_member() # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role]) {:ok, user} = Ash.load(user, :member, domain: Mv.Accounts) %{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 {:ok, updated_member} = linked_member |> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"}) |> Ash.update(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_raise Ash.Error.Forbidden, fn -> unlinked_member |> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"}) |> Ash.update!(actor: user) end 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_raise Ash.Error.Forbidden, fn -> Membership.Member |> Ash.Changeset.for_create(:create_member, %{ first_name: "New", last_name: "Member", email: "new#{System.unique_integer([:positive])}@example.com" }) |> Ash.create!(actor: user) end 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 do user = create_user_with_permission_set("read_only") linked_member = create_linked_member_for_user(user) unlinked_member = create_unlinked_member() # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role]) %{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_raise Ash.Error.Forbidden, fn -> Membership.Member |> Ash.Changeset.for_create(:create_member, %{ first_name: "New", last_name: "Member", email: "new#{System.unique_integer([:positive])}@example.com" }) |> Ash.create!(actor: user) end end test "cannot update any member (returns forbidden)", %{ user: user, linked_member: linked_member } do assert_raise Ash.Error.Forbidden, fn -> linked_member |> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"}) |> Ash.update!(actor: user) end 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 do user = create_user_with_permission_set("normal_user") linked_member = create_linked_member_for_user(user) unlinked_member = create_unlinked_member() # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role]) %{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.Member |> Ash.Changeset.for_create(:create_member, %{ first_name: "New", last_name: "Member", email: "new#{System.unique_integer([:positive])}@example.com" }) |> Ash.create(actor: user) assert member.first_name == "New" end test "can update any member", %{user: user, unlinked_member: unlinked_member} do {:ok, updated_member} = unlinked_member |> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"}) |> Ash.update(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 do user = create_user_with_permission_set("admin") linked_member = create_linked_member_for_user(user) unlinked_member = create_unlinked_member() # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role]) %{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.Member |> Ash.Changeset.for_create(:create_member, %{ first_name: "New", last_name: "Member", email: "new#{System.unique_integer([:positive])}@example.com" }) |> Ash.create(actor: user) assert member.first_name == "New" end test "can update any member", %{user: user, unlinked_member: unlinked_member} do {:ok, updated_member} = unlinked_member |> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"}) |> Ash.update(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 access linked member" do test "own_data user can read linked member even without explicit permission" do user = create_user_with_permission_set("own_data") linked_member = create_linked_member_for_user(user) # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role]) {:ok, user} = Ash.load(user, :member, domain: Mv.Accounts) # Should succeed (special case policy takes precedence) {:ok, member} = Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership) assert member.id == linked_member.id end test "read_only user can read linked member (via special case)" do user = create_user_with_permission_set("read_only") linked_member = create_linked_member_for_user(user) # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role]) {:ok, user} = Ash.load(user, :member, domain: Mv.Accounts) # Should succeed (special case policy 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 even without explicit permission" do user = create_user_with_permission_set("own_data") linked_member = create_linked_member_for_user(user) # Reload user to get updated member_id {:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role]) {:ok, user} = Ash.load(user, :member, domain: Mv.Accounts) # Should succeed (special case policy takes precedence) {:ok, updated_member} = linked_member |> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"}) |> Ash.update(actor: user) assert updated_member.first_name == "Updated" end end end