Merge remote-tracking branch 'origin/main' into feature/ui-for-adding-members-groups
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/promote/production Build is passing

This commit is contained in:
Simon 2026-02-12 15:16:35 +01:00
commit 2f8a6a2768
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
136 changed files with 9999 additions and 3601 deletions

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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}"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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

View 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

View 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

View 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

View 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

View file

@ -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

View 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

View 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

View 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

View 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

View 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
View 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

View file

@ -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

View file

@ -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

View file

@ -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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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

View 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

View 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

View file

@ -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

View file

@ -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

View file

@ -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 ->

View file

@ -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

View 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

View file

@ -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()

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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

View file

@ -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