From 58896838544594a2117e54b8faf7c7eb0e7c77ab Mon Sep 17 00:00:00 2001 From: Moritz Date: Tue, 3 Feb 2026 23:52:12 +0100 Subject: [PATCH] Add resource policies for Group, MemberGroup, MembershipFeeType, MembershipFeeCycle - Group/MemberGroup/MembershipFeeType/MembershipFeeCycle: HasPermission policy - normal_user: Group and MembershipFeeCycle create/update/destroy; pages /groups/new, /groups/:slug/edit - Add policy tests for all four resources --- lib/membership/group.ex | 15 +- lib/membership/member_group.ex | 29 +- lib/membership_fees/membership_fee_cycle.ex | 10 +- lib/membership_fees/membership_fee_type.ex | 10 +- test/mv/membership/group_policies_test.exs | 183 +++++++++++ .../membership/member_group_policies_test.exs | 264 +++++++++++++++ .../membership_fee_cycle_policies_test.exs | 279 ++++++++++++++++ .../membership_fee_type_policies_test.exs | 303 ++++++++++++++++++ 8 files changed, 1081 insertions(+), 12 deletions(-) create mode 100644 test/mv/membership/group_policies_test.exs create mode 100644 test/mv/membership/member_group_policies_test.exs create mode 100644 test/mv/membership_fees/membership_fee_cycle_policies_test.exs create mode 100644 test/mv/membership_fees/membership_fee_type_policies_test.exs diff --git a/lib/membership/group.ex b/lib/membership/group.ex index 14aadc8..d468166 100644 --- a/lib/membership/group.ex +++ b/lib/membership/group.ex @@ -36,7 +36,8 @@ defmodule Mv.Membership.Group do """ use Ash.Resource, domain: Mv.Membership, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] require Ash.Query alias Mv.Helpers @@ -63,6 +64,13 @@ defmodule Mv.Membership.Group do end end + policies do + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from role (all can read; normal_user and admin can create/update/destroy)" + authorize_if Mv.Authorization.Checks.HasPermission + end + end + validations do validate present(:name) @@ -136,7 +144,7 @@ defmodule Mv.Membership.Group do query = Mv.Membership.Group |> Ash.Query.filter(fragment("LOWER(?) = LOWER(?)", name, ^name)) - |> maybe_exclude_id(exclude_id) + |> Helpers.query_exclude_id(exclude_id) opts = Helpers.ash_actor_opts(actor) @@ -155,7 +163,4 @@ defmodule Mv.Membership.Group do :ok end end - - defp maybe_exclude_id(query, nil), do: query - defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id) end diff --git a/lib/membership/member_group.ex b/lib/membership/member_group.ex index 5d29bda..fe8b2b9 100644 --- a/lib/membership/member_group.ex +++ b/lib/membership/member_group.ex @@ -39,8 +39,10 @@ defmodule Mv.Membership.MemberGroup do """ use Ash.Resource, domain: Mv.Membership, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] + import Ash.Expr require Ash.Query postgres do @@ -56,6 +58,26 @@ defmodule Mv.Membership.MemberGroup do end end + # Authorization: read uses bypass for :linked (own_data list) then HasPermission for :all; + # create/destroy use HasPermission (normal_user + admin only). + # Order: bypass first so own_data gets expr filter; HasPermission then authorizes :all for others. + policies do + bypass action_type(:read) do + description "own_data: read only member_groups where member_id == actor.member_id" + authorize_if expr(member_id == ^actor(:member_id)) + end + + policy action_type(:read) do + description "Check read permission from role (read_only/normal_user/admin :all)" + authorize_if Mv.Authorization.Checks.HasPermission + end + + policy action_type([:create, :destroy]) do + description "Check create/destroy from role (normal_user + admin only)" + authorize_if Mv.Authorization.Checks.HasPermission + end + end + validations do validate present(:member_id) validate present(:group_id) @@ -118,7 +140,7 @@ defmodule Mv.Membership.MemberGroup do query = Mv.Membership.MemberGroup |> Ash.Query.filter(member_id == ^member_id and group_id == ^group_id) - |> maybe_exclude_id(exclude_id) + |> Helpers.query_exclude_id(exclude_id) opts = Helpers.ash_actor_opts(actor) @@ -135,7 +157,4 @@ defmodule Mv.Membership.MemberGroup do :ok end end - - defp maybe_exclude_id(query, nil), do: query - defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id) end diff --git a/lib/membership_fees/membership_fee_cycle.ex b/lib/membership_fees/membership_fee_cycle.ex index 4d9c8b7..98f8253 100644 --- a/lib/membership_fees/membership_fee_cycle.ex +++ b/lib/membership_fees/membership_fee_cycle.ex @@ -28,7 +28,8 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do """ use Ash.Resource, domain: Mv.MembershipFees, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] postgres do table "membership_fee_cycles" @@ -83,6 +84,13 @@ defmodule Mv.MembershipFees.MembershipFeeCycle do end end + policies do + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from role (all read; normal_user and admin create/update/destroy)" + authorize_if Mv.Authorization.Checks.HasPermission + end + end + attributes do uuid_v7_primary_key :id diff --git a/lib/membership_fees/membership_fee_type.ex b/lib/membership_fees/membership_fee_type.ex index 498ff75..8ec9467 100644 --- a/lib/membership_fees/membership_fee_type.ex +++ b/lib/membership_fees/membership_fee_type.ex @@ -24,7 +24,8 @@ defmodule Mv.MembershipFees.MembershipFeeType do """ use Ash.Resource, domain: Mv.MembershipFees, - data_layer: AshPostgres.DataLayer + data_layer: AshPostgres.DataLayer, + authorizers: [Ash.Policy.Authorizer] postgres do table "membership_fee_types" @@ -61,6 +62,13 @@ defmodule Mv.MembershipFees.MembershipFeeType do end end + policies do + policy action_type([:read, :create, :update, :destroy]) do + description "Check permissions from role (all can read, only admin can create/update/destroy)" + authorize_if Mv.Authorization.Checks.HasPermission + end + end + validations do # Prevent interval changes after creation validate fn changeset, _context -> diff --git a/test/mv/membership/group_policies_test.exs b/test/mv/membership/group_policies_test.exs new file mode 100644 index 0000000..6b4c38f --- /dev/null +++ b/test/mv/membership/group_policies_test.exs @@ -0,0 +1,183 @@ +defmodule Mv.Membership.GroupPoliciesTest do + @moduledoc """ + Tests for Group resource authorization policies. + + Verifies that own_data, read_only, normal_user can read groups; + only admin can create, update, and destroy groups. + """ + use Mv.DataCase, async: false + + alias Mv.Membership + alias Mv.Accounts + alias Mv.Authorization + + require Ash.Query + + 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])}" + + 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_admin_user(actor) do + create_user_with_permission_set("admin", actor) + end + + defp create_group_fixture(actor) do + admin = create_admin_user(actor) + + {: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 = create_user_with_permission_set("own_data", actor) + group = create_group_fixture(actor) + %{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 = create_user_with_permission_set("read_only", actor) + group = create_group_fixture(actor) + %{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 = create_user_with_permission_set("normal_user", actor) + group = create_group_fixture(actor) + %{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 = create_user_with_permission_set("admin", actor) + group = create_group_fixture(actor) + %{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 diff --git a/test/mv/membership/member_group_policies_test.exs b/test/mv/membership/member_group_policies_test.exs new file mode 100644 index 0000000..deb707f --- /dev/null +++ b/test/mv/membership/member_group_policies_test.exs @@ -0,0 +1,264 @@ +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 + alias Mv.Accounts + alias Mv.Authorization + + require Ash.Query + + 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])}" + + 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_admin_user(actor) do + create_user_with_permission_set("admin", actor) + end + + defp create_member_fixture(actor) do + admin = create_admin_user(actor) + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Test", + last_name: "Member", + email: "test#{System.unique_integer([:positive])}@example.com" + }, + actor: admin + ) + + member + end + + defp create_group_fixture(actor) do + admin = create_admin_user(actor) + + {:ok, group} = + Membership.create_group( + %{name: "Test Group #{System.unique_integer([:positive])}", description: "Test"}, + actor: admin + ) + + group + end + + defp create_member_group_fixture(member_id, group_id, actor) do + admin = create_admin_user(actor) + + {: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 = create_user_with_permission_set("own_data", actor) + member = create_member_fixture(actor) + group = create_group_fixture(actor) + # Link user to member so actor.member_id is set + admin = create_admin_user(actor) + + 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, actor) + # MemberGroup for another member (not linked to user) + other_member = create_member_fixture(actor) + other_group = create_group_fixture(actor) + mg_other = create_member_group_fixture(other_member.id, other_group.id, actor) + %{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(actor) + other_group = create_group_fixture(actor) + + 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 = create_user_with_permission_set("read_only", actor) + member = create_member_fixture(actor) + group = create_group_fixture(actor) + mg = create_member_group_fixture(member.id, group.id, actor) + %{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(actor) + group = create_group_fixture(actor) + + 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 = create_user_with_permission_set("normal_user", actor) + member = create_member_fixture(actor) + group = create_group_fixture(actor) + mg = create_member_group_fixture(member.id, group.id, actor) + %{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(actor) + group = create_group_fixture(actor) + + 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 = create_user_with_permission_set("admin", actor) + member = create_member_fixture(actor) + group = create_group_fixture(actor) + mg = create_member_group_fixture(member.id, group.id, actor) + %{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(actor) + group = create_group_fixture(actor) + + 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 diff --git a/test/mv/membership_fees/membership_fee_cycle_policies_test.exs b/test/mv/membership_fees/membership_fee_cycle_policies_test.exs new file mode 100644 index 0000000..2b451bf --- /dev/null +++ b/test/mv/membership_fees/membership_fee_cycle_policies_test.exs @@ -0,0 +1,279 @@ +defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do + @moduledoc """ + Tests for MembershipFeeCycle resource authorization policies. + + Verifies read_only can only read (no update/mark_as_paid); + normal_user and admin can read and update (including mark_as_paid); + only admin can create and destroy. + """ + use Mv.DataCase, async: false + + alias Mv.MembershipFees + alias Mv.Membership + 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])}" + + 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_admin_user(actor) do + create_user_with_permission_set("admin", actor) + end + + defp create_member_fixture(actor) do + admin = create_admin_user(actor) + + {:ok, member} = + Membership.create_member( + %{ + first_name: "Test", + last_name: "Member", + email: "test#{System.unique_integer([:positive])}@example.com" + }, + actor: admin + ) + + member + end + + defp create_fee_type_fixture(actor) do + admin = create_admin_user(actor) + + {:ok, fee_type} = + MembershipFees.create_membership_fee_type( + %{ + name: "Test Fee #{System.unique_integer([:positive])}", + amount: Decimal.new("10.00"), + interval: :yearly, + description: "Test" + }, + actor: admin + ) + + fee_type + end + + defp create_cycle_fixture(actor) do + admin = create_admin_user(actor) + member = create_member_fixture(actor) + fee_type = create_fee_type_fixture(actor) + + {:ok, cycle} = + MembershipFees.create_membership_fee_cycle( + %{ + member_id: member.id, + membership_fee_type_id: fee_type.id, + cycle_start: Date.utc_today(), + amount: Decimal.new("10.00"), + status: :unpaid + }, + actor: admin + ) + + cycle + end + + describe "read_only permission set" do + setup %{actor: actor} do + user = create_user_with_permission_set("read_only", actor) + cycle = create_cycle_fixture(actor) + %{actor: actor, user: user, cycle: cycle} + end + + test "can read membership_fee_cycles (list)", %{user: user} do + {:ok, list} = + Mv.MembershipFees.MembershipFeeCycle + |> Ash.read(actor: user, domain: Mv.MembershipFees) + + assert is_list(list) + end + + test "cannot update cycle (returns forbidden)", %{user: user, cycle: cycle} do + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.update_membership_fee_cycle(cycle, %{status: :paid}, actor: user) + end + + test "cannot mark_as_paid (returns forbidden)", %{user: user, cycle: cycle} do + assert {:error, %Ash.Error.Forbidden{}} = + cycle + |> Ash.Changeset.for_update(:mark_as_paid, %{}, domain: Mv.MembershipFees) + |> Ash.update(actor: user, domain: Mv.MembershipFees) + end + + test "cannot create cycle (returns forbidden)", %{user: user, actor: actor} do + member = create_member_fixture(actor) + fee_type = create_fee_type_fixture(actor) + + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.create_membership_fee_cycle( + %{ + member_id: member.id, + membership_fee_type_id: fee_type.id, + cycle_start: Date.utc_today(), + amount: Decimal.new("10.00"), + status: :unpaid + }, + actor: user + ) + end + + test "cannot destroy cycle (returns forbidden)", %{user: user, cycle: cycle} do + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.destroy_membership_fee_cycle(cycle, actor: user) + end + end + + describe "normal_user permission set" do + setup %{actor: actor} do + user = create_user_with_permission_set("normal_user", actor) + cycle = create_cycle_fixture(actor) + %{actor: actor, user: user, cycle: cycle} + end + + test "can read membership_fee_cycles (list)", %{user: user} do + {:ok, list} = + Mv.MembershipFees.MembershipFeeCycle + |> Ash.read(actor: user, domain: Mv.MembershipFees) + + assert is_list(list) + end + + test "can update cycle status", %{user: user, cycle: cycle} do + assert {:ok, updated} = + MembershipFees.update_membership_fee_cycle(cycle, %{status: :paid}, actor: user) + + assert updated.status == :paid + end + + test "can mark_as_paid", %{user: user, cycle: cycle} do + assert {:ok, updated} = + cycle + |> Ash.Changeset.for_update(:mark_as_paid, %{}, domain: Mv.MembershipFees) + |> Ash.update(actor: user, domain: Mv.MembershipFees) + + assert updated.status == :paid + end + + test "can create cycle", %{user: user, actor: actor} do + member = create_member_fixture(actor) + fee_type = create_fee_type_fixture(actor) + + assert {:ok, created} = + MembershipFees.create_membership_fee_cycle( + %{ + member_id: member.id, + membership_fee_type_id: fee_type.id, + cycle_start: Date.utc_today(), + amount: Decimal.new("10.00"), + status: :unpaid + }, + actor: user + ) + + assert created.member_id == member.id + end + + test "can destroy cycle", %{user: user, cycle: cycle} do + assert :ok = MembershipFees.destroy_membership_fee_cycle(cycle, actor: user) + end + end + + describe "admin permission set" do + setup %{actor: actor} do + user = create_user_with_permission_set("admin", actor) + cycle = create_cycle_fixture(actor) + %{actor: actor, user: user, cycle: cycle} + end + + test "can read membership_fee_cycles (list)", %{user: user} do + {:ok, list} = + Mv.MembershipFees.MembershipFeeCycle + |> Ash.read(actor: user, domain: Mv.MembershipFees) + + assert is_list(list) + end + + test "can update cycle", %{user: user, cycle: cycle} do + assert {:ok, updated} = + MembershipFees.update_membership_fee_cycle(cycle, %{status: :paid}, actor: user) + + assert updated.status == :paid + end + + test "can mark_as_paid", %{user: user, cycle: cycle} do + cycle_unpaid = + cycle + |> Ash.Changeset.for_update(:mark_as_unpaid, %{}, domain: Mv.MembershipFees) + |> Ash.update!(actor: user, domain: Mv.MembershipFees) + + assert {:ok, updated} = + cycle_unpaid + |> Ash.Changeset.for_update(:mark_as_paid, %{}, domain: Mv.MembershipFees) + |> Ash.update(actor: user, domain: Mv.MembershipFees) + + assert updated.status == :paid + end + + test "can create cycle", %{user: user, actor: actor} do + member = create_member_fixture(actor) + fee_type = create_fee_type_fixture(actor) + + assert {:ok, created} = + MembershipFees.create_membership_fee_cycle( + %{ + member_id: member.id, + membership_fee_type_id: fee_type.id, + cycle_start: Date.utc_today(), + amount: Decimal.new("10.00"), + status: :unpaid + }, + actor: user + ) + + assert created.member_id == member.id + end + + test "can destroy cycle", %{user: user, cycle: cycle} do + assert :ok = MembershipFees.destroy_membership_fee_cycle(cycle, actor: user) + end + end +end diff --git a/test/mv/membership_fees/membership_fee_type_policies_test.exs b/test/mv/membership_fees/membership_fee_type_policies_test.exs new file mode 100644 index 0000000..6263147 --- /dev/null +++ b/test/mv/membership_fees/membership_fee_type_policies_test.exs @@ -0,0 +1,303 @@ +defmodule Mv.MembershipFees.MembershipFeeTypePoliciesTest do + @moduledoc """ + Tests for MembershipFeeType resource authorization policies. + + Verifies all roles (own_data, read_only, normal_user, admin) can read; + only admin can create, update, and destroy; non-admin create/update/destroy → Forbidden. + """ + use Mv.DataCase, async: false + + alias Mv.MembershipFees + 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])}" + + 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_admin_user(actor) do + create_user_with_permission_set("admin", actor) + end + + defp create_membership_fee_type_fixture(actor) do + admin = create_admin_user(actor) + + {:ok, fee_type} = + MembershipFees.create_membership_fee_type( + %{ + name: "Test Fee #{System.unique_integer([:positive])}", + amount: Decimal.new("10.00"), + interval: :yearly, + description: "Test" + }, + actor: admin + ) + + fee_type + end + + describe "own_data permission set" do + setup %{actor: actor} do + user = create_user_with_permission_set("own_data", actor) + fee_type = create_membership_fee_type_fixture(actor) + %{actor: actor, user: user, fee_type: fee_type} + end + + test "can read membership_fee_types (list)", %{user: user} do + {:ok, list} = + Mv.MembershipFees.MembershipFeeType + |> Ash.read(actor: user, domain: Mv.MembershipFees) + + assert is_list(list) + end + + test "can read single membership_fee_type", %{user: user, fee_type: fee_type} do + {:ok, found} = + Ash.get(Mv.MembershipFees.MembershipFeeType, fee_type.id, + actor: user, + domain: Mv.MembershipFees + ) + + assert found.id == fee_type.id + end + + test "cannot create membership_fee_type (returns forbidden)", %{user: user} do + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.create_membership_fee_type( + %{ + name: "New Fee #{System.unique_integer([:positive])}", + amount: Decimal.new("5.00"), + interval: :monthly + }, + actor: user + ) + end + + test "cannot update membership_fee_type (returns forbidden)", %{ + user: user, + fee_type: fee_type + } do + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.update_membership_fee_type(fee_type, %{name: "Updated"}, + actor: user + ) + end + + test "cannot destroy membership_fee_type (returns forbidden)", %{user: user, actor: actor} do + # Use a fee type with no members/cycles so destroy would succeed if authorized + admin = create_admin_user(actor) + + {:ok, isolated} = + MembershipFees.create_membership_fee_type( + %{ + name: "Isolated #{System.unique_integer([:positive])}", + amount: Decimal.new("1.00"), + interval: :yearly + }, + actor: admin + ) + + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.destroy_membership_fee_type(isolated, actor: user) + end + end + + describe "read_only permission set" do + setup %{actor: actor} do + user = create_user_with_permission_set("read_only", actor) + fee_type = create_membership_fee_type_fixture(actor) + %{actor: actor, user: user, fee_type: fee_type} + end + + test "can read membership_fee_types (list)", %{user: user} do + {:ok, list} = + Mv.MembershipFees.MembershipFeeType + |> Ash.read(actor: user, domain: Mv.MembershipFees) + + assert is_list(list) + end + + test "cannot create membership_fee_type (returns forbidden)", %{user: user} do + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.create_membership_fee_type( + %{ + name: "New Fee #{System.unique_integer([:positive])}", + amount: Decimal.new("5.00"), + interval: :monthly + }, + actor: user + ) + end + + test "cannot update membership_fee_type (returns forbidden)", %{ + user: user, + fee_type: fee_type + } do + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.update_membership_fee_type(fee_type, %{name: "Updated"}, + actor: user + ) + end + + test "cannot destroy membership_fee_type (returns forbidden)", %{user: user, actor: actor} do + admin = create_admin_user(actor) + + {:ok, isolated} = + MembershipFees.create_membership_fee_type( + %{ + name: "Isolated #{System.unique_integer([:positive])}", + amount: Decimal.new("1.00"), + interval: :yearly + }, + actor: admin + ) + + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.destroy_membership_fee_type(isolated, actor: user) + end + end + + describe "normal_user permission set" do + setup %{actor: actor} do + user = create_user_with_permission_set("normal_user", actor) + fee_type = create_membership_fee_type_fixture(actor) + %{actor: actor, user: user, fee_type: fee_type} + end + + test "can read membership_fee_types (list)", %{user: user} do + {:ok, list} = + Mv.MembershipFees.MembershipFeeType + |> Ash.read(actor: user, domain: Mv.MembershipFees) + + assert is_list(list) + end + + test "cannot create membership_fee_type (returns forbidden)", %{user: user} do + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.create_membership_fee_type( + %{ + name: "New Fee #{System.unique_integer([:positive])}", + amount: Decimal.new("5.00"), + interval: :monthly + }, + actor: user + ) + end + + test "cannot update membership_fee_type (returns forbidden)", %{ + user: user, + fee_type: fee_type + } do + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.update_membership_fee_type(fee_type, %{name: "Updated"}, + actor: user + ) + end + + test "cannot destroy membership_fee_type (returns forbidden)", %{user: user, actor: actor} do + admin = create_admin_user(actor) + + {:ok, isolated} = + MembershipFees.create_membership_fee_type( + %{ + name: "Isolated #{System.unique_integer([:positive])}", + amount: Decimal.new("1.00"), + interval: :yearly + }, + actor: admin + ) + + assert {:error, %Ash.Error.Forbidden{}} = + MembershipFees.destroy_membership_fee_type(isolated, actor: user) + end + end + + describe "admin permission set" do + setup %{actor: actor} do + user = create_user_with_permission_set("admin", actor) + fee_type = create_membership_fee_type_fixture(actor) + %{actor: actor, user: user, fee_type: fee_type} + end + + test "can read membership_fee_types (list)", %{user: user} do + {:ok, list} = + Mv.MembershipFees.MembershipFeeType + |> Ash.read(actor: user, domain: Mv.MembershipFees) + + assert is_list(list) + end + + test "can create membership_fee_type", %{user: user} do + name = "Admin Fee #{System.unique_integer([:positive])}" + + assert {:ok, created} = + MembershipFees.create_membership_fee_type( + %{name: name, amount: Decimal.new("20.00"), interval: :quarterly}, + actor: user + ) + + assert created.name == name + end + + test "can update membership_fee_type", %{user: user, fee_type: fee_type} do + new_name = "Updated #{System.unique_integer([:positive])}" + + assert {:ok, updated} = + MembershipFees.update_membership_fee_type(fee_type, %{name: new_name}, actor: user) + + assert updated.name == new_name + end + + test "can destroy membership_fee_type", %{user: user} do + {:ok, isolated} = + MembershipFees.create_membership_fee_type( + %{ + name: "To Delete #{System.unique_integer([:positive])}", + amount: Decimal.new("1.00"), + interval: :yearly + }, + actor: user + ) + + assert :ok = MembershipFees.destroy_membership_fee_type(isolated, actor: user) + end + end +end