Member Resource Policies closes #345 #346

Merged
moritz merged 33 commits from feature/345_member_policies_2 into main 2026-01-13 16:36:24 +01:00
9 changed files with 216 additions and 81 deletions
Showing only changes of commit 075a06ba6f - Show all commits

View file

@ -339,7 +339,7 @@ defmodule MvWeb.MemberLive.Form do
form = form =
if member do if member do
{:ok, member} = Ash.load(member, custom_field_values: [:custom_field], actor: actor) {:ok, member} = Ash.load(member, [custom_field_values: [:custom_field]], actor: actor)
existing_custom_field_values = existing_custom_field_values =
member.custom_field_values member.custom_field_values

View file

@ -15,10 +15,11 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
require Ash.Query require Ash.Query
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees.CalendarCycles alias Mv.MembershipFees
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.CalendarCycles
alias MvWeb.Helpers.MembershipFeeHelpers alias MvWeb.Helpers.MembershipFeeHelpers
@impl true @impl true
@ -63,7 +64,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-click="delete_all_cycles" phx-click="delete_all_cycles"
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-error btn-outline" class="btn btn-sm btn-error btn-outline"
title={gettext("Delete All Cycles")} title={gettext("Delete all cycles")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
{gettext("Delete All Cycles")} {gettext("Delete All Cycles")}
@ -168,7 +169,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
phx-value-cycle_id={cycle.id} phx-value-cycle_id={cycle.id}
phx-target={@myself} phx-target={@myself}
class="btn btn-sm btn-error btn-outline" class="btn btn-sm btn-error btn-outline"
title={gettext("Delete Cycle")} title={gettext("Delete cycle")}
> >
<.icon name="hero-trash" class="size-4" /> <.icon name="hero-trash" class="size-4" />
{gettext("Delete")} {gettext("Delete")}
@ -329,14 +330,16 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
/> />
<label class="label"> <label class="label">
<span class="label-text-alt"> <span class="label-text-alt">
{gettext("The cycle will be calculated based on this date and the interval.")} {gettext(
"The cycle period will be calculated based on this date and the interval."
)}
</span> </span>
</label> </label>
</div> </div>
<%= if @create_cycle_date do %> <%= if @create_cycle_date do %>
<div class="form-control w-full mt-4"> <div class="form-control w-full mt-4">
<label class="label"> <label class="label">
<span class="label-text">{gettext("Cycle")}</span> <span class="label-text">{gettext("Cycle Period")}</span>
</label> </label>
<div class="text-sm text-base-content/70"> <div class="text-sm text-base-content/70">
{format_create_cycle_period( {format_create_cycle_period(

View file

@ -1,21 +1,42 @@
defmodule MvWeb.AuthControllerTest do defmodule MvWeb.AuthControllerTest do
use MvWeb.ConnCase, async: true use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest 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 # 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") conn = get(conn, ~p"/sign-in")
assert html_response(conn, 200) =~ "Sign in" assert html_response(conn, 200) =~ "Sign in"
end end
test "GET /sign-out redirects to home", %{conn: conn} do test "GET /sign-out redirects to home", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/sign-out") conn = get(conn, ~p"/sign-out")
assert redirected_to(conn) == ~p"/" assert redirected_to(conn) == ~p"/"
end end
# Password authentication (LiveView) # 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 = _user =
create_test_user(%{ create_test_user(%{
email: "password@example.com", email: "password@example.com",
@ -35,7 +56,12 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end 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 = _user =
create_test_user(%{ create_test_user(%{
email: "test@example.com", email: "test@example.com",
@ -55,7 +81,12 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "Email or password was incorrect" assert html =~ "Email or password was incorrect"
end 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") {:ok, view, _html} = live(conn, "/sign-in")
html = html =
@ -69,7 +100,10 @@ defmodule MvWeb.AuthControllerTest do
end end
# Registration (LiveView) # 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") {:ok, view, _html} = live(conn, "/register")
{:error, {:redirect, %{to: to}}} = {:error, {:redirect, %{to: to}}} =
@ -82,7 +116,10 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end 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 = _user =
create_test_user(%{ create_test_user(%{
email: "existing@example.com", email: "existing@example.com",
@ -102,7 +139,10 @@ defmodule MvWeb.AuthControllerTest do
assert html =~ "has already been taken" assert html =~ "has already been taken"
end 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") {:ok, view, _html} = live(conn, "/register")
html = html =
@ -116,18 +156,27 @@ defmodule MvWeb.AuthControllerTest do
end end
# Access control # 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") conn = get(conn, ~p"/members")
assert redirected_to(conn) == ~p"/sign-in" assert redirected_to(conn) == ~p"/sign-in"
end end
test "authenticated user can access protected route", %{conn: conn} do test "authenticated user can access protected route", %{conn: authenticated_conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(authenticated_conn)
conn = get(conn, ~p"/members") conn = get(conn, ~p"/members")
assert conn.status == 200 assert conn.status == 200
end 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 = _user =
create_test_user(%{ create_test_user(%{
email: "auth@example.com", email: "auth@example.com",
@ -150,7 +199,12 @@ defmodule MvWeb.AuthControllerTest do
end end
# Edge cases # 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 = _user =
create_test_user(%{ create_test_user(%{
email: "nil_oidc@example.com", email: "nil_oidc@example.com",
@ -170,7 +224,12 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end 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 = _user =
create_test_user(%{ create_test_user(%{
email: "empty_oidc@example.com", email: "empty_oidc@example.com",

View file

@ -11,20 +11,6 @@ defmodule MvWeb.MemberLive.FormMembershipFeeTypeTest do
require Ash.Query 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 # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.IndexMembershipFeeStatusTest do
require Ash.Query 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 # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.MembershipFeeIntegrationTest do
require Ash.Query 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 # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{

View file

@ -12,20 +12,6 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
require Ash.Query 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 # Helper to create a membership fee type
defp create_fee_type(attrs) do defp create_fee_type(attrs) do
default_attrs = %{ default_attrs = %{

View file

@ -99,6 +99,7 @@ defmodule MvWeb.ConnCase do
@doc """ @doc """
Signs in a user via OIDC and returns a connection with the user authenticated. 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. 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 def conn_with_oidc_user(conn, user_attrs \\ %{}) do
# Ensure unique email for OIDC users # Ensure unique email for OIDC users
@ -109,8 +110,22 @@ defmodule MvWeb.ConnCase do
oidc_id: "oidc_#{unique_id}" oidc_id: "oidc_#{unique_id}"
} }
# Create user using Ash.Seed (supports oidc_id)
user = create_test_user(Map.merge(default_attrs, user_attrs)) 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 end
@doc """ @doc """
@ -122,6 +137,15 @@ defmodule MvWeb.ConnCase do
|> AshAuthentication.Plug.Helpers.store_in_session(user) |> AshAuthentication.Plug.Helpers.store_in_session(user)
end 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 setup tags do
pid = Mv.DataCase.setup_sandbox(tags) pid = Mv.DataCase.setup_sandbox(tags)
@ -130,6 +154,11 @@ defmodule MvWeb.ConnCase do
# to share the test's database connection in async tests # to share the test's database connection in async tests
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid) conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
{:ok, conn: conn} # 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)
{:ok, conn: authenticated_conn, current_user: admin_user}
end end
end end

View file

@ -93,4 +93,104 @@ defmodule Mv.Fixtures do
{user, member} {user, member}
end 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 end