-
-
- <%= if length(@available_members) > 0 do %>
-
- <%= for {member, index} <- Enum.with_index(@available_members) do %>
-
-
{MvWeb.Helpers.MemberHelpers.display_name(member)}
-
{member.email}
-
- <% end %>
+ <%= if @user && @user.member && !@unlink_member do %>
+
+
+
+
+
+ {MvWeb.Helpers.MemberHelpers.display_name(@user.member)}
+
+
{@user.member.email}
- <% end %>
+
+ {gettext("Unlink Member")}
+
+
-
- <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
-
+ <% else %>
+ <%= if @unlink_member do %>
+
+
- {gettext("Note")}: {gettext(
- "A member with this email already exists. To link with a different member, please change one of the email addresses first."
+ {gettext("Unlinking scheduled")}: {gettext(
+ "Member will be unlinked when you save. Cannot select new member until saved."
)}
<% end %>
+
+
+
+
- <%= if @selected_member_id && @selected_member_name do %>
-
-
- {gettext("Selected")}: {@selected_member_name}
-
-
- {gettext("Save to confirm linking.")}
-
+ <%= if length(@available_members) > 0 do %>
+
+ <%= for {member, index} <- Enum.with_index(@available_members) do %>
+
+
+ {MvWeb.Helpers.MemberHelpers.display_name(member)}
+
+
{member.email}
+
+ <% end %>
+
+ <% end %>
- <% end %>
-
- <% end %>
-
+
+ <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
+
+
+ {gettext("Note")}: {gettext(
+ "A member with this email already exists. To link with a different member, please change one of the email addresses first."
+ )}
+
+
+ <% end %>
+
+ <%= if @selected_member_id && @selected_member_name do %>
+
+
+ {gettext("Selected")}: {@selected_member_name}
+
+
+ {gettext("Save to confirm linking.")}
+
+
+ <% end %>
+
+ <% end %>
+
+ <% end %>
<.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
diff --git a/lib/mv_web/live_helpers.ex b/lib/mv_web/live_helpers.ex
index b8f070c..ff99ad8 100644
--- a/lib/mv_web/live_helpers.ex
+++ b/lib/mv_web/live_helpers.ex
@@ -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]
diff --git a/lib/mv_web/plugs/check_page_permission.ex b/lib/mv_web/plugs/check_page_permission.ex
new file mode 100644
index 0000000..616d7fc
--- /dev/null
+++ b/lib/mv_web/plugs/check_page_permission.ex
@@ -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
diff --git a/lib/mv_web/router.ex b/lib/mv_web/router.ex
index 86e7413..2cbd6ab 100644
--- a/lib/mv_web/router.ex
+++ b/lib/mv_web/router.ex
@@ -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
diff --git a/test/membership/custom_field_value_validation_test.exs b/test/membership/custom_field_value_validation_test.exs
index 1c237be..679a0c8 100644
--- a/test/membership/custom_field_value_validation_test.exs
+++ b/test/membership/custom_field_value_validation_test.exs
@@ -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()
diff --git a/test/membership/member_cycle_calculations_test.exs b/test/membership/member_cycle_calculations_test.exs
index da08d81..98cdb7c 100644
--- a/test/membership/member_cycle_calculations_test.exs
+++ b/test/membership/member_cycle_calculations_test.exs
@@ -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
diff --git a/test/membership/member_type_change_integration_test.exs b/test/membership/member_type_change_integration_test.exs
index 69d722d..6c252d6 100644
--- a/test/membership/member_type_change_integration_test.exs
+++ b/test/membership/member_type_change_integration_test.exs
@@ -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
diff --git a/test/membership_fees/member_cycle_integration_test.exs b/test/membership_fees/member_cycle_integration_test.exs
index 761d249..76f4d08 100644
--- a/test/membership_fees/member_cycle_integration_test.exs
+++ b/test/membership_fees/member_cycle_integration_test.exs
@@ -6,7 +6,6 @@ defmodule Mv.MembershipFees.MemberCycleIntegrationTest do
alias Mv.MembershipFees.MembershipFeeCycle
alias Mv.MembershipFees.MembershipFeeType
- alias Mv.Membership.Member
require Ash.Query
diff --git a/test/membership_fees/membership_fee_cycle_test.exs b/test/membership_fees/membership_fee_cycle_test.exs
index 2fdd009..fefc838 100644
--- a/test/membership_fees/membership_fee_cycle_test.exs
+++ b/test/membership_fees/membership_fee_cycle_test.exs
@@ -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()
diff --git a/test/mv/accounts/user_policies_test.exs b/test/mv/accounts/user_policies_test.exs
index 7676403..736b336 100644
--- a/test/mv/accounts/user_policies_test.exs
+++ b/test/mv/accounts/user_policies_test.exs
@@ -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
diff --git a/test/mv/authorization/permission_sets_test.exs b/test/mv/authorization/permission_sets_test.exs
index dcd0680..404a87e 100644
--- a/test/mv/authorization/permission_sets_test.exs
+++ b/test/mv/authorization/permission_sets_test.exs
@@ -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
diff --git a/test/mv/membership_fees/cycle_generator_edge_cases_test.exs b/test/mv/membership_fees/cycle_generator_edge_cases_test.exs
index fbf1740..a9e3316 100644
--- a/test/mv/membership_fees/cycle_generator_edge_cases_test.exs
+++ b/test/mv/membership_fees/cycle_generator_edge_cases_test.exs
@@ -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
diff --git a/test/mv/membership_fees/cycle_generator_test.exs b/test/mv/membership_fees/cycle_generator_test.exs
index 9c1fd60..f193903 100644
--- a/test/mv/membership_fees/cycle_generator_test.exs
+++ b/test/mv/membership_fees/cycle_generator_test.exs
@@ -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
diff --git a/test/mv_web/authorization_test.exs b/test/mv_web/authorization_test.exs
index 17bbe4b..d07e482 100644
--- a/test/mv_web/authorization_test.exs
+++ b/test/mv_web/authorization_test.exs
@@ -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
diff --git a/test/mv_web/helpers/membership_fee_helpers_test.exs b/test/mv_web/helpers/membership_fee_helpers_test.exs
index 7f9afaf..530143f 100644
--- a/test/mv_web/helpers/membership_fee_helpers_test.exs
+++ b/test/mv_web/helpers/membership_fee_helpers_test.exs
@@ -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
diff --git a/test/mv_web/live/global_settings_live_test.exs b/test/mv_web/live/global_settings_live_test.exs
index aabec7b..f217311 100644
--- a/test/mv_web/live/global_settings_live_test.exs
+++ b/test/mv_web/live/global_settings_live_test.exs
@@ -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
diff --git a/test/mv_web/live/membership_fee_type_live/form_test.exs b/test/mv_web/live/membership_fee_type_live/form_test.exs
index 0902200..f0a21c7 100644
--- a/test/mv_web/live/membership_fee_type_live/form_test.exs
+++ b/test/mv_web/live/membership_fee_type_live/form_test.exs
@@ -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
diff --git a/test/mv_web/live/membership_fee_type_live/index_test.exs b/test/mv_web/live/membership_fee_type_live/index_test.exs
index 38c81fb..7d2d4be 100644
--- a/test/mv_web/live/membership_fee_type_live/index_test.exs
+++ b/test/mv_web/live/membership_fee_type_live/index_test.exs
@@ -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
diff --git a/test/mv_web/live/profile_navigation_test.exs b/test/mv_web/live/profile_navigation_test.exs
index b104900..b8562cd 100644
--- a/test/mv_web/live/profile_navigation_test.exs
+++ b/test/mv_web/live/profile_navigation_test.exs
@@ -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
diff --git a/test/mv_web/live/role_live_test.exs b/test/mv_web/live/role_live_test.exs
index 8cac22a..0edd2a4 100644
--- a/test/mv_web/live/role_live_test.exs
+++ b/test/mv_web/live/role_live_test.exs
@@ -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
diff --git a/test/mv_web/live/user_live/show_test.exs b/test/mv_web/live/user_live/show_test.exs
index 8f7ea93..084e346 100644
--- a/test/mv_web/live/user_live/show_test.exs
+++ b/test/mv_web/live/user_live/show_test.exs
@@ -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"})
diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs
new file mode 100644
index 0000000..4b2217c
--- /dev/null
+++ b/test/mv_web/plugs/check_page_permission_test.exs
@@ -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
diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs
index 6dbbe3d..cf1cc80 100644
--- a/test/mv_web/user_live/index_test.exs
+++ b/test/mv_web/user_live/index_test.exs
@@ -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})
diff --git a/test/support/conn_case.ex b/test/support/conn_case.ex
index 7dd118b..745be5a 100644
--- a/test/support/conn_case.ex
+++ b/test/support/conn_case.ex
@@ -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}