mitgliederverwaltung/test/mv/membership/member_policies_test.exs
Moritz 5194b20b5c
Some checks failed
continuous-integration/drone/push Build is failing
Fix unlink-by-omission: on_missing :ignore, test, doc, string-key
- Member update_member: on_missing :unrelate → :ignore (no unlink when :user omitted)
- Test: normal_user update linked member without :user keeps link
- Doc: unlink only explicit (user: nil), admin-only; Actor.admin?(nil) note
- Check: defense-in-depth for "user" string key
2026-02-04 14:07:39 +01:00

586 lines
19 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
setup %{actor: actor} do
normal_user =
Mv.Fixtures.user_with_role_fixture("normal_user")
|> Mv.Authorization.Actor.ensure_loaded()
admin =
Mv.Fixtures.user_with_role_fixture("admin")
|> Mv.Authorization.Actor.ensure_loaded()
unlinked_member = create_unlinked_member(actor)
%{normal_user: normal_user, admin: admin, unlinked_member: unlinked_member}
end
test "normal_user can create member without :user argument", %{normal_user: normal_user} do
{: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, actor: normal_user)
assert is_nil(member.user)
end
test "normal_user cannot create member with :user argument (forbidden)", %{
normal_user: normal_user
} do
other_user =
Mv.Fixtures.user_with_role_fixture("read_only")
|> Mv.Authorization.Actor.ensure_loaded()
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", %{
normal_user: normal_user,
unlinked_member: unlinked_member
} do
{: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)", %{
normal_user: normal_user,
unlinked_member: unlinked_member
} do
other_user =
Mv.Fixtures.user_with_role_fixture("own_data")
|> Mv.Authorization.Actor.ensure_loaded()
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 "normal_user cannot update member with user: nil (unlink forbidden)", %{
normal_user: normal_user,
unlinked_member: unlinked_member
} do
# Link member first (via admin), then normal_user tries to unlink via user: nil
admin =
Mv.Fixtures.user_with_role_fixture("admin") |> Mv.Authorization.Actor.ensure_loaded()
link_target =
Mv.Fixtures.user_with_role_fixture("own_data") |> Mv.Authorization.Actor.ensure_loaded()
{:ok, linked_member} =
Membership.update_member(
unlinked_member,
%{user: %{id: link_target.id}},
actor: admin
)
# Passing user: nil explicitly tries to unlink; only admin may do that
assert {:error, %Ash.Error.Forbidden{}} =
Membership.update_member(linked_member, %{user: nil}, actor: normal_user)
end
test "normal_user update linked member without :user keeps link", %{
normal_user: normal_user,
admin: admin,
unlinked_member: unlinked_member
} do
# Admin links member to a user
link_target =
Mv.Fixtures.user_with_role_fixture("own_data")
|> Mv.Authorization.Actor.ensure_loaded()
{:ok, linked_member} =
Membership.update_member(
unlinked_member,
%{user: %{id: link_target.id}},
actor: admin
)
# normal_user updates only first_name (no :user) link must remain (on_missing: :ignore)
{:ok, updated} =
Membership.update_member(linked_member, %{first_name: "Updated"}, actor: normal_user)
assert updated.first_name == "Updated"
{:ok, user} =
Ash.get(Mv.Accounts.User, link_target.id, domain: Mv.Accounts, actor: admin)
assert user.member_id == updated.id
end
test "admin can create member with :user argument", %{admin: admin} do
link_target =
Mv.Fixtures.user_with_role_fixture("own_data")
|> Mv.Authorization.Actor.ensure_loaded()
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"
{: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)", %{
admin: admin,
unlinked_member: unlinked_member
} do
link_target =
Mv.Fixtures.user_with_role_fixture("read_only")
|> Mv.Authorization.Actor.ensure_loaded()
{:ok, updated} =
Membership.update_member(
unlinked_member,
%{user: %{id: link_target.id}},
actor: admin
)
assert updated.id == unlinked_member.id
{: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