Page Permission Router Plug closes #388 #390

Merged
moritz merged 16 commits from feature/388_page_permissions into main 2026-01-30 12:20:00 +01:00
31 changed files with 1736 additions and 257 deletions

View file

@ -0,0 +1,92 @@
# Page Permission Route and Test Coverage
This document lists all protected routes, which permission set may access them, and how they are covered by tests.
## Protected Routes (Router scope with CheckPagePermission in :browser)
| Route | own_data | read_only | normal_user | admin |
|-------|----------|-----------|-------------|-------|
| `/` | ✗ | ✓ | ✓ | ✓ |
| `/members` | ✗ | ✓ | ✓ | ✓ |
| `/members/new` | ✗ | ✗ | ✓ | ✓ |
| `/members/:id` | ✓ (linked only) | ✓ | ✓ | ✓ |
| `/members/:id/edit` | ✓ (linked only) | ✗ | ✓ | ✓ |
| `/members/:id/show/edit` | ✓ (linked only) | ✗ | ✓ | ✓ |
| `/users` | ✗ | ✗ | ✗ | ✓ |
| `/users/new` | ✗ | ✗ | ✗ | ✓ |
| `/users/:id` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
| `/users/:id/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
| `/users/:id/show/edit` | ✓ (own only) | ✓ (own only) | ✓ (own only) | ✓ |
| `/settings` | ✗ | ✗ | ✗ | ✓ |
| `/membership_fee_settings` | ✗ | ✗ | ✗ | ✓ |
| `/membership_fee_types` | ✗ | ✗ | ✗ | ✓ |
| `/membership_fee_types/new` | ✗ | ✗ | ✗ | ✓ |
| `/membership_fee_types/:id/edit` | ✗ | ✗ | ✗ | ✓ |
| `/groups` | ✗ | ✓ | ✓ | ✓ |
| `/groups/new` | ✗ | ✗ | ✗ | ✓ |
| `/groups/:slug` | ✗ | ✓ | ✓ | ✓ |
| `/groups/:slug/edit` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ |
| `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ |
**Note:** Permission sets define `/custom_field_values` and related paths, but there are no such routes in the router; those entries are for future use.
## Public Paths (no permission check)
- `/auth*`, `/register`, `/reset`, `/sign-in`, `/sign-out`, `/confirm*`, `/password-reset*`, `/set_locale`
## Test Coverage
**File:** `test/mv_web/plugs/check_page_permission_test.exs`
### Unit tests (plug called directly with mock conn)
- Static: own_data denied `/members`; read_only allowed `/members`; flash on denial.
- Dynamic: read_only allowed `/members/123`; normal_user allowed `/members/456/edit`; read_only denied `/members/123/edit`.
- read_only / normal_user: denied `/admin/roles`; read_only denied `/members/new`.
- Wildcard: admin allowed `/admin/roles`, `/members/999/edit`.
- Unauthenticated: nil user denied, redirect `/sign-in`.
- Public: unauthenticated allowed `/auth/sign-in`, `/register`.
- Error: no role, invalid permission_set_name → denied.
### Integration tests (full router, Mitglied = own_data)
**Denied (Mitglied gets 302 → `/users/:id`):**
- `/members`, `/members/new`, `/users`, `/users/new`, `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/membership_fee_types/new`, `/groups`, `/groups/new`, `/admin/roles`, `/admin/roles/new`
- `/members/:id/edit`, `/members/:id/show/edit`, `/users/:id` (other user), `/users/:id/edit` (other), `/users/:id/show/edit` (other), `/membership_fee_types/:id/edit`, `/groups/:slug`, `/admin/roles/:id`, `/admin/roles/:id/edit`
**Allowed (Mitglied gets 200):**
- `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`
- `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit` for linked member (plug unit tests; full-router tests for linked member skipped: session/LiveView constraints)
**Root:** `GET /` redirects Mitglied to profile (root not allowed for own_data).
All protected routes above are either covered by integration “denied” tests for Mitglied or by unit tests for the relevant permission set.
### Integration tests (full router, read_only = Vorstand/Buchhaltung)
**Allowed (200):** `/`, `/members`, `/members/:id`, `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`, `/groups`, `/groups/:slug`.
**Denied (302 → `/users/:id`):** `/members/new`, `/members/:id/edit`, `/members/:id/show/edit`, `/users`, `/users/new`, `/users/:id` (other user), `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/groups/new`, `/groups/:slug/edit`, `/admin/roles`, `/admin/roles/:id`.
### Integration tests (full router, normal_user = Kassenwart)
**Allowed (200):** `/`, `/members`, `/members/new`, `/members/:id`, `/members/:id/edit`, `/members/:id/show/edit`, `/users/:id` (own profile), `/users/:id/edit`, `/users/:id/show/edit`, `/groups`, `/groups/:slug`.
**Denied (302 → `/users/:id`):** `/users`, `/users/new`, `/users/:id` (other user), `/settings`, `/membership_fee_settings`, `/membership_fee_types`, `/groups/new`, `/groups/:slug/edit`, `/admin/roles`, `/admin/roles/:id`.
### Integration tests (full router, admin)
**Allowed (200):** All protected routes (sample covered: `/`, `/members`, `/users`, `/settings`, `/membership_fee_settings`, `/admin/roles`, `/members/:id`, `/admin/roles/:id`, `/groups/:slug`).
## Plug behaviour: reserved segments
The plug treats `"new"` as a reserved path segment so that patterns like `/members/:id` and `/groups/:slug` do not match `/members/new` or `/groups/new`. Thus `/groups/new` is only allowed when the permission set explicitly lists `/groups/new` (currently only admin).
## Role and member_id loading
The plug may reload the user's role (and optionally `member_id`) before checking page permission. Session/`load_from_session` can leave the role unloaded; the plug uses `Mv.Authorization.Actor.ensure_loaded/1` (and, when needed, loads `member_id`) so that permission checks always have the required data. No change to session loading is required; this is documented for clarity.

View file

@ -2002,6 +2002,8 @@ Users and Members are separate entities that can be linked. Special rules:
- A user cannot link themselves to an existing member
- A user CAN create a new member and be directly linked to it (self-service)
**Enforcement:** The User resource restricts the `update_user` action (which accepts the `member` argument for link/unlink) to admins only via `Mv.Authorization.Checks.ActorIsAdmin`. The UserLive.Form shows the Member-Linking UI and runs member link/unlink on save only when the current user is admin; non-admins use the `:update` action (email only) for profile edit.
### Approach: Separate Ash Actions
We use **different Ash actions** to enforce different policies:

View file

@ -103,6 +103,7 @@ defmodule Mv.Accounts.User do
# the specialized :update_user action below.
update :update do
primary? true
accept [:email]
# Required because custom validation functions (email validation, member relationship validation)
# cannot be executed atomically. These validations need to query the database and perform
@ -310,6 +311,14 @@ defmodule Mv.Accounts.User do
authorize_if expr(id == ^actor(:id))
end
# update_user allows :member argument (link/unlink). Only admins may use it to prevent
# privilege escalation (own_data could otherwise link to any member and get :linked scope).
policy action(:update_user) do
description "Only admins can update user with member link/unlink"
forbid_unless Mv.Authorization.Checks.ActorIsAdmin
authorize_if Mv.Authorization.Checks.ActorIsAdmin
end
# UPDATE/DESTROY via HasPermission (evaluates PermissionSets scope)
policy action_type([:read, :create, :update, :destroy]) do
description "Check permissions from user's role and permission set"

View file

@ -0,0 +1,22 @@
defmodule Mv.Authorization.Checks.ActorIsAdmin do
@moduledoc """
Policy check: true when the actor's role has permission_set_name "admin".
Used to restrict actions (e.g. User.update_user for member link/unlink) to admins only.
"""
use Ash.Policy.SimpleCheck
@impl true
def describe(_opts), do: "actor has admin permission set"
@impl true
def match?(nil, _context, _opts), do: false
def match?(actor, _context, _opts) do
ps_name =
get_in(actor, [Access.key(:role), Access.key(:permission_set_name)]) ||
get_in(actor, [Access.key("role"), Access.key("permission_set_name")])
ps_name == "admin"
end
end

View file

@ -118,12 +118,15 @@ defmodule Mv.Authorization.PermissionSets do
%{resource: "Group", action: :read, scope: :all, granted: true}
],
pages: [
# Home page
"/",
# Own profile
"/profile",
# Linked member detail (filtered by policy)
"/members/:id"
# No "/" - Mitglied must not see member index at root (same content as /members).
# Own profile (sidebar links to /users/:id) and own user edit
"/users/:id",
"/users/:id/edit",
"/users/:id/show/edit",
# Linked member detail and edit (data access filtered by policy scope: :linked)
"/members/:id",
"/members/:id/edit",
"/members/:id/show/edit"
]
}
end
@ -151,8 +154,10 @@ defmodule Mv.Authorization.PermissionSets do
],
pages: [
"/",
# Own profile
"/profile",
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
"/users/:id",
"/users/:id/edit",
"/users/:id/show/edit",
# Member list
"/members",
# Member detail
@ -198,14 +203,17 @@ defmodule Mv.Authorization.PermissionSets do
],
pages: [
"/",
# Own profile
"/profile",
# Own profile (sidebar links to /users/:id; redirect target must be allowed)
"/users/:id",
"/users/:id/edit",
"/users/:id/show/edit",
"/members",
# Create member
"/members/new",
"/members/:id",
# Edit member
"/members/:id/edit",
"/members/:id/show/edit",
"/custom_field_values",
# Custom field value detail
"/custom_field_values/:id",

View file

@ -30,6 +30,7 @@ defmodule MvWeb.Authorization do
"""
alias Mv.Authorization.PermissionSets
alias MvWeb.Plugs.CheckPagePermission
@doc """
Checks if user has permission for an action on a resource.
@ -111,16 +112,9 @@ defmodule MvWeb.Authorization do
def can_access_page?(nil, _page_path), do: false
def can_access_page?(user, page_path) do
# Convert verified route to string if needed
# Delegate to plug logic so UI uses same rules (reserved "new", own/linked path checks).
page_path_str = if is_binary(page_path), do: page_path, else: to_string(page_path)
with %{role: %{permission_set_name: ps_name}} when not is_nil(ps_name) <- user,
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom) do
page_matches?(permissions.pages, page_path_str)
else
_ -> false
end
CheckPagePermission.user_can_access_page?(user, page_path_str, router: MvWeb.Router)
end
# Check if scope allows access to record
@ -172,33 +166,6 @@ defmodule MvWeb.Authorization do
end
end
# Check if page path matches any allowed pattern
defp page_matches?(allowed_pages, requested_path) do
Enum.any?(allowed_pages, fn pattern ->
cond do
pattern == "*" -> true
pattern == requested_path -> true
String.contains?(pattern, ":") -> match_pattern?(pattern, requested_path)
true -> false
end
end)
end
# Match dynamic route pattern
defp match_pattern?(pattern, path) do
pattern_segments = String.split(pattern, "/", trim: true)
path_segments = String.split(path, "/", trim: true)
if length(pattern_segments) == length(path_segments) do
Enum.zip(pattern_segments, path_segments)
|> Enum.all?(fn {pattern_seg, path_seg} ->
String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
end)
else
false
end
end
# Extract resource name from module
defp get_resource_name(resource) when is_atom(resource) do
resource |> Module.split() |> List.last()

View file

@ -125,9 +125,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_last_completed_cycle(member)
# => %MembershipFeeCycle{cycle_start: ~D[2024-01-01], ...}
"""
@spec get_last_completed_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
@spec get_last_completed_cycle(Member.t() | nil, Date.t() | nil) :: MembershipFeeCycle.t() | nil
def get_last_completed_cycle(member, today \\ nil)
def get_last_completed_cycle(nil, _today), do: nil
def get_last_completed_cycle(%Member{} = member, today) do
today = today || Date.utc_today()
@ -174,9 +176,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpers do
iex> cycle = MvWeb.Helpers.MembershipFeeHelpers.get_current_cycle(member)
# => %MembershipFeeCycle{cycle_start: ~D[2024-04-01], ...}
"""
@spec get_current_cycle(Member.t(), Date.t() | nil) :: MembershipFeeCycle.t() | nil
@spec get_current_cycle(Member.t() | nil, Date.t() | nil) :: MembershipFeeCycle.t() | nil
def get_current_cycle(member, today \\ nil)
def get_current_cycle(nil, _today), do: nil
def get_current_cycle(%Member{} = member, today) do
today = today || Date.utc_today()

View file

@ -36,6 +36,7 @@ defmodule MvWeb.UserLive.Form do
require Jason
import MvWeb.LiveHelpers, only: [current_actor: 1, submit_form: 3]
import MvWeb.Authorization, only: [can?: 3]
@impl true
def render(assigns) do
@ -94,7 +95,7 @@ defmodule MvWeb.UserLive.Form do
</ul>
</div>
<%= if @user do %>
<%= if @user && @can_manage_member_linking do %>
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
<p class="text-sm text-orange-800">
<strong>{gettext("Admin Note")}:</strong> {gettext(
@ -125,129 +126,133 @@ defmodule MvWeb.UserLive.Form do
<% end %>
</div>
<!-- Member Linking Section -->
<div class="mt-6">
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
<!-- Member Linking Section (admin only: only admins can link/unlink users to members) -->
<%= if @can_manage_member_linking do %>
<div class="mt-6">
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
<%= if @user && @user.member && !@unlink_member do %>
<!-- Show linked member with unlink button -->
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-green-900">
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</p>
<p class="text-sm text-green-700">{@user.member.email}</p>
</div>
<button
type="button"
phx-click="unlink_member"
class="btn btn-sm btn-error"
>
{gettext("Unlink Member")}
</button>
</div>
</div>
<% else %>
<%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
type="text"
id="member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-change="search_members"
phx-debounce="300"
phx-window-keydown="member_dropdown_keydown"
value={@member_search_query}
placeholder={gettext("Search for a member to link...")}
class="w-full input"
name="member_search"
disabled={@unlink_member}
aria-label={gettext("Search for member to link")}
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
aria-activedescendant={
if @focused_member_index,
do: "member-option-#{@focused_member_index}",
else: nil
}
autocomplete="off"
/>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
phx-click-away="hide_member_dropdown"
>
<%= for {member, index} <- Enum.with_index(@available_members) do %>
<div
id={"member-option-#{index}"}
role="option"
tabindex="0"
aria-selected={to_string(@focused_member_index == index)}
phx-click="select_member"
phx-value-id={member.id}
data-member-id={member.id}
class={[
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
if(@focused_member_index == index,
do: "bg-base-300",
else: "hover:bg-base-200"
)
]}
>
<p class="font-medium">{MvWeb.Helpers.MemberHelpers.display_name(member)}</p>
<p class="text-sm text-base-content/70">{member.email}</p>
</div>
<% end %>
<%= if @user && @user.member && !@unlink_member do %>
<!-- Show linked member with unlink button -->
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-green-900">
{MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
</p>
<p class="text-sm text-green-700">{@user.member.email}</p>
</div>
<% end %>
<button
type="button"
phx-click="unlink_member"
class="btn btn-sm btn-error"
>
{gettext("Unlink Member")}
</button>
</div>
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
<% else %>
<%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
type="text"
id="member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-change="search_members"
phx-debounce="300"
phx-window-keydown="member_dropdown_keydown"
value={@member_search_query}
placeholder={gettext("Search for a member to link...")}
class="w-full input"
name="member_search"
disabled={@unlink_member}
aria-label={gettext("Search for member to link")}
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
aria-activedescendant={
if @focused_member_index,
do: "member-option-#{@focused_member_index}",
else: nil
}
autocomplete="off"
/>
<%= if @selected_member_id && @selected_member_name do %>
<div
id="member-selected"
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
>
<p class="text-sm text-blue-800">
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
</p>
<p class="mt-1 text-xs text-blue-600">
{gettext("Save to confirm linking.")}
</p>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
phx-click-away="hide_member_dropdown"
>
<%= for {member, index} <- Enum.with_index(@available_members) do %>
<div
id={"member-option-#{index}"}
role="option"
tabindex="0"
aria-selected={to_string(@focused_member_index == index)}
phx-click="select_member"
phx-value-id={member.id}
data-member-id={member.id}
class={[
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
if(@focused_member_index == index,
do: "bg-base-300",
else: "hover:bg-base-200"
)
]}
>
<p class="font-medium">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</p>
<p class="text-sm text-base-content/70">{member.email}</p>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
)}
</p>
</div>
<% end %>
<%= if @selected_member_id && @selected_member_name do %>
<div
id="member-selected"
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
>
<p class="text-sm text-blue-800">
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
</p>
<p class="mt-1 text-xs text-blue-600">
{gettext("Save to confirm linking.")}
</p>
</div>
<% end %>
</div>
<% end %>
</div>
<% end %>
<div class="mt-4">
<.button phx-disable-with={gettext("Saving...")} variant="primary">
@ -289,14 +294,19 @@ defmodule MvWeb.UserLive.Form do
end
defp mount_continue(user, params, socket) do
actor = current_actor(socket)
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
page_title = action <> " " <> gettext("User")
# Only admins can link/unlink users to members (permission docs; prevents privilege escalation).
can_manage_member_linking = can?(actor, :destroy, Mv.Accounts.User)
{:ok,
socket
|> assign(:return_to, return_to(params["return_to"]))
|> assign(user: user)
|> assign(:page_title, page_title)
|> assign(:can_manage_member_linking, can_manage_member_linking)
|> assign(:show_password_fields, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
@ -329,9 +339,9 @@ defmodule MvWeb.UserLive.Form do
def handle_event("validate", %{"user" => user_params}, socket) do
validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params)
# Reload members if email changed (for email-match priority)
# Reload members if email changed (for email-match priority; only when member linking UI is shown)
socket =
if Map.has_key?(user_params, "email") do
if Map.has_key?(user_params, "email") and socket.assigns[:can_manage_member_linking] do
user_email = user_params["email"]
members = load_members_for_linking(user_email, socket.assigns.member_search_query, socket)
@ -480,20 +490,25 @@ defmodule MvWeb.UserLive.Form do
end
defp perform_member_link_action(socket, user, actor) do
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
actor: actor
)
# Only admins may link/unlink (backend policy also restricts update_user; UI must not call it).
if can?(actor, :destroy, Mv.Accounts.User) do
cond do
# Selected member ID takes precedence (new link)
socket.assigns.selected_member_id ->
Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}},
actor: actor
)
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
# Unlink flag is set
socket.assigns[:unlink_member] ->
Mv.Accounts.update_user(user, %{member: nil}, actor: actor)
# No changes to member relationship
true ->
{:ok, user}
# No changes to member relationship
true ->
{:ok, user}
end
else
{:ok, user}
end
end
@ -552,13 +567,28 @@ defmodule MvWeb.UserLive.Form do
end
@spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
defp assign_form(
%{
assigns: %{
user: user,
show_password_fields: show_password_fields,
can_manage_member_linking: can_manage_member_linking
}
} = socket
) do
actor = current_actor(socket)
form =
if user do
# For existing users, use admin password action if password fields are shown
action = if show_password_fields, do: :admin_set_password, else: :update_user
# For existing users: admin uses update_user (email + member); non-admin uses update (email only).
# Password change uses admin_set_password for both.
action =
cond do
show_password_fields -> :admin_set_password
can_manage_member_linking -> :update_user
true -> :update
end
AshPhoenix.Form.for_update(user, action, domain: Mv.Accounts, as: "user", actor: actor)
else
# For new users, use password registration if password fields are shown

View file

@ -5,15 +5,18 @@ defmodule MvWeb.LiveHelpers do
## on_mount Hooks
- `:default` - Sets the user's locale from session (defaults to "de")
- `:ensure_user_role_loaded` - Ensures current_user has role relationship loaded
- `:check_page_permission_on_params` - Attaches handle_params hook to enforce page permission on client-side navigation (push_patch)
## Usage
Add to LiveView modules via:
```elixir
on_mount {MvWeb.LiveHelpers, :default}
on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded}
on_mount {MvWeb.LiveHelpers, :check_page_permission_on_params}
```
"""
import Phoenix.Component
alias MvWeb.Plugs.CheckPagePermission
def on_mount(:default, _params, session, socket) do
locale = session["locale"] || "de"
@ -26,6 +29,40 @@ defmodule MvWeb.LiveHelpers do
{:cont, socket}
end
def on_mount(:check_page_permission_on_params, _params, _session, socket) do
{:cont,
Phoenix.LiveView.attach_hook(
socket,
:check_page_permission,
:handle_params,
&check_page_permission_handle_params/3
)}
end
defp check_page_permission_handle_params(_params, uri, socket) do
path = uri |> URI.parse() |> Map.get(:path, "/") || "/"
if CheckPagePermission.public_path?(path) do
{:cont, socket}
else
user = socket.assigns[:current_user]
host = uri |> URI.parse() |> Map.get(:host) || "localhost"
if CheckPagePermission.user_can_access_page?(user, path, router: MvWeb.Router, host: host) do
{:cont, socket}
else
redirect_to = CheckPagePermission.redirect_target_for_user(user)
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You don't have permission to access this page.")
|> Phoenix.LiveView.push_navigate(to: redirect_to)
{:halt, socket}
end
end
end
defp ensure_user_role_loaded(socket) do
user = socket.assigns[:current_user]

View file

@ -0,0 +1,315 @@
defmodule MvWeb.Plugs.CheckPagePermission do
@moduledoc """
Plug that checks if the current user has permission to access the requested page.
Runs in the router pipeline before LiveView mounts. Uses PermissionSets page list
and matches the current route template (or request path) against allowed patterns.
## How It Works
1. Public paths (e.g. /auth, /register) are exempt and pass through.
2. Extracts page path from conn via `Phoenix.Router.route_info/4` (route template
like "/members/:id") or falls back to `conn.request_path`.
3. Gets current user from `conn.assigns[:current_user]`.
4. Gets user's permission_set_name from role and calls `PermissionSets.get_permissions/1`.
5. Matches requested path against allowed patterns (exact, dynamic `:param`, wildcard "*").
6. If unauthorized: redirects to "/sign-in" (no user) or "/users/:id" (user profile) with flash error and halts.
## Pattern Matching
- Exact: "/members" == "/members"
- Dynamic: "/members/:id" matches "/members/123"
- Wildcard: "*" matches everything (admin)
- Reserved: the segment "new" is never matched by `:id` or `:slug` (e.g. `/members/new` and `/groups/new` require an explicit page permission).
"""
import Plug.Conn
import Phoenix.Controller
alias Mv.Authorization.PermissionSets
require Logger
def init(opts), do: opts
def call(conn, _opts) do
if public_path?(conn.request_path) do
conn
else
# Ensure role is loaded (load_from_session does not load it; required for permission check)
user =
conn.assigns[:current_user]
|> Mv.Authorization.Actor.ensure_loaded()
conn = Plug.Conn.assign(conn, :current_user, user)
page_path = get_page_path(conn)
request_path = conn.request_path
if has_page_permission?(user, page_path, request_path) do
conn
else
log_page_access_denied(user, page_path)
redirect_to = redirect_target(user)
conn
|> fetch_session()
|> fetch_flash()
|> put_flash(:error, "You don't have permission to access this page.")
|> redirect(to: redirect_to)
|> halt()
end
end
end
@doc """
Returns the redirect URL for an unauthorized user (for LiveView push_redirect).
"""
def redirect_target_for_user(nil), do: "/sign-in"
def redirect_target_for_user(user) when is_map(user) or is_struct(user) do
id = Map.get(user, :id) || Map.get(user, "id")
if id, do: "/users/#{to_string(id)}", else: "/sign-in"
end
def redirect_target_for_user(_), do: "/sign-in"
defp redirect_target(user), do: redirect_target_for_user(user)
@doc """
Returns true if the path is public (no auth/permission check).
Used by LiveView hook to skip redirect on sign-in etc.
"""
def public_path?(path) when is_binary(path) do
path in ["/register", "/reset", "/set_locale", "/sign-in", "/sign-out"] or
String.starts_with?(path, "/auth") or
String.starts_with?(path, "/confirm") or
String.starts_with?(path, "/password-reset")
end
defp get_page_path(conn) do
router = conn.private[:phoenix_router]
get_page_path_from_router(router, conn.method, conn.request_path, conn.host)
end
@doc """
Returns whether the user is allowed to access the given request path.
Used by the plug and by LiveView on_mount/handle_params for client-side navigation.
Options: `:router` (default MvWeb.Router), `:host` (default "localhost").
"""
def user_can_access_page?(user, request_path, opts \\ []) do
router = Keyword.get(opts, :router, MvWeb.Router)
host = Keyword.get(opts, :host, "localhost")
page_path = get_page_path_from_router(router, "GET", request_path, host)
has_page_permission?(user, page_path, request_path)
end
defp get_page_path_from_router(router, method, request_path, host) do
case Phoenix.Router.route_info(router, method, request_path, host) do
%{route: route} -> route
_ -> request_path
end
end
defp has_page_permission?(nil, _page_path, _request_path), do: false
defp has_page_permission?(user, page_path, request_path) do
with ps_name when is_binary(ps_name) <- permission_set_name_from_user(user),
{:ok, ps_atom} <- PermissionSets.permission_set_name_to_atom(ps_name),
permissions <- PermissionSets.get_permissions(ps_atom) do
page_matches?(permissions.pages, page_path, request_path, user)
else
_ -> false
end
end
defp permission_set_name_from_user(user) when is_map(user) or is_struct(user) do
get_in(user, [Access.key(:role), Access.key(:permission_set_name)]) ||
get_in(user, [Access.key("role"), Access.key("permission_set_name")])
end
defp permission_set_name_from_user(_), do: nil
defp user_id_from_user(user) when is_map(user) or is_struct(user) do
id = Map.get(user, :id) || Map.get(user, "id")
if id, do: to_string(id), else: nil
end
defp user_id_from_user(_), do: nil
# Reserved path segments that must not match a single :id param (e.g. /members/new, /users/new).
@reserved_id_segments ["new"]
# For "/users/:id" with own_data we only allow when the id in the path equals the current user's id.
# For "/members/:id" we reject when the segment is reserved (e.g. "new") so /members/new is not allowed.
defp page_matches?(allowed_pages, requested_path, request_path, user) do
Enum.any?(allowed_pages, fn pattern ->
pattern_match?(pattern, requested_path, request_path, user)
end)
end
defp pattern_match?("*", _requested_path, _request_path, _user), do: true
defp pattern_match?(pattern, _requested_path, request_path, user)
when pattern == "/users/:id" do
match_dynamic_route?(pattern, request_path) and
path_param_equals(pattern, request_path, "id", user_id_from_user(user))
end
defp pattern_match?(pattern, _requested_path, request_path, user)
when pattern in ["/users/:id/edit", "/users/:id/show/edit"] do
match_dynamic_route?(pattern, request_path) and
path_param_equals(pattern, request_path, "id", user_id_from_user(user))
end
defp pattern_match?(pattern, _requested_path, request_path, user)
when pattern == "/members/:id" do
match_dynamic_route?(pattern, request_path) and
path_param_not_reserved(pattern, request_path, "id", @reserved_id_segments) and
members_show_allowed?(pattern, request_path, user)
end
defp pattern_match?(pattern, _requested_path, request_path, user)
when pattern in ["/members/:id/edit", "/members/:id/show/edit"] do
match_dynamic_route?(pattern, request_path) and
members_edit_allowed?(pattern, request_path, user)
end
defp pattern_match?(pattern, _requested_path, request_path, _user)
when pattern == "/groups/:slug" do
match_dynamic_route?(pattern, request_path) and
path_param_not_reserved(pattern, request_path, "slug", @reserved_id_segments)
end
defp pattern_match?(pattern, requested_path, _request_path, _user)
when pattern == requested_path do
true
end
defp pattern_match?(pattern, _requested_path, request_path, _user) do
if String.contains?(pattern, ":") do
match_dynamic_route?(pattern, request_path)
else
false
end
end
defp path_param_not_reserved(pattern, request_path, param_name, reserved)
when is_list(reserved) do
segments = String.split(request_path, "/", trim: true)
idx = param_index(pattern, param_name)
if idx < 0 do
false
else
value = Enum.at(segments, idx)
value not in reserved
end
end
defp path_param_equals(pattern, request_path, param_name, expected_value)
when is_binary(expected_value) do
segments = String.split(request_path, "/", trim: true)
idx = param_index(pattern, param_name)
if idx < 0 do
false
else
value = Enum.at(segments, idx)
value == expected_value
end
end
defp path_param_equals(_, _, _, _), do: false
# For own_data: only allow show/edit when :id is the user's linked member. For other permission sets: allow when not reserved.
defp members_show_allowed?(pattern, request_path, user) do
if permission_set_name_from_user(user) == "own_data" do
path_param_equals(pattern, request_path, "id", user_member_id(user))
else
true
end
end
defp members_edit_allowed?(pattern, request_path, user) do
if permission_set_name_from_user(user) == "own_data" do
path_param_equals(pattern, request_path, "id", user_member_id(user))
else
path_param_not_reserved(pattern, request_path, "id", @reserved_id_segments)
end
end
defp user_member_id(user) when is_map(user) or is_struct(user) do
member_id = Map.get(user, :member_id) || Map.get(user, "member_id")
if is_nil(member_id) do
load_member_id_for_user(user)
else
to_string(member_id)
end
end
defp user_member_id(_), do: nil
defp load_member_id_for_user(user) do
id = user_id_from_user(user)
if id do
case Ash.get(Mv.Accounts.User, id, load: [:member], domain: Mv.Accounts, authorize?: false) do
{:ok, loaded} when not is_nil(loaded.member_id) -> to_string(loaded.member_id)
_ -> nil
end
else
nil
end
end
defp param_index(pattern, param_name) do
pattern
|> String.split("/", trim: true)
|> Enum.find_index(fn seg ->
String.starts_with?(seg, ":") and String.trim_leading(seg, ":") == param_name
end)
|> case do
nil -> -1
i -> i
end
end
defp match_dynamic_route?(pattern, path) do
pattern_segments = String.split(pattern, "/", trim: true)
path_segments = String.split(path, "/", trim: true)
if length(pattern_segments) == length(path_segments) do
Enum.zip(pattern_segments, path_segments)
|> Enum.all?(fn {pattern_seg, path_seg} ->
String.starts_with?(pattern_seg, ":") or pattern_seg == path_seg
end)
else
false
end
end
defp log_page_access_denied(user, page_path) do
user_id =
if user do
Map.get(user, :id) || Map.get(user, "id") || "nil"
else
"nil"
end
role_name =
if user do
get_in(user, [Access.key(:role), Access.key(:name)]) ||
get_in(user, [Access.key("role"), Access.key("name")]) || "nil"
else
"nil"
end
Logger.info("""
Page access denied:
User: #{user_id}
Role: #{role_name}
Page: #{page_path}
""")
end
end

View file

@ -14,6 +14,7 @@ defmodule MvWeb.Router do
plug :put_secure_browser_headers
plug :load_from_session
plug :set_locale
plug MvWeb.Plugs.CheckPagePermission
end
pipeline :api do
@ -48,7 +49,8 @@ defmodule MvWeb.Router do
ash_authentication_live_session :authentication_required,
on_mount: [
{MvWeb.LiveUserAuth, :live_user_required},
{MvWeb.LiveHelpers, :ensure_user_role_loaded}
{MvWeb.LiveHelpers, :ensure_user_role_loaded},
{MvWeb.LiveHelpers, :check_page_permission_on_params}
] do
live "/", MemberLive.Index, :index

View file

@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomFieldValueValidationTest do
"""
use Mv.DataCase, async: true
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
alias Mv.Membership.{CustomField, CustomFieldValue}
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -4,7 +4,6 @@ defmodule Mv.Membership.MemberCycleCalculationsTest do
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CalendarCycles

View file

@ -4,7 +4,6 @@ defmodule Mv.Membership.MemberTypeChangeIntegrationTest do
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.CalendarCycles

View file

@ -6,7 +6,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query

View file

@ -6,7 +6,6 @@ defmodule Mv.MembershipFees.MembershipFeeCycleTest do
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()

View file

@ -95,9 +95,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
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_user, %{email: new_email})
|> Ash.Changeset.for_update(:update, %{email: new_email})
|> Ash.update(actor: user)
assert updated_user.email == Ash.CiString.new(new_email)
@ -118,7 +119,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
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_user, %{email: "hacked@example.com"})
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|> Ash.update!(actor: user)
end
end
@ -163,9 +164,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
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_user, %{email: new_email})
|> Ash.Changeset.for_update(:update, %{email: new_email})
|> Ash.update(actor: user)
assert updated_user.email == Ash.CiString.new(new_email)
@ -186,7 +188,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
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_user, %{email: "hacked@example.com"})
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|> Ash.update!(actor: user)
end
end
@ -231,9 +233,10 @@ defmodule Mv.Accounts.UserPoliciesTest do
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_user, %{email: new_email})
|> Ash.Changeset.for_update(:update, %{email: new_email})
|> Ash.update(actor: user)
assert updated_user.email == Ash.CiString.new(new_email)
@ -254,7 +257,7 @@ defmodule Mv.Accounts.UserPoliciesTest do
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_user, %{email: "hacked@example.com"})
|> Ash.Changeset.for_update(:update, %{email: "hacked@example.com"})
|> Ash.update!(actor: user)
end
end

View file

@ -127,8 +127,10 @@ defmodule Mv.Authorization.PermissionSetsTest do
test "includes correct pages" do
permissions = PermissionSets.get_permissions(:own_data)
assert "/" in permissions.pages
assert "/profile" in permissions.pages
# Root "/" is not allowed for own_data (Mitglied is redirected to profile)
refute "/" in permissions.pages
# Profile is at /users/:id, not a separate /profile route
assert "/users/:id" in permissions.pages
assert "/members/:id" in permissions.pages
end
end
@ -229,7 +231,7 @@ defmodule Mv.Authorization.PermissionSetsTest do
permissions = PermissionSets.get_permissions(:read_only)
assert "/" in permissions.pages
assert "/profile" in permissions.pages
assert "/users/:id" in permissions.pages
assert "/members" in permissions.pages
assert "/members/:id" in permissions.pages
assert "/custom_field_values" in permissions.pages
@ -333,7 +335,7 @@ defmodule Mv.Authorization.PermissionSetsTest do
permissions = PermissionSets.get_permissions(:normal_user)
assert "/" in permissions.pages
assert "/profile" in permissions.pages
assert "/users/:id" in permissions.pages
assert "/members" in permissions.pages
assert "/members/new" in permissions.pages
assert "/members/:id" in permissions.pages

View file

@ -15,7 +15,6 @@ defmodule Mv.MembershipFees.CycleGeneratorEdgeCasesTest do
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query

View file

@ -7,7 +7,6 @@ defmodule Mv.MembershipFees.CycleGeneratorTest do
alias Mv.MembershipFees.CycleGenerator
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
alias Mv.Membership.Member
require Ash.Query

View file

@ -183,6 +183,39 @@ defmodule MvWeb.AuthorizationTest do
assert Authorization.can_access_page?(read_only_user, "/members/123/edit") == false
end
test "read_only can access own profile /users/:id only" do
read_only_user = %{
id: "read-only-123",
role: %{permission_set_name: "read_only"}
}
assert Authorization.can_access_page?(read_only_user, "/users/read-only-123") == true
assert Authorization.can_access_page?(read_only_user, "/users/read-only-123/edit") == true
assert Authorization.can_access_page?(read_only_user, "/users/other-id") == false
assert Authorization.can_access_page?(read_only_user, "/users/other-id/edit") == false
end
test "normal_user can access own profile /users/:id only" do
normal_user = %{
id: "normal-456",
role: %{permission_set_name: "normal_user"}
}
assert Authorization.can_access_page?(normal_user, "/users/normal-456") == true
assert Authorization.can_access_page?(normal_user, "/users/normal-456/edit") == true
assert Authorization.can_access_page?(normal_user, "/users/other-id") == false
end
test "reserved segment 'new' is not matched by :id" do
read_only_user = %{
id: "read-only-123",
role: %{permission_set_name: "read_only"}
}
assert Authorization.can_access_page?(read_only_user, "/members/new") == false
assert Authorization.can_access_page?(read_only_user, "/groups/new") == false
end
test "returns false for nil user" do
assert Authorization.can_access_page?(nil, "/members") == false
assert Authorization.can_access_page?(nil, "/admin/roles") == false

View file

@ -68,6 +68,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
end
describe "get_last_completed_cycle/2" do
test "returns nil when member is nil" do
assert MembershipFeeHelpers.get_last_completed_cycle(nil) == nil
assert MembershipFeeHelpers.get_last_completed_cycle(nil, Date.utc_today()) == nil
end
test "returns last completed cycle for member", %{actor: actor} do
# Create test data
fee_type =
@ -184,6 +189,11 @@ defmodule MvWeb.Helpers.MembershipFeeHelpersTest do
end
describe "get_current_cycle/2" do
test "returns nil when member is nil" do
assert MembershipFeeHelpers.get_current_cycle(nil) == nil
assert MembershipFeeHelpers.get_current_cycle(nil, Date.utc_today()) == nil
end
test "returns current cycle for member", %{actor: actor} do
fee_type =
Mv.MembershipFees.MembershipFeeType

View file

@ -158,15 +158,12 @@ defmodule MvWeb.GlobalSettingsLiveTest do
end
test "non-admin user does not see import section", %{conn: conn} do
# Create non-admin user (member role)
# 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)
{:ok, _view, html} = live(conn, ~p"/settings")
# Import section should not be visible
refute html =~ "Import Members" or html =~ "CSV Import" or
(html =~ "Import" and html =~ "CSV")
assert {:error, {:redirect, %{to: to}}} = live(conn, ~p"/settings")
assert to == "/users/#{member_user.id}"
end
end
@ -236,15 +233,12 @@ defmodule MvWeb.GlobalSettingsLiveTest do
end
test "non-admin cannot start import", %{conn: conn} do
# Create non-admin user
# 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)
{:ok, view, _html} = live(conn, ~p"/settings")
# Since non-admin shouldn't see the section, we check that import section is not visible
html = render(view)
refute html =~ "Import Members" or html =~ "CSV Import" or html =~ "start_import"
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

View file

@ -11,17 +11,8 @@ defmodule MvWeb.MembershipFeeTypeLive.FormTest do
require Ash.Query
setup %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
# 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(actor: system_actor)
# User must have admin role (or normal_user) to access /membership_fee_types pages
user = Mv.Fixtures.user_with_role_fixture("admin")
authenticated_conn = conn_with_password_user(conn, user)
%{conn: authenticated_conn, user: user}
end

View file

@ -29,8 +29,8 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
|> Ash.create!(actor: admin_user)
end
# Helper to create a member
# Uses admin actor from global setup to ensure authorization; falls back to system_actor when nil
# Helper to create a member. Requires actor (e.g. admin_user from setup); no fallback so
# missing-actor bugs are not masked in tests.
defp create_member(attrs, actor) do
default_attrs = %{
first_name: "Test",
@ -39,8 +39,7 @@ defmodule MvWeb.MembershipFeeTypeLive.IndexTest do
}
attrs = Map.merge(default_attrs, attrs)
effective_actor = actor || Mv.Helpers.SystemActor.get_system_actor()
{:ok, member} = Mv.Membership.create_member(attrs, actor: effective_actor)
{:ok, member} = Mv.Membership.create_member(attrs, actor: actor)
member
end

View file

@ -9,8 +9,8 @@ defmodule MvWeb.ProfileNavigationTest do
describe "profile navigation" do
test "clicking profile button redirects to current user profile", %{conn: conn} do
# Setup: Create and login a user
user = create_test_user(%{email: "test@example.com"})
# User needs a role with page permission for "/" (e.g. admin)
user = Mv.Fixtures.user_with_role_fixture("admin")
conn = conn_with_password_user(conn, user)
{:ok, view, _html} = live(conn, "/")
@ -21,9 +21,18 @@ defmodule MvWeb.ProfileNavigationTest do
assert_redirected(view, "/users/#{user.id}")
end
test "profile navigation shows correct user data", %{conn: conn} do
# Setup: Create and login a user
test "profile navigation shows correct user data", %{conn: conn, actor: actor} do
# User with password (from create_test_user) and admin role so they can access "/"
user = create_test_user(%{email: "test@example.com"})
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(actor: actor)
user = Ash.load!(user, :role, domain: Mv.Accounts, actor: actor)
conn = conn_with_password_user(conn, user)
# Navigate to profile
@ -40,8 +49,8 @@ defmodule MvWeb.ProfileNavigationTest do
describe "sidebar" do
test "renders profile button with correct attributes", %{conn: conn} do
# Setup: Create and login a user
user = create_test_user(%{email: "test@example.com"})
# User needs a role with page permission for "/"
user = Mv.Fixtures.user_with_role_fixture("admin")
conn = conn_with_password_user(conn, user)
{:ok, _view, html} = live(conn, "/")
@ -85,16 +94,27 @@ defmodule MvWeb.ProfileNavigationTest do
})
|> Ash.create!(domain: Mv.Accounts, actor: actor)
# Assign role so user can access "/" (page permission)
admin_role = Mv.Fixtures.role_fixture("admin")
{:ok, user_with_role} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update(actor: actor)
user_with_role = Ash.load!(user_with_role, :role, domain: Mv.Accounts, actor: actor)
# Login user via OIDC
conn = sign_in_user_via_oidc(conn, user)
conn = sign_in_user_via_oidc(conn, user_with_role)
# Navigate to home and click profile
{:ok, view, _html} = live(conn, "/")
view |> element("a", "Profil") |> render_click()
# Verify we're on the correct profile page with OIDC specific information
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
assert html =~ to_string(user.email)
{:ok, _profile_view, html} = live(conn, "/users/#{user_with_role.id}")
assert html =~ to_string(user_with_role.email)
# Password auth should be disabled for OIDC users
assert html =~ "Not enabled"
end
@ -103,14 +123,10 @@ defmodule MvWeb.ProfileNavigationTest do
conn: conn,
actor: actor
} do
# Create password user
password_user =
create_test_user(%{
email: "password2@example.com",
password: "test_password123"
})
# Users need a role with page permission for "/"
password_user = Mv.Fixtures.user_with_role_fixture("admin")
# Create OIDC user
# Create OIDC user and assign admin role
user_info = %{
"sub" => "oidc_789",
"preferred_username" => "oidc@example.com"
@ -129,6 +145,17 @@ defmodule MvWeb.ProfileNavigationTest do
})
|> Ash.create!(domain: Mv.Accounts, actor: actor)
admin_role = Mv.Fixtures.role_fixture("admin")
{:ok, oidc_user_with_role} =
oidc_user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|> Ash.update(actor: actor)
oidc_user_with_role =
Ash.load!(oidc_user_with_role, :role, domain: Mv.Accounts, actor: actor)
# Test with password user
conn_password = conn_with_password_user(conn, password_user)
{:ok, view_password, _html} = live(conn_password, "/")
@ -136,16 +163,17 @@ defmodule MvWeb.ProfileNavigationTest do
assert_redirected(view_password, "/users/#{password_user.id}")
# Test with OIDC user
conn_oidc = sign_in_user_via_oidc(conn, oidc_user)
conn_oidc = sign_in_user_via_oidc(conn, oidc_user_with_role)
{:ok, view_oidc, _html} = live(conn_oidc, "/")
view_oidc |> element("a", "Profil") |> render_click()
assert_redirected(view_oidc, "/users/#{oidc_user.id}")
assert_redirected(view_oidc, "/users/#{oidc_user_with_role.id}")
end
end
describe "authenticated views" do
# User must have a role with page permission to access /members, /users, etc.
setup %{conn: conn} do
user = create_test_user(%{email: "test@example.com"})
user = Mv.Fixtures.user_with_role_fixture("admin")
conn = conn_with_password_user(conn, user)
{:ok, conn: conn, user: user}
end

View file

@ -441,18 +441,11 @@ defmodule MvWeb.RoleLiveTest do
end
test "only admin can access /admin/roles", %{conn: conn, actor: actor} do
{conn, _user} = create_non_admin_user(conn, actor)
{conn, user} = create_non_admin_user(conn, actor)
# Non-admin should be redirected or see error
# Note: Authorization is checked via can_access_page? which returns false
# The page might still mount but show no content or redirect
# For now, we just verify the page doesn't work as expected for non-admin
{:ok, _view, html} = live(conn, "/admin/roles")
# Non-admin should not see "New Role" button (can? returns false)
# But the button might still be in HTML, just hidden or disabled
# We verify that the page loads but admin features are restricted
assert html =~ "Listing Roles" || html =~ "Roles"
# Non-admin (no role or non-admin role) is redirected by CheckPagePermission plug
assert {:error, {:redirect, %{to: to}}} = live(conn, "/admin/roles")
assert to == "/users/#{user.id}"
end
test "admin can access /admin/roles", %{conn: conn, actor: actor} do

View file

@ -14,8 +14,6 @@ defmodule MvWeb.UserLive.ShowTest do
require Ash.Query
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership.Member
setup do
# Create test user
user = create_test_user(%{email: "test@example.com", oidc_id: "test123"})

View file

@ -0,0 +1,934 @@
defmodule MvWeb.Plugs.CheckPagePermissionTest do
@moduledoc """
Tests for the CheckPagePermission plug.
"""
use MvWeb.ConnCase, async: true
alias MvWeb.Plugs.CheckPagePermission
alias Mv.Fixtures
defp conn_with_user(path, user) do
build_conn(:get, path)
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_private(:phoenix_router, MvWeb.Router)
|> Plug.Conn.assign(:current_user, user)
end
defp conn_without_user(path) do
build_conn(:get, path)
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_private(:phoenix_router, MvWeb.Router)
end
describe "static routes" do
test "user with permission for \"/members\" can access (conn not halted)" do
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/members", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "user without permission for \"/members\" is denied (conn halted, redirected to user profile)" do
user = Fixtures.user_with_role_fixture("own_data")
conn = conn_with_user("/members", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "flash error message present after denial" do
user = Fixtures.user_with_role_fixture("own_data")
conn = conn_with_user("/members", user) |> CheckPagePermission.call([])
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
"You don't have permission to access this page."
end
end
describe "dynamic routes" do
test "user with \"/members/:id\" permission can access \"/members/123\"" do
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/members/123", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "user with \"/members/:id/edit\" permission can access \"/members/456/edit\"" do
user = Fixtures.user_with_role_fixture("normal_user")
conn = conn_with_user("/members/456/edit", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "user with only \"/members/:id\" cannot access \"/members/123/edit\"" do
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/members/123/edit", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "own_data user with linked member can access /members/:id/edit (plug direct call)" do
member = Fixtures.member_fixture()
user = Fixtures.user_with_role_fixture("own_data")
user_with_member = Mv.Authorization.Actor.ensure_loaded(user)
# Simulate user with linked member (struct may not have member_id after session load)
user_with_member = %{user_with_member | member_id: member.id}
assert CheckPagePermission.user_can_access_page?(
user_with_member,
"/members/#{member.id}/edit"
),
"plug must allow own_data user with linked member to access member edit"
conn =
conn_with_user("/members/#{member.id}/edit", user_with_member)
|> CheckPagePermission.call([])
refute conn.halted
end
test "own_data user with linked member can access /members/:id/show/edit (plug direct call)" do
member = Fixtures.member_fixture()
user = Fixtures.user_with_role_fixture("own_data")
user_with_member = Mv.Authorization.Actor.ensure_loaded(user)
user_with_member = %{user_with_member | member_id: member.id}
assert CheckPagePermission.user_can_access_page?(
user_with_member,
"/members/#{member.id}/show/edit"
)
conn =
conn_with_user("/members/#{member.id}/show/edit", user_with_member)
|> CheckPagePermission.call([])
refute conn.halted
end
end
describe "read_only and normal_user denied on admin routes" do
test "read_only cannot access /admin/roles" do
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "normal_user cannot access /admin/roles" do
user = Fixtures.user_with_role_fixture("normal_user")
conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "read_only cannot access /members/new" do
user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_user("/members/new", user) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
end
describe "wildcard" do
test "admin with \"*\" permission can access any page" do
user = Fixtures.user_with_role_fixture("admin")
conn = conn_with_user("/admin/roles", user) |> CheckPagePermission.call([])
refute conn.halted
end
test "admin can access \"/members/999/edit\"" do
user = Fixtures.user_with_role_fixture("admin")
conn = conn_with_user("/members/999/edit", user) |> CheckPagePermission.call([])
refute conn.halted
end
end
describe "unauthenticated user" do
test "nil current_user is denied and redirected to \"/sign-in\"" do
conn = conn_without_user("/members") |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/sign-in"
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) ==
"You don't have permission to access this page."
end
end
describe "public paths" do
test "unauthenticated user can access /auth/sign-in (no redirect)" do
conn = conn_without_user("/auth/sign-in") |> CheckPagePermission.call([])
refute conn.halted
end
test "unauthenticated user can access /register" do
conn = conn_without_user("/register") |> CheckPagePermission.call([])
refute conn.halted
end
end
describe "error handling" do
test "user with no role is denied" do
user = Fixtures.user_with_role_fixture("admin")
user_without_role = %{user | role: nil}
conn = conn_with_user("/members", user_without_role) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
test "user with invalid permission_set_name is denied" do
user = Fixtures.user_with_role_fixture("admin")
bad_role = %{user.role | permission_set_name: "invalid_set"}
user_bad_role = %{user | role: bad_role}
conn = conn_with_user("/members", user_bad_role) |> CheckPagePermission.call([])
assert conn.halted
assert redirected_to(conn) == "/users/#{user.id}"
end
end
# Integration: dispatch through full router (endpoint) so pipeline and load_from_session run.
# These tests ensure a Mitglied (own_data) user is denied on every forbidden path.
describe "integration: Mitglied (own_data) denied on all forbidden paths via full router" do
@tag role: :member
test "GET /members redirects to user profile with error flash", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/members")
assert redirected_to(conn) == "/users/#{user.id}"
assert Phoenix.Flash.get(conn.assigns[:flash] || %{}, :error) =~
"don't have permission"
end
@tag role: :member
test "GET /members/new redirects to user profile", %{conn: conn, current_user: user} do
assert user.role.permission_set_name == "own_data",
"setup must provide Mitglied (own_data) user"
conn = get(conn, "/members/new")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /users redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/users")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/users/new")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/settings")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /membership_fee_settings redirects to user profile", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/membership_fee_settings")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /membership_fee_types redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/membership_fee_types")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /membership_fee_types/new redirects to user profile", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/membership_fee_types/new")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /groups redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/groups")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
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: :member
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/admin/roles")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /admin/roles/new redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/admin/roles/new")
assert redirected_to(conn) == "/users/#{user.id}"
end
end
# Dynamic routes need a valid path segment; use a real UUID from fixtures.
describe "integration: Mitglied denied on dynamic forbidden paths via full router" do
setup %{conn: conn, current_user: current_user} do
member = Mv.Fixtures.member_fixture()
role = Mv.Fixtures.role_fixture("admin")
group = Mv.Fixtures.group_fixture()
{:ok,
conn: conn,
current_user: current_user,
member_id: member.id,
role_id: role.id,
group_slug: group.slug}
end
@tag role: :member
test "GET /members/:id/edit redirects to user profile", %{
conn: conn,
member_id: id,
current_user: user
} do
conn = get(conn, "/members/#{id}/edit")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /members/:id/show/edit redirects to user profile", %{
conn: conn,
member_id: id,
current_user: user
} do
conn = get(conn, "/members/#{id}/show/edit")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /members/:id (unlinked member show) redirects to user profile", %{
conn: conn,
member_id: id,
current_user: user
} do
conn = get(conn, "/members/#{id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /users/:id redirects to user profile", %{conn: conn, current_user: user} do
other_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = get(conn, "/users/#{other_user.id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /users/:id/edit redirects to user profile", %{conn: conn, current_user: user} do
other_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = get(conn, "/users/#{other_user.id}/edit")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /users/:id/show/edit redirects to user profile", %{conn: conn, current_user: user} do
other_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = get(conn, "/users/#{other_user.id}/show/edit")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /membership_fee_types/:id/edit redirects to user profile", %{
conn: conn,
current_user: user
} do
type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Query.limit(1)
|> Ash.read!(actor: Mv.Helpers.SystemActor.get_system_actor())
|> List.first()
if type do
conn = get(conn, "/membership_fee_types/#{type.id}/edit")
assert redirected_to(conn) == "/users/#{user.id}"
end
end
@tag role: :member
test "GET /groups/:slug redirects to user profile", %{
conn: conn,
current_user: user,
group_slug: slug
} do
assert redirected_to(get(conn, "/groups/#{slug}")) == "/users/#{user.id}"
end
@tag role: :member
test "GET /admin/roles/:id redirects to user profile", %{
conn: conn,
role_id: id,
current_user: user
} do
conn = get(conn, "/admin/roles/#{id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /admin/roles/:id/edit redirects to user profile", %{
conn: conn,
role_id: id,
current_user: user
} do
conn = get(conn, "/admin/roles/#{id}/edit")
assert redirected_to(conn) == "/users/#{user.id}"
end
end
describe "integration: Mitglied (own_data) can access allowed paths via full router" do
@tag role: :member
test "GET / redirects to user profile (root not allowed for own_data)", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :member
test "GET /users/:id (own profile) returns 200", %{conn: conn, current_user: user} do
conn = get(conn, "/users/#{user.id}")
assert conn.status == 200
end
@tag role: :member
test "GET /users/:id/edit (own profile edit) returns 200", %{conn: conn, current_user: user} do
conn = get(conn, "/users/#{user.id}/edit")
assert conn.status == 200
end
@tag role: :member
test "GET /users/:id/show/edit (own profile show edit) returns 200", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/users/#{user.id}/show/edit")
assert conn.status == 200
end
# Full-router test: session may not preserve member_id; plug logic covered by unit test "own_data user with linked member can access /members/:id/edit (plug direct call)"
@tag role: :member
@tag :skip
test "GET /members/:id/edit (linked member edit) returns 200 when user has linked member", %{
conn: conn,
current_user: user
} do
member = Mv.Fixtures.member_fixture()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, user_after_update} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_set_argument(:member, %{id: member.id})
|> Ash.update(actor: system_actor)
user_with_member =
user_after_update
|> Ash.load!([:role], domain: Mv.Accounts)
|> Mv.Authorization.Actor.ensure_loaded()
|> Map.put(:member_id, member.id)
conn = conn_with_password_user(conn, user_with_member)
conn = get(conn, "/members/#{member.id}/edit")
assert conn.status == 200
end
@tag role: :member
@tag :skip
test "GET /members/:id/show/edit (linked member show edit) returns 200 when user has linked member",
%{
conn: conn,
current_user: user
} do
member = Mv.Fixtures.member_fixture()
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, user_after_update} =
user
|> Ash.Changeset.for_update(:update, %{})
|> Ash.Changeset.force_set_argument(:member, %{id: member.id})
|> Ash.update(actor: system_actor)
user_with_member =
user_after_update
|> Ash.load!([:role], domain: Mv.Accounts)
|> Mv.Authorization.Actor.ensure_loaded()
|> Map.put(:member_id, member.id)
conn = conn_with_password_user(conn, user_with_member)
conn = get(conn, "/members/#{member.id}/show/edit")
assert conn.status == 200
end
# Skipped: MemberLive.Show requires membership fee cycle data; plug allows access (page loads then LiveView may error).
@tag role: :member
@tag :skip
test "GET /members/:id for linked member returns 200", %{conn: conn, current_user: user} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
member = Mv.Fixtures.member_fixture()
user =
user
|> Ash.Changeset.for_update(:update_user, %{})
|> Ash.Changeset.force_set_argument(:member, %{id: member.id})
|> Ash.update(actor: system_actor)
|> case do
{:ok, u} -> Ash.load!(u, :role, domain: Mv.Accounts, actor: system_actor)
{:error, _} -> user
end
conn =
conn
|> MvWeb.ConnCase.conn_with_password_user(user)
|> get("/members/#{member.id}")
assert conn.status == 200
end
end
# read_only (Vorstand/Buchhaltung): allowed /, /members, /members/:id, /groups, /groups/:slug
describe "integration: read_only (Vorstand/Buchhaltung) allowed paths via full router" do
setup %{conn: conn, current_user: current_user} do
member = Mv.Fixtures.member_fixture()
group = Mv.Fixtures.group_fixture()
{:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug}
end
@tag role: :read_only
test "GET / returns 200", %{conn: conn} do
conn = get(conn, "/")
assert conn.status == 200
end
@tag role: :read_only
test "GET /members returns 200", %{conn: conn} do
conn = get(conn, "/members")
assert conn.status == 200
end
@tag role: :read_only
test "GET /members/:id returns 200", %{conn: conn, member_id: id} do
conn = get(conn, "/members/#{id}")
assert conn.status == 200
end
@tag role: :read_only
test "GET /groups returns 200", %{conn: conn} do
conn = get(conn, "/groups")
assert conn.status == 200
end
@tag role: :read_only
test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do
conn = get(conn, "/groups/#{slug}")
assert conn.status == 200
end
@tag role: :read_only
test "GET /users/:id (own profile) returns 200", %{conn: conn, current_user: user} do
conn = get(conn, "/users/#{user.id}")
assert conn.status == 200
end
@tag role: :read_only
test "GET /users/:id/edit (own profile edit) returns 200", %{conn: conn, current_user: user} do
conn = get(conn, "/users/#{user.id}/edit")
assert conn.status == 200
end
@tag role: :read_only
test "GET /users/:id/show/edit (own profile show edit) returns 200", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/users/#{user.id}/show/edit")
assert conn.status == 200
end
end
describe "integration: read_only denied paths via full router" do
setup %{conn: conn, current_user: current_user} do
member = Mv.Fixtures.member_fixture()
role = Mv.Fixtures.role_fixture("admin")
group = Mv.Fixtures.group_fixture()
type =
Mv.MembershipFees.MembershipFeeType
|> Ash.Query.limit(1)
|> Ash.read!(actor: Mv.Helpers.SystemActor.get_system_actor())
|> List.first()
{:ok,
conn: conn,
current_user: current_user,
member_id: member.id,
role_id: role.id,
group_slug: group.slug,
fee_type_id: type && type.id}
end
@tag role: :read_only
test "GET /members/new redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/members/new")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /members/:id/edit redirects to user profile", %{
conn: conn,
member_id: id,
current_user: user
} do
conn = get(conn, "/members/#{id}/edit")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /users redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/users")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/users/new")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /users/:id (other user) redirects to user profile", %{
conn: conn,
current_user: user,
role_id: _role_id
} do
other_user = Mv.Fixtures.user_with_role_fixture("admin")
conn = get(conn, "/users/#{other_user.id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/settings")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /membership_fee_settings redirects to user profile", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/membership_fee_settings")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /membership_fee_types redirects to user profile", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/membership_fee_types")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
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: :read_only
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: :read_only
test "GET /admin/roles redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/admin/roles")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :read_only
test "GET /admin/roles/:id redirects to user profile", %{
conn: conn,
role_id: id,
current_user: user
} do
conn = get(conn, "/admin/roles/#{id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
end
# normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug
describe "integration: normal_user (Kassenwart) allowed paths via full router" do
setup %{conn: conn, current_user: current_user} do
member = Mv.Fixtures.member_fixture()
group = Mv.Fixtures.group_fixture()
{:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug}
end
@tag role: :normal_user
test "GET / returns 200", %{conn: conn} do
conn = get(conn, "/")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /members returns 200", %{conn: conn} do
conn = get(conn, "/members")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /members/new returns 200", %{conn: conn} do
conn = get(conn, "/members/new")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /members/:id returns 200", %{conn: conn, member_id: id} do
conn = get(conn, "/members/#{id}")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /members/:id/edit returns 200", %{conn: conn, member_id: id} do
conn = get(conn, "/members/#{id}/edit")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /groups returns 200", %{conn: conn} do
conn = get(conn, "/groups")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do
conn = get(conn, "/groups/#{slug}")
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")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /users/:id (own profile) returns 200", %{conn: conn, current_user: user} do
conn = get(conn, "/users/#{user.id}")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /users/:id/edit (own profile edit) returns 200", %{conn: conn, current_user: user} do
conn = get(conn, "/users/#{user.id}/edit")
assert conn.status == 200
end
@tag role: :normal_user
test "GET /users/:id/show/edit (own profile show edit) returns 200", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/users/#{user.id}/show/edit")
assert conn.status == 200
end
end
describe "integration: normal_user denied paths via full router" do
setup %{conn: conn, current_user: current_user} do
other_user = Mv.Fixtures.user_with_role_fixture("admin")
role = Mv.Fixtures.role_fixture("admin")
group = Mv.Fixtures.group_fixture()
{:ok,
conn: conn,
current_user: current_user,
other_user_id: other_user.id,
role_id: role.id,
group_slug: group.slug}
end
@tag role: :normal_user
test "GET /users redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/users")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :normal_user
test "GET /users/new redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/users/new")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :normal_user
test "GET /users/:id redirects to user profile", %{
conn: conn,
current_user: user,
other_user_id: id
} do
conn = get(conn, "/users/#{id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :normal_user
test "GET /settings redirects to user profile", %{conn: conn, current_user: user} do
conn = get(conn, "/settings")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :normal_user
test "GET /membership_fee_settings redirects to user profile", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/membership_fee_settings")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :normal_user
test "GET /membership_fee_types redirects to user profile", %{
conn: conn,
current_user: user
} do
conn = get(conn, "/membership_fee_types")
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")
assert redirected_to(conn) == "/users/#{user.id}"
end
@tag role: :normal_user
test "GET /admin/roles/:id redirects to user profile", %{
conn: conn,
role_id: id,
current_user: user
} do
conn = get(conn, "/admin/roles/#{id}")
assert redirected_to(conn) == "/users/#{user.id}"
end
end
describe "integration: admin can access all protected routes via full router" do
setup %{conn: conn, current_user: current_user} do
member = Mv.Fixtures.member_fixture()
role = Mv.Fixtures.role_fixture("admin")
group = Mv.Fixtures.group_fixture()
{:ok,
conn: conn,
current_user: current_user,
member_id: member.id,
role_id: role.id,
group_slug: group.slug}
end
@tag role: :admin
test "GET / returns 200", %{conn: conn} do
conn = get(conn, "/")
assert conn.status == 200
end
@tag role: :admin
test "GET /members returns 200", %{conn: conn} do
conn = get(conn, "/members")
assert conn.status == 200
end
@tag role: :admin
test "GET /users returns 200", %{conn: conn} do
conn = get(conn, "/users")
assert conn.status == 200
end
@tag role: :admin
test "GET /settings returns 200", %{conn: conn} do
conn = get(conn, "/settings")
assert conn.status == 200
end
@tag role: :admin
test "GET /membership_fee_settings returns 200", %{conn: conn} do
conn = get(conn, "/membership_fee_settings")
assert conn.status == 200
end
@tag role: :admin
test "GET /admin/roles returns 200", %{conn: conn} do
conn = get(conn, "/admin/roles")
assert conn.status == 200
end
@tag role: :admin
test "GET /members/:id returns 200", %{conn: conn, member_id: id} do
conn = get(conn, "/members/#{id}")
assert conn.status == 200
end
@tag role: :admin
test "GET /admin/roles/:id returns 200", %{conn: conn, role_id: id} do
conn = get(conn, "/admin/roles/#{id}")
assert conn.status == 200
end
@tag role: :admin
test "GET /groups/:slug returns 200", %{conn: conn, group_slug: slug} do
conn = get(conn, "/groups/#{slug}")
assert conn.status == 200
end
end
end

View file

@ -297,7 +297,7 @@ defmodule MvWeb.UserLive.IndexTest do
test "navigation links point to correct pages", %{conn: conn} do
user = create_test_user(%{email: "navigate@example.com"})
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/users")
{:ok, _view, html} = live(conn, "/users")
# Check that user row contains link to show page
assert html =~ ~s(/users/#{user.id})

View file

@ -175,6 +175,18 @@ defmodule MvWeb.ConnCase do
authenticated_conn = conn_with_password_user(conn, member_user)
{authenticated_conn, member_user}
:read_only ->
# Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
authenticated_conn = conn_with_password_user(conn, read_only_user)
{authenticated_conn, read_only_user}
:normal_user ->
# Kassenwart: can read/update members, groups; cannot access users/settings/admin
normal_user = Mv.Fixtures.user_with_role_fixture("normal_user")
authenticated_conn = conn_with_password_user(conn, normal_user)
{authenticated_conn, normal_user}
:unauthenticated ->
# No authentication for unauthenticated tests
{conn, nil}