Some checks failed
continuous-integration/drone/push Build is failing
- 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
586 lines
19 KiB
Elixir
586 lines
19 KiB
Elixir
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
|