diff --git a/Justfile b/Justfile index c68c473..f25041c 100644 --- a/Justfile +++ b/Justfile @@ -41,7 +41,7 @@ audit: mix deps.audit mix hex.audit -test *args: install-dependencies start-database +test *args: install-dependencies mix test {{args}} format: diff --git a/docs/roles-and-permissions-architecture.md b/docs/roles-and-permissions-architecture.md index 8934688..063de32 100644 --- a/docs/roles-and-permissions-architecture.md +++ b/docs/roles-and-permissions-architecture.md @@ -501,9 +501,11 @@ defmodule Mv.Authorization.PermissionSets do %{resource: "Member", action: :read, scope: :linked, granted: true}, %{resource: "Member", action: :update, scope: :linked, granted: true}, - # CustomFieldValue: Can read/update custom field values of linked member + # CustomFieldValue: Can read/update/create/destroy custom field values of linked member %{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true}, %{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true}, + %{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true}, + %{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true}, # CustomField: Can read all (needed for forms) %{resource: "CustomField", action: :read, scope: :all, granted: true} @@ -678,7 +680,7 @@ Quick reference table showing what each permission set allows: | **User** (all) | - | - | - | R, C, U, D | | **Member** (linked) | R, U | - | - | - | | **Member** (all) | - | R | R, C, U | R, C, U, D | -| **CustomFieldValue** (linked) | R, U | - | - | - | +| **CustomFieldValue** (linked) | R, U, C, D | - | - | - | | **CustomFieldValue** (all) | - | R | R, C, U, D | R, C, U, D | | **CustomField** (all) | R | R | R | R, C, U, D | | **Role** (all) | - | - | - | R, C, U, D | @@ -1053,35 +1055,35 @@ end ### CustomFieldValue Resource Policies -**Location:** `lib/mv/membership/custom_field_value.ex` +**Location:** `lib/membership/custom_field_value.ex` -**Special Case:** Users can access custom field values of their linked member. +**Pattern:** Bypass for READ (list queries), CustomFieldValueCreateScope for create (no filter), HasPermission for read/update/destroy. Create uses a dedicated check because Ash cannot apply filters to create actions. + +The bypass `action_type(:read)` is a production-side rule: reading own CFVs (where `member_id == actor.member_id`) is always allowed and overrides Permission-Sets; no further policies are needed for that. It applies to all read actions (get, list, load). ```elixir defmodule Mv.Membership.CustomFieldValue do use Ash.Resource, ... policies do - # SPECIAL CASE: Users can access custom field values of their linked member - # Note: This uses member_id relationship (CustomFieldValue.member_id → Member.id → User.member_id) - policy action_type([:read, :update]) do - description "Users can access custom field values of their linked member" + # Bypass for READ (list queries; expr triggers auto_filter) + bypass action_type(:read) do + description "Users can read custom field values of their linked member" authorize_if expr(member_id == ^actor(:member_id)) end - # GENERAL: Check permissions from role - policy action_type([:read, :create, :update, :destroy]) do - description "Check permissions from user's role" - authorize_if Mv.Authorization.Checks.HasPermission + # CREATE: CustomFieldValueCreateScope (no filter; Ash rejects filters on create) + # own_data -> create when member_id == actor.member_id; normal_user/admin -> create (scope :all) + policy action_type(:create) do + authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope end - # DEFAULT: Forbid - policy action_type([:read, :create, :update, :destroy]) do - forbid_if always() + # READ/UPDATE/DESTROY: HasPermission (scope :linked / :all) + policy action_type([:read, :update, :destroy]) do + authorize_if Mv.Authorization.Checks.HasPermission end + # DEFAULT: Ash implicitly forbids if no policy authorized (fail-closed) end - - # ... end ``` @@ -1089,11 +1091,13 @@ end | Action | Mitglied | Vorstand | Kassenwart | Buchhaltung | Admin | |--------|----------|----------|------------|-------------|-------| -| Read linked | ✅ (special) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | -| Update linked | ✅ (special) | ❌ | ✅ | ❌ | ✅ | +| Read linked | ✅ (bypass) | ✅ (if linked) | ✅ | ✅ (if linked) | ✅ | +| Update linked | ✅ (scope :linked) | ❌ | ✅ | ❌ | ✅ | +| Create linked | ✅ (CustomFieldValueCreateScope) | ❌ | ✅ | ❌ | ✅ | +| Destroy linked | ✅ (scope :linked) | ❌ | ✅ | ❌ | ✅ | | Read all | ❌ | ✅ | ✅ | ✅ | ✅ | -| Create | ❌ | ❌ | ✅ | ❌ | ✅ | -| Destroy | ❌ | ❌ | ✅ | ❌ | ✅ | +| Create all | ❌ | ❌ | ✅ | ❌ | ✅ | +| Destroy all | ❌ | ❌ | ✅ | ❌ | ✅ | ### CustomField Resource Policies diff --git a/lib/membership/custom_field_value.ex b/lib/membership/custom_field_value.ex index 232ba99..623455d 100644 --- a/lib/membership/custom_field_value.ex +++ b/lib/membership/custom_field_value.ex @@ -39,7 +39,10 @@ defmodule Mv.Membership.CustomFieldValue do """ use Ash.Resource, domain: Mv.Membership, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + + import Ash.Expr postgres do table "custom_field_values" @@ -62,6 +65,36 @@ defmodule Mv.Membership.CustomFieldValue do end end + # Authorization Policies + # Order matters: Most specific policies first, then general permission check + # Pattern aligns with User and Member resources (bypass for READ, HasPermission for update/destroy) + # Create uses CustomFieldValueCreateScope because Ash cannot apply filters to create actions. + policies do + # SPECIAL CASE: Users can READ custom field values of their linked member + # Bypass needed for list queries (expr triggers auto_filter in Ash) + bypass action_type(:read) do + description "Users can read custom field values of their linked member" + authorize_if expr(member_id == ^actor(:member_id)) + end + + # CREATE: CustomFieldValueCreateScope (no filter; Ash rejects filters on create) + # - :own_data -> create allowed when member_id == actor.member_id (scope :linked) + # - :read_only -> no create permission + # - :normal_user / :admin -> create allowed (scope :all) + policy action_type(:create) do + description "CustomFieldValue create allowed by permission set scope" + authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope + end + + # READ/UPDATE/DESTROY: HasPermission (scope :linked / :all) + policy action_type([:read, :update, :destroy]) do + description "Check permissions from user's role and permission set" + authorize_if Mv.Authorization.Checks.HasPermission + end + + # DEFAULT: Ash implicitly forbids if no policy authorized (fail-closed) + end + attributes do uuid_primary_key :id diff --git a/lib/mv/authorization/checks/custom_field_value_create_scope.ex b/lib/mv/authorization/checks/custom_field_value_create_scope.ex new file mode 100644 index 0000000..0b24e74 --- /dev/null +++ b/lib/mv/authorization/checks/custom_field_value_create_scope.ex @@ -0,0 +1,71 @@ +defmodule Mv.Authorization.Checks.CustomFieldValueCreateScope do + @moduledoc """ + Policy check for CustomFieldValue create actions only. + + Use this for create instead of HasPermission because Ash cannot apply + filters to create actions ("Cannot use a filter to authorize a create"). + This check performs the same scope logic as HasPermission for create + (PermissionSets + :linked/:all) but only implements strict_check, so it + never adds a filter. + + ## member_id source + + The check reads `member_id` from the create changeset via + `Ash.Changeset.get_argument_or_attribute/2`, so it works when member_id + is set as an attribute or as an action argument. The CustomFieldValue + resource's default create action must accept and require `member_id` + (e.g. via `default_accept [:value, :member_id, :custom_field_id]`). + + Used in CustomFieldValue policies: + policy action_type(:create) do + authorize_if Mv.Authorization.Checks.CustomFieldValueCreateScope + end + """ + use Ash.Policy.Check + alias Mv.Authorization.PermissionSets + + @impl true + def describe(_opts), + do: "CustomFieldValue create allowed by permission set scope (:linked or :all)" + + @impl true + def strict_check(actor, authorizer, _opts) do + actor = ensure_role_loaded(actor) + + with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- actor, + {:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name), + permissions <- PermissionSets.get_permissions(ps_atom), + perm <- find_custom_field_value_create(permissions.resources) do + case perm do + nil -> {:ok, false} + %{scope: :all} -> {:ok, true} + %{scope: :linked} -> {:ok, member_id_matches?(authorizer, actor)} + end + else + _ -> {:ok, false} + end + end + + defp find_custom_field_value_create(resources) do + Enum.find(resources, fn p -> + p.resource == "CustomFieldValue" and p.action == :create and p.granted + end) + end + + defp member_id_matches?(authorizer, actor) do + member_id = get_create_member_id(authorizer) + !is_nil(member_id) and member_id == actor.member_id + end + + defp get_create_member_id(authorizer) do + changeset = authorizer.changeset || authorizer.subject + + if changeset && function_exported?(Ash.Changeset, :get_argument_or_attribute, 2) do + Ash.Changeset.get_argument_or_attribute(changeset, :member_id) + else + nil + end + end + + defp ensure_role_loaded(actor), do: Mv.Authorization.Actor.ensure_loaded(actor) +end diff --git a/lib/mv/authorization/checks/has_permission.ex b/lib/mv/authorization/checks/has_permission.ex index 1a478b8..774e767 100644 --- a/lib/mv/authorization/checks/has_permission.ex +++ b/lib/mv/authorization/checks/has_permission.ex @@ -148,6 +148,7 @@ defmodule Mv.Authorization.Checks.HasPermission do else # No record yet (e.g., read/list queries) - deny at strict_check level # Resources must use expr-based bypass policies for list filtering + # Create: use a dedicated check that does not return a filter (e.g. CustomFieldValueCreateScope) {:ok, false} end @@ -213,7 +214,7 @@ defmodule Mv.Authorization.Checks.HasPermission do {:filter, filter_expr} -> # :linked or :own scope - apply filter - # filter_expr is a keyword list from expr(...), return it directly + # Create actions must not use HasPermission (use a dedicated check, e.g. CustomFieldValueCreateScope) filter_expr false -> diff --git a/lib/mv/authorization/permission_sets.ex b/lib/mv/authorization/permission_sets.ex index 22132cb..e133ed7 100644 --- a/lib/mv/authorization/permission_sets.ex +++ b/lib/mv/authorization/permission_sets.ex @@ -105,9 +105,11 @@ defmodule Mv.Authorization.PermissionSets do %{resource: "Member", action: :read, scope: :linked, granted: true}, %{resource: "Member", action: :update, scope: :linked, granted: true}, - # CustomFieldValue: Can read/update custom field values of linked member + # CustomFieldValue: Can read/update/create/destroy custom field values of linked member %{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true}, %{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true}, + %{resource: "CustomFieldValue", action: :create, scope: :linked, granted: true}, + %{resource: "CustomFieldValue", action: :destroy, scope: :linked, granted: true}, # CustomField: Can read all (needed for forms) %{resource: "CustomField", action: :read, scope: :all, granted: true} diff --git a/test/membership/member_search_with_custom_fields_test.exs b/test/membership/member_search_with_custom_fields_test.exs index bd28ce5..284fcff 100644 --- a/test/membership/member_search_with_custom_fields_test.exs +++ b/test/membership/member_search_with_custom_fields_test.exs @@ -348,7 +348,7 @@ defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do assert List.first(results).id == member1.id # Delete custom field value - assert :ok = Ash.destroy(cfv) + assert :ok = Ash.destroy(cfv, actor: system_actor) # Value should no longer be found deleted_results = 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..d400aec --- /dev/null +++ b/test/mv/membership/custom_field_value_policies_test.exs @@ -0,0 +1,494 @@ +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_linked_member_for_user(user, actor) do + {: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: actor, return_notifications?: false) + + 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} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Unlinked", + last_name: "Member", + email: "unlinked#{System.unique_integer([:positive])}@example.com" + }) + |> Ash.create(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 diff --git a/test/mv/membership/import/member_csv_test.exs b/test/mv/membership/import/member_csv_test.exs index 778e82b..0304989 100644 --- a/test/mv/membership/import/member_csv_test.exs +++ b/test/mv/membership/import/member_csv_test.exs @@ -247,7 +247,7 @@ defmodule Mv.Membership.Import.MemberCSVTest do member = Enum.find(members, &(&1.email == "withcustom@example.com")) assert member != nil - {:ok, member_with_cf} = Ash.load(member, :custom_field_values) + {:ok, member_with_cf} = Ash.load(member, :custom_field_values, actor: actor) assert length(member_with_cf.custom_field_values) == 1 cfv = List.first(member_with_cf.custom_field_values) assert cfv.custom_field_id == custom_field.id