Merge remote-tracking branch 'origin/main' into feature/ui-for-adding-members-groups
This commit is contained in:
commit
2f8a6a2768
136 changed files with 9999 additions and 3601 deletions
|
|
@ -41,18 +41,6 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
assert is_nil(found_user.oidc_id)
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "password authentication uses email as identity_field" do
|
||||
# Verify the configuration: password strategy should use email as identity_field
|
||||
# This test checks the AshAuthentication configuration
|
||||
|
||||
strategies = AshAuthentication.Info.authentication_strategies(Mv.Accounts.User)
|
||||
password_strategy = Enum.find(strategies, fn s -> s.name == :password end)
|
||||
|
||||
assert password_strategy != nil
|
||||
assert password_strategy.identity_field == :email
|
||||
end
|
||||
|
||||
@tag :test_proposal
|
||||
test "multiple users can exist with different emails" do
|
||||
user1 =
|
||||
|
|
@ -130,6 +118,10 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
)
|
||||
|
||||
case result do
|
||||
{:ok, found_user} when is_struct(found_user) ->
|
||||
assert found_user.id == user.id
|
||||
assert found_user.oidc_id == "oidc_identifier_12345"
|
||||
|
||||
{:ok, [found_user]} ->
|
||||
assert found_user.id == user.id
|
||||
assert found_user.oidc_id == "oidc_identifier_12345"
|
||||
|
|
@ -137,6 +129,9 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
{:ok, []} ->
|
||||
flunk("User should be found by oidc_id")
|
||||
|
||||
{:ok, nil} ->
|
||||
flunk("User should be found by oidc_id")
|
||||
|
||||
{:error, error} ->
|
||||
flunk("Unexpected error: #{inspect(error)}")
|
||||
end
|
||||
|
|
@ -231,11 +226,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
# Either returns empty/nil OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -272,11 +270,14 @@ defmodule Mv.Accounts.UserAuthenticationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
# Either returns empty/nil OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
defmodule Mv.Membership.CustomFieldSlugTest do
|
||||
@moduledoc """
|
||||
Tests for automatic slug generation on CustomField resource.
|
||||
Tests for CustomField slug business rules only.
|
||||
|
||||
This test suite verifies:
|
||||
1. Slugs are automatically generated from the name attribute
|
||||
2. Slugs are unique (cannot have duplicates)
|
||||
3. Slugs are immutable (don't change when name changes)
|
||||
4. Slugs handle various edge cases (unicode, special chars, etc.)
|
||||
5. Slugs can be used for lookups
|
||||
We test our business logic, not Ash/slugify implementation details:
|
||||
- Slug is generated from name on create (one smoke test)
|
||||
- Slug is unique (business rule)
|
||||
- Slug is immutable (does not change when name is updated; cannot be set manually)
|
||||
- Slug cannot be empty (rejects name with only special characters)
|
||||
|
||||
We do not test: slugify edge cases (umlauts, truncation, etc.) or Ash/Ecto struct/load behavior.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
|
|
@ -18,8 +19,8 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
|||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
describe "automatic slug generation on create" do
|
||||
test "generates slug from name with simple ASCII text", %{actor: actor} do
|
||||
describe "slug generation (business rule)" do
|
||||
test "slug is generated from name on create", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
|
|
@ -30,78 +31,6 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
|||
|
||||
assert custom_field.slug == "mobile-phone"
|
||||
end
|
||||
|
||||
test "generates slug from name with German umlauts", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Café Müller",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "cafe-muller"
|
||||
end
|
||||
|
||||
test "generates slug with lowercase conversion", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "TEST NAME",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "generates slug by removing special characters", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "E-Mail & Address!",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "e-mail-address"
|
||||
end
|
||||
|
||||
test "generates slug by replacing multiple spaces with single hyphen", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Multiple Spaces",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "multiple-spaces"
|
||||
end
|
||||
|
||||
test "trims leading and trailing hyphens", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "-Test-",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "test"
|
||||
end
|
||||
|
||||
test "handles unicode characters properly (ß becomes ss)", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Straße",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "strasse"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug uniqueness" do
|
||||
|
|
@ -248,29 +177,8 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "slug edge cases" do
|
||||
test "handles very long names by truncating slug", %{actor: actor} do
|
||||
# Create a name at the maximum length (100 chars)
|
||||
long_name = String.duplicate("abcdefghij", 10)
|
||||
# 100 characters exactly
|
||||
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: long_name,
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Slug should be truncated to maximum 100 characters
|
||||
assert String.length(custom_field.slug) <= 100
|
||||
# Should be the full slugified version since name is exactly 100 chars
|
||||
assert custom_field.slug == long_name
|
||||
end
|
||||
|
||||
describe "slug cannot be empty (business rule)" do
|
||||
test "rejects name with only special characters", %{actor: actor} do
|
||||
# When name contains only special characters, slug would be empty
|
||||
# This should fail validation
|
||||
assert {:error, %Ash.Error.Invalid{} = error} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
|
|
@ -279,107 +187,9 @@ defmodule Mv.Membership.CustomFieldSlugTest do
|
|||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Should fail because slug would be empty
|
||||
error_message = Exception.message(error)
|
||||
assert error_message =~ "Slug cannot be empty" or error_message =~ "is required"
|
||||
end
|
||||
|
||||
test "handles mixed special characters and text", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test@#$%Name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# slugify keeps the hyphen between words
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "handles numbers in name", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Field 123 Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.slug == "field-123-test"
|
||||
end
|
||||
|
||||
test "handles consecutive hyphens in name", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test---Name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Should reduce multiple hyphens to single hyphen
|
||||
assert custom_field.slug == "test-name"
|
||||
end
|
||||
|
||||
test "handles name with dots and underscores", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test.field_name",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Dots and underscores should be handled (either kept or converted)
|
||||
assert custom_field.slug =~ ~r/^[a-z0-9-]+$/
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug in queries and responses" do
|
||||
test "slug is included in struct after create", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Slug should be present in the struct
|
||||
assert Map.has_key?(custom_field, :slug)
|
||||
assert custom_field.slug != nil
|
||||
end
|
||||
|
||||
test "can load custom field and slug is present", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
# Load it back
|
||||
loaded_custom_field = Ash.get!(CustomField, custom_field.id, actor: actor)
|
||||
|
||||
assert loaded_custom_field.slug == "test"
|
||||
end
|
||||
|
||||
test "slug is returned in list queries", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Test",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
custom_fields = Ash.read!(CustomField, actor: actor)
|
||||
|
||||
found = Enum.find(custom_fields, &(&1.id == custom_field.id))
|
||||
assert found.slug == "test"
|
||||
end
|
||||
end
|
||||
|
||||
describe "slug-based lookup (future feature)" do
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule Mv.Membership.GroupTest do
|
||||
@moduledoc """
|
||||
Tests for Group resource validations, CRUD operations, and relationships.
|
||||
Uses async: true; no shared DB state or sandbox constraints.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
|
|
@ -232,23 +233,7 @@ defmodule Mv.Membership.GroupTest do
|
|||
end
|
||||
|
||||
describe "Relationships & Deletion" do
|
||||
test "group has many_to_many members relationship (load with preloading)", %{actor: actor} do
|
||||
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
|
||||
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
|
||||
|
||||
{:ok, _mg} =
|
||||
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
|
||||
actor: actor
|
||||
)
|
||||
|
||||
# Load group with members
|
||||
{:ok, group_with_members} =
|
||||
Ash.load(group, :members, actor: actor, domain: Mv.Membership)
|
||||
|
||||
assert length(group_with_members.members) == 1
|
||||
assert hd(group_with_members.members).id == member.id
|
||||
end
|
||||
|
||||
# We test business/data rules (CASCADE), not Ash relationship loading (framework).
|
||||
test "delete group cascades to member_groups (members remain intact)", %{actor: actor} do
|
||||
{:ok, group} = Membership.create_group(%{name: "Test Group"}, actor: actor)
|
||||
{:ok, member} = Membership.create_member(%{email: "test@test.com"}, actor: actor)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule Mv.Membership.MemberGroupTest do
|
||||
@moduledoc """
|
||||
Tests for MemberGroup join table resource - validations and cascade delete behavior.
|
||||
Uses async: true; no shared DB state or sandbox constraints.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
||||
@moduledoc """
|
||||
Tests for MembershipFeeType resource.
|
||||
Tests for MembershipFeeType business rules only.
|
||||
|
||||
We test: required fields, allowed interval values, uniqueness, amount constraints,
|
||||
interval immutability, and referential integrity (cannot delete when in use).
|
||||
We do not test: standard CRUD (create/update/delete when no constraints apply).
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
|
|
@ -11,34 +15,7 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
%{actor: system_actor}
|
||||
end
|
||||
|
||||
describe "create MembershipFeeType" do
|
||||
test "can create membership fee type with valid attributes", %{actor: actor} do
|
||||
attrs = %{
|
||||
name: "Standard Membership",
|
||||
amount: Decimal.new("120.00"),
|
||||
interval: :yearly,
|
||||
description: "Standard yearly membership fee"
|
||||
}
|
||||
|
||||
assert {:ok, %MembershipFeeType{} = fee_type} =
|
||||
Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
|
||||
assert fee_type.name == "Standard Membership"
|
||||
assert Decimal.equal?(fee_type.amount, Decimal.new("120.00"))
|
||||
assert fee_type.interval == :yearly
|
||||
assert fee_type.description == "Standard yearly membership fee"
|
||||
end
|
||||
|
||||
test "can create membership fee type without description", %{actor: actor} do
|
||||
attrs = %{
|
||||
name: "Basic",
|
||||
amount: Decimal.new("60.00"),
|
||||
interval: :monthly
|
||||
}
|
||||
|
||||
assert {:ok, %MembershipFeeType{}} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
end
|
||||
|
||||
describe "create MembershipFeeType - business rules" do
|
||||
test "requires name", %{actor: actor} do
|
||||
attrs = %{
|
||||
amount: Decimal.new("100.00"),
|
||||
|
|
@ -69,28 +46,24 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
assert error_on_field?(error, :interval)
|
||||
end
|
||||
|
||||
test "validates interval enum values - monthly", %{actor: actor} do
|
||||
attrs = %{name: "Monthly", amount: Decimal.new("10.00"), interval: :monthly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
assert fee_type.interval == :monthly
|
||||
end
|
||||
test "accepts valid interval values (monthly, quarterly, half_yearly, yearly)", %{
|
||||
actor: actor
|
||||
} do
|
||||
for {interval, name} <- [
|
||||
monthly: "Monthly",
|
||||
quarterly: "Quarterly",
|
||||
half_yearly: "Half Yearly",
|
||||
yearly: "Yearly"
|
||||
] do
|
||||
attrs = %{
|
||||
name: "#{name} #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("10.00"),
|
||||
interval: interval
|
||||
}
|
||||
|
||||
test "validates interval enum values - quarterly", %{actor: actor} do
|
||||
attrs = %{name: "Quarterly", amount: Decimal.new("30.00"), interval: :quarterly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
assert fee_type.interval == :quarterly
|
||||
end
|
||||
|
||||
test "validates interval enum values - half_yearly", %{actor: actor} do
|
||||
attrs = %{name: "Half Yearly", amount: Decimal.new("60.00"), interval: :half_yearly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
assert fee_type.interval == :half_yearly
|
||||
end
|
||||
|
||||
test "validates interval enum values - yearly", %{actor: actor} do
|
||||
attrs = %{name: "Yearly", amount: Decimal.new("120.00"), interval: :yearly}
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
assert fee_type.interval == :yearly
|
||||
assert {:ok, fee_type} = Ash.create(MembershipFeeType, attrs, actor: actor)
|
||||
assert fee_type.interval == interval
|
||||
end
|
||||
end
|
||||
|
||||
test "rejects invalid interval values", %{actor: actor} do
|
||||
|
|
@ -128,13 +101,13 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "update MembershipFeeType" do
|
||||
describe "update MembershipFeeType - business rules" do
|
||||
setup %{actor: actor} do
|
||||
{:ok, fee_type} =
|
||||
Ash.create(
|
||||
MembershipFeeType,
|
||||
%{
|
||||
name: "Original Name",
|
||||
name: "Original Name #{System.unique_integer([:positive])}",
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :yearly,
|
||||
description: "Original description"
|
||||
|
|
@ -145,28 +118,6 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
%{fee_type: fee_type}
|
||||
end
|
||||
|
||||
test "can update name", %{actor: actor, fee_type: fee_type} do
|
||||
assert {:ok, updated} = Ash.update(fee_type, %{name: "Updated Name"}, actor: actor)
|
||||
assert updated.name == "Updated Name"
|
||||
end
|
||||
|
||||
test "can update amount", %{actor: actor, fee_type: fee_type} do
|
||||
assert {:ok, updated} = Ash.update(fee_type, %{amount: Decimal.new("150.00")}, actor: actor)
|
||||
assert Decimal.equal?(updated.amount, Decimal.new("150.00"))
|
||||
end
|
||||
|
||||
test "can update description", %{actor: actor, fee_type: fee_type} do
|
||||
assert {:ok, updated} =
|
||||
Ash.update(fee_type, %{description: "Updated description"}, actor: actor)
|
||||
|
||||
assert updated.description == "Updated description"
|
||||
end
|
||||
|
||||
test "can clear description", %{actor: actor, fee_type: fee_type} do
|
||||
assert {:ok, updated} = Ash.update(fee_type, %{description: nil}, actor: actor)
|
||||
assert updated.description == nil
|
||||
end
|
||||
|
||||
test "interval immutability: update fails when interval is changed", %{
|
||||
actor: actor,
|
||||
fee_type: fee_type
|
||||
|
|
@ -179,7 +130,7 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "delete MembershipFeeType" do
|
||||
describe "delete MembershipFeeType - business rules (referential integrity)" do
|
||||
setup %{actor: actor} do
|
||||
{:ok, fee_type} =
|
||||
Ash.create(
|
||||
|
|
@ -195,12 +146,6 @@ defmodule Mv.MembershipFees.MembershipFeeTypeTest do
|
|||
%{fee_type: fee_type}
|
||||
end
|
||||
|
||||
test "can delete when not in use", %{actor: actor, fee_type: fee_type} do
|
||||
result = Ash.destroy(fee_type, actor: actor)
|
||||
# Ash.destroy returns :ok or {:ok, _} depending on version
|
||||
assert result == :ok or match?({:ok, _}, result)
|
||||
end
|
||||
|
||||
test "cannot delete when members are assigned", %{actor: actor, fee_type: fee_type} do
|
||||
alias Mv.Membership.Member
|
||||
|
||||
|
|
@ -264,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
|
||||
90
test/mv/membership/member_export_sort_test.exs
Normal file
90
test/mv/membership/member_export_sort_test.exs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
defmodule Mv.Membership.MemberExportSortTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mv.Membership.MemberExportSort
|
||||
|
||||
describe "custom_field_sort_key/2" do
|
||||
test "nil has rank 1 (sorts last in asc, first in desc)" do
|
||||
assert MemberExportSort.custom_field_sort_key(:string, nil) == {1, nil}
|
||||
assert MemberExportSort.custom_field_sort_key(:date, nil) == {1, nil}
|
||||
end
|
||||
|
||||
test "date: chronological key (ISO8601 string)" do
|
||||
earlier = ~D[2023-01-15]
|
||||
later = ~D[2024-06-01]
|
||||
assert MemberExportSort.custom_field_sort_key(:date, earlier) == {0, "2023-01-15"}
|
||||
assert MemberExportSort.custom_field_sort_key(:date, later) == {0, "2024-06-01"}
|
||||
assert {0, "2023-01-15"} < {0, "2024-06-01"}
|
||||
end
|
||||
|
||||
test "date + nil: nil sorts after any date in asc" do
|
||||
key_date = MemberExportSort.custom_field_sort_key(:date, ~D[2024-01-01])
|
||||
key_nil = MemberExportSort.custom_field_sort_key(:date, nil)
|
||||
assert key_date == {0, "2024-01-01"}
|
||||
assert key_nil == {1, nil}
|
||||
assert key_date < key_nil
|
||||
end
|
||||
|
||||
test "boolean: false < true" do
|
||||
key_f = MemberExportSort.custom_field_sort_key(:boolean, false)
|
||||
key_t = MemberExportSort.custom_field_sort_key(:boolean, true)
|
||||
assert key_f == {0, 0}
|
||||
assert key_t == {0, 1}
|
||||
assert key_f < key_t
|
||||
end
|
||||
|
||||
test "boolean + nil: nil sorts after false and true in asc" do
|
||||
key_f = MemberExportSort.custom_field_sort_key(:boolean, false)
|
||||
key_t = MemberExportSort.custom_field_sort_key(:boolean, true)
|
||||
key_nil = MemberExportSort.custom_field_sort_key(:boolean, nil)
|
||||
assert key_f < key_nil and key_t < key_nil
|
||||
end
|
||||
|
||||
test "integer: numerical key" do
|
||||
assert MemberExportSort.custom_field_sort_key(:integer, 10) == {0, 10}
|
||||
assert MemberExportSort.custom_field_sort_key(:integer, -5) == {0, -5}
|
||||
assert MemberExportSort.custom_field_sort_key(:integer, 0) == {0, 0}
|
||||
assert {0, -5} < {0, 0} and {0, 0} < {0, 10}
|
||||
end
|
||||
|
||||
test "string: case-insensitive key (downcased)" do
|
||||
key_a = MemberExportSort.custom_field_sort_key(:string, "Anna")
|
||||
key_b = MemberExportSort.custom_field_sort_key(:string, "bert")
|
||||
assert key_a == {0, "anna"}
|
||||
assert key_b == {0, "bert"}
|
||||
assert key_a < key_b
|
||||
end
|
||||
|
||||
test "email: case-insensitive key" do
|
||||
assert MemberExportSort.custom_field_sort_key(:email, "User@Example.com") ==
|
||||
{0, "user@example.com"}
|
||||
end
|
||||
|
||||
test "Ash.Union value is unwrapped" do
|
||||
union = %Ash.Union{value: ~D[2024-01-01], type: :date}
|
||||
assert MemberExportSort.custom_field_sort_key(:date, union) == {0, "2024-01-01"}
|
||||
end
|
||||
end
|
||||
|
||||
describe "key_lt/3" do
|
||||
test "asc: smaller key first, nil last" do
|
||||
k_nil = {1, nil}
|
||||
k_early = {0, "2023-01-01"}
|
||||
k_late = {0, "2024-01-01"}
|
||||
refute MemberExportSort.key_lt(k_nil, k_early, "asc")
|
||||
refute MemberExportSort.key_lt(k_nil, k_late, "asc")
|
||||
assert MemberExportSort.key_lt(k_early, k_late, "asc")
|
||||
assert MemberExportSort.key_lt(k_early, k_nil, "asc")
|
||||
end
|
||||
|
||||
test "desc: larger key first, nil first" do
|
||||
k_nil = {1, nil}
|
||||
k_early = {0, "2023-01-01"}
|
||||
k_late = {0, "2024-01-01"}
|
||||
assert MemberExportSort.key_lt(k_nil, k_early, "desc")
|
||||
assert MemberExportSort.key_lt(k_nil, k_late, "desc")
|
||||
assert MemberExportSort.key_lt(k_late, k_early, "desc")
|
||||
refute MemberExportSort.key_lt(k_early, k_nil, "desc")
|
||||
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
|
||||
|
|
|
|||
277
test/mv/membership/members_csv_test.exs
Normal file
277
test/mv/membership/members_csv_test.exs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
defmodule Mv.Membership.MembersCSVTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Mv.Membership.MembersCSV
|
||||
|
||||
describe "export/2" do
|
||||
test "returns CSV with header and one data row (member fields only)" do
|
||||
member = %{first_name: "Jane", email: "jane@example.com"}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"}
|
||||
]
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "First Name"
|
||||
assert csv =~ "Email"
|
||||
assert csv =~ "Jane"
|
||||
assert csv =~ "jane@example.com"
|
||||
lines = String.split(csv, "\n", trim: true)
|
||||
assert length(lines) == 2
|
||||
end
|
||||
|
||||
test "header uses display labels not raw field names (regression guard)" do
|
||||
member = %{first_name: "Jane", email: "jane@example.com"}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"}
|
||||
]
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
header_line = csv |> String.split("\n", trim: true) |> hd()
|
||||
|
||||
assert header_line =~ "First Name"
|
||||
assert header_line =~ "Email"
|
||||
refute header_line =~ "first_name"
|
||||
refute header_line =~ "email"
|
||||
end
|
||||
|
||||
test "escapes cell containing comma (RFC 4180 quoted)" do
|
||||
member = %{first_name: "Doe, John", email: "john@example.com"}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"}
|
||||
]
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ ~s("Doe, John")
|
||||
assert csv =~ "john@example.com"
|
||||
end
|
||||
|
||||
test "escapes cell containing double-quote (RFC 4180 doubled and quoted)" do
|
||||
member = %{first_name: ~s(He said "Hi"), email: "a@b.com"}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"}
|
||||
]
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ ~s("He said ""Hi""")
|
||||
assert csv =~ "a@b.com"
|
||||
end
|
||||
|
||||
test "formats date as ISO8601 for member fields" do
|
||||
member = %{first_name: "D", email: "d@d.com", join_date: ~D[2024-03-15]}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"},
|
||||
%{header: "Join Date", kind: :member_field, key: "join_date"}
|
||||
]
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "2024-03-15"
|
||||
assert csv =~ "Join Date"
|
||||
end
|
||||
|
||||
test "formats nil as empty string" do
|
||||
member = %{first_name: "Only", last_name: nil, email: "x@y.com"}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Last Name", kind: :member_field, key: "last_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"}
|
||||
]
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "First Name"
|
||||
assert csv =~ "Only"
|
||||
assert csv =~ "x@y.com"
|
||||
assert csv =~ "Only,,x@y"
|
||||
end
|
||||
|
||||
test "custom field column uses header and formats value" do
|
||||
custom_cf = %{id: "cf-1", name: "Active", value_type: :boolean}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"},
|
||||
%{header: "Active", kind: :custom_field, key: "cf-1", custom_field: custom_cf}
|
||||
]
|
||||
|
||||
member = %{
|
||||
first_name: "Test",
|
||||
email: "e@e.com",
|
||||
custom_field_values: [
|
||||
%{custom_field_id: "cf-1", value: true, custom_field: custom_cf}
|
||||
]
|
||||
}
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "Active"
|
||||
assert csv =~ "Yes"
|
||||
end
|
||||
|
||||
test "custom field uses display_name when present, else name" do
|
||||
custom_cf = %{id: "cf-a", name: "FieldA", value_type: :string}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{
|
||||
header: "Display Label",
|
||||
kind: :custom_field,
|
||||
key: "cf-a",
|
||||
custom_field: Map.put(custom_cf, :display_name, "Display Label")
|
||||
}
|
||||
]
|
||||
|
||||
member = %{
|
||||
first_name: "X",
|
||||
email: "x@x.com",
|
||||
custom_field_values: [
|
||||
%{custom_field_id: "cf-a", value: "only_a", custom_field: custom_cf}
|
||||
]
|
||||
}
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "Display Label"
|
||||
assert csv =~ "only_a"
|
||||
end
|
||||
|
||||
test "missing custom field value yields empty cell" do
|
||||
cf1 = %{id: "cf-a", name: "FieldA", value_type: :string}
|
||||
cf2 = %{id: "cf-b", name: "FieldB", value_type: :string}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"},
|
||||
%{header: "FieldA", kind: :custom_field, key: "cf-a", custom_field: cf1},
|
||||
%{header: "FieldB", kind: :custom_field, key: "cf-b", custom_field: cf2}
|
||||
]
|
||||
|
||||
member = %{
|
||||
first_name: "X",
|
||||
email: "x@x.com",
|
||||
custom_field_values: [%{custom_field_id: "cf-a", value: "only_a", custom_field: cf1}]
|
||||
}
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "First Name,Email,FieldA,FieldB"
|
||||
assert csv =~ "only_a"
|
||||
assert csv =~ "X,x@x.com,only_a,"
|
||||
end
|
||||
|
||||
test "computed column exports membership fee status label" do
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"},
|
||||
%{header: "Membership Fee Status", kind: :computed, key: :membership_fee_status}
|
||||
]
|
||||
|
||||
member = %{first_name: "M", email: "m@m.com", membership_fee_status: "Paid"}
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "Membership Fee Status"
|
||||
assert csv =~ "Paid"
|
||||
assert csv =~ "M,m@m.com,Paid"
|
||||
end
|
||||
|
||||
test "CSV injection: formula-like and dangerous prefixes are escaped with apostrophe" do
|
||||
member = %{
|
||||
first_name: "=SUM(A1:A10)",
|
||||
last_name: "+1",
|
||||
email: "@cmd|evil"
|
||||
}
|
||||
|
||||
custom_cf = %{id: "cf-1", name: "Note", value_type: :string}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Last Name", kind: :member_field, key: "last_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"},
|
||||
%{header: "Note", kind: :custom_field, key: "cf-1", custom_field: custom_cf}
|
||||
]
|
||||
|
||||
member_with_cf =
|
||||
Map.put(member, :custom_field_values, [
|
||||
%{custom_field_id: "cf-1", value: "normal text", custom_field: custom_cf}
|
||||
])
|
||||
|
||||
iodata = MembersCSV.export([member_with_cf], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "'=SUM(A1:A10)"
|
||||
assert csv =~ "'+1"
|
||||
assert csv =~ "'@cmd|evil"
|
||||
assert csv =~ "normal text"
|
||||
refute csv =~ ",'normal text"
|
||||
end
|
||||
|
||||
test "CSV injection: minus and tab prefix are escaped" do
|
||||
member = %{first_name: "-2", last_name: "\tleading", email: "safe@x.com"}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Last Name", kind: :member_field, key: "last_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"}
|
||||
]
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "'-2"
|
||||
assert csv =~ "'\tleading"
|
||||
assert csv =~ "safe@x.com"
|
||||
end
|
||||
|
||||
test "column order is preserved (headers and values)" do
|
||||
cf1 = %{id: "a", name: "Custom1", value_type: :string}
|
||||
cf2 = %{id: "b", name: "Custom2", value_type: :string}
|
||||
|
||||
columns = [
|
||||
%{header: "First Name", kind: :member_field, key: "first_name"},
|
||||
%{header: "Email", kind: :member_field, key: "email"},
|
||||
%{header: "Custom2", kind: :custom_field, key: "b", custom_field: cf2},
|
||||
%{header: "Custom1", kind: :custom_field, key: "a", custom_field: cf1}
|
||||
]
|
||||
|
||||
member = %{
|
||||
first_name: "M",
|
||||
email: "m@m.com",
|
||||
custom_field_values: [
|
||||
%{custom_field_id: "a", value: "v1", custom_field: cf1},
|
||||
%{custom_field_id: "b", value: "v2", custom_field: cf2}
|
||||
]
|
||||
}
|
||||
|
||||
iodata = MembersCSV.export([member], columns)
|
||||
csv = IO.iodata_to_binary(iodata)
|
||||
|
||||
assert csv =~ "First Name,Email,Custom2,Custom1"
|
||||
assert csv =~ "M,m@m.com,v2,v1"
|
||||
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
|
||||
49
test/mv/oidc_role_sync_config_test.exs
Normal file
49
test/mv/oidc_role_sync_config_test.exs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
defmodule Mv.OidcRoleSyncConfigTest do
|
||||
@moduledoc """
|
||||
Tests for OIDC role sync configuration (OIDC_ADMIN_GROUP_NAME, OIDC_GROUPS_CLAIM).
|
||||
"""
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
alias Mv.OidcRoleSyncConfig
|
||||
|
||||
describe "oidc_admin_group_name/0" do
|
||||
test "returns nil when OIDC_ADMIN_GROUP_NAME is not configured" do
|
||||
restore = put_config(admin_group_name: nil)
|
||||
on_exit(restore)
|
||||
|
||||
assert OidcRoleSyncConfig.oidc_admin_group_name() == nil
|
||||
end
|
||||
|
||||
test "returns configured admin group name when set" do
|
||||
restore = put_config(admin_group_name: "mila-admin")
|
||||
on_exit(restore)
|
||||
|
||||
assert OidcRoleSyncConfig.oidc_admin_group_name() == "mila-admin"
|
||||
end
|
||||
end
|
||||
|
||||
describe "oidc_groups_claim/0" do
|
||||
test "returns default \"groups\" when OIDC_GROUPS_CLAIM is not configured" do
|
||||
restore = put_config(groups_claim: nil)
|
||||
on_exit(restore)
|
||||
|
||||
assert OidcRoleSyncConfig.oidc_groups_claim() == "groups"
|
||||
end
|
||||
|
||||
test "returns configured claim name when OIDC_GROUPS_CLAIM is set" do
|
||||
restore = put_config(groups_claim: "ak_groups")
|
||||
on_exit(restore)
|
||||
|
||||
assert OidcRoleSyncConfig.oidc_groups_claim() == "ak_groups"
|
||||
end
|
||||
end
|
||||
|
||||
defp put_config(opts) do
|
||||
current = Application.get_env(:mv, :oidc_role_sync, [])
|
||||
Application.put_env(:mv, :oidc_role_sync, Keyword.merge(current, opts))
|
||||
|
||||
fn ->
|
||||
Application.put_env(:mv, :oidc_role_sync, current)
|
||||
end
|
||||
end
|
||||
end
|
||||
181
test/mv/oidc_role_sync_test.exs
Normal file
181
test/mv/oidc_role_sync_test.exs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
defmodule Mv.OidcRoleSyncTest do
|
||||
@moduledoc """
|
||||
Tests for OIDC group → Admin/Mitglied role sync (apply_admin_role_from_user_info/2).
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Accounts.User
|
||||
alias Mv.Authorization.Role
|
||||
alias Mv.OidcRoleSync
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
ensure_roles_exist()
|
||||
restore_config = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "groups")
|
||||
on_exit(restore_config)
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "apply_admin_role_from_user_info/2" do
|
||||
test "when OIDC_ADMIN_GROUP_NAME not configured: does not change user (Mitglied stays)" do
|
||||
restore = put_oidc_config(admin_group_name: nil, groups_claim: "groups")
|
||||
on_exit(restore)
|
||||
|
||||
email = "sync-no-config-#{System.unique_integer([:positive])}@test.example.com"
|
||||
{:ok, user} = create_user_with_mitglied(email)
|
||||
role_id_before = user.role_id
|
||||
user_info = %{"groups" => ["mila-admin"]}
|
||||
|
||||
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
|
||||
|
||||
{:ok, after_user} = get_user(user.id)
|
||||
assert after_user.role_id == role_id_before
|
||||
end
|
||||
|
||||
test "when user_info contains configured admin group: user gets Admin role" do
|
||||
email = "sync-to-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||
{:ok, user} = create_user_with_mitglied(email)
|
||||
user_info = %{"groups" => ["mila-admin"]}
|
||||
|
||||
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
|
||||
|
||||
{:ok, after_user} = get_user(user.id)
|
||||
assert after_user.role_id == admin_role_id()
|
||||
end
|
||||
|
||||
test "when user_info does not contain admin group: user gets Mitglied role" do
|
||||
email1 = "sync-to-mitglied-#{System.unique_integer([:positive])}@test.example.com"
|
||||
email2 = "other-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||
{:ok, user} = create_user_with_admin(email1)
|
||||
{:ok, _} = create_user_with_admin(email2)
|
||||
user_info = %{"groups" => ["other-group"]}
|
||||
|
||||
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
|
||||
|
||||
{:ok, after_user} = get_user(user.id)
|
||||
assert after_user.role_id == mitglied_role_id()
|
||||
end
|
||||
|
||||
test "when OIDC_GROUPS_CLAIM is different: reads groups from that claim" do
|
||||
restore = put_oidc_config(admin_group_name: "mila-admin", groups_claim: "ak_groups")
|
||||
on_exit(restore)
|
||||
|
||||
email = "sync-claim-#{System.unique_integer([:positive])}@test.example.com"
|
||||
{:ok, user} = create_user_with_mitglied(email)
|
||||
user_info = %{"ak_groups" => ["mila-admin"]}
|
||||
|
||||
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info)
|
||||
|
||||
{:ok, after_user} = get_user(user.id)
|
||||
assert after_user.role_id == admin_role_id()
|
||||
end
|
||||
|
||||
test "user already Admin and user_info without admin group: downgrade to Mitglied" do
|
||||
email1 = "sync-downgrade-#{System.unique_integer([:positive])}@test.example.com"
|
||||
email2 = "sync-other-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||
{:ok, user1} = create_user_with_admin(email1)
|
||||
{:ok, _user2} = create_user_with_admin(email2)
|
||||
user_info = %{"groups" => []}
|
||||
|
||||
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user1, user_info)
|
||||
|
||||
{:ok, after_user} = get_user(user1.id)
|
||||
assert after_user.role_id == mitglied_role_id()
|
||||
end
|
||||
|
||||
test "when user_info has no groups, groups are read from access_token JWT (e.g. Rauthy)" do
|
||||
email = "sync-from-token-#{System.unique_integer([:positive])}@test.example.com"
|
||||
{:ok, user} = create_user_with_mitglied(email)
|
||||
user_info = %{"sub" => "oidc-123"}
|
||||
|
||||
# Minimal JWT: header.payload.signature with "groups" in payload (Rauthy puts groups in access_token)
|
||||
payload = Jason.encode!(%{"groups" => ["mila-admin"], "sub" => "oidc-123"})
|
||||
payload_b64 = Base.url_encode64(payload, padding: false)
|
||||
header_b64 = Base.url_encode64("{\"alg\":\"HS256\",\"typ\":\"JWT\"}", padding: false)
|
||||
sig_b64 = Base.url_encode64("sig", padding: false)
|
||||
access_token = "#{header_b64}.#{payload_b64}.#{sig_b64}"
|
||||
oauth_tokens = %{"access_token" => access_token}
|
||||
|
||||
assert :ok = OidcRoleSync.apply_admin_role_from_user_info(user, user_info, oauth_tokens)
|
||||
|
||||
{:ok, after_user} = get_user(user.id)
|
||||
assert after_user.role_id == admin_role_id()
|
||||
end
|
||||
end
|
||||
|
||||
# B3: Role sync after registration is implemented via after_action in register_with_rauthy.
|
||||
# Full integration tests (create_register_with_rauthy + assert role) are skipped: when the
|
||||
# nested Ash.update! runs inside the create's after_action, authorization may evaluate in
|
||||
# the create context so set_role_from_oidc_sync bypass does not apply. Sync logic is covered
|
||||
# by the apply_admin_role_from_user_info tests above. B4 sign-in sync will also use that.
|
||||
|
||||
defp ensure_roles_exist do
|
||||
for {name, perm} <- [{"Admin", "admin"}, {"Mitglied", "own_data"}] do
|
||||
case Role
|
||||
|> Ash.Query.filter(name == ^name)
|
||||
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
|
||||
{:ok, nil} ->
|
||||
Role
|
||||
|> Ash.Changeset.for_create(:create_role_with_system_flag, %{
|
||||
name: name,
|
||||
description: name,
|
||||
permission_set_name: perm,
|
||||
is_system_role: name == "Mitglied"
|
||||
})
|
||||
|> Ash.create!(authorize?: false, domain: Mv.Authorization)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp put_oidc_config(opts) do
|
||||
current = Application.get_env(:mv, :oidc_role_sync, [])
|
||||
merged = Keyword.merge(current, opts)
|
||||
Application.put_env(:mv, :oidc_role_sync, merged)
|
||||
|
||||
fn ->
|
||||
Application.put_env(:mv, :oidc_role_sync, current)
|
||||
end
|
||||
end
|
||||
|
||||
defp admin_role_id do
|
||||
{:ok, role} = Role.get_admin_role()
|
||||
role.id
|
||||
end
|
||||
|
||||
defp mitglied_role_id do
|
||||
{:ok, role} = Role.get_mitglied_role()
|
||||
role.id
|
||||
end
|
||||
|
||||
defp get_user(id) do
|
||||
User
|
||||
|> Ash.Query.filter(id == ^id)
|
||||
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
|
||||
end
|
||||
|
||||
defp create_user_with_mitglied(email) do
|
||||
{:ok, _} = Accounts.create_user(%{email: email}, authorize?: false)
|
||||
get_user_by_email(email)
|
||||
end
|
||||
|
||||
defp create_user_with_admin(email) do
|
||||
{:ok, _} = Accounts.create_user(%{email: email}, authorize?: false)
|
||||
{:ok, u} = get_user_by_email(email)
|
||||
|
||||
u
|
||||
|> Ash.Changeset.for_update(:update_user, %{role_id: admin_role_id()})
|
||||
|> Ash.update!(authorize?: false)
|
||||
|
||||
get_user(u.id)
|
||||
end
|
||||
|
||||
defp get_user_by_email(email) do
|
||||
User
|
||||
|> Ash.Query.filter(email == ^email)
|
||||
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
|
||||
end
|
||||
end
|
||||
222
test/mv/release_test.exs
Normal file
222
test/mv/release_test.exs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
defmodule Mv.ReleaseTest do
|
||||
@moduledoc """
|
||||
Tests for release tasks (e.g. seed_admin/0).
|
||||
|
||||
These tests verify that the admin user is created or updated from ENV
|
||||
(ADMIN_EMAIL, ADMIN_PASSWORD / ADMIN_PASSWORD_FILE) in an idempotent way.
|
||||
"""
|
||||
use Mv.DataCase, async: false
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Accounts.User
|
||||
alias Mv.Authorization.Role
|
||||
|
||||
require Ash.Query
|
||||
|
||||
setup do
|
||||
ensure_admin_role_exists()
|
||||
clear_admin_env()
|
||||
:ok
|
||||
end
|
||||
|
||||
describe "seed_admin/0" do
|
||||
test "without ADMIN_EMAIL does nothing (idempotent), no user created" do
|
||||
clear_admin_env()
|
||||
user_count_before = count_users()
|
||||
|
||||
Mv.Release.seed_admin()
|
||||
|
||||
assert count_users() == user_count_before
|
||||
end
|
||||
|
||||
test "with ADMIN_EMAIL but without ADMIN_PASSWORD and user does not exist: does not create user" do
|
||||
System.delete_env("ADMIN_PASSWORD")
|
||||
System.delete_env("ADMIN_PASSWORD_FILE")
|
||||
|
||||
email = "admin-no-password-#{System.unique_integer([:positive])}@test.example.com"
|
||||
System.put_env("ADMIN_EMAIL", email)
|
||||
on_exit(fn -> System.delete_env("ADMIN_EMAIL") end)
|
||||
|
||||
user_count_before = count_users()
|
||||
Mv.Release.seed_admin()
|
||||
|
||||
assert count_users() == user_count_before,
|
||||
"seed_admin must not create any user when ADMIN_PASSWORD is unset (expected #{user_count_before}, got #{count_users()})"
|
||||
end
|
||||
|
||||
test "with ADMIN_EMAIL but without ADMIN_PASSWORD and user exists: sets Admin role (OIDC-only bootstrap)" do
|
||||
System.delete_env("ADMIN_PASSWORD")
|
||||
System.delete_env("ADMIN_PASSWORD_FILE")
|
||||
|
||||
email = "existing-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||
System.put_env("ADMIN_EMAIL", email)
|
||||
on_exit(fn -> System.delete_env("ADMIN_EMAIL") end)
|
||||
|
||||
{:ok, _user} = create_user_with_mitglied_role(email)
|
||||
|
||||
Mv.Release.seed_admin()
|
||||
|
||||
{:ok, updated} = get_user_by_email(email)
|
||||
assert updated.role_id == admin_role_id()
|
||||
end
|
||||
|
||||
test "with ADMIN_EMAIL and ADMIN_PASSWORD: creates user with Admin role and sets password" do
|
||||
email = "new-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||
password = "SecurePassword123!"
|
||||
System.put_env("ADMIN_EMAIL", email)
|
||||
System.put_env("ADMIN_PASSWORD", password)
|
||||
|
||||
on_exit(fn ->
|
||||
System.delete_env("ADMIN_EMAIL")
|
||||
System.delete_env("ADMIN_PASSWORD")
|
||||
end)
|
||||
|
||||
Mv.Release.seed_admin()
|
||||
|
||||
assert user_exists?(email),
|
||||
"seed_admin must create user when ADMIN_EMAIL and ADMIN_PASSWORD are set"
|
||||
|
||||
{:ok, user} = get_user_by_email(email)
|
||||
assert user.role_id == admin_role_id()
|
||||
assert user.hashed_password != nil
|
||||
assert AshAuthentication.BcryptProvider.valid?(password, user.hashed_password)
|
||||
end
|
||||
|
||||
test "with ADMIN_EMAIL and ADMIN_PASSWORD, user already exists: assigns Admin role and updates password" do
|
||||
email = "existing-to-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||
password = "NewSecurePassword456!"
|
||||
System.put_env("ADMIN_EMAIL", email)
|
||||
System.put_env("ADMIN_PASSWORD", password)
|
||||
|
||||
on_exit(fn ->
|
||||
System.delete_env("ADMIN_EMAIL")
|
||||
System.delete_env("ADMIN_PASSWORD")
|
||||
end)
|
||||
|
||||
{:ok, user} = create_user_with_mitglied_role(email)
|
||||
assert user.role_id == mitglied_role_id()
|
||||
old_hashed = user.hashed_password
|
||||
|
||||
Mv.Release.seed_admin()
|
||||
|
||||
{:ok, updated} = get_user_by_email(email)
|
||||
assert updated.role_id == admin_role_id()
|
||||
assert updated.hashed_password != nil
|
||||
assert updated.hashed_password != old_hashed
|
||||
assert AshAuthentication.BcryptProvider.valid?(password, updated.hashed_password)
|
||||
end
|
||||
|
||||
test "with ADMIN_PASSWORD_FILE: reads password from file, same behavior as ADMIN_PASSWORD" do
|
||||
email = "admin-file-#{System.unique_integer([:positive])}@test.example.com"
|
||||
password = "FilePassword789!"
|
||||
|
||||
tmp =
|
||||
Path.join(
|
||||
System.tmp_dir!(),
|
||||
"mv_admin_password_#{System.unique_integer([:positive])}.txt"
|
||||
)
|
||||
|
||||
File.write!(tmp, password)
|
||||
System.put_env("ADMIN_EMAIL", email)
|
||||
System.put_env("ADMIN_PASSWORD_FILE", tmp)
|
||||
|
||||
on_exit(fn ->
|
||||
System.delete_env("ADMIN_EMAIL")
|
||||
System.delete_env("ADMIN_PASSWORD_FILE")
|
||||
File.rm(tmp)
|
||||
end)
|
||||
|
||||
Mv.Release.seed_admin()
|
||||
|
||||
assert user_exists?(email), "seed_admin must create user when ADMIN_PASSWORD_FILE is set"
|
||||
{:ok, user} = get_user_by_email(email)
|
||||
assert AshAuthentication.BcryptProvider.valid?(password, user.hashed_password)
|
||||
end
|
||||
|
||||
test "called twice: idempotent (no duplicate user, same state)" do
|
||||
email = "idempotent-admin-#{System.unique_integer([:positive])}@test.example.com"
|
||||
password = "IdempotentPassword123!"
|
||||
System.put_env("ADMIN_EMAIL", email)
|
||||
System.put_env("ADMIN_PASSWORD", password)
|
||||
|
||||
on_exit(fn ->
|
||||
System.delete_env("ADMIN_EMAIL")
|
||||
System.delete_env("ADMIN_PASSWORD")
|
||||
end)
|
||||
|
||||
Mv.Release.seed_admin()
|
||||
{:ok, user_after_first} = get_user_by_email(email)
|
||||
user_count_after_first = count_users()
|
||||
|
||||
Mv.Release.seed_admin()
|
||||
|
||||
assert count_users() == user_count_after_first
|
||||
{:ok, user_after_second} = get_user_by_email(email)
|
||||
assert user_after_second.id == user_after_first.id
|
||||
assert user_after_second.role_id == admin_role_id()
|
||||
end
|
||||
end
|
||||
|
||||
defp clear_admin_env do
|
||||
System.delete_env("ADMIN_EMAIL")
|
||||
System.delete_env("ADMIN_PASSWORD")
|
||||
System.delete_env("ADMIN_PASSWORD_FILE")
|
||||
end
|
||||
|
||||
defp ensure_admin_role_exists do
|
||||
case Role
|
||||
|> Ash.Query.filter(name == "Admin")
|
||||
|> Ash.read_one(authorize?: false, domain: Mv.Authorization) do
|
||||
{:ok, nil} ->
|
||||
Role
|
||||
|> Ash.Changeset.for_create(:create_role_with_system_flag, %{
|
||||
name: "Admin",
|
||||
description: "Administrator with full access",
|
||||
permission_set_name: "admin",
|
||||
is_system_role: false
|
||||
})
|
||||
|> Ash.create!(authorize?: false, domain: Mv.Authorization)
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
defp admin_role_id do
|
||||
{:ok, role} =
|
||||
Role
|
||||
|> Ash.Query.filter(name == "Admin")
|
||||
|> Ash.read_one(authorize?: false, domain: Mv.Authorization)
|
||||
|
||||
role.id
|
||||
end
|
||||
|
||||
defp mitglied_role_id do
|
||||
{:ok, role} = Role.get_mitglied_role()
|
||||
role.id
|
||||
end
|
||||
|
||||
defp count_users do
|
||||
User
|
||||
|> Ash.read!(authorize?: false, domain: Mv.Accounts)
|
||||
|> length()
|
||||
end
|
||||
|
||||
defp user_exists?(email) do
|
||||
case get_user_by_email(email) do
|
||||
{:ok, _} -> true
|
||||
{:error, _} -> false
|
||||
end
|
||||
end
|
||||
|
||||
defp get_user_by_email(email) do
|
||||
User
|
||||
|> Ash.Query.filter(email == ^email)
|
||||
|> Ash.read_one(authorize?: false, domain: Mv.Accounts)
|
||||
end
|
||||
|
||||
defp create_user_with_mitglied_role(email) do
|
||||
{:ok, _} = Accounts.create_user(%{email: email}, authorize?: false)
|
||||
get_user_by_email(email)
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -15,19 +15,19 @@ defmodule MvWeb.Components.SearchBarComponentTest do
|
|||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# simulate search input and check that other members are not listed
|
||||
html =
|
||||
_html =
|
||||
view
|
||||
|> element("form[role=search]")
|
||||
|> render_submit(%{"query" => "Friedrich"})
|
||||
|
||||
refute html =~ "Greta"
|
||||
refute has_element?(view, "input[data-testid='search-input'][value='Greta']")
|
||||
|
||||
html =
|
||||
_html =
|
||||
view
|
||||
|> element("form[role=search]")
|
||||
|> render_submit(%{"query" => "Greta"})
|
||||
|
||||
refute html =~ "Friedrich"
|
||||
refute has_element?(view, "input[data-testid='search-input'][value='Friedrich']")
|
||||
end
|
||||
end
|
||||
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
|
||||
504
test/mv_web/controllers/member_export_controller_test.exs
Normal file
504
test/mv_web/controllers/member_export_controller_test.exs
Normal file
|
|
@ -0,0 +1,504 @@
|
|||
defmodule MvWeb.MemberExportControllerTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
alias Mv.Fixtures
|
||||
|
||||
defp csrf_token_from_conn(conn) do
|
||||
get_session(conn, "_csrf_token") || csrf_token_from_html(response(conn, 200))
|
||||
end
|
||||
|
||||
defp csrf_token_from_html(html) when is_binary(html) do
|
||||
case Regex.run(~r/name="csrf-token"\s+content="([^"]+)"/, html) do
|
||||
[_, token] -> token
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
# Export uses humanize_field (e.g. "first_name" -> "First name"); normalize \r\n line endings
|
||||
defp export_lines(body) do
|
||||
body |> String.split(~r/\r?\n/, trim: true)
|
||||
end
|
||||
|
||||
describe "POST /members/export.csv" do
|
||||
setup %{conn: conn} do
|
||||
# Create 3 members for export tests
|
||||
m1 =
|
||||
Fixtures.member_fixture(%{
|
||||
first_name: "Alice",
|
||||
last_name: "One",
|
||||
email: "alice.one@example.com"
|
||||
})
|
||||
|
||||
m2 =
|
||||
Fixtures.member_fixture(%{
|
||||
first_name: "Bob",
|
||||
last_name: "Two",
|
||||
email: "bob.two@example.com"
|
||||
})
|
||||
|
||||
m3 =
|
||||
Fixtures.member_fixture(%{
|
||||
first_name: "Carol",
|
||||
last_name: "Three",
|
||||
email: "carol.three@example.com"
|
||||
})
|
||||
|
||||
%{member1: m1, member2: m2, member3: m3, conn: conn}
|
||||
end
|
||||
|
||||
test "exports selected members with specified fields", %{
|
||||
conn: conn,
|
||||
member1: m1,
|
||||
member2: m2
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [m1.id, m2.id],
|
||||
"member_fields" => ["first_name", "last_name", "email"],
|
||||
"custom_field_ids" => [],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
assert get_resp_header(conn, "content-type") |> List.first() =~ "text/csv"
|
||||
|
||||
body = response(conn, 200)
|
||||
lines = export_lines(body)
|
||||
header = hd(lines)
|
||||
|
||||
# Header + 2 data rows (controller uses humanize_field: "first_name" -> "First name")
|
||||
assert length(lines) == 3
|
||||
assert header =~ "First Name,Last Name,Email"
|
||||
assert body =~ "Alice"
|
||||
assert body =~ "Bob"
|
||||
refute body =~ "Carol"
|
||||
end
|
||||
|
||||
test "exports all members when selected_ids is empty", %{
|
||||
conn: conn,
|
||||
member1: _m1,
|
||||
member2: _m2,
|
||||
member3: _m3
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [],
|
||||
"member_fields" => ["first_name", "email"],
|
||||
"custom_field_ids" => [],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
lines = export_lines(body)
|
||||
|
||||
# Header + at least 3 data rows (controller uses humanize_field)
|
||||
assert length(lines) >= 4
|
||||
assert body =~ "Alice"
|
||||
assert body =~ "Bob"
|
||||
assert body =~ "Carol"
|
||||
end
|
||||
|
||||
test "filters out unknown member fields from export", %{conn: conn, member1: m1} do
|
||||
payload = %{
|
||||
"selected_ids" => [m1.id],
|
||||
"member_fields" => ["first_name", "unknown_field", "email"],
|
||||
"custom_field_ids" => [],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
header = body |> export_lines() |> hd()
|
||||
|
||||
assert header =~ "First Name,Email"
|
||||
refute header =~ "unknown_field"
|
||||
end
|
||||
|
||||
test "export includes membership_fee_status computed field when requested", %{
|
||||
conn: conn,
|
||||
member1: m1
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [m1.id],
|
||||
"member_fields" => ["first_name"],
|
||||
"computed_fields" => ["membership_fee_status"],
|
||||
"custom_field_ids" => [],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
header = body |> export_lines() |> hd()
|
||||
|
||||
assert header =~ "First Name,Membership Fee Status"
|
||||
assert body =~ "Alice"
|
||||
end
|
||||
|
||||
test "exports membership fee status computed field with show_current_cycle option", %{
|
||||
conn: conn,
|
||||
member1: _m1,
|
||||
member2: _m2,
|
||||
member3: _m3
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [],
|
||||
"member_fields" => [],
|
||||
"computed_fields" => ["membership_fee_status"],
|
||||
"custom_field_ids" => [],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil,
|
||||
"show_current_cycle" => true
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
lines = export_lines(body)
|
||||
header = hd(lines)
|
||||
|
||||
assert header =~ "Membership Fee Status"
|
||||
end
|
||||
|
||||
setup %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
# Create custom fields for different types
|
||||
{:ok, string_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Phone Number",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, integer_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Membership Number",
|
||||
value_type: :integer
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, boolean_field} =
|
||||
Mv.Membership.CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Active Member",
|
||||
value_type: :boolean
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
# Create members with custom field values
|
||||
{:ok, member_with_string} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "String",
|
||||
email: "test.string@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _cfv_string} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_string.id,
|
||||
custom_field_id: string_field.id,
|
||||
value: "+49 123 456789"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, member_with_integer} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Integer",
|
||||
email: "test.integer@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _cfv_integer} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_integer.id,
|
||||
custom_field_id: integer_field.id,
|
||||
value: 12_345
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, member_with_boolean} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "Boolean",
|
||||
email: "test.boolean@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _cfv_boolean} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_boolean.id,
|
||||
custom_field_id: boolean_field.id,
|
||||
value: true
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, member_without_value} =
|
||||
Mv.Membership.create_member(
|
||||
%{
|
||||
first_name: "Test",
|
||||
last_name: "NoValue",
|
||||
email: "test.novalue@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
%{
|
||||
conn: conn,
|
||||
string_field: string_field,
|
||||
integer_field: integer_field,
|
||||
boolean_field: boolean_field,
|
||||
member_with_string: member_with_string,
|
||||
member_with_integer: member_with_integer,
|
||||
member_with_boolean: member_with_boolean,
|
||||
member_without_value: member_without_value
|
||||
}
|
||||
end
|
||||
|
||||
test "export includes custom field column with string value", %{
|
||||
conn: conn,
|
||||
string_field: string_field,
|
||||
member_with_string: member
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [member.id],
|
||||
"member_fields" => ["first_name", "last_name"],
|
||||
"custom_field_ids" => [string_field.id],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
lines = export_lines(body)
|
||||
header = hd(lines)
|
||||
|
||||
assert header =~ "First Name"
|
||||
assert header =~ "Last Name"
|
||||
assert header =~ "Phone Number"
|
||||
assert body =~ "Test"
|
||||
assert body =~ "String"
|
||||
assert body =~ "+49 123 456789"
|
||||
end
|
||||
|
||||
test "export includes custom field column with integer value", %{
|
||||
conn: conn,
|
||||
integer_field: integer_field,
|
||||
member_with_integer: member
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [member.id],
|
||||
"member_fields" => ["first_name"],
|
||||
"custom_field_ids" => [integer_field.id],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
header = body |> export_lines() |> hd()
|
||||
|
||||
assert header =~ "First Name"
|
||||
assert header =~ "Membership Number"
|
||||
assert body =~ "Test"
|
||||
assert body =~ "12345"
|
||||
end
|
||||
|
||||
test "export includes custom field column with boolean value", %{
|
||||
conn: conn,
|
||||
boolean_field: boolean_field,
|
||||
member_with_boolean: member
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [member.id],
|
||||
"member_fields" => ["first_name"],
|
||||
"custom_field_ids" => [boolean_field.id],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
header = body |> export_lines() |> hd()
|
||||
|
||||
assert header =~ "First Name"
|
||||
assert header =~ "Active Member"
|
||||
assert body =~ "Test"
|
||||
# Boolean values are formatted as "Yes" or "No" by CustomFieldValueFormatter
|
||||
assert body =~ "Yes"
|
||||
end
|
||||
|
||||
test "export shows empty cell for member without custom field value", %{
|
||||
conn: conn,
|
||||
string_field: string_field,
|
||||
member_without_value: member
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [member.id],
|
||||
"member_fields" => ["first_name", "last_name"],
|
||||
"custom_field_ids" => [string_field.id],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
lines = export_lines(body)
|
||||
header = hd(lines)
|
||||
data_line = Enum.at(lines, 1)
|
||||
|
||||
assert header =~ "Phone Number"
|
||||
# Empty custom field value should result in empty cell (two consecutive commas)
|
||||
assert data_line =~ "Test,NoValue,"
|
||||
end
|
||||
|
||||
test "export includes multiple custom fields in correct order", %{
|
||||
conn: conn,
|
||||
string_field: string_field,
|
||||
integer_field: integer_field,
|
||||
boolean_field: boolean_field,
|
||||
member_with_string: member
|
||||
} do
|
||||
payload = %{
|
||||
"selected_ids" => [member.id],
|
||||
"member_fields" => ["first_name"],
|
||||
"custom_field_ids" => [string_field.id, integer_field.id, boolean_field.id],
|
||||
"query" => nil,
|
||||
"sort_field" => nil,
|
||||
"sort_order" => nil
|
||||
}
|
||||
|
||||
conn = get(conn, "/members")
|
||||
csrf_token = csrf_token_from_conn(conn)
|
||||
|
||||
conn =
|
||||
post(conn, "/members/export.csv", %{
|
||||
"payload" => Jason.encode!(payload),
|
||||
"_csrf_token" => csrf_token
|
||||
})
|
||||
|
||||
assert conn.status == 200
|
||||
body = response(conn, 200)
|
||||
header = body |> export_lines() |> hd()
|
||||
|
||||
assert header =~ "First Name"
|
||||
assert header =~ "Phone Number"
|
||||
assert header =~ "Membership Number"
|
||||
assert header =~ "Active Member"
|
||||
# Verify order: member fields first, then custom fields in the order specified
|
||||
header_parts = String.split(header, ",")
|
||||
first_name_idx = Enum.find_index(header_parts, &String.contains?(&1, "First Name"))
|
||||
phone_idx = Enum.find_index(header_parts, &String.contains?(&1, "Phone Number"))
|
||||
membership_idx = Enum.find_index(header_parts, &String.contains?(&1, "Membership Number"))
|
||||
active_idx = Enum.find_index(header_parts, &String.contains?(&1, "Active Member"))
|
||||
|
||||
assert first_name_idx < phone_idx
|
||||
assert phone_idx < membership_idx
|
||||
assert membership_idx < active_idx
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -37,7 +37,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
assert is_nil(new_user.hashed_password)
|
||||
|
||||
# Verify user can be found by oidc_id
|
||||
{:ok, [found_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: user_info,
|
||||
|
|
@ -46,6 +46,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
actor: actor
|
||||
)
|
||||
|
||||
found_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert found_user.id == new_user.id
|
||||
end
|
||||
end
|
||||
|
|
@ -177,7 +184,7 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
assert linked_user.hashed_password == password_user.hashed_password
|
||||
|
||||
# Step 5: User can now sign in via OIDC
|
||||
{:ok, [signed_in_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: user_info,
|
||||
|
|
@ -186,6 +193,13 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
actor: actor
|
||||
)
|
||||
|
||||
signed_in_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert signed_in_user.id == password_user.id
|
||||
assert signed_in_user.oidc_id == "oidc_link_888"
|
||||
end
|
||||
|
|
@ -331,6 +345,9 @@ defmodule MvWeb.OidcE2EFlowTest do
|
|||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{}} ->
|
||||
:ok
|
||||
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
# Test sign_in_with_rauthy action directly
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, [found_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: user_info,
|
||||
|
|
@ -36,6 +36,13 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
found_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert found_user.id == user.id
|
||||
assert to_string(found_user.email) == "existing@example.com"
|
||||
assert found_user.oidc_id == "existing_oidc_123"
|
||||
|
|
@ -104,6 +111,9 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -129,7 +139,7 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, [found_user]} =
|
||||
result =
|
||||
Mv.Accounts.read_sign_in_with_rauthy(
|
||||
%{
|
||||
user_info: correct_user_info,
|
||||
|
|
@ -138,6 +148,13 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
found_user =
|
||||
case result do
|
||||
{:ok, u} when is_struct(u) -> u
|
||||
{:ok, [u]} -> u
|
||||
_ -> flunk("Expected user, got: #{inspect(result)}")
|
||||
end
|
||||
|
||||
assert found_user.id == user.id
|
||||
|
||||
# Try with wrong oidc_id but correct email
|
||||
|
|
@ -155,11 +172,14 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
# Either returns empty/nil OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
@ -193,11 +213,14 @@ defmodule MvWeb.OidcIntegrationTest do
|
|||
actor: system_actor
|
||||
)
|
||||
|
||||
# Either returns empty list OR authentication error - both mean "user not found"
|
||||
# Either returns empty/nil OR authentication error - both mean "user not found"
|
||||
case result do
|
||||
{:ok, []} ->
|
||||
:ok
|
||||
|
||||
{:ok, nil} ->
|
||||
:ok
|
||||
|
||||
{:error, %Ash.Error.Forbidden{errors: [%AshAuthentication.Errors.AuthenticationFailed{}]}} ->
|
||||
:ok
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -39,9 +39,10 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do
|
|||
original_config = Application.get_env(:mv, :csv_import, [])
|
||||
|
||||
try do
|
||||
# Arrange: Set custom row limit to 500
|
||||
Application.put_env(:mv, :csv_import, max_rows: 500)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
# Generate CSV with 501 rows (exceeding custom limit of 500)
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
|
@ -53,17 +54,17 @@ defmodule MvWeb.GlobalSettingsLiveConfigTest do
|
|||
|
||||
large_csv = header <> Enum.join(rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
# Act: Upload CSV and submit form
|
||||
upload_csv_file(view, large_csv, "too_many_rows_custom.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Assert: Import should be rejected with error message
|
||||
html = render(view)
|
||||
# Business rule: import should be rejected when exceeding configured limit
|
||||
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or
|
||||
html =~ "Failed to prepare"
|
||||
assert html =~ "Failed to prepare CSV import"
|
||||
after
|
||||
# Restore original config
|
||||
Application.put_env(:mv, :csv_import, original_config)
|
||||
|
|
|
|||
|
|
@ -3,22 +3,6 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
|||
import Phoenix.LiveViewTest
|
||||
alias Mv.Membership
|
||||
|
||||
# Helper function to upload CSV file in tests
|
||||
# Reduces code duplication across multiple test cases
|
||||
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: filename,
|
||||
content: csv_content,
|
||||
size: byte_size(csv_content),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload(filename)
|
||||
end
|
||||
|
||||
describe "Global Settings LiveView" do
|
||||
setup %{conn: conn} do
|
||||
user = create_test_user(%{email: "admin@example.com"})
|
||||
|
|
@ -97,595 +81,4 @@ defmodule MvWeb.GlobalSettingsLiveTest do
|
|||
assert render(view) =~ "updated" or render(view) =~ "success"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import Section" do
|
||||
test "admin user sees import section", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check for import section heading or identifier
|
||||
assert html =~ "Import" or html =~ "CSV" or html =~ "member_import"
|
||||
end
|
||||
|
||||
test "admin user sees custom fields notice", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check for custom fields notice text
|
||||
assert html =~ "Use the data field name"
|
||||
end
|
||||
|
||||
test "admin user sees template download links", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for English template link
|
||||
assert html =~ "member_import_en.csv" or html =~ "/templates/member_import_en.csv"
|
||||
|
||||
# Check for German template link
|
||||
assert html =~ "member_import_de.csv" or html =~ "/templates/member_import_de.csv"
|
||||
end
|
||||
|
||||
test "template links use static path helper", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check that links contain the static path pattern
|
||||
# Static paths typically start with /templates/ or contain the full path
|
||||
assert html =~ "/templates/member_import_en.csv" or
|
||||
html =~ ~r/href=["'][^"']*member_import_en\.csv["']/
|
||||
|
||||
assert html =~ "/templates/member_import_de.csv" or
|
||||
html =~ ~r/href=["'][^"']*member_import_de\.csv["']/
|
||||
end
|
||||
|
||||
test "admin user sees file upload input", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for file input element
|
||||
assert html =~ ~r/type=["']file["']/i or html =~ "phx-hook" or html =~ "upload"
|
||||
end
|
||||
|
||||
test "file upload has CSV-only restriction", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for CSV file type restriction in help text or accept attribute
|
||||
assert html =~ ~r/\.csv/i or html =~ "CSV" or html =~ ~r/accept=["'][^"']*csv["']/i
|
||||
end
|
||||
|
||||
test "non-admin user does not see import section", %{conn: conn} do
|
||||
# Member (own_data) is redirected when accessing /settings (no page permission)
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
|
||||
assert to == "/users/#{member_user.id}"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Import" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok, conn: conn, admin_user: admin_user, csv_content: csv_content}
|
||||
end
|
||||
|
||||
test "admin can upload CSV and start import", %{conn: conn, csv_content: csv_content} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
# Trigger start_import event via form submit
|
||||
assert view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check that import has started or shows appropriate message
|
||||
html = render(view)
|
||||
# Either import started successfully OR we see a specific error (not admin error)
|
||||
import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress"
|
||||
no_admin_error = not (html =~ "Only administrators can import")
|
||||
# If import failed, it should be a CSV parsing error, not an admin error
|
||||
if html =~ "Failed to prepare CSV import" do
|
||||
# This is acceptable - CSV might have issues, but admin check passed
|
||||
assert no_admin_error
|
||||
else
|
||||
# Import should have started
|
||||
assert import_started or html =~ "CSV File"
|
||||
end
|
||||
end
|
||||
|
||||
test "admin import initializes progress correctly", %{conn: conn, csv_content: csv_content} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check that import has started or shows appropriate message
|
||||
html = render(view)
|
||||
# Either import started successfully OR we see a specific error (not admin error)
|
||||
import_started = html =~ "Import in progress" or html =~ "running" or html =~ "progress"
|
||||
no_admin_error = not (html =~ "Only administrators can import")
|
||||
# If import failed, it should be a CSV parsing error, not an admin error
|
||||
if html =~ "Failed to prepare CSV import" do
|
||||
# This is acceptable - CSV might have issues, but admin check passed
|
||||
assert no_admin_error
|
||||
else
|
||||
# Import should have started
|
||||
assert import_started or html =~ "CSV File"
|
||||
end
|
||||
end
|
||||
|
||||
test "non-admin cannot start import", %{conn: conn} do
|
||||
# Member (own_data) is redirected when accessing /settings (no page permission)
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, member_user)
|
||||
|
||||
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
|
||||
assert to == "/users/#{member_user.id}"
|
||||
end
|
||||
|
||||
test "invalid CSV shows user-friendly error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Create invalid CSV (missing required fields)
|
||||
invalid_csv = "invalid_header\nincomplete_row"
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, invalid_csv, "invalid.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check for error message (flash)
|
||||
html = render(view)
|
||||
assert html =~ "error" or html =~ "failed" or html =~ "Failed to prepare"
|
||||
end
|
||||
|
||||
@tag :skip
|
||||
test "empty CSV shows error", %{conn: conn} do
|
||||
# Skip this test - Phoenix LiveView has issues with empty file uploads in tests
|
||||
# The error is handled correctly in production, but test framework has limitations
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
empty_csv = " "
|
||||
csv_path = Path.join([System.tmp_dir!(), "empty_#{System.unique_integer()}.csv"])
|
||||
File.write!(csv_path, empty_csv)
|
||||
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: "empty.csv",
|
||||
content: empty_csv,
|
||||
size: byte_size(empty_csv),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload("empty.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Check for error message
|
||||
html = render(view)
|
||||
assert html =~ "error" or html =~ "empty" or html =~ "failed" or html =~ "Failed to prepare"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 3: Chunk Processing" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
valid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read invalid CSV fixture
|
||||
invalid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok,
|
||||
conn: conn,
|
||||
admin_user: admin_user,
|
||||
valid_csv_content: valid_csv_content,
|
||||
invalid_csv_content: invalid_csv_content}
|
||||
end
|
||||
|
||||
test "happy path: valid CSV processes all chunks and shows done status", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing to complete
|
||||
# In test mode, chunks are processed synchronously and messages are sent via send/2
|
||||
# render(view) processes handle_info messages, so we call it multiple times
|
||||
# to ensure all messages are processed
|
||||
# Use the same approach as "success rendering" test which works
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show success count (inserted count)
|
||||
assert html =~ "Inserted" or html =~ "inserted" or html =~ "2"
|
||||
# Should show completed status
|
||||
assert html =~ "completed" or html =~ "done" or html =~ "Import completed" or
|
||||
has_element?(view, "[data-testid='import-results-panel']")
|
||||
end
|
||||
|
||||
test "error handling: invalid CSV shows errors with line numbers", %{
|
||||
conn: conn,
|
||||
invalid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for chunk processing
|
||||
Process.sleep(500)
|
||||
|
||||
html = render(view)
|
||||
# Should show failure count > 0
|
||||
assert html =~ "failed" or html =~ "error" or html =~ "Failed"
|
||||
|
||||
# Should show line numbers in errors (from service, not recalculated)
|
||||
# Line numbers should be 2, 3 (header is line 1)
|
||||
assert html =~ "2" or html =~ "3" or html =~ "line"
|
||||
end
|
||||
|
||||
test "error cap: many failing rows caps errors at 50", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Generate CSV with 100 invalid rows (all missing email)
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
invalid_rows = for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
|
||||
large_invalid_csv = header <> Enum.join(invalid_rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, large_invalid_csv, "large_invalid.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for chunk processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show failed count == 100
|
||||
assert html =~ "100" or html =~ "failed"
|
||||
|
||||
# Errors should be capped at 50 (but we can't easily check exact count in HTML)
|
||||
# The important thing is that processing completes without crashing
|
||||
assert html =~ "done" or html =~ "complete" or html =~ "finished"
|
||||
end
|
||||
|
||||
test "chunk scheduling: progress updates show chunk processing", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait a bit for processing to start
|
||||
Process.sleep(200)
|
||||
|
||||
# Check that status area exists (with aria-live for accessibility)
|
||||
html = render(view)
|
||||
|
||||
assert html =~ "aria-live" or html =~ "status" or html =~ "progress" or
|
||||
html =~ "Processing" or html =~ "chunk"
|
||||
|
||||
# Final state should be :done
|
||||
Process.sleep(500)
|
||||
final_html = render(view)
|
||||
assert final_html =~ "done" or final_html =~ "complete" or final_html =~ "finished"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 4: Results UI" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
# Read valid CSV fixture
|
||||
valid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read invalid CSV fixture
|
||||
invalid_csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Read CSV with unknown custom field
|
||||
unknown_custom_field_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok,
|
||||
conn: conn,
|
||||
admin_user: admin_user,
|
||||
valid_csv_content: valid_csv_content,
|
||||
invalid_csv_content: invalid_csv_content,
|
||||
unknown_custom_field_csv: unknown_custom_field_csv}
|
||||
end
|
||||
|
||||
test "success rendering: valid CSV shows success count", %{
|
||||
conn: conn,
|
||||
valid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content)
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing to complete
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show success count (inserted count)
|
||||
assert html =~ "Inserted" or html =~ "inserted" or html =~ "2"
|
||||
# Should show completed status
|
||||
assert html =~ "completed" or html =~ "done" or html =~ "Import completed"
|
||||
end
|
||||
|
||||
test "error rendering: invalid CSV shows failure count and error list with line numbers", %{
|
||||
conn: conn,
|
||||
invalid_csv_content: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show failure count
|
||||
assert html =~ "Failed" or html =~ "failed"
|
||||
|
||||
# Should show error list with line numbers (from service, not recalculated)
|
||||
assert html =~ "Line" or html =~ "line" or html =~ "2" or html =~ "3"
|
||||
# Should show error messages
|
||||
assert html =~ "error" or html =~ "Error" or html =~ "Errors"
|
||||
end
|
||||
|
||||
test "warning rendering: CSV with unknown custom field shows warnings block", %{
|
||||
conn: conn,
|
||||
unknown_custom_field_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
csv_path =
|
||||
Path.join([System.tmp_dir!(), "unknown_custom_#{System.unique_integer()}.csv"])
|
||||
|
||||
File.write!(csv_path, csv_content)
|
||||
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: "unknown_custom.csv",
|
||||
content: csv_content,
|
||||
size: byte_size(csv_content),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload("unknown_custom.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show warnings block (if warnings were generated)
|
||||
# Warnings are generated when unknown custom field columns are detected
|
||||
# Check if warnings section exists OR if import completed successfully
|
||||
has_warnings = html =~ "Warning" or html =~ "warning" or html =~ "Warnings"
|
||||
import_completed = html =~ "completed" or html =~ "done" or html =~ "Import Results"
|
||||
|
||||
# If warnings exist, they should contain the column name
|
||||
if has_warnings do
|
||||
assert html =~ "UnknownCustomField" or html =~ "unknown" or html =~ "Unknown column" or
|
||||
html =~ "will be ignored"
|
||||
end
|
||||
|
||||
# Import should complete (either with or without warnings)
|
||||
assert import_completed
|
||||
end
|
||||
|
||||
test "A11y: file input has label", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check for label associated with file input
|
||||
assert html =~ ~r/<label[^>]*for=["']csv_file["']/i or
|
||||
html =~ ~r/<label[^>]*>.*CSV File/i
|
||||
end
|
||||
|
||||
test "A11y: status/progress container has aria-live", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
html = render(view)
|
||||
# Check for aria-live attribute in status area
|
||||
assert html =~ ~r/aria-live=["']polite["']/i
|
||||
end
|
||||
|
||||
test "A11y: links have descriptive text", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check that links have descriptive text (not just "click here")
|
||||
# Template links should have text like "English Template" or "German Template"
|
||||
assert html =~ "English Template" or html =~ "German Template" or
|
||||
html =~ "English" or html =~ "German"
|
||||
|
||||
# Custom Fields section should have descriptive text (Data Field button)
|
||||
# The component uses "New Data Field" button, not a link
|
||||
assert html =~ "Data Field" or html =~ "New Data Field"
|
||||
end
|
||||
end
|
||||
|
||||
describe "CSV Import - Step 5: Edge Cases" do
|
||||
setup %{conn: conn} do
|
||||
# Ensure admin user
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
conn = MvWeb.ConnCase.conn_with_password_user(conn, admin_user)
|
||||
|
||||
{:ok, conn: conn, admin_user: admin_user}
|
||||
end
|
||||
|
||||
test "BOM + semicolon delimiter: import succeeds", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Read CSV with BOM
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "bom_import.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should succeed (BOM is stripped automatically)
|
||||
assert html =~ "completed" or html =~ "done" or html =~ "Inserted"
|
||||
# Should not show error about BOM
|
||||
refute html =~ "BOM" or html =~ "encoding"
|
||||
end
|
||||
|
||||
test "empty lines: line numbers in errors correspond to physical CSV lines", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# CSV with empty line: header (line 1), valid row (line 2), empty (line 3), invalid (line 4)
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|
||||
|> File.read!()
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, csv_content, "empty_lines.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
# Wait for processing
|
||||
Process.sleep(1000)
|
||||
|
||||
html = render(view)
|
||||
# Should show error with correct line number (line 4, not line 3)
|
||||
# The error should be on the line with invalid email, which is after the empty line
|
||||
assert html =~ "Line 4" or html =~ "line 4" or html =~ "4"
|
||||
# Should show error message
|
||||
assert html =~ "error" or html =~ "Error" or html =~ "invalid"
|
||||
end
|
||||
|
||||
test "too many rows (1001): import is rejected with user-friendly error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Generate CSV with 1001 rows dynamically
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
||||
rows =
|
||||
for i <- 1..1001 do
|
||||
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
|
||||
end
|
||||
|
||||
large_csv = header <> Enum.join(rows)
|
||||
|
||||
# Simulate file upload using helper function
|
||||
upload_csv_file(view, large_csv, "too_many_rows.csv")
|
||||
|
||||
view
|
||||
|> form("#csv-upload-form", %{})
|
||||
|> render_submit()
|
||||
|
||||
html = render(view)
|
||||
# Should show user-friendly error about row limit
|
||||
assert html =~ "exceeds" or html =~ "maximum" or html =~ "limit" or html =~ "1000" or
|
||||
html =~ "Failed to prepare"
|
||||
end
|
||||
|
||||
test "wrong file type (.txt): upload shows error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Create .txt file (not .csv)
|
||||
txt_content = "This is not a CSV file\nJust some text\n"
|
||||
txt_path = Path.join([System.tmp_dir!(), "wrong_type_#{System.unique_integer()}.txt"])
|
||||
File.write!(txt_path, txt_content)
|
||||
|
||||
# Try to upload .txt file
|
||||
# Note: allow_upload is configured to accept only .csv, so this should fail
|
||||
# In tests, we can't easily simulate file type rejection, but we can check
|
||||
# that the UI shows appropriate help text
|
||||
html = render(view)
|
||||
# Should show CSV-only restriction in help text
|
||||
assert html =~ "CSV" or html =~ "csv" or html =~ ".csv"
|
||||
end
|
||||
|
||||
test "file input has correct accept attribute for CSV only", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
# Check that file input has accept attribute for CSV
|
||||
assert html =~ ~r/accept=["'][^"']*csv["']/i or html =~ "CSV files only"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
282
test/mv_web/live/import_export_live_test.exs
Normal file
282
test/mv_web/live/import_export_live_test.exs
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
defmodule MvWeb.ImportExportLiveTest do
|
||||
@moduledoc """
|
||||
Tests for Import/Export LiveView: authorization (business rule), CSV import integration,
|
||||
and minimal UI smoke tests. CSV parsing/validation logic is covered by
|
||||
Mv.Membership.Import.MemberCSVTest; here we verify access control and end-to-end outcomes.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
defp put_locale_en(conn), do: Plug.Conn.put_session(conn, "locale", "en")
|
||||
|
||||
defp upload_csv_file(view, csv_content, filename \\ "test_import.csv") do
|
||||
view
|
||||
|> file_input("#csv-upload-form", :csv_file, [
|
||||
%{
|
||||
last_modified: System.system_time(:second),
|
||||
name: filename,
|
||||
content: csv_content,
|
||||
size: byte_size(csv_content),
|
||||
type: "text/csv"
|
||||
}
|
||||
])
|
||||
|> render_upload(filename)
|
||||
end
|
||||
|
||||
defp submit_import(view), do: view |> form("#csv-upload-form", %{}) |> render_submit()
|
||||
|
||||
defp wait_for_import_completion, do: Process.sleep(1000)
|
||||
|
||||
# ---------- Business logic: Authorization ----------
|
||||
describe "Authorization" do
|
||||
test "non-admin user cannot access import/export page and sees permission error", %{
|
||||
conn: conn
|
||||
} do
|
||||
member_user = Mv.Fixtures.user_with_role_fixture("own_data")
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> MvWeb.ConnCase.conn_with_password_user(member_user)
|
||||
|> put_locale_en()
|
||||
|
||||
assert {:error, {:redirect, %{to: redirect_path, flash: %{"error" => msg}}}} =
|
||||
live(conn, ~p"/admin/import-export")
|
||||
|
||||
assert redirect_path =~ "/users/"
|
||||
assert msg =~ "don't have permission"
|
||||
end
|
||||
|
||||
test "admin user can access page and run import", %{conn: conn} do
|
||||
conn = put_locale_en(conn)
|
||||
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, csv_content)
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-summary']")
|
||||
html = render(view)
|
||||
refute html =~ "Import aborted"
|
||||
assert html =~ "Successfully inserted"
|
||||
|
||||
# Business outcome: two members from fixture were created
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
{:ok, members} = Membership.list_members(actor: system_actor)
|
||||
|
||||
imported =
|
||||
Enum.filter(members, fn m ->
|
||||
m.email in ["alice.smith@example.com", "bob.johnson@example.com"]
|
||||
end)
|
||||
|
||||
assert length(imported) == 2
|
||||
end
|
||||
end
|
||||
|
||||
# ---------- Business logic: Import behaviour (integration) ----------
|
||||
describe "CSV Import - integration" do
|
||||
setup %{conn: conn} do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|
||||
|> put_locale_en()
|
||||
|
||||
valid_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
invalid_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "invalid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
unknown_cf_csv =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_unknown_custom_field.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok,
|
||||
conn: conn,
|
||||
valid_csv: valid_csv,
|
||||
invalid_csv: invalid_csv,
|
||||
unknown_custom_field_csv: unknown_cf_csv}
|
||||
end
|
||||
|
||||
test "invalid CSV shows user-friendly prepare error", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, "invalid_header\nincomplete_row", "invalid.csv")
|
||||
submit_import(view)
|
||||
html = render(view)
|
||||
assert html =~ "Failed to prepare CSV import"
|
||||
end
|
||||
|
||||
test "invalid rows show errors with correct CSV line numbers", %{
|
||||
conn: conn,
|
||||
invalid_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, csv_content, "invalid_import.csv")
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
html = render(view)
|
||||
assert html =~ "Failed"
|
||||
# Fixture has invalid email on line 2 and missing email on line 3
|
||||
assert html =~ "Line 2"
|
||||
assert html =~ "Line 3"
|
||||
end
|
||||
|
||||
test "error list is capped and truncation message is shown", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
||||
invalid_rows =
|
||||
for i <- 1..100, do: "Row#{i};Last#{i};;Street#{i};12345;City#{i}\n"
|
||||
|
||||
upload_csv_file(view, header <> Enum.join(invalid_rows), "large_invalid.csv")
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
html = render(view)
|
||||
assert html =~ "100"
|
||||
assert html =~ "Error list truncated"
|
||||
end
|
||||
|
||||
test "row limit is enforced (1001 rows rejected)", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
header = "first_name;last_name;email;street;postal_code;city\n"
|
||||
|
||||
rows =
|
||||
for i <- 1..1001 do
|
||||
"Row#{i};Last#{i};email#{i}@example.com;Street#{i};12345;City#{i}\n"
|
||||
end
|
||||
|
||||
upload_csv_file(view, header <> Enum.join(rows), "too_many_rows.csv")
|
||||
submit_import(view)
|
||||
html = render(view)
|
||||
assert html =~ "exceeds"
|
||||
end
|
||||
|
||||
test "BOM and semicolon delimiter are accepted", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_bom_semicolon.csv"])
|
||||
|> File.read!()
|
||||
|
||||
upload_csv_file(view, csv_content, "bom_import.csv")
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
html = render(view)
|
||||
assert html =~ "Successfully inserted"
|
||||
refute html =~ "BOM"
|
||||
end
|
||||
|
||||
test "physical line numbers in errors (empty line does not shift numbering)", %{
|
||||
conn: conn
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "csv_with_empty_lines.csv"])
|
||||
|> File.read!()
|
||||
|
||||
upload_csv_file(view, csv_content, "empty_lines.csv")
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-error-list']")
|
||||
html = render(view)
|
||||
# Invalid row is on physical line 4 (header, valid row, empty line, then invalid)
|
||||
assert html =~ "Line 4"
|
||||
end
|
||||
|
||||
test "unknown custom field column produces warnings", %{
|
||||
conn: conn,
|
||||
unknown_custom_field_csv: csv_content
|
||||
} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, csv_content, "unknown_custom.csv")
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
|
||||
assert has_element?(view, "[data-testid='import-results-panel']")
|
||||
assert has_element?(view, "[data-testid='import-warnings']")
|
||||
html = render(view)
|
||||
assert html =~ "Warnings"
|
||||
end
|
||||
end
|
||||
|
||||
# ---------- UI (smoke / framework): tagged for exclusion from fast CI ----------
|
||||
describe "Import/Export page UI" do
|
||||
@describetag :ui
|
||||
setup %{conn: conn} do
|
||||
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
|
||||
|
||||
conn =
|
||||
conn
|
||||
|> MvWeb.ConnCase.conn_with_password_user(admin_user)
|
||||
|> put_locale_en()
|
||||
|
||||
{:ok, conn: conn}
|
||||
end
|
||||
|
||||
test "page loads and shows import form and export placeholder", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
assert has_element?(view, "[data-testid='csv-upload-form']")
|
||||
assert has_element?(view, "[data-testid='start-import-button']")
|
||||
assert has_element?(view, "[data-testid='custom-fields-link']")
|
||||
html = render(view)
|
||||
assert html =~ "Import Members (CSV)"
|
||||
assert html =~ "Export Members (CSV)"
|
||||
assert html =~ "Export functionality will be available"
|
||||
end
|
||||
|
||||
test "template links and file input are present", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
assert has_element?(view, "a[href*='/templates/member_import_en.csv']")
|
||||
assert has_element?(view, "a[href*='/templates/member_import_de.csv']")
|
||||
assert has_element?(view, "label[for='csv_file']")
|
||||
assert has_element?(view, "#csv_file_help")
|
||||
assert has_element?(view, "[data-testid='csv-upload-form'] input[type='file']")
|
||||
end
|
||||
|
||||
test "after successful import, progress container has aria-live", %{conn: conn} do
|
||||
csv_content =
|
||||
Path.join([__DIR__, "..", "..", "fixtures", "valid_member_import.csv"])
|
||||
|> File.read!()
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, csv_content)
|
||||
submit_import(view)
|
||||
wait_for_import_completion()
|
||||
assert has_element?(view, "[data-testid='import-progress-container']")
|
||||
html = render(view)
|
||||
assert html =~ "aria-live"
|
||||
end
|
||||
end
|
||||
|
||||
# Skip: LiveView test harness does not reliably support empty/minimal file uploads.
|
||||
# See docs/csv-member-import-v1.md (Issue #9).
|
||||
@tag :skip
|
||||
test "empty CSV shows error", %{conn: conn} do
|
||||
conn = put_locale_en(conn)
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/import-export")
|
||||
upload_csv_file(view, " ", "empty.csv")
|
||||
submit_import(view)
|
||||
html = render(view)
|
||||
assert html =~ "Failed to prepare"
|
||||
end
|
||||
end
|
||||
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
|
||||
|
|
@ -9,6 +9,7 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
|
|||
require Ash.Query
|
||||
|
||||
describe "error handling - flash messages" do
|
||||
@describetag :ui
|
||||
test "shows flash message when member creation fails with validation error", %{conn: conn} do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -225,7 +225,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
|||
|> element("[data-testid='custom_field_#{field.id}']")
|
||||
|> render_click()
|
||||
|
||||
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
||||
# Patch URL may include fields param (current field selection); assert sort outcome instead
|
||||
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']")
|
||||
end
|
||||
|
||||
test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -46,78 +46,76 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|> Ash.create!(actor: actor)
|
||||
end
|
||||
|
||||
test "shows translated title in German", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
# Expected German title
|
||||
assert html =~ "Mitglieder"
|
||||
end
|
||||
describe "translations" do
|
||||
@describetag :ui
|
||||
|
||||
test "shows translated title in English", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
# Expected English title
|
||||
assert html =~ "Members"
|
||||
end
|
||||
test "shows translated title and button text by locale", %{conn: conn} do
|
||||
locales = [
|
||||
{"de", "Mitglieder", "Speichern",
|
||||
fn c -> Plug.Test.init_test_session(c, locale: "de") end},
|
||||
{"en", "Members", "Save",
|
||||
fn c ->
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
c
|
||||
end}
|
||||
]
|
||||
|
||||
test "shows translated button text in German", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, _view, html} = live(conn, "/members/new")
|
||||
assert html =~ "Speichern"
|
||||
end
|
||||
for {_locale, expected_title, expected_button, set_locale} <- locales do
|
||||
base = conn_with_oidc_user(conn) |> set_locale.()
|
||||
|
||||
test "shows translated button text in English", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/members/new")
|
||||
assert html =~ "Save"
|
||||
end
|
||||
{:ok, _view, index_html} = live(base, "/members")
|
||||
assert index_html =~ expected_title
|
||||
|
||||
test "shows translated flash message after creating a member in German", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||||
base_form = conn_with_oidc_user(conn) |> set_locale.()
|
||||
{:ok, _view, form_html} = live(base_form, "/members/new")
|
||||
assert form_html =~ expected_button
|
||||
end
|
||||
end
|
||||
|
||||
form_data = %{
|
||||
"member[first_name]" => "Max",
|
||||
"member[last_name]" => "Mustermann",
|
||||
"member[email]" => "max@example.com"
|
||||
}
|
||||
test "shows translated flash message after creating a member in German", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "de")
|
||||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||||
|
||||
# Submit form and follow the redirect to get the flash message
|
||||
{:ok, index_view, _html} =
|
||||
form_view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/members")
|
||||
form_data = %{
|
||||
"member[first_name]" => "Max",
|
||||
"member[last_name]" => "Mustermann",
|
||||
"member[email]" => "max@example.com"
|
||||
}
|
||||
|
||||
assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt")
|
||||
end
|
||||
# Submit form and follow the redirect to get the flash message
|
||||
{:ok, index_view, _html} =
|
||||
form_view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/members")
|
||||
|
||||
test "shows translated flash message after creating a member in English", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||||
assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt")
|
||||
end
|
||||
|
||||
form_data = %{
|
||||
"member[first_name]" => "Max",
|
||||
"member[last_name]" => "Mustermann",
|
||||
"member[email]" => "max@example.com"
|
||||
}
|
||||
test "shows translated flash message after creating a member in English", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||||
|
||||
# Submit form and follow the redirect to get the flash message
|
||||
{:ok, index_view, _html} =
|
||||
form_view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/members")
|
||||
form_data = %{
|
||||
"member[first_name]" => "Max",
|
||||
"member[last_name]" => "Mustermann",
|
||||
"member[email]" => "max@example.com"
|
||||
}
|
||||
|
||||
assert has_element?(index_view, "#flash-group", "Member created successfully")
|
||||
# Submit form and follow the redirect to get the flash message
|
||||
{:ok, index_view, _html} =
|
||||
form_view
|
||||
|> form("#member-form", form_data)
|
||||
|> render_submit()
|
||||
|> follow_redirect(conn, "/members")
|
||||
|
||||
assert has_element?(index_view, "#flash-group", "Member created successfully")
|
||||
end
|
||||
end
|
||||
|
||||
describe "sorting integration" do
|
||||
@describetag :ui
|
||||
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
|
@ -200,6 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
|
||||
describe "URL param handling" do
|
||||
@describetag :ui
|
||||
test "handle_params reads sort query and applies it", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||||
|
|
@ -226,6 +225,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
|
||||
describe "search and sort integration" do
|
||||
@describetag :ui
|
||||
test "search maintains sort state", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||||
|
|
@ -253,6 +253,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
|
@ -521,6 +522,50 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "export to CSV" do
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, m1} =
|
||||
Mv.Membership.create_member(
|
||||
%{first_name: "Export", last_name: "One", email: "export1@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
%{member1: m1}
|
||||
end
|
||||
|
||||
test "export button is rendered when no selection and shows (all)", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Button text shows "all" when 0 selected (locale-dependent)
|
||||
assert html =~ "Export to CSV"
|
||||
assert html =~ "all" or html =~ "All"
|
||||
end
|
||||
|
||||
test "after select_member event export button shows (1)", %{conn: conn, member1: member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "Export to CSV"
|
||||
assert html =~ "(1)"
|
||||
end
|
||||
|
||||
test "form has correct action and payload hidden input", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "/members/export.csv"
|
||||
assert html =~ ~s(name="payload")
|
||||
assert html =~ ~s(type="hidden")
|
||||
assert html =~ ~s(name="_csrf_token")
|
||||
end
|
||||
end
|
||||
|
||||
describe "cycle status filter" do
|
||||
# Helper to create a member (only used in this describe block)
|
||||
defp create_member(attrs, actor) do
|
||||
|
|
@ -780,6 +825,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|> Ash.create!(actor: system_actor)
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "mount initializes boolean_custom_field_filters as empty map", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
|
@ -788,6 +834,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
assert state.socket.assigns.boolean_custom_field_filters == %{}
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "mount initializes boolean_custom_fields as empty list when no boolean fields exist", %{
|
||||
conn: conn
|
||||
} do
|
||||
|
|
@ -1762,6 +1809,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
refute html_false =~ "NoValue"
|
||||
end
|
||||
|
||||
@tag :ui
|
||||
test "boolean custom field appears in filter dropdown after being added", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
|
|
|
|||
|
|
@ -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