Merge branch 'main' into feature/export_csv
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2026-02-06 08:02:05 +01:00
commit 36e57b24be
102 changed files with 5332 additions and 1219 deletions

View file

@ -8,67 +8,30 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
use Mv.DataCase, async: false
alias Mv.Membership.CustomField
alias Mv.Accounts
alias Mv.Authorization
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_role_with_permission_set(permission_set_name, actor) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
defp create_custom_field do
admin = Mv.Fixtures.user_with_role_fixture("admin")
case Authorization.create_role(
%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
},
actor: actor
) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
defp create_user_with_permission_set(permission_set_name, actor) do
role = create_role_with_permission_set(permission_set_name, actor)
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create(actor: actor)
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update(actor: actor)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
defp create_custom_field(actor) do
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_#{System.unique_integer([:positive])}",
value_type: :string
})
|> Ash.create(actor: actor, domain: Mv.Membership)
|> Ash.create(actor: admin, domain: Mv.Membership)
field
end
describe "read access (all roles)" do
test "user with own_data can read all custom fields", %{actor: actor} do
custom_field = create_custom_field(actor)
user = create_user_with_permission_set("own_data", actor)
test "user with own_data can read all custom fields", %{actor: _actor} do
custom_field = create_custom_field()
user = Mv.Fixtures.user_with_role_fixture("own_data")
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id)
@ -78,9 +41,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
assert fetched.id == custom_field.id
end
test "user with read_only can read all custom fields", %{actor: actor} do
custom_field = create_custom_field(actor)
user = create_user_with_permission_set("read_only", actor)
test "user with read_only can read all custom fields", %{actor: _actor} do
custom_field = create_custom_field()
user = Mv.Fixtures.user_with_role_fixture("read_only")
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id)
@ -90,9 +53,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
assert fetched.id == custom_field.id
end
test "user with normal_user can read all custom fields", %{actor: actor} do
custom_field = create_custom_field(actor)
user = create_user_with_permission_set("normal_user", actor)
test "user with normal_user can read all custom fields", %{actor: _actor} do
custom_field = create_custom_field()
user = Mv.Fixtures.user_with_role_fixture("normal_user")
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id)
@ -102,9 +65,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
assert fetched.id == custom_field.id
end
test "user with admin can read all custom fields", %{actor: actor} do
custom_field = create_custom_field(actor)
user = create_user_with_permission_set("admin", actor)
test "user with admin can read all custom fields", %{actor: _actor} do
custom_field = create_custom_field()
user = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, fields} = Ash.read(CustomField, actor: user, domain: Mv.Membership)
ids = Enum.map(fields, & &1.id)
@ -116,9 +79,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
end
describe "write access - non-admin cannot create/update/destroy" do
setup %{actor: actor} do
user = create_user_with_permission_set("normal_user", actor)
custom_field = create_custom_field(actor)
setup %{actor: _actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
custom_field = create_custom_field()
%{user: user, custom_field: custom_field}
end
@ -152,9 +115,9 @@ defmodule Mv.Membership.CustomFieldPoliciesTest do
end
describe "write access - admin can create/update/destroy" do
setup %{actor: actor} do
user = create_user_with_permission_set("admin", actor)
custom_field = create_custom_field(actor)
setup %{actor: _actor} do
user = Mv.Fixtures.user_with_role_fixture("admin")
custom_field = create_custom_field()
%{user: user, custom_field: custom_field}
end

View file

@ -11,7 +11,6 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
alias Mv.Membership.{CustomField, CustomFieldValue}
alias Mv.Accounts
alias Mv.Authorization
require Ash.Query
@ -20,47 +19,9 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
%{actor: system_actor}
end
# Helper to create a role with a specific permission set
defp create_role_with_permission_set(permission_set_name, actor) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
defp create_linked_member_for_user(user, _actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
case Authorization.create_role(
%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
},
actor: actor
) 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, actor) do
role = create_role_with_permission_set(permission_set_name, actor)
{:ok, user} =
Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "user#{System.unique_integer([:positive])}@example.com",
password: "testpassword123"
})
|> Ash.create(actor: actor)
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update(actor: actor)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
defp create_linked_member_for_user(user, actor) do
{:ok, member} =
Mv.Membership.create_member(
%{
@ -68,18 +29,20 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
},
actor: actor
actor: admin
)
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|> Ash.update(actor: actor, domain: Mv.Accounts, return_notifications?: false)
|> Ash.update(actor: admin, domain: Mv.Accounts, return_notifications?: false)
member
end
defp create_unlinked_member(actor) do
defp create_unlinked_member(_actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} =
Mv.Membership.create_member(
%{
@ -87,25 +50,29 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
},
actor: actor
actor: admin
)
member
end
defp create_custom_field(actor) do
defp create_custom_field do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_#{System.unique_integer([:positive])}",
value_type: :string
})
|> Ash.create(actor: actor)
|> Ash.create(actor: admin, domain: Mv.Membership)
field
end
defp create_custom_field_value(member_id, custom_field_id, value, actor) do
defp create_custom_field_value(member_id, custom_field_id, value) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
@ -113,22 +80,22 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
custom_field_id: custom_field_id,
value: %{"_union_type" => "string", "_union_value" => value}
})
|> Ash.create(actor: actor, domain: Mv.Membership)
|> Ash.create(actor: admin, domain: Mv.Membership)
cfv
end
describe "own_data permission set (Mitglied)" do
setup %{actor: actor} do
user = create_user_with_permission_set("own_data", actor)
user = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
custom_field = create_custom_field(actor)
custom_field = create_custom_field()
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
@ -177,10 +144,10 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
test "can create custom field value for linked member", %{
user: user,
linked_member: linked_member,
actor: actor
actor: _actor
} do
# Create a second custom field via admin (own_data cannot create CustomField)
custom_field2 = create_custom_field(actor)
custom_field2 = create_custom_field()
{:ok, cfv} =
CustomFieldValue
@ -257,15 +224,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
describe "read_only permission set (Vorstand/Buchhaltung)" do
setup %{actor: actor} do
user = create_user_with_permission_set("read_only", actor)
user = Mv.Fixtures.user_with_role_fixture("read_only")
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
custom_field = create_custom_field(actor)
custom_field = create_custom_field()
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
@ -340,15 +307,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
describe "normal_user permission set (Kassenwart)" do
setup %{actor: actor} do
user = create_user_with_permission_set("normal_user", actor)
user = Mv.Fixtures.user_with_role_fixture("normal_user")
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
custom_field = create_custom_field(actor)
custom_field = create_custom_field()
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
@ -379,10 +346,10 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
test "can create custom field value", %{
user: user,
unlinked_member: unlinked_member,
actor: actor
actor: _actor
} do
# normal_user cannot create CustomField; use actor (admin) to create it
custom_field = create_custom_field(actor)
custom_field = create_custom_field()
{:ok, cfv} =
CustomFieldValue
@ -421,15 +388,15 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
describe "admin permission set" do
setup %{actor: actor} do
user = create_user_with_permission_set("admin", actor)
user = Mv.Fixtures.user_with_role_fixture("admin")
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
custom_field = create_custom_field(actor)
custom_field = create_custom_field()
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor)
cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked")
cfv_unlinked =
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor)
create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked")
{:ok, user} =
Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor)
@ -457,7 +424,7 @@ defmodule Mv.Membership.CustomFieldValuePoliciesTest do
end
test "can create custom field value", %{user: user, unlinked_member: unlinked_member} do
custom_field = create_custom_field(user)
custom_field = create_custom_field()
{:ok, cfv} =
CustomFieldValue

View file

@ -0,0 +1,140 @@
defmodule Mv.Membership.GroupPoliciesTest do
@moduledoc """
Tests for Group resource authorization policies.
Verifies that own_data, read_only, normal_user can read groups;
normal_user and admin can create, update, and destroy groups.
"""
use Mv.DataCase, async: false
alias Mv.Membership
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_group_fixture do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, group} =
Membership.create_group(
%{name: "Test Group #{System.unique_integer([:positive])}", description: "Test"},
actor: admin
)
group
end
describe "own_data permission set" do
setup %{actor: _actor} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
group = create_group_fixture()
%{user: user, group: group}
end
test "can read groups (list)", %{user: user} do
{:ok, groups} = Membership.list_groups(actor: user)
assert is_list(groups)
end
test "can read single group", %{user: user, group: group} do
{:ok, found} = Ash.get(Membership.Group, group.id, actor: user, domain: Mv.Membership)
assert found.id == group.id
end
end
describe "read_only permission set" do
setup %{actor: _actor} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
group = create_group_fixture()
%{user: user, group: group}
end
test "can read groups (list)", %{user: user} do
{:ok, groups} = Membership.list_groups(actor: user)
assert is_list(groups)
end
test "can read single group", %{user: user, group: group} do
{:ok, found} = Ash.get(Membership.Group, group.id, actor: user, domain: Mv.Membership)
assert found.id == group.id
end
end
describe "normal_user permission set" do
setup %{actor: _actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
group = create_group_fixture()
%{user: user, group: group}
end
test "can read groups (list)", %{user: user} do
{:ok, groups} = Membership.list_groups(actor: user)
assert is_list(groups)
end
test "can read single group", %{user: user, group: group} do
{:ok, found} = Ash.get(Membership.Group, group.id, actor: user, domain: Mv.Membership)
assert found.id == group.id
end
test "can create group", %{user: user} do
assert {:ok, created} =
Membership.create_group(
%{name: "New Group #{System.unique_integer([:positive])}", description: "New"},
actor: user
)
assert created.name =~ "New Group"
end
test "can update group", %{user: user, group: group} do
assert {:ok, updated} =
Membership.update_group(group, %{description: "Updated"}, actor: user)
assert updated.description == "Updated"
end
test "can destroy group", %{user: user, group: group} do
assert :ok = Membership.destroy_group(group, actor: user)
end
end
describe "admin permission set" do
setup %{actor: _actor} do
user = Mv.Fixtures.user_with_role_fixture("admin")
group = create_group_fixture()
%{user: user, group: group}
end
test "can read groups (list)", %{user: user} do
{:ok, groups} = Membership.list_groups(actor: user)
assert is_list(groups)
end
test "can create group", %{user: user} do
name = "Admin Group #{System.unique_integer([:positive])}"
assert {:ok, group} =
Membership.create_group(%{name: name, description: "Admin created"}, actor: user)
assert group.name == name
end
test "can update group", %{user: user, group: group} do
assert {:ok, updated} =
Membership.update_group(group, %{description: "Updated by admin"}, actor: user)
assert updated.description == "Updated by admin"
end
test "can destroy group", %{user: user, group: group} do
assert :ok = Membership.destroy_group(group, actor: user)
assert {:error, _} = Ash.get(Membership.Group, group.id, actor: user, domain: Mv.Membership)
end
end
end

View file

@ -0,0 +1,194 @@
defmodule Mv.Membership.MemberEmailValidationTest do
@moduledoc """
Tests for Member email-change permission validation.
When a member is linked to a user, only admins or the linked user may change
that member's email. Unlinked members and non-email updates are unaffected.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Helpers.SystemActor
alias Mv.Membership
setup do
system_actor = SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_linked_member_for_user(user, _actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} =
Membership.create_member(
%{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
},
actor: admin
)
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)
member
end
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 "unlinked member" do
test "normal_user can update email of unlinked member", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
unlinked_member = create_unlinked_member(actor)
new_email = "new#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(unlinked_member, %{email: new_email}, actor: normal_user)
assert updated.email == new_email
end
test "validation does not block when member has no linked user", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
unlinked_member = create_unlinked_member(actor)
new_email = "other#{System.unique_integer([:positive])}@example.com"
assert {:ok, _} =
Membership.update_member(unlinked_member, %{email: new_email}, actor: normal_user)
end
end
describe "linked member another user's member" do
test "normal_user cannot update email of another user's linked member", %{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor)
normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user")
new_email = "other#{System.unique_integer([:positive])}@example.com"
assert {:error, %Ash.Error.Invalid{} = error} =
Membership.update_member(linked_member, %{email: new_email}, actor: normal_user_b)
assert Enum.any?(error.errors, &(&1.field == :email)),
"expected an error for field :email, got: #{inspect(error.errors)}"
end
test "admin can update email of linked member", %{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor)
admin = Mv.Fixtures.user_with_role_fixture("admin")
new_email = "admin_changed#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: admin)
assert updated.email == new_email
end
end
describe "linked member own member" do
test "own_data user can update email of their own linked member", %{actor: actor} do
own_data_user = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(own_data_user, actor)
{:ok, own_data_user} =
Ash.get(Accounts.User, own_data_user.id, domain: Mv.Accounts, load: [:role], actor: actor)
{:ok, own_data_user} =
Ash.load(own_data_user, :member, domain: Mv.Accounts, actor: actor)
new_email = "own_updated#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: own_data_user)
assert updated.email == new_email
end
test "normal_user with linked member can update email of that same member", %{actor: actor} do
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
linked_member = create_linked_member_for_user(normal_user, actor)
{:ok, normal_user} =
Ash.get(Accounts.User, normal_user.id, domain: Mv.Accounts, load: [:role], actor: actor)
{:ok, normal_user} = Ash.load(normal_user, :member, domain: Mv.Accounts, actor: actor)
new_email = "normal_own#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: normal_user)
assert updated.email == new_email
end
end
describe "no-op / other fields" do
test "updating only other attributes on linked member as normal_user does not trigger validation error",
%{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor)
normal_user_b = Mv.Fixtures.user_with_role_fixture("normal_user")
assert {:ok, updated} =
Membership.update_member(linked_member, %{first_name: "UpdatedName"},
actor: normal_user_b
)
assert updated.first_name == "UpdatedName"
assert updated.email == linked_member.email
end
test "updating email of linked member as admin succeeds", %{actor: actor} do
user_a = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user_a, actor)
admin = Mv.Fixtures.user_with_role_fixture("admin")
new_email = "admin_ok#{System.unique_integer([:positive])}@example.com"
assert {:ok, updated} =
Membership.update_member(linked_member, %{email: new_email}, actor: admin)
assert updated.email == new_email
end
end
describe "read_only" do
test "read_only cannot update any member (policy rejects before validation)", %{actor: actor} do
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
linked_member = create_linked_member_for_user(read_only_user, actor)
{:ok, read_only_user} =
Ash.get(Accounts.User, read_only_user.id,
domain: Mv.Accounts,
load: [:role],
actor: actor
)
assert {:error, %Ash.Error.Forbidden{}} =
Membership.update_member(linked_member, %{email: "changed@example.com"},
actor: read_only_user
)
end
end
end

View file

@ -0,0 +1,234 @@
defmodule Mv.Membership.MemberGroupPoliciesTest do
@moduledoc """
Tests for MemberGroup resource authorization policies.
Verifies own_data can only read linked member's associations;
read_only can read all, cannot create/destroy;
normal_user and admin can read, create, destroy.
"""
use Mv.DataCase, async: false
alias Mv.Membership
require Ash.Query
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
%{actor: system_actor}
end
defp create_member_fixture do
Mv.Fixtures.member_fixture()
end
defp create_group_fixture do
Mv.Fixtures.group_fixture()
end
defp create_member_group_fixture(member_id, group_id) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member_group} =
Membership.create_member_group(%{member_id: member_id, group_id: group_id}, actor: admin)
member_group
end
describe "own_data permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("own_data")
member = create_member_fixture()
group = create_group_fixture()
# Link user to member so actor.member_id is set
admin = Mv.Fixtures.user_with_role_fixture("admin")
user =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:member_id, member.id)
|> Ash.update(actor: admin)
{:ok, user} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
mg_linked = create_member_group_fixture(member.id, group.id)
# MemberGroup for another member (not linked to user)
other_member = create_member_fixture()
other_group = create_group_fixture()
mg_other = create_member_group_fixture(other_member.id, other_group.id)
%{user: user, member: member, group: group, mg_linked: mg_linked, mg_other: mg_other}
end
test "can read member_groups for linked member only", %{user: user, mg_linked: mg_linked} do
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: user, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg_linked.id in ids
refute Enum.empty?(list)
end
test "list returns only member_groups where member_id == actor.member_id", %{
user: user,
mg_linked: mg_linked,
mg_other: mg_other
} do
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: user, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg_linked.id in ids
refute mg_other.id in ids
end
test "cannot create member_group (returns forbidden)", %{user: user, actor: _actor} do
# Use fresh member/group so we assert on Forbidden, not on duplicate validation
other_member = create_member_fixture()
other_group = create_group_fixture()
assert {:error, %Ash.Error.Forbidden{}} =
Membership.create_member_group(
%{member_id: other_member.id, group_id: other_group.id},
actor: user
)
end
test "cannot destroy member_group (returns forbidden)", %{user: user, mg_linked: mg_linked} do
assert {:error, %Ash.Error.Forbidden{}} =
Membership.destroy_member_group(mg_linked, actor: user)
end
end
describe "read_only permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("read_only")
member = create_member_fixture()
group = create_group_fixture()
mg = create_member_group_fixture(member.id, group.id)
%{actor: actor, user: user, member: member, group: group, mg: mg}
end
test "can read all member_groups", %{user: user, mg: mg} do
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: user, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg.id in ids
end
test "cannot create member_group (returns forbidden)", %{user: user, actor: _actor} do
member = create_member_fixture()
group = create_group_fixture()
assert {:error, %Ash.Error.Forbidden{}} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: user
)
end
test "cannot destroy member_group (returns forbidden)", %{user: user, mg: mg} do
assert {:error, %Ash.Error.Forbidden{}} =
Membership.destroy_member_group(mg, actor: user)
end
end
describe "normal_user permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("normal_user")
member = create_member_fixture()
group = create_group_fixture()
mg = create_member_group_fixture(member.id, group.id)
%{actor: actor, user: user, member: member, group: group, mg: mg}
end
test "can read all member_groups", %{user: user, mg: mg} do
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: user, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg.id in ids
end
test "can create member_group", %{user: user, actor: _actor} do
member = create_member_fixture()
group = create_group_fixture()
assert {:ok, _mg} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: user
)
end
test "can destroy member_group", %{user: user, mg: mg} do
assert :ok = Membership.destroy_member_group(mg, actor: user)
end
end
describe "admin permission set" do
setup %{actor: actor} do
user = Mv.Fixtures.user_with_role_fixture("admin")
member = create_member_fixture()
group = create_group_fixture()
mg = create_member_group_fixture(member.id, group.id)
%{actor: actor, user: user, member: member, group: group, mg: mg}
end
test "can read all member_groups", %{user: user, mg: mg} do
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: user, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg.id in ids
end
test "admin with member_id set (linked to member) still reads all member_groups", %{
actor: actor
} do
# Admin linked to a member (e.g. viewing as member context) must still get :all scope,
# not restricted to linked member's groups (bypass is only for own_data).
admin = Mv.Fixtures.user_with_role_fixture("admin")
linked_member = create_member_fixture()
other_member = create_member_fixture()
group_a = create_group_fixture()
group_b = create_group_fixture()
admin =
admin
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_change_attribute(:member_id, linked_member.id)
|> Ash.update(actor: actor)
{:ok, admin} = Ash.load(admin, :role, domain: Mv.Accounts, actor: actor)
mg_linked = create_member_group_fixture(linked_member.id, group_a.id)
mg_other = create_member_group_fixture(other_member.id, group_b.id)
{:ok, list} =
Mv.Membership.MemberGroup
|> Ash.read(actor: admin, domain: Mv.Membership)
ids = Enum.map(list, & &1.id)
assert mg_linked.id in ids, "Admin with member_id must see linked member's MemberGroups"
assert mg_other.id in ids,
"Admin with member_id must see all MemberGroups (:all), not only linked"
end
test "can create member_group", %{user: user, actor: _actor} do
member = create_member_fixture()
group = create_group_fixture()
assert {:ok, _mg} =
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: user
)
end
test "can destroy member_group", %{user: user, mg: mg} do
assert :ok = Membership.destroy_member_group(mg, actor: user)
end
end
end

View file

@ -12,7 +12,6 @@ defmodule Mv.Membership.MemberPoliciesTest do
alias Mv.Membership
alias Mv.Accounts
alias Mv.Authorization
require Ash.Query
@ -21,58 +20,9 @@ defmodule Mv.Membership.MemberPoliciesTest do
%{actor: system_actor}
end
# Helper to create a role with a specific permission set
defp create_role_with_permission_set(permission_set_name, actor) 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
},
actor: actor
) 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, actor) do
# Create role with permission set
role = create_role_with_permission_set(permission_set_name, actor)
# 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(actor: actor)
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update(actor: actor)
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
user_with_role
end
# Helper to create an admin user (for creating test fixtures)
defp create_admin_user(actor) do
create_user_with_permission_set("admin", actor)
end
# Helper to create a member linked to a user
defp create_linked_member_for_user(user, actor) do
admin = create_admin_user(actor)
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
@ -105,8 +55,8 @@ defmodule Mv.Membership.MemberPoliciesTest do
end
# Helper to create an unlinked member (no user relationship)
defp create_unlinked_member(actor) do
admin = create_admin_user(actor)
defp create_unlinked_member(_actor) do
admin = Mv.Fixtures.user_with_role_fixture("admin")
{:ok, member} =
Membership.create_member(
@ -123,7 +73,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "own_data permission set (Mitglied)" do
setup %{actor: actor} do
user = create_user_with_permission_set("own_data", actor)
user = Mv.Fixtures.user_with_role_fixture("own_data")
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
@ -207,7 +157,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "read_only permission set (Vorstand/Buchhaltung)" do
setup %{actor: actor} do
user = create_user_with_permission_set("read_only", actor)
user = Mv.Fixtures.user_with_role_fixture("read_only")
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
@ -273,7 +223,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "normal_user permission set (Kassenwart)" do
setup %{actor: actor} do
user = create_user_with_permission_set("normal_user", actor)
user = Mv.Fixtures.user_with_role_fixture("normal_user")
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
@ -330,7 +280,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
describe "admin permission set" do
setup %{actor: actor} do
user = create_user_with_permission_set("admin", actor)
user = Mv.Fixtures.user_with_role_fixture("admin")
linked_member = create_linked_member_for_user(user, actor)
unlinked_member = create_unlinked_member(actor)
@ -397,7 +347,7 @@ defmodule Mv.Membership.MemberPoliciesTest 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 = create_user_with_permission_set("read_only", actor)
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
@ -416,7 +366,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
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 = create_user_with_permission_set("own_data", actor)
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
@ -437,7 +387,7 @@ defmodule Mv.Membership.MemberPoliciesTest do
} do
# Update is NOT handled by special case - it's handled by HasPermission
# with :linked scope. own_data has Member.update scope :linked.
user = create_user_with_permission_set("own_data", actor)
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
@ -453,4 +403,184 @@ defmodule Mv.Membership.MemberPoliciesTest do
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