From 49af921336bfe19e0087909f432ad8389163ea59 Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 27 Jan 2026 13:40:34 +0100 Subject: [PATCH] Add CustomFieldValue policy tests (own_data, read_only, normal_user, admin) Covers read/update/create/destroy for linked vs unlinked members and CRUD permissions per permission set. --- .../custom_field_value_policies_test.exs | 487 ++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 test/mv/membership/custom_field_value_policies_test.exs diff --git a/test/mv/membership/custom_field_value_policies_test.exs b/test/mv/membership/custom_field_value_policies_test.exs new file mode 100644 index 0000000..c7a80db --- /dev/null +++ b/test/mv/membership/custom_field_value_policies_test.exs @@ -0,0 +1,487 @@ +defmodule Mv.Membership.CustomFieldValuePoliciesTest do + @moduledoc """ + Tests for CustomFieldValue 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 + use Mv.DataCase, async: false + + alias Mv.Membership.{CustomField, CustomFieldValue, Member} + alias Mv.Accounts + alias Mv.Authorization + + require Ash.Query + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + %{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 + 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_admin_user(actor) do + create_user_with_permission_set("admin", actor) + end + + defp create_linked_member_for_user(user, actor) do + admin = create_admin_user(actor) + + {:ok, member} = + 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) + + 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 = create_admin_user(actor) + + {:ok, member} = + 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 + + 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) + + field + end + + defp create_custom_field_value(member_id, custom_field_id, value, actor) do + {:ok, cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_id, + custom_field_id: custom_field_id, + value: %{"_union_type" => "string", "_union_value" => value} + }) + |> Ash.create(actor: actor, 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) + linked_member = create_linked_member_for_user(user, actor) + unlinked_member = create_unlinked_member(actor) + custom_field = create_custom_field(actor) + + cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor) + + cfv_unlinked = + create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor) + + {: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, + custom_field: custom_field, + cfv_linked: cfv_linked, + cfv_unlinked: cfv_unlinked, + actor: actor + } + end + + test "can read custom field values of linked member", %{user: user, cfv_linked: cfv_linked} do + {:ok, cfv} = + Ash.get(CustomFieldValue, cfv_linked.id, actor: user, domain: Mv.Membership) + + assert cfv.id == cfv_linked.id + end + + test "can list custom field values returns only linked member's values", %{ + user: user, + cfv_linked: cfv_linked + } do + {:ok, values} = Ash.read(CustomFieldValue, actor: user, domain: Mv.Membership) + + assert length(values) == 1 + assert hd(values).id == cfv_linked.id + end + + test "can update custom field value of linked member", %{user: user, cfv_linked: cfv_linked} do + {:ok, updated} = + cfv_linked + |> Ash.Changeset.for_update(:update, %{ + value: %{"_union_type" => "string", "_union_value" => "updated"} + }) + |> Ash.update(actor: user, domain: Mv.Membership) + + assert %Ash.Union{value: "updated", type: :string} = updated.value + end + + test "can create custom field value for linked member", %{ + user: user, + linked_member: linked_member, + actor: actor + } do + # Create a second custom field via admin (own_data cannot create CustomField) + custom_field2 = create_custom_field(actor) + + {:ok, cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: linked_member.id, + custom_field_id: custom_field2.id, + value: %{"_union_type" => "string", "_union_value" => "new"} + }) + |> Ash.create(actor: user, domain: Mv.Membership) + + assert cfv.member_id == linked_member.id + assert cfv.custom_field_id == custom_field2.id + end + + test "can destroy custom field value of linked member", %{user: user, cfv_linked: cfv_linked} do + result = Ash.destroy(cfv_linked, actor: user, domain: Mv.Membership) + assert :ok = result + + assert {:error, _} = Ash.get(CustomFieldValue, cfv_linked.id, domain: Mv.Membership) + end + + test "cannot read custom field values of unlinked member", %{ + user: user, + cfv_unlinked: cfv_unlinked + } do + assert_raise Ash.Error.Invalid, fn -> + Ash.get!(CustomFieldValue, cfv_unlinked.id, actor: user, domain: Mv.Membership) + end + end + + test "cannot update custom field value of unlinked member", %{ + user: user, + cfv_unlinked: cfv_unlinked + } do + assert_raise Ash.Error.Forbidden, fn -> + cfv_unlinked + |> Ash.Changeset.for_update(:update, %{ + value: %{"_union_type" => "string", "_union_value" => "hacked"} + }) + |> Ash.update!(actor: user, domain: Mv.Membership) + end + end + + test "cannot create custom field value for unlinked member", %{ + user: user, + unlinked_member: unlinked_member, + custom_field: custom_field + } do + assert_raise Ash.Error.Forbidden, fn -> + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: unlinked_member.id, + custom_field_id: custom_field.id, + value: %{"_union_type" => "string", "_union_value" => "forbidden"} + }) + |> Ash.create!(actor: user, domain: Mv.Membership) + end + end + + test "cannot destroy custom field value of unlinked member", %{ + user: user, + cfv_unlinked: cfv_unlinked + } do + assert_raise Ash.Error.Forbidden, fn -> + Ash.destroy!(cfv_unlinked, actor: user, domain: Mv.Membership) + end + end + end + + describe "read_only permission set (Vorstand/Buchhaltung)" do + setup %{actor: actor} do + user = create_user_with_permission_set("read_only", actor) + linked_member = create_linked_member_for_user(user, actor) + unlinked_member = create_unlinked_member(actor) + custom_field = create_custom_field(actor) + + cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor) + + cfv_unlinked = + create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor) + + {:ok, user} = + Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) + + %{ + user: user, + cfv_linked: cfv_linked, + cfv_unlinked: cfv_unlinked, + custom_field: custom_field, + linked_member: linked_member, + unlinked_member: unlinked_member + } + end + + test "can read all custom field values", %{ + user: user, + cfv_linked: cfv_linked, + cfv_unlinked: cfv_unlinked + } do + {:ok, values} = Ash.read(CustomFieldValue, actor: user, domain: Mv.Membership) + + ids = Enum.map(values, & &1.id) + assert cfv_linked.id in ids + assert cfv_unlinked.id in ids + end + + test "can read individual custom field value", %{user: user, cfv_unlinked: cfv_unlinked} do + {:ok, cfv} = + Ash.get(CustomFieldValue, cfv_unlinked.id, actor: user, domain: Mv.Membership) + + assert cfv.id == cfv_unlinked.id + end + + test "cannot create custom field value (returns forbidden)", %{ + user: user, + linked_member: linked_member, + custom_field: custom_field + } do + assert_raise Ash.Error.Forbidden, fn -> + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: linked_member.id, + custom_field_id: custom_field.id, + value: %{"_union_type" => "string", "_union_value" => "forbidden"} + }) + |> Ash.create!(actor: user, domain: Mv.Membership) + end + end + + test "cannot update custom field value (returns forbidden)", %{ + user: user, + cfv_linked: cfv_linked + } do + assert_raise Ash.Error.Forbidden, fn -> + cfv_linked + |> Ash.Changeset.for_update(:update, %{ + value: %{"_union_type" => "string", "_union_value" => "hacked"} + }) + |> Ash.update!(actor: user, domain: Mv.Membership) + end + end + + test "cannot destroy custom field value (returns forbidden)", %{ + user: user, + cfv_linked: cfv_linked + } do + assert_raise Ash.Error.Forbidden, fn -> + Ash.destroy!(cfv_linked, actor: user, domain: Mv.Membership) + end + end + end + + describe "normal_user permission set (Kassenwart)" do + setup %{actor: actor} do + user = create_user_with_permission_set("normal_user", actor) + linked_member = create_linked_member_for_user(user, actor) + unlinked_member = create_unlinked_member(actor) + custom_field = create_custom_field(actor) + + cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor) + + cfv_unlinked = + create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor) + + {:ok, user} = + Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) + + %{ + user: user, + cfv_linked: cfv_linked, + cfv_unlinked: cfv_unlinked, + custom_field: custom_field, + linked_member: linked_member, + unlinked_member: unlinked_member, + actor: actor + } + end + + test "can read all custom field values", %{ + user: user, + cfv_linked: cfv_linked, + cfv_unlinked: cfv_unlinked + } do + {:ok, values} = Ash.read(CustomFieldValue, actor: user, domain: Mv.Membership) + + ids = Enum.map(values, & &1.id) + assert cfv_linked.id in ids + assert cfv_unlinked.id in ids + end + + test "can create custom field value", %{ + user: user, + unlinked_member: unlinked_member, + actor: actor + } do + # normal_user cannot create CustomField; use actor (admin) to create it + custom_field = create_custom_field(actor) + + {:ok, cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: unlinked_member.id, + custom_field_id: custom_field.id, + value: %{"_union_type" => "string", "_union_value" => "new"} + }) + |> Ash.create(actor: user, domain: Mv.Membership) + + assert cfv.member_id == unlinked_member.id + end + + test "can update any custom field value", %{user: user, cfv_unlinked: cfv_unlinked} do + {:ok, updated} = + cfv_unlinked + |> Ash.Changeset.for_update(:update, %{ + value: %{"_union_type" => "string", "_union_value" => "updated"} + }) + |> Ash.update(actor: user, domain: Mv.Membership) + + assert %Ash.Union{value: "updated", type: :string} = updated.value + end + + test "can destroy any custom field value", %{user: user, cfv_unlinked: cfv_unlinked} do + :ok = Ash.destroy(cfv_unlinked, actor: user, domain: Mv.Membership) + + assert {:error, _} = Ash.get(CustomFieldValue, cfv_unlinked.id, domain: Mv.Membership) + end + end + + describe "admin permission set" do + setup %{actor: actor} do + user = create_user_with_permission_set("admin", actor) + linked_member = create_linked_member_for_user(user, actor) + unlinked_member = create_unlinked_member(actor) + custom_field = create_custom_field(actor) + + cfv_linked = create_custom_field_value(linked_member.id, custom_field.id, "linked", actor) + + cfv_unlinked = + create_custom_field_value(unlinked_member.id, custom_field.id, "unlinked", actor) + + {:ok, user} = + Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role], actor: actor) + + %{ + user: user, + cfv_linked: cfv_linked, + cfv_unlinked: cfv_unlinked, + custom_field: custom_field, + linked_member: linked_member, + unlinked_member: unlinked_member + } + end + + test "can read all custom field values", %{ + user: user, + cfv_linked: cfv_linked, + cfv_unlinked: cfv_unlinked + } do + {:ok, values} = Ash.read(CustomFieldValue, actor: user, domain: Mv.Membership) + + ids = Enum.map(values, & &1.id) + assert cfv_linked.id in ids + assert cfv_unlinked.id in ids + end + + test "can create custom field value", %{user: user, unlinked_member: unlinked_member} do + custom_field = create_custom_field(user) + + {:ok, cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: unlinked_member.id, + custom_field_id: custom_field.id, + value: %{"_union_type" => "string", "_union_value" => "new"} + }) + |> Ash.create(actor: user, domain: Mv.Membership) + + assert cfv.member_id == unlinked_member.id + end + + test "can update any custom field value", %{user: user, cfv_unlinked: cfv_unlinked} do + {:ok, updated} = + cfv_unlinked + |> Ash.Changeset.for_update(:update, %{ + value: %{"_union_type" => "string", "_union_value" => "updated"} + }) + |> Ash.update(actor: user, domain: Mv.Membership) + + assert %Ash.Union{value: "updated", type: :string} = updated.value + end + + test "can destroy any custom field value", %{user: user, cfv_unlinked: cfv_unlinked} do + :ok = Ash.destroy(cfv_unlinked, actor: user, domain: Mv.Membership) + + assert {:error, _} = Ash.get(CustomFieldValue, cfv_unlinked.id, domain: Mv.Membership) + end + end +end