Merge branch 'main' into feature/337_polish_import
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
3415faeb21
87 changed files with 4381 additions and 1171 deletions
|
|
@ -54,18 +54,26 @@ defmodule Mv.Membership.MembershipFeeSettingsTest do
|
|||
|
||||
# Create a valid fee type
|
||||
{:ok, fee_type} =
|
||||
Ash.create(MembershipFeeType, %{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly
|
||||
})
|
||||
Ash.create(
|
||||
MembershipFeeType,
|
||||
%{
|
||||
name: "Test Fee Type #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Setting a valid fee type should work
|
||||
{:ok, updated} =
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(
|
||||
:update_membership_fee_settings,
|
||||
%{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
assert updated.default_membership_fee_type_id == fee_type.id
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
|
@ -68,7 +68,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: monthly_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{errors: errors} = changeset
|
||||
|
|
@ -90,7 +90,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: yearly_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
|
@ -102,7 +102,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: nil}, actor: actor)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
refute changeset.valid?
|
||||
assert %{errors: errors} = changeset
|
||||
|
|
@ -120,7 +120,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
changeset =
|
||||
member
|
||||
|> Ash.Changeset.for_update(:update_member, %{first_name: "New Name"}, actor: actor)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
assert changeset.valid?
|
||||
end
|
||||
|
|
@ -136,7 +136,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: quarterly_type.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
error = Enum.find(changeset.errors, &(&1.field == :membership_fee_type_id))
|
||||
assert error.message =~ "yearly"
|
||||
|
|
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.Changes.ValidateSameIntervalTest do
|
|||
|> Ash.Changeset.for_update(:update_member, %{membership_fee_type_id: type2.id},
|
||||
actor: actor
|
||||
)
|
||||
|> ValidateSameInterval.change(%{}, %{})
|
||||
|> ValidateSameInterval.change(%{}, %{actor: actor})
|
||||
|
||||
refute changeset.valid?,
|
||||
"Should prevent change from #{interval1} to #{interval2}"
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
|||
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||
cycle = create_cycle(member, fee_type, %{status: :paid}, actor)
|
||||
|
||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
||||
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid)
|
||||
assert updated.status == :unpaid
|
||||
end
|
||||
|
||||
|
|
@ -175,7 +175,7 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
|
|||
member = create_member(%{membership_fee_type_id: fee_type.id}, actor)
|
||||
cycle = create_cycle(member, fee_type, %{status: :suspended}, actor)
|
||||
|
||||
assert {:ok, updated} = Ash.update(cycle, %{}, action: :mark_as_unpaid)
|
||||
assert {:ok, updated} = Ash.update(cycle, %{}, actor: actor, action: :mark_as_unpaid)
|
||||
assert updated.status == :unpaid
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -155,9 +155,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
|||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(
|
||||
:update_membership_fee_settings,
|
||||
%{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Try to delete
|
||||
|
|
@ -176,9 +180,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeIntegrationTest do
|
|||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(
|
||||
:update_membership_fee_settings,
|
||||
%{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Create a member without explicitly setting membership_fee_type_id
|
||||
|
|
|
|||
|
|
@ -209,9 +209,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_membership_fee_settings, %{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
})
|
||||
|> Ash.Changeset.for_update(
|
||||
:update_membership_fee_settings,
|
||||
%{
|
||||
default_membership_fee_type_id: fee_type.id
|
||||
},
|
||||
actor: actor
|
||||
)
|
||||
|> Ash.update!(actor: actor)
|
||||
|
||||
# Try to delete
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Authorization
|
||||
|
||||
require Ash.Query
|
||||
|
||||
|
|
@ -19,59 +18,10 @@ defmodule Mv.Accounts.UserPoliciesTest 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 another user (for testing access to other users)
|
||||
defp create_other_user(actor) do
|
||||
create_user_with_permission_set("own_data", actor)
|
||||
end
|
||||
|
||||
# Shared test setup for permission sets with scope :own access
|
||||
defp setup_user_with_own_access(permission_set, actor) do
|
||||
user = create_user_with_permission_set(permission_set, actor)
|
||||
other_user = create_other_user(actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture(permission_set)
|
||||
other_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|
||||
# Reload user to ensure role is preloaded
|
||||
{:ok, user} =
|
||||
|
|
@ -80,217 +30,101 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
%{user: user, other_user: other_user}
|
||||
end
|
||||
|
||||
describe "own_data permission set (Mitglied)" do
|
||||
setup %{actor: actor} do
|
||||
setup_user_with_own_access("own_data", actor)
|
||||
# Data-driven: same behaviour for own_data, read_only, normal_user (scope :own for User)
|
||||
describe "non-admin permission sets (own_data, read_only, normal_user)" do
|
||||
setup %{actor: actor} = context do
|
||||
permission_set = context[:permission_set] || "own_data"
|
||||
setup_user_with_own_access(permission_set, actor)
|
||||
end
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
for permission_set <- ["own_data", "read_only", "normal_user"] do
|
||||
@tag permission_set: permission_set
|
||||
test "can read own user record (#{permission_set})", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
|
||||
test "can update own email", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
|
||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
@tag permission_set: permission_set
|
||||
test "can update own email (#{permission_set})", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
end
|
||||
|
||||
test "list users returns only own user", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
# Should only return the own user (scope :own filters)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
|
||||
test "cannot create user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
@tag permission_set: permission_set
|
||||
test "cannot read other users - not found due to auto_filter (#{permission_set})", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
@tag permission_set: permission_set
|
||||
test "cannot update other users - forbidden (#{permission_set})", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_only permission set (Vorstand/Buchhaltung)" do
|
||||
setup %{actor: actor} do
|
||||
setup_user_with_own_access("read_only", actor)
|
||||
end
|
||||
@tag permission_set: permission_set
|
||||
test "list users returns only own user (#{permission_set})", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
|
||||
test "can update own email", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
|
||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
@tag permission_set: permission_set
|
||||
test "cannot create user - forbidden (#{permission_set})", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "list users returns only own user", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
# Should only return the own user (scope :own filters)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
|
||||
test "cannot create user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
@tag permission_set: permission_set
|
||||
test "cannot destroy user - forbidden (#{permission_set})", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
@tag permission_set: permission_set
|
||||
test "cannot change role via update_user - forbidden (#{permission_set})", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
other_role = Mv.Fixtures.role_fixture("read_only")
|
||||
|
||||
describe "normal_user permission set (Kassenwart)" do
|
||||
setup %{actor: actor} do
|
||||
setup_user_with_own_access("normal_user", actor)
|
||||
end
|
||||
|
||||
test "can read own user record", %{user: user} do
|
||||
{:ok, fetched_user} =
|
||||
Ash.get(Accounts.User, user.id, actor: user, domain: Mv.Accounts)
|
||||
|
||||
assert fetched_user.id == user.id
|
||||
end
|
||||
|
||||
test "can update own email", %{user: user} do
|
||||
new_email = "updated#{System.unique_integer([:positive])}@example.com"
|
||||
|
||||
# Non-admins use :update (email only); :update_user is admin-only (member link/unlink).
|
||||
{:ok, updated_user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{email: new_email})
|
||||
|> Ash.update(actor: user)
|
||||
|
||||
assert updated_user.email == Ash.CiString.new(new_email)
|
||||
end
|
||||
|
||||
test "cannot read other users (returns not found due to auto_filter)", %{
|
||||
user: user,
|
||||
other_user: other_user
|
||||
} do
|
||||
# Note: With auto_filter policies, when a user tries to read a user that doesn't
|
||||
# match the filter (id == actor.id), Ash returns NotFound, not Forbidden.
|
||||
# This is the expected behavior - the filter makes the record "invisible" to the user.
|
||||
assert_raise Ash.Error.Invalid, fn ->
|
||||
Ash.get!(Accounts.User, other_user.id, actor: user, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot update other users (returns forbidden)", %{user: user, other_user: other_user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|
||||
|> Ash.update!(actor: user)
|
||||
end
|
||||
end
|
||||
|
||||
test "list users returns only own user", %{user: user} do
|
||||
{:ok, users} = Ash.read(Accounts.User, actor: user, domain: Mv.Accounts)
|
||||
|
||||
# Should only return the own user (scope :own filters)
|
||||
assert length(users) == 1
|
||||
assert hd(users).id == user.id
|
||||
end
|
||||
|
||||
test "cannot create user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Accounts.User
|
||||
|> Ash.Changeset.for_create(:create_user, %{
|
||||
email: "new#{System.unique_integer([:positive])}@example.com"
|
||||
})
|
||||
|> Ash.create!(actor: user)
|
||||
end
|
||||
end
|
||||
|
||||
test "cannot destroy user (returns forbidden)", %{user: user} do
|
||||
assert_raise Ash.Error.Forbidden, fn ->
|
||||
Ash.destroy!(user, actor: user)
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: other_role.id})
|
||||
|> Ash.update(actor: user, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "admin permission set" do
|
||||
setup %{actor: actor} do
|
||||
user = create_user_with_permission_set("admin", actor)
|
||||
other_user = create_other_user(actor)
|
||||
user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
other_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|
||||
# Reload user to ensure role is preloaded
|
||||
{:ok, user} =
|
||||
|
|
@ -343,6 +177,88 @@ defmodule Mv.Accounts.UserPoliciesTest do
|
|||
# Verify user is deleted
|
||||
assert {:error, _} = Ash.get(Accounts.User, other_user.id, domain: Mv.Accounts)
|
||||
end
|
||||
|
||||
test "admin can assign role to another user via update_user", %{
|
||||
other_user: other_user
|
||||
} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
|
||||
|
||||
{:ok, updated} =
|
||||
other_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|
||||
|> Ash.update(actor: admin)
|
||||
|
||||
assert updated.role_id == normal_user_role.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "admin role assignment and last-admin validation" do
|
||||
test "two admins: one can change own role to normal_user (other remains admin)", %{
|
||||
actor: _actor
|
||||
} do
|
||||
_admin_role = Mv.Fixtures.role_fixture("admin")
|
||||
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
|
||||
|
||||
admin_a = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
_admin_b = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, updated} =
|
||||
admin_a
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|
||||
|> Ash.update(actor: admin_a)
|
||||
|
||||
assert updated.role_id == normal_user_role.id
|
||||
end
|
||||
|
||||
test "single admin: changing own role to normal_user returns validation error", %{
|
||||
actor: _actor
|
||||
} do
|
||||
normal_user_role = Mv.Fixtures.role_fixture("normal_user")
|
||||
single_admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
single_admin
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: normal_user_role.id})
|
||||
|> Ash.update(actor: single_admin)
|
||||
|
||||
error_messages =
|
||||
Enum.flat_map(errors, fn
|
||||
%Ash.Error.Changes.InvalidAttribute{message: msg} when is_binary(msg) -> [msg]
|
||||
%{message: msg} when is_binary(msg) -> [msg]
|
||||
_ -> []
|
||||
end)
|
||||
|
||||
assert Enum.any?(error_messages, fn msg ->
|
||||
msg =~ "least one user must keep the Admin role" or msg =~ "Admin role"
|
||||
end),
|
||||
"Expected last-admin validation message, got: #{inspect(error_messages)}"
|
||||
end
|
||||
|
||||
test "admin can switch to another admin role (two roles with permission_set_name admin)", %{
|
||||
actor: _actor
|
||||
} do
|
||||
# Two distinct roles both with permission_set_name "admin" (e.g. "Admin" and "Superadmin")
|
||||
admin_role_a = Mv.Fixtures.role_fixture("admin")
|
||||
admin_role_b = Mv.Fixtures.role_fixture("admin")
|
||||
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
# Ensure user has role_a so we can switch to role_b
|
||||
{:ok, admin_user} =
|
||||
admin_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_a.id})
|
||||
|> Ash.update(actor: admin_user)
|
||||
|
||||
assert admin_user.role_id == admin_role_a.id
|
||||
|
||||
# Switching to another admin role must be allowed (no last-admin error)
|
||||
{:ok, updated} =
|
||||
admin_user
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_b.id})
|
||||
|> Ash.update(actor: admin_user)
|
||||
|
||||
assert updated.role_id == admin_role_b.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "AshAuthentication bypass" do
|
||||
|
|
|
|||
|
|
@ -496,6 +496,281 @@ defmodule Mv.Authorization.PermissionSetsTest do
|
|||
|
||||
assert "*" in permissions.pages
|
||||
end
|
||||
|
||||
test "admin pages include explicit /settings and /membership_fee_settings" do
|
||||
permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
assert "/settings" in permissions.pages
|
||||
assert "/membership_fee_settings" in permissions.pages
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_permissions/1 - MemberGroup resource" do
|
||||
test "own_data has MemberGroup read with scope :linked only" do
|
||||
permissions = PermissionSets.get_permissions(:own_data)
|
||||
|
||||
mg_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :read
|
||||
end)
|
||||
|
||||
mg_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :create
|
||||
end)
|
||||
|
||||
assert mg_read != nil
|
||||
assert mg_read.scope == :linked
|
||||
assert mg_read.granted == true
|
||||
assert mg_create == nil || mg_create.granted == false
|
||||
end
|
||||
|
||||
test "read_only has MemberGroup read with scope :all, no create/destroy" do
|
||||
permissions = PermissionSets.get_permissions(:read_only)
|
||||
|
||||
mg_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :read
|
||||
end)
|
||||
|
||||
mg_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :create
|
||||
end)
|
||||
|
||||
mg_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mg_read != nil
|
||||
assert mg_read.scope == :all
|
||||
assert mg_read.granted == true
|
||||
assert mg_create == nil || mg_create.granted == false
|
||||
assert mg_destroy == nil || mg_destroy.granted == false
|
||||
end
|
||||
|
||||
test "normal_user has MemberGroup read/create/destroy with scope :all" do
|
||||
permissions = PermissionSets.get_permissions(:normal_user)
|
||||
|
||||
mg_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :read
|
||||
end)
|
||||
|
||||
mg_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :create
|
||||
end)
|
||||
|
||||
mg_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mg_read != nil
|
||||
assert mg_read.scope == :all
|
||||
assert mg_read.granted == true
|
||||
assert mg_create != nil
|
||||
assert mg_create.scope == :all
|
||||
assert mg_create.granted == true
|
||||
assert mg_destroy != nil
|
||||
assert mg_destroy.scope == :all
|
||||
assert mg_destroy.granted == true
|
||||
end
|
||||
|
||||
test "admin has MemberGroup read/create/destroy with scope :all" do
|
||||
permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
mg_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :read
|
||||
end)
|
||||
|
||||
mg_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :create
|
||||
end)
|
||||
|
||||
mg_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MemberGroup" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mg_read != nil
|
||||
assert mg_read.scope == :all
|
||||
assert mg_read.granted == true
|
||||
assert mg_create != nil
|
||||
assert mg_create.granted == true
|
||||
assert mg_destroy != nil
|
||||
assert mg_destroy.granted == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_permissions/1 - MembershipFeeType resource" do
|
||||
test "all permission sets have MembershipFeeType read with scope :all" do
|
||||
for set <- PermissionSets.all_permission_sets() do
|
||||
permissions = PermissionSets.get_permissions(set)
|
||||
|
||||
mft_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :read
|
||||
end)
|
||||
|
||||
assert mft_read != nil, "Permission set #{set} should have MembershipFeeType read"
|
||||
assert mft_read.scope == :all
|
||||
assert mft_read.granted == true
|
||||
end
|
||||
end
|
||||
|
||||
test "only admin has MembershipFeeType create/update/destroy" do
|
||||
for set <- [:own_data, :read_only, :normal_user] do
|
||||
permissions = PermissionSets.get_permissions(set)
|
||||
|
||||
mft_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :create
|
||||
end)
|
||||
|
||||
mft_update =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :update
|
||||
end)
|
||||
|
||||
mft_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mft_create == nil || mft_create.granted == false,
|
||||
"Permission set #{set} should not allow MembershipFeeType create"
|
||||
|
||||
assert mft_update == nil || mft_update.granted == false,
|
||||
"Permission set #{set} should not allow MembershipFeeType update"
|
||||
|
||||
assert mft_destroy == nil || mft_destroy.granted == false,
|
||||
"Permission set #{set} should not allow MembershipFeeType destroy"
|
||||
end
|
||||
|
||||
admin_permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
mft_create =
|
||||
Enum.find(admin_permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :create
|
||||
end)
|
||||
|
||||
mft_update =
|
||||
Enum.find(admin_permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :update
|
||||
end)
|
||||
|
||||
mft_destroy =
|
||||
Enum.find(admin_permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeType" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mft_create != nil
|
||||
assert mft_create.scope == :all
|
||||
assert mft_create.granted == true
|
||||
assert mft_update != nil
|
||||
assert mft_update.granted == true
|
||||
assert mft_destroy != nil
|
||||
assert mft_destroy.granted == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_permissions/1 - MembershipFeeCycle resource" do
|
||||
test "all permission sets have MembershipFeeCycle read; own_data uses :linked, others :all" do
|
||||
for set <- PermissionSets.all_permission_sets() do
|
||||
permissions = PermissionSets.get_permissions(set)
|
||||
|
||||
mfc_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||
end)
|
||||
|
||||
assert mfc_read != nil, "Permission set #{set} should have MembershipFeeCycle read"
|
||||
assert mfc_read.granted == true
|
||||
|
||||
expected_scope = if set == :own_data, do: :linked, else: :all
|
||||
|
||||
assert mfc_read.scope == expected_scope,
|
||||
"Permission set #{set} should have MembershipFeeCycle read scope #{expected_scope}, got #{mfc_read.scope}"
|
||||
end
|
||||
end
|
||||
|
||||
test "read_only has MembershipFeeCycle read only, no update" do
|
||||
permissions = PermissionSets.get_permissions(:read_only)
|
||||
|
||||
mfc_update =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||
end)
|
||||
|
||||
assert mfc_update == nil || mfc_update.granted == false
|
||||
end
|
||||
|
||||
test "normal_user has MembershipFeeCycle read/create/update/destroy with scope :all" do
|
||||
permissions = PermissionSets.get_permissions(:normal_user)
|
||||
|
||||
mfc_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||
end)
|
||||
|
||||
mfc_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :create
|
||||
end)
|
||||
|
||||
mfc_update =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||
end)
|
||||
|
||||
mfc_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mfc_read != nil && mfc_read.granted == true
|
||||
assert mfc_create != nil && mfc_create.scope == :all && mfc_create.granted == true
|
||||
assert mfc_update != nil && mfc_update.granted == true
|
||||
assert mfc_destroy != nil && mfc_destroy.scope == :all && mfc_destroy.granted == true
|
||||
end
|
||||
|
||||
test "admin has MembershipFeeCycle read/create/update/destroy with scope :all" do
|
||||
permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
mfc_read =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :read
|
||||
end)
|
||||
|
||||
mfc_create =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :create
|
||||
end)
|
||||
|
||||
mfc_update =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :update
|
||||
end)
|
||||
|
||||
mfc_destroy =
|
||||
Enum.find(permissions.resources, fn p ->
|
||||
p.resource == "MembershipFeeCycle" && p.action == :destroy
|
||||
end)
|
||||
|
||||
assert mfc_read != nil
|
||||
assert mfc_read.granted == true
|
||||
assert mfc_create != nil
|
||||
assert mfc_create.granted == true
|
||||
assert mfc_update != nil
|
||||
assert mfc_update.granted == true
|
||||
assert mfc_destroy != nil
|
||||
assert mfc_destroy.granted == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "valid_permission_set?/1" do
|
||||
|
|
|
|||
226
test/mv/authorization/role_policies_test.exs
Normal file
226
test/mv/authorization/role_policies_test.exs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
defmodule Mv.Authorization.RolePoliciesTest do
|
||||
@moduledoc """
|
||||
Tests for Role resource authorization policies.
|
||||
|
||||
Rule: All permission sets (own_data, read_only, normal_user, admin) can **read** roles.
|
||||
Only **admin** can create, update, or destroy roles.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Authorization
|
||||
alias Mv.Authorization.Role
|
||||
|
||||
describe "read access - all permission sets can read roles" do
|
||||
setup do
|
||||
# Create a role to read (via system_actor; once policies exist, system_actor is admin)
|
||||
role = Mv.Fixtures.role_fixture("read_only")
|
||||
%{role: role}
|
||||
end
|
||||
|
||||
@tag :permission_set_own_data
|
||||
test "own_data can list roles", %{role: _role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
{:ok, roles} = Authorization.list_roles(actor: user)
|
||||
|
||||
assert is_list(roles)
|
||||
assert roles != []
|
||||
end
|
||||
|
||||
@tag :permission_set_own_data
|
||||
test "own_data can get role by id", %{role: role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
{:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization)
|
||||
|
||||
assert loaded.id == role.id
|
||||
end
|
||||
|
||||
@tag :permission_set_read_only
|
||||
test "read_only can list roles", %{role: _role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
{:ok, roles} = Authorization.list_roles(actor: user)
|
||||
|
||||
assert is_list(roles)
|
||||
assert roles != []
|
||||
end
|
||||
|
||||
@tag :permission_set_read_only
|
||||
test "read_only can get role by id", %{role: role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
{:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization)
|
||||
|
||||
assert loaded.id == role.id
|
||||
end
|
||||
|
||||
@tag :permission_set_normal_user
|
||||
test "normal_user can list roles", %{role: _role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
{:ok, roles} = Authorization.list_roles(actor: user)
|
||||
|
||||
assert is_list(roles)
|
||||
assert roles != []
|
||||
end
|
||||
|
||||
@tag :permission_set_normal_user
|
||||
test "normal_user can get role by id", %{role: role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
{:ok, loaded} = Ash.get(Role, role.id, actor: user, domain: Mv.Authorization)
|
||||
|
||||
assert loaded.id == role.id
|
||||
end
|
||||
|
||||
@tag :permission_set_admin
|
||||
test "admin can list roles", %{role: _role} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
admin = Mv.Authorization.Actor.ensure_loaded(admin)
|
||||
|
||||
{:ok, roles} = Authorization.list_roles(actor: admin)
|
||||
|
||||
assert is_list(roles)
|
||||
assert roles != []
|
||||
end
|
||||
|
||||
@tag :permission_set_admin
|
||||
test "admin can get role by id", %{role: role} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
admin = Mv.Authorization.Actor.ensure_loaded(admin)
|
||||
|
||||
{:ok, loaded} = Ash.get(Role, role.id, actor: admin, domain: Mv.Authorization)
|
||||
|
||||
assert loaded.id == role.id
|
||||
end
|
||||
end
|
||||
|
||||
describe "create/update/destroy - only admin allowed" do
|
||||
setup do
|
||||
# Non-system role for destroy test (role_fixture creates non-system roles)
|
||||
role = Mv.Fixtures.role_fixture("normal_user")
|
||||
%{role: role}
|
||||
end
|
||||
|
||||
test "admin can create_role", %{role: _role} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
admin = Mv.Authorization.Actor.ensure_loaded(admin)
|
||||
|
||||
attrs = %{
|
||||
name: "New Role #{System.unique_integer([:positive])}",
|
||||
description: "Test",
|
||||
permission_set_name: "read_only"
|
||||
}
|
||||
|
||||
assert {:ok, _created} = Authorization.create_role(attrs, actor: admin)
|
||||
end
|
||||
|
||||
test "admin can update_role", %{role: role} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
admin = Mv.Authorization.Actor.ensure_loaded(admin)
|
||||
|
||||
assert {:ok, updated} =
|
||||
Authorization.update_role(role, %{description: "Updated by admin"}, actor: admin)
|
||||
|
||||
assert updated.description == "Updated by admin"
|
||||
end
|
||||
|
||||
test "admin can destroy non-system role", %{role: role} do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
admin = Mv.Authorization.Actor.ensure_loaded(admin)
|
||||
|
||||
assert :ok = Authorization.destroy_role(role, actor: admin)
|
||||
end
|
||||
|
||||
test "own_data cannot create_role (forbidden)", %{role: _role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
attrs = %{
|
||||
name: "New Role #{System.unique_integer([:positive])}",
|
||||
description: "Test",
|
||||
permission_set_name: "read_only"
|
||||
}
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user)
|
||||
end
|
||||
|
||||
test "own_data cannot update_role (forbidden)", %{role: role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Authorization.update_role(role, %{description: "Updated"}, actor: user)
|
||||
end
|
||||
|
||||
test "own_data cannot destroy_role (forbidden)", %{role: role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user)
|
||||
end
|
||||
|
||||
test "read_only cannot create_role (forbidden)", %{role: _role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
attrs = %{
|
||||
name: "New Role #{System.unique_integer([:positive])}",
|
||||
description: "Test",
|
||||
permission_set_name: "read_only"
|
||||
}
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user)
|
||||
end
|
||||
|
||||
test "read_only cannot update_role (forbidden)", %{role: role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Authorization.update_role(role, %{description: "Updated"}, actor: user)
|
||||
end
|
||||
|
||||
test "read_only cannot destroy_role (forbidden)", %{role: role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user)
|
||||
end
|
||||
|
||||
test "normal_user cannot create_role (forbidden)", %{role: _role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
attrs = %{
|
||||
name: "New Role #{System.unique_integer([:positive])}",
|
||||
description: "Test",
|
||||
permission_set_name: "normal_user"
|
||||
}
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} = Authorization.create_role(attrs, actor: user)
|
||||
end
|
||||
|
||||
test "normal_user cannot update_role (forbidden)", %{role: role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Authorization.update_role(role, %{description: "Updated"}, actor: user)
|
||||
end
|
||||
|
||||
test "normal_user cannot destroy_role (forbidden)", %{role: role} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
user = Mv.Authorization.Actor.ensure_loaded(user)
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} = Authorization.destroy_role(role, actor: user)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -12,27 +12,29 @@ defmodule Mv.Authorization.RoleTest do
|
|||
end
|
||||
|
||||
describe "permission_set_name validation" do
|
||||
test "accepts valid permission set names" do
|
||||
test "accepts valid permission set names", %{actor: actor} do
|
||||
attrs = %{
|
||||
name: "Test Role",
|
||||
permission_set_name: "own_data"
|
||||
}
|
||||
|
||||
assert {:ok, role} = Authorization.create_role(attrs)
|
||||
assert {:ok, role} = Authorization.create_role(attrs, actor: actor)
|
||||
assert role.permission_set_name == "own_data"
|
||||
end
|
||||
|
||||
test "rejects invalid permission set names" do
|
||||
test "rejects invalid permission set names", %{actor: actor} do
|
||||
attrs = %{
|
||||
name: "Test Role",
|
||||
permission_set_name: "invalid_set"
|
||||
}
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs)
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Authorization.create_role(attrs, actor: actor)
|
||||
|
||||
assert error_message(errors, :permission_set_name) =~ "must be one of"
|
||||
end
|
||||
|
||||
test "accepts all four valid permission sets" do
|
||||
test "accepts all four valid permission sets", %{actor: actor} do
|
||||
valid_sets = ["own_data", "read_only", "normal_user", "admin"]
|
||||
|
||||
for permission_set <- valid_sets do
|
||||
|
|
@ -41,7 +43,7 @@ defmodule Mv.Authorization.RoleTest do
|
|||
permission_set_name: permission_set
|
||||
}
|
||||
|
||||
assert {:ok, _role} = Authorization.create_role(attrs)
|
||||
assert {:ok, _role} = Authorization.create_role(attrs, actor: actor)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -60,34 +62,36 @@ defmodule Mv.Authorization.RoleTest do
|
|||
{:ok, system_role} = Ash.create(changeset, actor: actor)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Authorization.destroy_role(system_role)
|
||||
Authorization.destroy_role(system_role, actor: actor)
|
||||
|
||||
message = error_message(errors, :is_system_role)
|
||||
assert message =~ "Cannot delete system role"
|
||||
end
|
||||
|
||||
test "allows deletion of non-system roles" do
|
||||
test "allows deletion of non-system roles", %{actor: actor} do
|
||||
# is_system_role defaults to false, so regular create works
|
||||
{:ok, regular_role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Regular Role",
|
||||
permission_set_name: "read_only"
|
||||
})
|
||||
Authorization.create_role(
|
||||
%{name: "Regular Role", permission_set_name: "read_only"},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
assert :ok = Authorization.destroy_role(regular_role)
|
||||
assert :ok = Authorization.destroy_role(regular_role, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
describe "name uniqueness" do
|
||||
test "enforces unique role names" do
|
||||
test "enforces unique role names", %{actor: actor} do
|
||||
attrs = %{
|
||||
name: "Unique Role",
|
||||
permission_set_name: "own_data"
|
||||
}
|
||||
|
||||
assert {:ok, _} = Authorization.create_role(attrs)
|
||||
assert {:ok, _} = Authorization.create_role(attrs, actor: actor)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Authorization.create_role(attrs, actor: actor)
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Authorization.create_role(attrs)
|
||||
assert error_message(errors, :name) =~ "has already been taken"
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -18,18 +18,21 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
Ecto.Adapters.SQL.query!(Mv.Repo, "DELETE FROM users WHERE id = $1", [id])
|
||||
end
|
||||
|
||||
# Helper function to ensure admin role exists
|
||||
# Helper function to ensure admin role exists (bootstrap: no actor yet, use authorize?: false)
|
||||
defp ensure_admin_role do
|
||||
case Authorization.list_roles() do
|
||||
case Authorization.list_roles(authorize?: false) do
|
||||
{:ok, roles} ->
|
||||
case Enum.find(roles, &(&1.permission_set_name == "admin")) do
|
||||
nil ->
|
||||
{:ok, role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
})
|
||||
Authorization.create_role(
|
||||
%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
},
|
||||
authorize?: false
|
||||
)
|
||||
|
||||
role
|
||||
|
||||
|
|
@ -39,11 +42,14 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
_ ->
|
||||
{:ok, role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
})
|
||||
Authorization.create_role(
|
||||
%{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin"
|
||||
},
|
||||
authorize?: false
|
||||
)
|
||||
|
||||
role
|
||||
end
|
||||
|
|
@ -364,12 +370,17 @@ defmodule Mv.Helpers.SystemActorTest do
|
|||
|
||||
test "raises error if system user has wrong role", %{system_user: system_user} do
|
||||
# Create a non-admin role (using read_only as it's a valid permission set)
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
{:ok, read_only_role} =
|
||||
Authorization.create_role(%{
|
||||
name: "Read Only Role",
|
||||
description: "Read-only access",
|
||||
permission_set_name: "read_only"
|
||||
})
|
||||
Authorization.create_role(
|
||||
%{
|
||||
name: "Read Only Role",
|
||||
description: "Read-only access",
|
||||
permission_set_name: "read_only"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
140
test/mv/membership/group_policies_test.exs
Normal file
140
test/mv/membership/group_policies_test.exs
Normal 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
|
||||
194
test/mv/membership/member_email_validation_test.exs
Normal file
194
test/mv/membership/member_email_validation_test.exs
Normal 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
|
||||
234
test/mv/membership/member_group_policies_test.exs
Normal file
234
test/mv/membership/member_group_policies_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
294
test/mv/membership_fees/membership_fee_cycle_policies_test.exs
Normal file
294
test/mv/membership_fees/membership_fee_cycle_policies_test.exs
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
defmodule Mv.MembershipFees.MembershipFeeCyclePoliciesTest do
|
||||
@moduledoc """
|
||||
Tests for MembershipFeeCycle resource authorization policies.
|
||||
|
||||
Verifies own_data can only read :linked (linked member's cycles);
|
||||
read_only can only read (no create/update/destroy);
|
||||
normal_user and admin can read, create, update, destroy (including mark_as_paid).
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.MembershipFees
|
||||
alias Mv.Membership
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
defp create_member_fixture do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{: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 do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{: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 do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
member = create_member_fixture()
|
||||
fee_type = create_fee_type_fixture()
|
||||
|
||||
{: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 "own_data permission set" do
|
||||
setup %{actor: actor} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
linked_member = create_member_fixture()
|
||||
other_member = create_member_fixture()
|
||||
fee_type = create_fee_type_fixture()
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
user =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{}, domain: Mv.Accounts)
|
||||
|> Ash.Changeset.force_change_attribute(:member_id, linked_member.id)
|
||||
|> Ash.update(actor: admin, domain: Mv.Accounts)
|
||||
|
||||
{:ok, user} = Ash.load(user, :role, domain: Mv.Accounts, actor: actor)
|
||||
|
||||
{:ok, cycle_linked} =
|
||||
MembershipFees.create_membership_fee_cycle(
|
||||
%{
|
||||
member_id: linked_member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
cycle_start: Date.utc_today(),
|
||||
amount: Decimal.new("10.00"),
|
||||
status: :unpaid
|
||||
},
|
||||
actor: admin
|
||||
)
|
||||
|
||||
{:ok, cycle_other} =
|
||||
MembershipFees.create_membership_fee_cycle(
|
||||
%{
|
||||
member_id: other_member.id,
|
||||
membership_fee_type_id: fee_type.id,
|
||||
cycle_start: Date.add(Date.utc_today(), -365),
|
||||
amount: Decimal.new("10.00"),
|
||||
status: :unpaid
|
||||
},
|
||||
actor: admin
|
||||
)
|
||||
|
||||
%{user: user, cycle_linked: cycle_linked, cycle_other: cycle_other}
|
||||
end
|
||||
|
||||
test "can read only linked member's cycles", %{
|
||||
user: user,
|
||||
cycle_linked: cycle_linked,
|
||||
cycle_other: cycle_other
|
||||
} do
|
||||
{:ok, list} =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.read(actor: user, domain: Mv.MembershipFees)
|
||||
|
||||
ids = Enum.map(list, & &1.id)
|
||||
assert cycle_linked.id in ids
|
||||
refute cycle_other.id in ids
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_only permission set" do
|
||||
setup %{actor: actor} do
|
||||
user = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
cycle = create_cycle_fixture()
|
||||
%{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()
|
||||
fee_type = create_fee_type_fixture()
|
||||
|
||||
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 = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
cycle = create_cycle_fixture()
|
||||
%{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()
|
||||
fee_type = create_fee_type_fixture()
|
||||
|
||||
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 = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
cycle = create_cycle_fixture()
|
||||
%{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()
|
||||
fee_type = create_fee_type_fixture()
|
||||
|
||||
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
|
||||
260
test/mv/membership_fees/membership_fee_type_policies_test.exs
Normal file
260
test/mv/membership_fees/membership_fee_type_policies_test.exs
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
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
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
defp create_membership_fee_type_fixture do
|
||||
admin = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{: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 = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
fee_type = create_membership_fee_type_fixture()
|
||||
%{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 = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{: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 = Mv.Fixtures.user_with_role_fixture("read_only")
|
||||
fee_type = create_membership_fee_type_fixture()
|
||||
%{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 = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{: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 = Mv.Fixtures.user_with_role_fixture("normal_user")
|
||||
fee_type = create_membership_fee_type_fixture()
|
||||
%{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 = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{: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 = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
fee_type = create_membership_fee_type_fixture()
|
||||
%{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
|
||||
|
|
@ -50,14 +50,14 @@ defmodule MvWeb.AuthorizationTest do
|
|||
assert Authorization.can?(admin, :destroy, Mv.Authorization.Role) == true
|
||||
end
|
||||
|
||||
test "non-admin cannot manage roles" do
|
||||
test "non-admin can read roles but cannot create/update/destroy" do
|
||||
normal_user = %{
|
||||
id: "normal-123",
|
||||
role: %{permission_set_name: "normal_user"}
|
||||
}
|
||||
|
||||
assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == true
|
||||
assert Authorization.can?(normal_user, :create, Mv.Authorization.Role) == false
|
||||
assert Authorization.can?(normal_user, :read, Mv.Authorization.Role) == false
|
||||
assert Authorization.can?(normal_user, :update, Mv.Authorization.Role) == false
|
||||
assert Authorization.can?(normal_user, :destroy, Mv.Authorization.Role) == false
|
||||
end
|
||||
|
|
|
|||
|
|
@ -22,9 +22,14 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
# =============================================================================
|
||||
|
||||
# Returns assigns for an authenticated user with all required attributes.
|
||||
# User has admin role so can_access_page? returns true for all sidebar links.
|
||||
defp authenticated_assigns(mobile \\ false) do
|
||||
%{
|
||||
current_user: %{id: "user-123", email: "test@example.com"},
|
||||
current_user: %{
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
role: %{permission_set_name: "admin"}
|
||||
},
|
||||
club_name: "Test Club",
|
||||
mobile: mobile
|
||||
}
|
||||
|
|
@ -144,7 +149,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
assert menu_item_count > 0, "Should have at least one top-level menu item"
|
||||
|
||||
# Check that nested menu groups exist
|
||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||
assert html =~
|
||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||
|
||||
assert html =~ ~s(role="group")
|
||||
assert has_class?(html, "expanded-menu-group")
|
||||
|
||||
|
|
@ -193,7 +200,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for nested menu structure
|
||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||
assert html =~
|
||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||
|
||||
assert html =~ ~s(role="group")
|
||||
assert html =~ ~s(aria-label="Administration")
|
||||
assert has_class?(html, "expanded-menu-group")
|
||||
|
|
@ -521,7 +530,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
assert html =~ ~s(role="menuitem")
|
||||
|
||||
# Check that nested menus exist
|
||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||
assert html =~
|
||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||
|
||||
assert html =~ ~s(role="group")
|
||||
|
||||
# Footer section
|
||||
|
|
@ -629,7 +640,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# expanded-menu-group structure present
|
||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||
assert html =~
|
||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||
|
||||
assert html =~ ~s(role="group")
|
||||
assert html =~ ~s(aria-label="Administration")
|
||||
assert has_class?(html, "expanded-menu-group")
|
||||
|
|
@ -843,7 +856,9 @@ defmodule MvWeb.Layouts.SidebarTest do
|
|||
|
||||
# Expanded menu group should have correct structure
|
||||
# (CSS handles hover effects, but we verify structure)
|
||||
assert html =~ ~s(<li role="none" class="expanded-menu-group">)
|
||||
assert html =~
|
||||
~s(<li role="none" class="expanded-menu-group" data-testid="sidebar-administration">)
|
||||
|
||||
assert html =~ ~s(role="group")
|
||||
end
|
||||
|
||||
|
|
|
|||
120
test/mv_web/components/sidebar_authorization_test.exs
Normal file
120
test/mv_web/components/sidebar_authorization_test.exs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
defmodule MvWeb.SidebarAuthorizationTest do
|
||||
@moduledoc """
|
||||
Tests for sidebar menu visibility based on user permissions (can_access_page?).
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import MvWeb.Layouts.Sidebar
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
defp render_sidebar(assigns) do
|
||||
render_component(&sidebar/1, assigns)
|
||||
end
|
||||
|
||||
defp sidebar_assigns(current_user, opts \\ []) do
|
||||
mobile = Keyword.get(opts, :mobile, false)
|
||||
club_name = Keyword.get(opts, :club_name, "Test Club")
|
||||
|
||||
%{
|
||||
current_user: current_user,
|
||||
club_name: club_name,
|
||||
mobile: mobile
|
||||
}
|
||||
end
|
||||
|
||||
describe "sidebar menu with admin user" do
|
||||
test "shows Members, Fee Types and Administration with all subitems" do
|
||||
user = Fixtures.user_with_role_fixture("admin")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
assert html =~ ~s(href="/members")
|
||||
assert html =~ ~s(href="/membership_fee_types")
|
||||
assert html =~ ~s(data-testid="sidebar-administration")
|
||||
assert html =~ ~s(href="/users")
|
||||
assert html =~ ~s(href="/groups")
|
||||
assert html =~ ~s(href="/admin/roles")
|
||||
assert html =~ ~s(href="/membership_fee_settings")
|
||||
assert html =~ ~s(href="/settings")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sidebar menu with read_only user (Vorstand/Buchhaltung)" do
|
||||
test "shows Members and Groups (from Administration)" do
|
||||
user = Fixtures.user_with_role_fixture("read_only")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
assert html =~ ~s(href="/members")
|
||||
assert html =~ ~s(href="/groups")
|
||||
end
|
||||
|
||||
test "does not show Fee Types, Users, Roles or Settings" do
|
||||
user = Fixtures.user_with_role_fixture("read_only")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
refute html =~ ~s(href="/membership_fee_types")
|
||||
refute html =~ ~s(href="/users")
|
||||
refute html =~ ~s(href="/admin/roles")
|
||||
refute html =~ ~s(href="/settings")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sidebar menu with normal_user (Kassenwart)" do
|
||||
test "shows Members and Groups" do
|
||||
user = Fixtures.user_with_role_fixture("normal_user")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
assert html =~ ~s(href="/members")
|
||||
assert html =~ ~s(href="/groups")
|
||||
end
|
||||
|
||||
test "does not show Fee Types, Users, Roles or Settings" do
|
||||
user = Fixtures.user_with_role_fixture("normal_user")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
refute html =~ ~s(href="/membership_fee_types")
|
||||
refute html =~ ~s(href="/users")
|
||||
refute html =~ ~s(href="/admin/roles")
|
||||
refute html =~ ~s(href="/settings")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sidebar menu with own_data user (Mitglied)" do
|
||||
test "does not show Members link (no /members page access)" do
|
||||
user = Fixtures.user_with_role_fixture("own_data")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
refute html =~ ~s(href="/members")
|
||||
end
|
||||
|
||||
test "does not show Fee Types or Administration" do
|
||||
user = Fixtures.user_with_role_fixture("own_data")
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
refute html =~ ~s(href="/membership_fee_types")
|
||||
refute html =~ ~s(href="/users")
|
||||
refute html =~ ~s(data-testid="sidebar-administration")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sidebar with nil current_user" do
|
||||
test "does not render menu items (only header and footer when present)" do
|
||||
html = render_sidebar(sidebar_assigns(nil))
|
||||
|
||||
refute html =~ ~s(role="menubar")
|
||||
refute html =~ ~s(href="/members")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sidebar with user without role" do
|
||||
test "does not show any navigation links" do
|
||||
user = %{id: "user-no-role", email: "noreply@test.com", role: nil}
|
||||
html = render_sidebar(sidebar_assigns(user))
|
||||
|
||||
refute html =~ ~s(href="/members")
|
||||
refute html =~ ~s(href="/membership_fee_types")
|
||||
refute html =~ ~s(href="/users")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -134,8 +134,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: actor)
|
||||
|
||||
# Use a fixed date in 2024 to ensure 2023 is last completed
|
||||
today = ~D[2024-06-15]
|
||||
|
|
@ -180,8 +180,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
# Load cycles and fee type (will be empty)
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: actor)
|
||||
|
||||
last_cycle = MembershipFeeHelpers.get_last_completed_cycle(member, Date.utc_today())
|
||||
assert last_cycle == nil
|
||||
|
|
@ -245,8 +245,8 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
|
|||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: actor)
|
||||
|
||||
result = MembershipFeeHelpers.get_current_cycle(member, today)
|
||||
|
||||
|
|
|
|||
102
test/mv_web/live/member_live_authorization_test.exs
Normal file
102
test/mv_web/live/member_live_authorization_test.exs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
defmodule MvWeb.MemberLiveAuthorizationTest do
|
||||
@moduledoc """
|
||||
Tests for UI authorization on Member LiveViews (Index and Show).
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "Member Index - Vorstand (read_only)" do
|
||||
@tag role: :read_only
|
||||
test "sees member list but not New Member button", %{conn: conn} do
|
||||
_member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
refute has_element?(view, "[data-testid=member-new]")
|
||||
end
|
||||
|
||||
@tag role: :read_only
|
||||
test "does not see Edit or Delete buttons in table", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
refute has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
||||
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Member Index - Kassenwart (normal_user)" do
|
||||
@tag role: :normal_user
|
||||
test "sees New Member and Edit buttons", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assert has_element?(view, "[data-testid=member-new]")
|
||||
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "does not see Delete button", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
refute has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Member Index - Admin" do
|
||||
@tag role: :admin
|
||||
test "sees New Member, Edit and Delete buttons", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assert has_element?(view, "[data-testid=member-new]")
|
||||
assert has_element?(view, "#row-#{member.id} [data-testid=member-edit]")
|
||||
assert has_element?(view, "#row-#{member.id} [data-testid=member-delete]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "Member Index - Mitglied (own_data)" do
|
||||
@tag role: :member
|
||||
test "is redirected when accessing /members", %{conn: conn, current_user: user} do
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/members")
|
||||
assert to == "/users/#{user.id}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "Member Show - Edit button visibility" do
|
||||
@tag role: :admin
|
||||
test "admin sees Edit button", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=member-edit]")
|
||||
end
|
||||
|
||||
@tag role: :read_only
|
||||
test "read_only does not see Edit button", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
refute has_element?(view, "[data-testid=member-edit]")
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "normal_user sees Edit button", %{conn: conn} do
|
||||
member = Fixtures.member_fixture()
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=member-edit]")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -50,7 +50,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
end
|
||||
|
||||
describe "create form" do
|
||||
test "creates new membership fee type", %{conn: conn} do
|
||||
test "creates new membership fee type", %{conn: conn, user: user} do
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/new")
|
||||
|
||||
form_data = %{
|
||||
|
|
@ -67,12 +67,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
|
||||
assert to == "/membership_fee_types"
|
||||
|
||||
# Verify type was created
|
||||
# Verify type was created (use actor so read is authorized)
|
||||
type =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.filter(name == "New Type")
|
||||
|> Ash.read_one!()
|
||||
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
|
||||
|
||||
assert type != nil, "Expected membership fee type to be created"
|
||||
assert type.amount == Decimal.new("75.00")
|
||||
assert type.interval == :yearly
|
||||
end
|
||||
|
|
@ -140,7 +141,7 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
assert html =~ "3" || html =~ "members" || html =~ "Mitglieder"
|
||||
end
|
||||
|
||||
test "amount change can be confirmed", %{conn: conn} do
|
||||
test "amount change can be confirmed", %{conn: conn, user: user} do
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
|
@ -159,12 +160,17 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
|> form("#membership-fee-type-form", %{"membership_fee_type[amount]" => "75.00"})
|
||||
|> render_submit()
|
||||
|
||||
# Amount should be updated
|
||||
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
|
||||
# Amount should be updated (use actor so read is authorized)
|
||||
updated_type =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.filter(id == ^fee_type.id)
|
||||
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
|
||||
|
||||
assert updated_type != nil
|
||||
assert updated_type.amount == Decimal.new("75.00")
|
||||
end
|
||||
|
||||
test "amount change can be cancelled", %{conn: conn} do
|
||||
test "amount change can be cancelled", %{conn: conn, user: user} do
|
||||
fee_type = create_fee_type(%{amount: Decimal.new("50.00")})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/membership_fee_types/#{fee_type.id}/edit")
|
||||
|
|
@ -178,8 +184,13 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
|
|||
|> element("button[phx-click='cancel_amount_change']")
|
||||
|> render_click()
|
||||
|
||||
# Amount should remain unchanged
|
||||
updated_type = Ash.read_one!(MembershipFeeType |> Ash.Query.filter(id == ^fee_type.id))
|
||||
# Amount should remain unchanged (use actor so read is authorized)
|
||||
updated_type =
|
||||
MembershipFeeType
|
||||
|> Ash.Query.filter(id == ^fee_type.id)
|
||||
|> Ash.read_one!(domain: Mv.MembershipFees, actor: user)
|
||||
|
||||
assert updated_type != nil
|
||||
assert updated_type.amount == Decimal.new("50.00")
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ defmodule MvWeb.ProfileNavigationTest do
|
|||
end
|
||||
|
||||
@tag :skip
|
||||
# credo:disable-for-next-line Credo.Check.Design.TagTODO
|
||||
# TODO: Implement user initials in navbar avatar - see issue #170
|
||||
test "shows user initials in avatar", %{conn: conn} do
|
||||
# Setup: Create and login a user
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
alias Mv.Authorization
|
||||
alias Mv.Authorization.Role
|
||||
|
||||
# Helper to create a role
|
||||
# Helper to create a role (authorize?: false for test data setup)
|
||||
defp create_role(attrs \\ %{}) do
|
||||
default_attrs = %{
|
||||
name: "Test Role #{System.unique_integer([:positive])}",
|
||||
|
|
@ -28,7 +28,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
case Authorization.create_role(attrs) do
|
||||
case Authorization.create_role(attrs, authorize?: false) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
|
|
@ -38,7 +38,7 @@ defmodule MvWeb.RoleLive.ShowTest do
|
|||
defp create_admin_user(conn, actor) do
|
||||
# Create admin role
|
||||
admin_role =
|
||||
case Authorization.list_roles() do
|
||||
case Authorization.list_roles(authorize?: false) do
|
||||
{:ok, roles} ->
|
||||
case Enum.find(roles, &(&1.name == "Admin")) do
|
||||
nil ->
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
alias Mv.Authorization
|
||||
alias Mv.Authorization.Role
|
||||
|
||||
# Helper to create a role
|
||||
# Helper to create a role (authorize?: false for test data setup)
|
||||
defp create_role(attrs \\ %{}) do
|
||||
default_attrs = %{
|
||||
name: "Test Role #{System.unique_integer([:positive])}",
|
||||
|
|
@ -19,7 +19,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
|
||||
case Authorization.create_role(attrs) do
|
||||
case Authorization.create_role(attrs, authorize?: false) do
|
||||
{:ok, role} -> role
|
||||
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
|
||||
end
|
||||
|
|
@ -29,7 +29,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
defp create_admin_user(conn, actor) do
|
||||
# Create admin role
|
||||
admin_role =
|
||||
case Authorization.list_roles() do
|
||||
case Authorization.list_roles(authorize?: false) do
|
||||
{:ok, roles} ->
|
||||
case Enum.find(roles, &(&1.name == "Admin")) do
|
||||
nil ->
|
||||
|
|
@ -332,7 +332,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert match?({:error, {:redirect, %{to: "/admin/roles"}}}, result)
|
||||
end
|
||||
|
||||
test "updates role name", %{conn: conn, role: role} do
|
||||
test "updates role name", %{conn: conn, role: role, actor: actor} do
|
||||
{:ok, view, _html} = live(conn, "/admin/roles/#{role.id}/edit?return_to=show")
|
||||
|
||||
attrs = %{
|
||||
|
|
@ -348,7 +348,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert_redirect(view, "/admin/roles/#{role.id}")
|
||||
|
||||
# Verify update
|
||||
{:ok, updated_role} = Authorization.get_role(role.id)
|
||||
{:ok, updated_role} = Authorization.get_role(role.id, actor: actor)
|
||||
assert updated_role.name == "Updated Role Name"
|
||||
end
|
||||
|
||||
|
|
@ -377,7 +377,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert_redirect(view, "/admin/roles/#{system_role.id}")
|
||||
|
||||
# Verify update
|
||||
{:ok, updated_role} = Authorization.get_role(system_role.id)
|
||||
{:ok, updated_role} = Authorization.get_role(system_role.id, actor: actor)
|
||||
assert updated_role.permission_set_name == "read_only"
|
||||
end
|
||||
end
|
||||
|
|
@ -390,7 +390,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
end
|
||||
|
||||
@tag :slow
|
||||
test "deletes non-system role", %{conn: conn} do
|
||||
test "deletes non-system role", %{conn: conn, actor: actor} do
|
||||
role = create_role()
|
||||
|
||||
{:ok, view, html} = live(conn, "/admin/roles")
|
||||
|
|
@ -404,7 +404,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
|
||||
# Verify deletion by checking database
|
||||
assert {:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{}]}} =
|
||||
Authorization.get_role(role.id)
|
||||
Authorization.get_role(role.id, actor: actor)
|
||||
end
|
||||
|
||||
test "fails to delete system role with error message", %{conn: conn, actor: actor} do
|
||||
|
|
@ -430,7 +430,7 @@ defmodule MvWeb.RoleLiveTest do
|
|||
assert render(view) =~ "System roles cannot be deleted"
|
||||
|
||||
# Role should still exist
|
||||
{:ok, _role} = Authorization.get_role(system_role.id)
|
||||
{:ok, _role} = Authorization.get_role(system_role.id, actor: actor)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
81
test/mv_web/live/user_live_authorization_test.exs
Normal file
81
test/mv_web/live/user_live_authorization_test.exs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
defmodule MvWeb.UserLiveAuthorizationTest do
|
||||
@moduledoc """
|
||||
Tests for UI authorization on User LiveViews (Index and Show).
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
describe "User Index - Admin" do
|
||||
@tag role: :admin
|
||||
test "sees New User, Edit and Delete buttons", %{conn: conn} do
|
||||
user = Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
{:ok, view, _html} = live(conn, "/users")
|
||||
|
||||
assert has_element?(view, "[data-testid=user-new]")
|
||||
assert has_element?(view, "#row-#{user.id} [data-testid=user-edit]")
|
||||
assert has_element?(view, "#row-#{user.id} [data-testid=user-delete]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "User Index - Non-Admin is redirected" do
|
||||
@tag role: :read_only
|
||||
test "read_only is redirected when accessing /users", %{conn: conn, current_user: user} do
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
|
||||
assert to == "/users/#{user.id}"
|
||||
end
|
||||
|
||||
@tag role: :member
|
||||
test "member is redirected when accessing /users", %{conn: conn, current_user: user} do
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
|
||||
assert to == "/users/#{user.id}"
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "normal_user is redirected when accessing /users", %{conn: conn, current_user: user} do
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users")
|
||||
assert to == "/users/#{user.id}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "User Show - own profile" do
|
||||
@tag role: :member
|
||||
test "member sees Edit button on own profile", %{conn: conn, current_user: user} do
|
||||
{:ok, view, _html} = live(conn, "/users/#{user.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=user-edit]")
|
||||
end
|
||||
|
||||
@tag role: :read_only
|
||||
test "read_only sees Edit button on own profile", %{conn: conn, current_user: user} do
|
||||
{:ok, view, _html} = live(conn, "/users/#{user.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=user-edit]")
|
||||
end
|
||||
|
||||
@tag role: :admin
|
||||
test "admin sees Edit button on user show", %{conn: conn} do
|
||||
user = Fixtures.user_with_role_fixture("read_only")
|
||||
|
||||
{:ok, view, _html} = live(conn, "/users/#{user.id}")
|
||||
|
||||
assert has_element?(view, "[data-testid=user-edit]")
|
||||
end
|
||||
end
|
||||
|
||||
describe "User Show - other user (non-admin redirected)" do
|
||||
@tag role: :member
|
||||
test "member is redirected when accessing other user's profile", %{
|
||||
conn: conn,
|
||||
current_user: current_user
|
||||
} do
|
||||
other_user = Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, "/users/#{other_user.id}")
|
||||
assert to == "/users/#{current_user.id}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -127,10 +127,12 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
# Load cycles with membership_fee_type relationship
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
|
||||
# Use fixed date in 2024 to ensure 2023 is last completed
|
||||
# We need to manually set the date for the helper function
|
||||
|
|
@ -183,8 +185,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
# Load cycles with membership_fee_type relationship
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
|
||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, true)
|
||||
|
||||
|
|
@ -222,8 +224,8 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
# Load cycles and fee type first (will be empty)
|
||||
member =
|
||||
member
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
|
||||
status = MembershipFeeStatus.get_cycle_status_for_member(member, false)
|
||||
|
||||
|
|
@ -273,12 +275,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, false)
|
||||
|
|
@ -300,12 +304,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: last_year_start, status: :unpaid})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, false)
|
||||
|
|
@ -327,12 +333,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :paid, true)
|
||||
|
|
@ -354,12 +362,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
create_cycle(member2, fee_type, %{cycle_start: current_year_start, status: :unpaid})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
filtered = MembershipFeeStatus.filter_members_by_cycle_status(members, :unpaid, true)
|
||||
|
|
@ -373,12 +383,14 @@ defmodule MvWeb.MemberLive.Index.MembershipFeeStatusTest do
|
|||
member1 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member2 = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
members =
|
||||
[member1, member2]
|
||||
|> Enum.map(fn m ->
|
||||
m
|
||||
|> Ash.load!(membership_fee_cycles: [:membership_fee_type])
|
||||
|> Ash.load!(:membership_fee_type)
|
||||
|> Ash.load!([membership_fee_cycles: [:membership_fee_type]], actor: system_actor)
|
||||
|> Ash.load!(:membership_fee_type, actor: system_actor)
|
||||
end)
|
||||
|
||||
# filter_unpaid_members should still work for backwards compatibility
|
||||
|
|
|
|||
|
|
@ -28,21 +28,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
# Helper to create a member
|
||||
defp create_member(attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
default_attrs = %{
|
||||
first_name: "Test",
|
||||
last_name: "Member",
|
||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||
}
|
||||
|
||||
attrs = Map.merge(default_attrs, attrs)
|
||||
{:ok, member} = Mv.Membership.create_member(attrs, actor: system_actor)
|
||||
member
|
||||
end
|
||||
|
||||
# Helper to create a cycle
|
||||
defp create_cycle(member, fee_type, attrs) do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
|
@ -73,7 +58,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
describe "cycles table display" do
|
||||
test "displays all cycles for member", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
_cycle1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
|
||||
_cycle2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
|
@ -95,7 +80,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
test "table columns show correct data", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly, amount: Decimal.new("60.00")})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
create_cycle(member, fee_type, %{
|
||||
cycle_start: ~D[2023-01-01],
|
||||
|
|
@ -124,7 +109,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
yearly_type = create_fee_type(%{interval: :yearly, name: "Yearly Type"})
|
||||
_monthly_type = create_fee_type(%{interval: :monthly, name: "Monthly Type"})
|
||||
|
||||
member = create_member(%{membership_fee_type_id: yearly_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: yearly_type.id})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -132,20 +117,30 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
assert html =~ "Yearly Type"
|
||||
end
|
||||
|
||||
test "shows no type message when no type assigned", %{conn: conn} do
|
||||
member = create_member(%{})
|
||||
test "shows no type message when no type assigned and Regenerate Cycles button is hidden", %{
|
||||
conn: conn
|
||||
} do
|
||||
member = Mv.Fixtures.member_fixture(%{})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
{:ok, view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
# Should show message about no type assigned
|
||||
assert html =~ "No membership fee type assigned" || html =~ "No type"
|
||||
|
||||
# Switch to membership fees tab: message and no Regenerate Cycles button
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
refute has_element?(view, "button[phx-click='regenerate_cycles']"),
|
||||
"Regenerate Cycles should be hidden when no membership fee type is assigned"
|
||||
end
|
||||
end
|
||||
|
||||
describe "status change actions" do
|
||||
test "mark as paid works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
|
|
@ -176,7 +171,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
test "mark as suspended works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
|
|
@ -207,7 +202,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
|
||||
test "mark as unpaid works", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :paid})
|
||||
|
||||
|
|
@ -240,7 +235,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
describe "cycle regeneration" do
|
||||
test "manual regeneration button exists and can be clicked", %{conn: conn} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = create_member(%{membership_fee_type_id: fee_type.id})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -266,7 +261,7 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
describe "edge cases" do
|
||||
test "handles members without membership fee type gracefully", %{conn: conn} do
|
||||
# No fee type
|
||||
member = create_member(%{})
|
||||
member = Mv.Fixtures.member_fixture(%{})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
|
|
@ -274,4 +269,120 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|
|||
assert html =~ member.first_name
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_only user (Vorstand/Buchhaltung) - no cycle action buttons" do
|
||||
@tag role: :read_only
|
||||
test "read_only does not see Regenerate Cycles, Delete All Cycles, or Create Cycle buttons",
|
||||
%{
|
||||
conn: conn
|
||||
} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
_cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
refute has_element?(view, "button[phx-click='regenerate_cycles']")
|
||||
refute has_element?(view, "button[phx-click='delete_all_cycles']")
|
||||
refute has_element?(view, "button[phx-click='open_create_cycle_modal']")
|
||||
end
|
||||
|
||||
@tag role: :read_only
|
||||
test "read_only does not see Paid, Unpaid, Suspended, or Delete buttons in cycles table", %{
|
||||
conn: conn
|
||||
} do
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
# Row action buttons must not be present for read_only
|
||||
refute has_element?(view, "button[phx-click='mark_cycle_status']")
|
||||
refute has_element?(view, "button[phx-click='delete_cycle']")
|
||||
# Sanity: cycle row is present (read is allowed)
|
||||
assert has_element?(view, "tr[id='cycle-#{cycle.id}']")
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_only cannot delete all cycles (policy enforced via Ash.destroy)" do
|
||||
@tag role: :read_only
|
||||
test "Ash.destroy returns Forbidden for read_only so handler would reject", %{
|
||||
current_user: read_only_user
|
||||
} do
|
||||
# The handler uses Ash.destroy per cycle, so if the handler were triggered
|
||||
# (e.g. via dev tools), the server would enforce policy and show an error.
|
||||
# This test verifies that Ash.destroy(cycle, actor: read_only_user) returns Forbidden.
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
cycle = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
assert {:error, %Ash.Error.Forbidden{}} =
|
||||
Ash.destroy(cycle, domain: Mv.MembershipFees, actor: read_only_user)
|
||||
end
|
||||
end
|
||||
|
||||
describe "read_only cannot trigger regenerate_cycles (handler enforces can?)" do
|
||||
@tag role: :read_only
|
||||
test "read_only cannot create MembershipFeeCycle so regenerate_cycles handler would show flash error",
|
||||
%{current_user: read_only_user} do
|
||||
# The regenerate_cycles handler checks can?(actor, :create, MembershipFeeCycle) before
|
||||
# calling the generator. If a read_only user triggered the event (e.g. via DevTools),
|
||||
# the handler returns flash error and no new cycles are created.
|
||||
# This test verifies the condition the handler uses.
|
||||
refute MvWeb.Authorization.can?(read_only_user, :create, MembershipFeeCycle),
|
||||
"read_only must not be allowed to create MembershipFeeCycle so handler rejects regenerate_cycles"
|
||||
end
|
||||
end
|
||||
|
||||
describe "confirm_delete_all_cycles handler (policy enforced)" do
|
||||
@tag role: :admin
|
||||
test "admin can delete all cycles via UI and cycles are removed", %{conn: conn} do
|
||||
# Use English locale so confirmation "Yes" matches gettext("Yes")
|
||||
conn = put_session(conn, :locale, "en")
|
||||
|
||||
fee_type = create_fee_type(%{interval: :yearly})
|
||||
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
|
||||
_c1 = create_cycle(member, fee_type, %{cycle_start: ~D[2022-01-01], status: :paid})
|
||||
_c2 = create_cycle(member, fee_type, %{cycle_start: ~D[2023-01-01], status: :unpaid})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members/#{member.id}")
|
||||
|
||||
view
|
||||
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("button[phx-click='delete_all_cycles']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("input[phx-keyup='update_delete_all_confirmation']")
|
||||
|> render_keyup(%{"value" => "Yes"})
|
||||
|
||||
view
|
||||
|> element("button[phx-click='confirm_delete_all_cycles']")
|
||||
|> render_click()
|
||||
|
||||
_html = render(view)
|
||||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
remaining =
|
||||
Mv.MembershipFees.MembershipFeeCycle
|
||||
|> Ash.Query.filter(member_id == ^member.id)
|
||||
|> Ash.read!(actor: system_actor)
|
||||
|
||||
assert remaining == [],
|
||||
"Expected all cycles to be deleted (handler enforces policy via Ash.destroy)"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -742,6 +742,18 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
|||
assert conn.status == 200
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /groups/new returns 200", %{conn: conn} do
|
||||
conn = get(conn, "/groups/new")
|
||||
assert conn.status == 200
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /groups/:slug/edit returns 200", %{conn: conn, group_slug: slug} do
|
||||
conn = get(conn, "/groups/#{slug}/edit")
|
||||
assert conn.status == 200
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /members/:id/show/edit returns 200", %{conn: conn, member_id: id} do
|
||||
conn = get(conn, "/members/#{id}/show/edit")
|
||||
|
|
@ -830,22 +842,6 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do
|
|||
assert redirected_to(conn) == "/users/#{user.id}"
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /groups/new redirects to user profile", %{conn: conn, current_user: user} do
|
||||
conn = get(conn, "/groups/new")
|
||||
assert redirected_to(conn) == "/users/#{user.id}"
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /groups/:slug/edit redirects to user profile", %{
|
||||
conn: conn,
|
||||
current_user: user,
|
||||
group_slug: slug
|
||||
} do
|
||||
conn = get(conn, "/groups/#{slug}/edit")
|
||||
assert redirected_to(conn) == "/users/#{user.id}"
|
||||
end
|
||||
|
||||
@tag role: :normal_user
|
||||
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
|
||||
conn = get(conn, "/admin/roles")
|
||||
|
|
|
|||
|
|
@ -213,6 +213,35 @@ defmodule MvWeb.UserLive.FormTest do
|
|||
assert not is_nil(updated_user.hashed_password)
|
||||
assert updated_user.hashed_password != ""
|
||||
end
|
||||
|
||||
test "admin can change user role and change persists", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
role_a = Mv.Fixtures.role_fixture("normal_user")
|
||||
role_b = Mv.Fixtures.role_fixture("read_only")
|
||||
|
||||
user = create_test_user(%{email: "rolechange@example.com"})
|
||||
{:ok, user} = Mv.Accounts.update_user(user, %{role_id: role_a.id}, actor: system_actor)
|
||||
assert user.role_id == role_a.id
|
||||
|
||||
{:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit")
|
||||
|
||||
view
|
||||
|> form("#user-form",
|
||||
user: %{
|
||||
email: "rolechange@example.com",
|
||||
role_id: role_b.id
|
||||
}
|
||||
)
|
||||
|> render_submit()
|
||||
|
||||
assert_redirected(view, "/users")
|
||||
|
||||
updated_user = Ash.reload!(user, domain: Mv.Accounts, actor: system_actor)
|
||||
|
||||
assert updated_user.role_id == role_b.id,
|
||||
"Expected role_id to persist as #{role_b.id}, got #{inspect(updated_user.role_id)}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edit user form - validation" do
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|
||||
# Should show ascending indicator (up arrow)
|
||||
assert html =~ "hero-chevron-up"
|
||||
assert html =~ ~s(aria-sort="ascending")
|
||||
|
||||
# Test actual sort order: alpha should appear before mike, mike before zulu
|
||||
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
|
||||
|
|
@ -76,7 +75,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|
||||
# Should now show descending indicator (down arrow)
|
||||
assert html =~ "hero-chevron-down"
|
||||
assert html =~ ~s(aria-sort="descending")
|
||||
|
||||
# Test actual sort order reversed: zulu should now appear before mike, mike before alpha
|
||||
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
|
||||
|
|
@ -107,7 +105,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
# Click again to toggle back to ascending
|
||||
html = view |> element("button[phx-value-field='email']") |> render_click()
|
||||
assert html =~ "hero-chevron-up"
|
||||
assert html =~ ~s(aria-sort="ascending")
|
||||
|
||||
# Should be back to original ascending order
|
||||
alpha_pos = html |> :binary.match("alpha@example.com") |> elem(0)
|
||||
|
|
@ -379,6 +376,45 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "Password column display" do
|
||||
test "user without password shows em dash in Password column", %{conn: conn} do
|
||||
# User created with hashed_password: nil (no password) - must not get default password
|
||||
user_no_pw =
|
||||
create_test_user(%{
|
||||
email: "no-password@example.com",
|
||||
hashed_password: nil
|
||||
})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/users")
|
||||
|
||||
assert html =~ "no-password@example.com"
|
||||
|
||||
# Password column must show "—" (em dash) for user without password, not "Enabled"
|
||||
row = view |> element("tr#row-#{user_no_pw.id}") |> render()
|
||||
assert row =~ "—", "Password column should show em dash for user without password"
|
||||
|
||||
refute row =~ "Enabled",
|
||||
"Password column must not show Enabled when user has no password"
|
||||
end
|
||||
|
||||
test "user with password shows Enabled in Password column", %{conn: conn} do
|
||||
user_with_pw =
|
||||
create_test_user(%{
|
||||
email: "with-password@example.com",
|
||||
password: "test123"
|
||||
})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/users")
|
||||
|
||||
assert html =~ "with-password@example.com"
|
||||
|
||||
row = view |> element("tr#row-#{user_with_pw.id}") |> render()
|
||||
assert row =~ "Enabled", "Password column should show Enabled when user has password"
|
||||
end
|
||||
end
|
||||
|
||||
describe "member linking display" do
|
||||
@tag :slow
|
||||
test "displays linked member name in user list", %{conn: conn} do
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue