Merge branch 'main' into feature/330_import_service_skeleton

This commit is contained in:
carla 2026-01-14 12:30:40 +01:00
commit 4b41ab37bb
50 changed files with 2594 additions and 1103 deletions

View file

@ -0,0 +1,44 @@
defmodule Mv.Authorization.Checks.HasPermissionFailClosedTest do
@moduledoc """
Regression tests to ensure deny-filter behavior is fail-closed (matches no records).
These tests verify that when HasPermission.auto_filter returns a deny-filter
(e.g., when actor is nil or no permission is found), the filter actually
matches zero records in the database.
This prevents regressions like the previous bug where [id: {:not, {:in, []}}]
was used, which logically evaluates to "NOT (id IN [])" = true for all IDs,
effectively allowing all records instead of denying them.
"""
use Mv.DataCase, async: true
alias Mv.Authorization.Checks.HasPermission
import Mv.Fixtures
test "auto_filter deny-filter matches no records (regression for NOT IN [] allow-all bug)" do
# Arrange: create some members in DB
_m1 = member_fixture()
_m2 = member_fixture()
# Build a minimal authorizer with a stable action type (:read)
authorizer = %Ash.Policy.Authorizer{
resource: Mv.Membership.Member,
action: %{type: :read}
}
# Act: missing actor must yield a deny-all filter (fail-closed)
deny_filter = HasPermission.auto_filter(nil, authorizer, [])
# Apply the returned filter to a real DB query (no authorization involved)
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.filter_input(deny_filter)
{:ok, results} = Ash.read(query, domain: Mv.Membership, authorize?: false)
# Assert: deny-filter must match nothing
assert results == []
end
end

View file

@ -14,16 +14,22 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
alias Mv.Authorization.Checks.HasPermission
# Helper to create mock actor with role
defp create_actor_with_role(permission_set_name) do
%{
defp create_actor_with_role(permission_set_name, opts \\ []) do
actor = %{
id: "user-#{System.unique_integer([:positive])}",
role: %{permission_set_name: permission_set_name}
}
# Add member_id if provided (needed for :linked scope tests)
case Keyword.get(opts, :member_id) do
nil -> actor
member_id -> Map.put(actor, :member_id, member_id)
end
end
describe "Filter Expression Structure - :linked scope" do
test "Member filter uses user.id relationship path" do
actor = create_actor_with_role("own_data")
test "Member filter uses actor.member_id (inverse relationship)" do
actor = create_actor_with_role("own_data", member_id: "member-123")
authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(actor, authorizer, [])
@ -36,8 +42,8 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
assert is_list(filter) or is_map(filter)
end
test "CustomFieldValue filter uses member.user.id relationship path" do
actor = create_actor_with_role("own_data")
test "CustomFieldValue filter uses actor.member_id (via member relationship)" do
actor = create_actor_with_role("own_data", member_id: "member-123")
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :read)
filter = HasPermission.auto_filter(actor, authorizer, [])
@ -66,14 +72,15 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
end
describe "Filter Expression Structure - :all scope" do
test "Admin can read all members without filter" do
test "Admin can read all members without filter (returns expr(true))" do
actor = create_actor_with_role("admin")
authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(actor, authorizer, [])
# :all scope should return nil (no filter needed)
assert is_nil(filter)
# :all scope should return [] (empty keyword list = no filter = allow all records)
# After auto_filter fix: no longer returns nil, returns [] instead
assert filter == []
end
end
@ -81,7 +88,10 @@ defmodule Mv.Authorization.Checks.HasPermissionIntegrationTest do
defp create_authorizer(resource, action) do
%Ash.Policy.Authorizer{
resource: resource,
subject: %{action: %{name: action}}
subject: %{
action: %{type: action},
data: nil
}
}
end
end

View file

@ -13,16 +13,25 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
defp create_authorizer(resource, action) do
%Ash.Policy.Authorizer{
resource: resource,
subject: %{action: %{name: action}}
subject: %{
action: %{type: action},
data: nil
}
}
end
# Helper to create actor with role
defp create_actor(id, permission_set_name) do
%{
defp create_actor(id, permission_set_name, opts \\ []) do
actor = %{
id: id,
role: %{permission_set_name: permission_set_name}
}
# Add member_id if provided (needed for :linked scope tests)
case Keyword.get(opts, :member_id) do
nil -> actor
member_id -> Map.put(actor, :member_id, member_id)
end
end
describe "describe/1" do
@ -120,7 +129,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
describe "auto_filter/3 - Scope :linked" do
test "scope :linked for Member returns user_id filter" do
user = create_actor("user-123", "own_data")
user = create_actor("user-123", "own_data", member_id: "member-456")
authorizer = create_authorizer(Mv.Membership.Member, :read)
filter = HasPermission.auto_filter(user, authorizer, [])
@ -130,7 +139,7 @@ defmodule Mv.Authorization.Checks.HasPermissionTest do
end
test "scope :linked for CustomFieldValue returns member.user_id filter" do
user = create_actor("user-123", "own_data")
user = create_actor("user-123", "own_data", member_id: "member-456")
authorizer = create_authorizer(Mv.Membership.CustomFieldValue, :update)
filter = HasPermission.auto_filter(user, authorizer, [])

View file

@ -0,0 +1,430 @@
defmodule Mv.Membership.MemberPoliciesTest do
@moduledoc """
Tests for Member resource authorization policies.
Tests all 4 permission sets (own_data, read_only, normal_user, admin)
and verifies that policies correctly enforce access control based on
user roles and permission sets.
"""
# async: false because we need database commits to be visible across queries
# in the same test (especially for unlinked members)
use Mv.DataCase, async: false
alias Mv.Membership
alias Mv.Accounts
alias Mv.Authorization
require Ash.Query
# Helper to create a role with a specific permission set
defp create_role_with_permission_set(permission_set_name) 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
}) 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) do
# Create role with permission set
role = create_role_with_permission_set(permission_set_name)
# 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()
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update()
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
user_with_role
end
# Helper to create an admin user (for creating test fixtures)
defp create_admin_user do
create_user_with_permission_set("admin")
end
# Helper to create a member linked to a user
defp create_linked_member_for_user(user) do
admin = create_admin_user()
# Create member
# NOTE: We need to ensure the member is actually persisted to the database
# before we try to link it. Ash may delay writes, so we explicitly return the struct.
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Linked",
last_name: "Member",
email: "linked#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: admin, return_notifications?: false)
# Link member to user (User.member_id = member.id)
# We use force_change_attribute because the member already exists and we just
# need to set the foreign key. This avoids the issue where manage_relationship
# tries to query the member without the actor context.
result =
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)
{:ok, _user} = result
# Return the member struct directly - no need to reload since we just created it
# and we're in the same transaction/sandbox
member
end
# Helper to create an unlinked member (no user relationship)
defp create_unlinked_member do
admin = create_admin_user()
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Unlinked",
last_name: "Member",
email: "unlinked#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: admin)
member
end
describe "own_data permission set (Mitglied)" do
setup do
user = create_user_with_permission_set("own_data")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read linked member", %{user: user, linked_member: linked_member} do
{:ok, member} =
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
assert member.id == linked_member.id
end
test "can update linked member", %{user: user, linked_member: linked_member} do
# Update is allowed via HasPermission check with :linked scope (not via special case)
# The special case policy only applies to :read actions
{:ok, updated_member} =
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
test "cannot read unlinked member (returns forbidden)", %{
user: user,
unlinked_member: unlinked_member
} do
# Note: With auto_filter policies, when a user tries to read a member that doesn't
# match the filter (id == actor.member_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!(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
end
end
test "cannot update unlinked member (returns forbidden)", %{
user: user,
unlinked_member: unlinked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update!(actor: user)
end
end
test "list members returns only linked member", %{user: user, linked_member: linked_member} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should only return the linked member (scope :linked filters)
assert length(members) == 1
assert hd(members).id == linked_member.id
end
test "cannot create member (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
end
test "cannot destroy member (returns forbidden)", %{user: user, linked_member: linked_member} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(linked_member, actor: user)
end
end
end
describe "read_only permission set (Vorstand/Buchhaltung)" do
setup do
user = create_user_with_permission_set("read_only")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read all members", %{
user: user,
linked_member: linked_member,
unlinked_member: unlinked_member
} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should return all members (scope :all)
member_ids = Enum.map(members, & &1.id)
assert linked_member.id in member_ids
assert unlinked_member.id in member_ids
end
test "can read individual member", %{user: user, unlinked_member: unlinked_member} do
{:ok, member} =
Ash.get(Membership.Member, unlinked_member.id, actor: user, domain: Mv.Membership)
assert member.id == unlinked_member.id
end
test "cannot create member (returns forbidden)", %{user: user} do
assert_raise Ash.Error.Forbidden, fn ->
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create!(actor: user)
end
end
test "cannot update any member (returns forbidden)", %{
user: user,
linked_member: linked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update!(actor: user)
end
end
test "cannot destroy any member (returns forbidden)", %{
user: user,
unlinked_member: unlinked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(unlinked_member, actor: user)
end
end
end
describe "normal_user permission set (Kassenwart)" do
setup do
user = create_user_with_permission_set("normal_user")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read all members", %{
user: user,
linked_member: linked_member,
unlinked_member: unlinked_member
} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should return all members (scope :all)
member_ids = Enum.map(members, & &1.id)
assert linked_member.id in member_ids
assert unlinked_member.id in member_ids
end
test "can create member", %{user: user} do
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: user)
assert member.first_name == "New"
end
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
{:ok, updated_member} =
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
test "cannot destroy member (safety - not in permission set)", %{
user: user,
unlinked_member: unlinked_member
} do
assert_raise Ash.Error.Forbidden, fn ->
Ash.destroy!(unlinked_member, actor: user)
end
end
end
describe "admin permission set" do
setup do
user = create_user_with_permission_set("admin")
linked_member = create_linked_member_for_user(user)
unlinked_member = create_unlinked_member()
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
%{user: user, linked_member: linked_member, unlinked_member: unlinked_member}
end
test "can read all members", %{
user: user,
linked_member: linked_member,
unlinked_member: unlinked_member
} do
{:ok, members} = Ash.read(Membership.Member, actor: user, domain: Mv.Membership)
# Should return all members (scope :all)
member_ids = Enum.map(members, & &1.id)
assert linked_member.id in member_ids
assert unlinked_member.id in member_ids
end
test "can create member", %{user: user} do
{:ok, member} =
Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "New",
last_name: "Member",
email: "new#{System.unique_integer([:positive])}@example.com"
})
|> Ash.create(actor: user)
assert member.first_name == "New"
end
test "can update any member", %{user: user, unlinked_member: unlinked_member} do
{:ok, updated_member} =
unlinked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
test "can destroy any member", %{user: user, unlinked_member: unlinked_member} do
:ok = Ash.destroy(unlinked_member, actor: user)
# Verify member is deleted
assert {:error, _} = Ash.get(Membership.Member, unlinked_member.id, domain: Mv.Membership)
end
end
describe "special case: user can always READ linked member" do
# Note: The special case policy only applies to :read actions.
# Updates are handled by HasPermission with :linked scope (if permission exists).
test "read_only user can read linked member (via special case bypass)" 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")
linked_member = create_linked_member_for_user(user)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
# Should succeed (special case bypass policy for :read takes precedence)
{:ok, member} =
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
assert member.id == linked_member.id
end
test "own_data user can read linked member (via special case bypass)" 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")
linked_member = create_linked_member_for_user(user)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
# Should succeed (special case bypass policy for :read takes precedence)
{:ok, member} =
Ash.get(Membership.Member, linked_member.id, actor: user, domain: Mv.Membership)
assert member.id == linked_member.id
end
test "own_data user can update linked member (via HasPermission :linked scope)" 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")
linked_member = create_linked_member_for_user(user)
# Reload user to get updated member_id
{:ok, user} = Ash.get(Accounts.User, user.id, domain: Mv.Accounts, load: [:role])
{:ok, user} = Ash.load(user, :member, domain: Mv.Accounts)
# Should succeed via HasPermission check (not special case)
{:ok, updated_member} =
linked_member
|> Ash.Changeset.for_update(:update_member, %{first_name: "Updated"})
|> Ash.update(actor: user)
assert updated_member.first_name == "Updated"
end
end
end

View file

@ -1,21 +1,42 @@
defmodule MvWeb.AuthControllerTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Phoenix.ConnTest
# Helper to create an unauthenticated conn (preserves sandbox metadata)
defp build_unauthenticated_conn(authenticated_conn) do
# Create new conn but preserve sandbox metadata for database access
new_conn = build_conn()
# Copy sandbox metadata from authenticated conn
if authenticated_conn.private[:ecto_sandbox] do
Plug.Conn.put_private(new_conn, :ecto_sandbox, authenticated_conn.private[:ecto_sandbox])
else
new_conn
end
end
# Basic UI tests
test "GET /sign-in shows sign in form", %{conn: conn} do
test "GET /sign-in shows sign in form", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/sign-in")
assert html_response(conn, 200) =~ "Sign in"
end
test "GET /sign-out redirects to home", %{conn: conn} do
conn = conn_with_oidc_user(conn)
test "GET /sign-out redirects to home", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/sign-out")
assert redirected_to(conn) == ~p"/"
end
# Password authentication (LiveView)
test "password user can sign in with valid credentials via LiveView", %{conn: conn} do
test "password user can sign in with valid credentials via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "password@example.com",
@ -35,7 +56,12 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token"
end
test "password user with invalid credentials shows error via LiveView", %{conn: conn} do
test "password user with invalid credentials shows error via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "test@example.com",
@ -55,7 +81,12 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "Email or password was incorrect"
end
test "password user with non-existent email shows error via LiveView", %{conn: conn} do
test "password user with non-existent email shows error via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/sign-in")
html =
@ -69,7 +100,10 @@ defmodule MvWeb.AuthControllerTest do
end
# Registration (LiveView)
test "user can register with valid credentials via LiveView", %{conn: conn} do
test "user can register with valid credentials via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/register")
{:error, {:redirect, %{to: to}}} =
@ -82,7 +116,10 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token"
end
test "registration with existing email shows error via LiveView", %{conn: conn} do
test "registration with existing email shows error via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "existing@example.com",
@ -102,7 +139,10 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "has already been taken"
end
test "registration with weak password shows error via LiveView", %{conn: conn} do
test "registration with weak password shows error via LiveView", %{conn: authenticated_conn} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
{:ok, view, _html} = live(conn, "/register")
html =
@ -116,18 +156,27 @@ defmodule MvWeb.AuthControllerTest do
end
# Access control
test "unauthenticated user accessing protected route gets redirected to sign-in", %{conn: conn} do
test "unauthenticated user accessing protected route gets redirected to sign-in", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
conn = get(conn, ~p"/members")
assert redirected_to(conn) == ~p"/sign-in"
end
test "authenticated user can access protected route", %{conn: conn} do
conn = conn_with_oidc_user(conn)
test "authenticated user can access protected route", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/members")
assert conn.status == 200
end
test "password authenticated user can access protected route via LiveView", %{conn: conn} do
test "password authenticated user can access protected route via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "auth@example.com",
@ -150,7 +199,12 @@ defmodule MvWeb.AuthControllerTest do
end
# Edge cases
test "user with nil oidc_id can still sign in with password via LiveView", %{conn: conn} do
test "user with nil oidc_id can still sign in with password via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "nil_oidc@example.com",
@ -170,7 +224,12 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token"
end
test "user with empty string oidc_id is handled correctly via LiveView", %{conn: conn} do
test "user with empty string oidc_id is handled correctly via LiveView", %{
conn: authenticated_conn
} do
# Create unauthenticated conn for this test
conn = build_unauthenticated_conn(authenticated_conn)
_user =
create_test_user(%{
email: "empty_oidc@example.com",

View file

@ -11,19 +11,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
end
# Use global setup from ConnCase which provides admin user with role
# No custom setup needed
# Helper to create a membership fee type
defp create_fee_type(attrs) do
@ -41,7 +30,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end
# Helper to create a member
defp create_member(attrs) do
# Uses admin actor from global setup to ensure authorization
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
last_name: "Member",
@ -50,9 +40,11 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
attrs = Map.merge(default_attrs, attrs)
opts = if actor, do: [actor: actor], else: []
Member
|> Ash.Changeset.for_create(:create_member, attrs)
|> Ash.create!()
|> Ash.create!(opts)
end
describe "list display" do
@ -72,12 +64,12 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
assert html =~ "Yearly" || html =~ "Jährlich"
end
test "member count column shows correct count", %{conn: conn} do
test "member count column shows correct count", %{conn: conn, current_user: admin_user} do
fee_type = create_fee_type(%{interval: :yearly})
# Create 3 members with this fee type
Enum.each(1..3, fn _ ->
create_member(%{membership_fee_type_id: fee_type.id})
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
end)
{:ok, _view, html} = live(conn, "/membership_fee_types")
@ -111,9 +103,9 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
end
describe "delete functionality" do
test "delete button disabled if type is in use", %{conn: conn} do
test "delete button disabled if type is in use", %{conn: conn, current_user: admin_user} do
fee_type = create_fee_type(%{interval: :yearly})
create_member(%{membership_fee_type_id: fee_type.id})
create_member(%{membership_fee_type_id: fee_type.id}, admin_user)
{:ok, _view, html} = live(conn, "/membership_fee_types")

View file

@ -11,20 +11,6 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{
@ -164,4 +150,153 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
assert html =~ fee_type.name || html =~ "selected"
end
end
describe "custom field value preservation" do
test "custom field values preserved when membership fee type changes", %{
conn: conn,
current_user: admin_user
} do
# Create custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test Field",
value_type: :string,
required: false
})
|> Ash.create!()
# Create two fee types with same interval
fee_type1 = create_fee_type(%{name: "Type 1", interval: :yearly})
fee_type2 = create_fee_type(%{name: "Type 2", interval: :yearly})
# Create member with fee type 1 and custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type1.id
})
|> Ash.create!(actor: admin_user)
# Add custom field value
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "Test Value"}
})
|> Ash.create!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Change membership fee type dropdown
html =
view
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type2.id})
|> render_change()
# Verify custom field value is still present (check for field name or value)
assert html =~ custom_field.name || html =~ "Test Value"
end
test "union/typed values roundtrip correctly", %{conn: conn, current_user: admin_user} do
# Create date custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Date Field",
value_type: :date,
required: false
})
|> Ash.create!()
fee_type = create_fee_type(%{interval: :yearly})
# Create member with date custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: admin_user)
test_date = ~D[2024-01-15]
# Add date custom field value
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "date", "_union_value" => test_date}
})
|> Ash.create!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Trigger validation (simulates dropdown change)
html =
view
|> form("#member-form", %{"member[membership_fee_type_id]" => fee_type.id})
|> render_change()
# Verify date value is still present (check for date input or formatted date)
assert html =~ "2024" || html =~ "date"
end
test "removing custom field values works correctly", %{conn: conn, current_user: admin_user} do
# Create custom field
custom_field =
Mv.Membership.CustomField
|> Ash.Changeset.for_create(:create, %{
name: "Test Field",
value_type: :string,
required: false
})
|> Ash.create!()
fee_type = create_fee_type(%{interval: :yearly})
# Create member with custom field value
member =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com",
membership_fee_type_id: fee_type.id
})
|> Ash.create!(actor: admin_user)
# Add custom field value
_cfv =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: custom_field.id,
value: %{"_union_type" => "string", "_union_value" => "Test Value"}
})
|> Ash.create!(actor: admin_user)
{:ok, view, _html} = live(conn, "/members/#{member.id}/edit")
# Change membership fee type to trigger validation
# This should preserve the custom field value
html =
view
|> form("#member-form", %{
"member[membership_fee_type_id]" => fee_type.id
})
|> render_change()
# Form should still be valid and custom field value should be preserved
# The custom field value should still be visible in the form
assert html =~ "Test Value" || html =~ custom_field.name
end
end
end

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(conn, user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
require Ash.Query
setup do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(build_conn(), user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
require Ash.Query
setup %{conn: conn} do
# Create admin user
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: "admin#{System.unique_integer([:positive])}@mv.local",
password: "testpassword123"
})
|> Ash.create()
conn = conn_with_password_user(conn, user)
%{conn: conn, user: user}
end
# Helper to create a membership fee type
defp create_fee_type(attrs) do
default_attrs = %{

View file

@ -99,6 +99,7 @@ defmodule MvWeb.ConnCase do
@doc """
Signs in a user via OIDC and returns a connection with the user authenticated.
By default creates a user with "user@example.com" for consistency.
The user will have an admin role for authorization.
"""
def conn_with_oidc_user(conn, user_attrs \\ %{}) do
# Ensure unique email for OIDC users
@ -109,8 +110,22 @@ defmodule MvWeb.ConnCase do
oidc_id: "oidc_#{unique_id}"
}
# Create user using Ash.Seed (supports oidc_id)
user = create_test_user(Map.merge(default_attrs, user_attrs))
sign_in_user_via_oidc(conn, user)
# Create admin role and assign it
admin_role = Mv.Fixtures.role_fixture("admin")
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update()
# Load role for authorization
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts)
sign_in_user_via_oidc(conn, user_with_role)
end
@doc """
@ -122,6 +137,15 @@ defmodule MvWeb.ConnCase do
|> AshAuthentication.Plug.Helpers.store_in_session(user)
end
@doc """
Creates a connection with an authenticated user that has an admin role.
This is useful for tests that need full access to resources.
"""
def conn_with_admin_user(conn) do
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
conn_with_password_user(conn, admin_user)
end
setup tags do
pid = Mv.DataCase.setup_sandbox(tags)
@ -130,6 +154,36 @@ defmodule MvWeb.ConnCase do
# to share the test's database connection in async tests
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
{:ok, conn: conn}
# Handle role tags for future test extensions
# Default to admin to maintain backward compatibility with existing tests
role = Map.get(tags, :role, :admin)
{conn, user} =
case role do
:admin ->
# Create admin user with role for all tests (unless test overrides with its own user)
# This ensures all tests have an authenticated user with proper authorization
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, admin_user)
{authenticated_conn, admin_user}
:member ->
# Create member user for role-based testing
member_user = Mv.Fixtures.user_with_role_fixture("member")
authenticated_conn = conn_with_password_user(conn, member_user)
{authenticated_conn, member_user}
:unauthenticated ->
# No authentication for unauthenticated tests
{conn, nil}
_other ->
# Fallback: treat unknown role as admin for safety
admin_user = Mv.Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, admin_user)
{authenticated_conn, admin_user}
end
{:ok, conn: conn, current_user: user}
end
end

View file

@ -93,4 +93,104 @@ defmodule Mv.Fixtures do
{user, member}
end
@doc """
Creates a role with a specific permission set.
## Parameters
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
## Returns
- Role struct
## Examples
iex> role_fixture("admin")
%Mv.Authorization.Role{permission_set_name: "admin", ...}
"""
def role_fixture(permission_set_name) do
role_name = "Test Role #{permission_set_name} #{System.unique_integer([:positive])}"
case Mv.Authorization.create_role(%{
name: role_name,
description: "Test role for #{permission_set_name}",
permission_set_name: permission_set_name
}) do
{:ok, role} -> role
{:error, error} -> raise "Failed to create role: #{inspect(error)}"
end
end
@doc """
Creates a user with a specific permission set (role).
## Parameters
- `permission_set_name` - The permission set name (e.g., "admin", "read_only", "normal_user", "own_data")
- `user_attrs` - Optional user attributes
## Returns
- User struct with role preloaded
## Examples
iex> admin_user = user_with_role_fixture("admin")
iex> admin_user.role.permission_set_name
"admin"
"""
def user_with_role_fixture(permission_set_name \\ "admin", user_attrs \\ %{}) do
# Create role with permission set
role = role_fixture(permission_set_name)
# Create user
{:ok, user} =
user_attrs
|> Enum.into(%{
email: "user#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Accounts.create_user()
# Assign role to user
{:ok, user} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, role, type: :append_and_remove)
|> Ash.update()
# Reload user with role preloaded (critical for authorization!)
{:ok, user_with_role} = Ash.load(user, :role, domain: Mv.Accounts)
user_with_role
end
@doc """
Creates a member with an actor (for use in tests with policies).
## Parameters
- `attrs` - Map or keyword list of attributes to override defaults
- `actor` - The actor (user) to use for authorization
## Returns
- Member struct
## Examples
iex> admin = user_with_role_fixture("admin")
iex> member_fixture_with_actor(%{first_name: "Alice"}, admin)
%Mv.Membership.Member{first_name: "Alice", ...}
"""
def member_fixture_with_actor(attrs \\ %{}, actor) do
attrs
|> Enum.into(%{
first_name: "Test",
last_name: "Member",
email: "test#{System.unique_integer([:positive])}@example.com"
})
|> Mv.Membership.create_member(actor: actor)
|> case do
{:ok, member} -> member
{:error, error} -> raise "Failed to create member: #{inspect(error)}"
end
end
end