From f7cda665988c77d9fc593fa89cd2db39705aa126 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 8 Jan 2026 21:03:17 +0100 Subject: [PATCH] test: add Member resource policy tests --- .../has_permission_integration_test.exs | 19 +- .../checks/has_permission_test.exs | 19 +- test/mv/membership/member_policies_test.exs | 418 ++++++++++++++++++ 3 files changed, 446 insertions(+), 10 deletions(-) create mode 100644 test/mv/membership/member_policies_test.exs diff --git a/test/mv/authorization/checks/has_permission_integration_test.exs b/test/mv/authorization/checks/has_permission_integration_test.exs index f1f32c3..5aa43a5 100644 --- a/test/mv/authorization/checks/has_permission_integration_test.exs +++ b/test/mv/authorization/checks/has_permission_integration_test.exs @@ -14,16 +14,22 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do alias Mv.Authorization.Checks.HasPermission # Helper to create mock actor with role - defp create_actor_with_role(permission_set_name) do - %{ + defp create_actor_with_role(permission_set_name, opts \\ []) do + actor = %{ id: "user-#{System.unique_integer([:positive])}", role: %{permission_set_name: permission_set_name} } + + # Add member_id if provided (needed for :linked scope tests) + case Keyword.get(opts, :member_id) do + nil -> actor + member_id -> Map.put(actor, :member_id, member_id) + end end describe "Filter Expression Structure - :linked scope" do test "Member filter uses user.id relationship path" do - actor = create_actor_with_role("own_data") + actor = create_actor_with_role("own_data", member_id: "member-123") authorizer = create_authorizer(Mv.Membership.Member, :read) filter = HasPermission.auto_filter(actor, authorizer, []) @@ -37,7 +43,7 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do end test "CustomFieldValue filter uses member.user.id relationship path" do - actor = create_actor_with_role("own_data") + actor = create_actor_with_role("own_data", member_id: "member-123") authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read) filter = HasPermission.auto_filter(actor, authorizer, []) @@ -81,7 +87,10 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do defp create_authorizer(resource, action) do %Ash.Policy.Authorizer{ resource: resource, - subject: %{action: %{name: action}} + subject: %{ + action: %{type: action}, + data: nil + } } end end diff --git a/test/mv/authorization/checks/has_permission_test.exs b/test/mv/authorization/checks/has_permission_test.exs index 5ab88c6..f577d03 100644 --- a/test/mv/authorization/checks/has_permission_test.exs +++ b/test/mv/authorization/checks/has_permission_test.exs @@ -13,16 +13,25 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do defp create_authorizer(resource, action) do %Ash.Policy.Authorizer{ resource: resource, - subject: %{action: %{name: action}} + subject: %{ + action: %{type: action}, + data: nil + } } end # Helper to create actor with role - defp create_actor(id, permission_set_name) do - %{ + defp create_actor(id, permission_set_name, opts \\ []) do + actor = %{ id: id, role: %{permission_set_name: permission_set_name} } + + # Add member_id if provided (needed for :linked scope tests) + case Keyword.get(opts, :member_id) do + nil -> actor + member_id -> Map.put(actor, :member_id, member_id) + end end describe "describe/1" do @@ -120,7 +129,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do describe "auto_filter/3 - Scope :linked" do test "scope :linked for Member returns user_id filter" do - user = create_actor("user-123", "own_data") + user = create_actor("user-123", "own_data", member_id: "member-456") authorizer = create_authorizer(Mv.Membership.Member, :read) filter = HasPermission.auto_filter(user, authorizer, []) @@ -130,7 +139,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do end test "scope :linked for CustomFieldValue returns member.user_id filter" do - user = create_actor("user-123", "own_data") + user = create_actor("user-123", "own_data", member_id: "member-456") authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :update) filter = HasPermission.auto_filter(user, authorizer, []) diff --git a/test/mv/membership/member_policies_test.exs b/test/mv/membership/member_policies_test.exs new file mode 100644 index 0000000..ce1e7ce --- /dev/null +++ b/test/mv/membership/member_policies_test.exs @@ -0,0 +1,418 @@ +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