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} 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_linked_member_for_user(user, actor) do {:ok, member} = Mv.Membership.create_member( %{ first_name: "Linked", last_name: "Member", email: "linked#{System.unique_integer([:positive])}@example.com" }, actor: actor ) 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) member end defp create_unlinked_member(actor) do {:ok, member} = Mv.Membership.create_member( %{ first_name: "Unlinked", last_name: "Member", email: "unlinked#{System.unique_integer([:positive])}@example.com" }, actor: actor ) 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, actor: actor } 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, actor: actor) 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, actor: actor } do :ok = Ash.destroy(cfv_unlinked, actor: user, domain: Mv.Membership) assert {:error, _} = Ash.get(CustomFieldValue, cfv_unlinked.id, domain: Mv.Membership, actor: actor) 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, actor: actor } do :ok = Ash.destroy(cfv_unlinked, actor: user, domain: Mv.Membership) assert {:error, _} = Ash.get(CustomFieldValue, cfv_unlinked.id, domain: Mv.Membership, actor: actor) end end end