diff --git a/docs/onboarding-join-concept.md b/docs/onboarding-join-concept.md index 680799d..d3e8c42 100644 --- a/docs/onboarding-join-concept.md +++ b/docs/onboarding-join-concept.md @@ -1,6 +1,6 @@ # Onboarding & Join – High-Level Concept -**Status:** Draft for design decisions and implementation specs +**Status:** Draft for design decisions and implementation specs. **Prio 1 (Subtasks 1–4) implemented.** **Scope:** Prio 1 = public Join form; Step 2 = Vorstand approval. Invite-Link and OIDC JIT are out of scope and documented only as future entry paths. **Related:** Issue #308, roles-and-permissions-architecture, page-permission-route-coverage. @@ -102,10 +102,56 @@ ## 3. Step 2: Vorstand Approval - **Goal:** Board (Vorstand) can review join requests (e.g. list status "submitted") and approve or reject. +- **Route:** **`/join_requests`** for the approval UI (list and detail). See §3.1 for full specification. - **Outcome of approval (admin-configurable):** - **Default:** Approval creates **Member only**; no User is created. An admin can link a User later if needed. - **Optional (configurable):** If an option is set, approval may also create a **User** (e.g. invite-to-set-password). This is **open for later**; implementation concepts will be detailed when that option is implemented. -- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and the approval page (e.g. `/join_requests` or `/onboarding/join_requests`) is added to normal_user’s allowed pages. +- **Permissions:** Approval uses the existing permission set **normal_user** (e.g. role "Kassenwart"). JoinRequest gets read and update (or dedicated approve/reject actions) for scope :all in normal_user, and **`/join_requests`** (and **`/join_requests/:id`** for detail) are added to normal_user’s allowed pages. + +### 3.1 Step 2 – Approval (detail) + +Implementation spec for Subtask 5. + +#### Route and pages + +- **List:** **`/join_requests`** – list of join requests. Filter by status (default or primary view: status `submitted`); optional view for "all" or "approved/rejected" for audit. +- **Detail:** **`/join_requests/:id`** – single join request with all data (typed fields + `form_data`), actions Approve / Reject. + +#### Backend (JoinRequest) + +- **New actions (authenticated only):** + - **`approve`** (update): allowed only when status is `submitted`. Sets status `approved`, `approved_at`, `reviewed_by_user_id` (actor). Triggers promotion to Member (see Promotion below). + - **`reject`** (update): allowed only when status is `submitted`. Sets status `rejected`, `rejected_at`, `reviewed_by_user_id`. No reason field in MVP. +- **Policies:** `approve` and `reject` permitted via **HasPermission** for permission set **normal_user** (read/update or explicit approve/reject on JoinRequest, scope :all). Not allowed for `actor: nil`. +- **Domain:** Expose `list_join_requests/1` (e.g. filter by status, with actor), `approve_join_request/2` (id, actor), `reject_join_request/2` (id, actor). Read action for JoinRequest for normal_user scope :all so list/detail can load data. + +#### Promotion: JoinRequest → Member + +- **When:** On successful `approve` only (status was `submitted`). +- **Mapping:** + - JoinRequest typed fields → Member: **email**, **first_name**, **last_name** copied to Member attributes. + - **form_data** (jsonb): keys that match `Mv.Constants.member_fields()` (atom names or string keys) → corresponding Member attributes. Keys that are custom field IDs (UUID format) → create **CustomFieldValue** records linked to the new Member. + - **Defaults:** e.g. `join_date` = today if not in form_data; `membership_fee_type_id` = default from settings (or first available) if not provided. Handle required Member validations (e.g. email already present from JoinRequest). +- **Implementation:** Prefer a single Ash change (e.g. `JoinRequest.Changes.PromoteToMember`) or a domain function that builds member attributes + custom_field_values from the approved JoinRequest and calls Member `create_member` (actor: reviewer or system actor as per CODE_GUIDELINES; document choice). No User created in MVP. +- **Idempotency:** If approve is called again by mistake (e.g. race), either reject transition when status is already `approved` or ensure Member creation is idempotent (e.g. do not create duplicate Member for same JoinRequest). + +#### Permission sets and routing + +- **PermissionSets (normal_user):** Add JoinRequest **read** :all and **update** :all (or **approve** / **reject** if using dedicated actions). Add pages **`/join_requests`** and **`/join_requests/:id`** to the normal_user pages list. +- **Router:** Register live routes for list and detail; add entries to **page-permission-route-coverage.md** and extend plug tests so normal_user is allowed, read_only/own_data denied. + +#### UI/UX (approval) + +- **List:** Table or card list with columns e.g. submitted_at, first_name, last_name, email, status. Primary filter or default filter: status = `submitted`. Buttons or links to open detail. Follow existing list patterns (e.g. Members/Groups): header, back link, CoreComponents table. +- **Detail:** Show all request data (typed + form_data rendered by field). Buttons: **Approve** (primary), **Reject** (secondary). Reject in MVP: no reason field; just set status to rejected and audit fields. +- **Accessibility and i18n:** Same standards as rest of app (labels, Gettext, keyboard, ARIA where needed). + +#### Tests + +- JoinRequest: policy tests – approve/reject allowed for normal_user (and admin), forbidden for nil/own_data/read_only. +- Domain: approve creates one Member with correct mapped data; reject only updates status and audit fields; approve when already approved is no-op or error. +- Page permission: normal_user can GET `/join_requests` and `/join_requests/:id`; read_only/own_data cannot. +- Optional: LiveView smoke test – list loads, approve/reject from detail updates state. --- @@ -153,6 +199,7 @@ - **Rate limiting:** Honeypot + rate limiting from the start (e.g. Hammer.Plug). - **Settings:** Own section "Onboarding / Join" in global settings; `join_form_enabled` plus field selection; display as list/badges; detailed UX in a **separate subtask**. - **Approval permission:** normal_user; JoinRequest read/update and approval page added to normal_user; no new permission set. +- **Approval route:** **`/join_requests`** (list) and **`/join_requests/:id`** (detail). - **Resend confirmation:** If not in Prio 1, create a separate ticket immediately. **Open for later:** @@ -180,26 +227,26 @@ ### Prio 1 – Public Join (4 subtasks) -#### 1. JoinRequest resource and public policies +#### 1. JoinRequest resource and public policies **(done)** - **Scope:** Ash resource `JoinRequest` per §2.3.2: status (`pending_confirmation`, `submitted`, `approved`, `rejected`), email (required), first_name, last_name (optional), form_data (jsonb), schema_version; confirmation_token_hash, confirmation_token_expires_at; submitted_at, approved_at, rejected_at, reviewed_by_user_id, source. Migration; unique_index on confirmation_token_hash (or equivalent for idempotency). - **Policies:** Public actions **submit** (create) and **confirm** (update) allowed with `actor: nil`; no system-actor fallback, no undocumented `authorize?: false`. - **Boundary:** No UI, no emails – only resource, persistence, and actions callable with nil actor. - **Done:** Resource and migration in place; tests for create/update with `actor: nil` and for idempotent confirm (same token twice → no second update). -#### 2. Submit and confirm flow +#### 2. Submit and confirm flow **(done)** - **Scope:** Form submit → **create** JoinRequest (status `pending_confirmation`, token hash + expiry, form data) → send confirmation email (reuse AshAuthentication sender pattern). Route **`/confirm_join/:token`** → verify token (hash and lookup) → **update** JoinRequest to status `submitted`, set submitted_at, invalidate token (idempotent if already submitted). Optional: Oban (or similar) job to **hard-delete** JoinRequests in `pending_confirmation` with confirmation_token_expires_at older than 24h. - **Boundary:** No join-form UI, no admin settings – only backend create/update and email/route. - **Done:** Submit creates one JoinRequest; confirm updates it to submitted; double-click idempotent; expired token shows clear message; cleanup job implemented and documented. Tests for these cases. -#### 3. Admin: Join form settings +#### 3. Admin: Join form settings **(done)** - **Scope:** Section "Onboarding / Join" in global settings (§2.6): `join_form_enabled`, selection of join-form fields (from member_fields + custom fields), "required" per field. Persist (e.g. Setting or existing config). UI e.g. badges with remove + dropdown/modal to add (details in sub-subtask if needed). - **Boundary:** No public form – only save/load of config and **server-side allowlist** for use in subtask 4. - **Done:** Settings save/load; allowlist available in backend for join form; tests. -#### 4. Public join page and anti-abuse +#### 4. Public join page and anti-abuse **(done)** - **Scope:** Route **`/join`** (public). **Add `/join` to the page-permission plug’s public path list** so unauthenticated access is allowed. LiveView (or controller + form). Form fields from allowlist (subtask 3); copy per §2.5. **Honeypot** and **rate limiting** (e.g. Hammer.Plug) on join/submit. After submit: show "We have saved your details … click the link …". Expired-link page: clear message + "submit form again". Public-path tests updated to include `/join`. - **Boundary:** No approval UI, no User/Member creation – only public page, form, anti-abuse, and wiring to submit/confirm flow (subtask 2). @@ -215,7 +262,8 @@ #### 5. Approval UI (Vorstand) -- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add page to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). +- **Route:** **`/join_requests`** (list), **`/join_requests/:id`** (detail). Full specification: §3.1. +- **Scope:** List JoinRequests (status "submitted"), approve/reject actions; on approve create Member (no User in MVP). Permission: normal_user; add JoinRequest read/update (or approve/reject) and pages `/join_requests`, `/join_requests/:id` to PermissionSets. Populate audit fields (approved_at, rejected_at, reviewed_by_user_id). Promotion: JoinRequest → Member per §3.1 (mapping, defaults, idempotency). - **Boundary:** Separate ticket; builds on JoinRequest and existing Member creation. --- @@ -223,7 +271,7 @@ ## 9. References - `docs/roles-and-permissions-architecture.md` – Permission sets, roles, page permissions. -- `docs/page-permission-route-coverage.md` – Public paths, plug behaviour, tests. +- `docs/page-permission-route-coverage.md` – Public paths, plug behaviour, tests; add `/join_requests` and `/join_requests/:id` for Step 2 (normal_user). - `lib/mv_web/plugs/check_page_permission.ex` – Public path list; **add `/join`** in `public_path?/1`. - `lib/mv/accounts/user/senders/send_new_user_confirmation_email.ex` – Existing confirmation-email pattern (token, link, Mailer). - Hammer / Hammer.Plug (e.g. hexdocs.pm/hammer) – Rate limiting for Phoenix/Plug. diff --git a/docs/page-permission-route-coverage.md b/docs/page-permission-route-coverage.md index 38625e6..6571a39 100644 --- a/docs/page-permission-route-coverage.md +++ b/docs/page-permission-route-coverage.md @@ -31,8 +31,10 @@ This document lists all protected routes, which permission set may access them, | `/admin/roles/new` | ✗ | ✗ | ✗ | ✓ | | `/admin/roles/:id` | ✗ | ✗ | ✗ | ✓ | | `/admin/roles/:id/edit` | ✗ | ✗ | ✗ | ✓ | +| `/join_requests` (Step 2) | ✗ | ✗ | ✓ | ✓ | +| `/join_requests/:id` (Step 2) | ✗ | ✗ | ✓ | ✓ | -**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. +**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. Step 2 (Approval UI) adds `/join_requests` and `/join_requests/:id` for normal_user and admin; routes and permission set entries are not yet implemented; tests exist in `check_page_permission_test.exs` (describe "join_requests routes" and integration blocks). ## Public Paths (no permission check) @@ -55,6 +57,7 @@ The join confirmation route `GET /confirm_join/:token` is public (matched by `/c - Unauthenticated: nil user denied, redirect `/sign-in`. - Public: unauthenticated allowed `/auth/sign-in`, `/register`. - Error: no role, invalid permission_set_name → denied. +- **Join requests (Step 2):** normal_user and admin allowed `/join_requests`, `/join_requests/:id`; read_only and own_data denied. Tests fail (red) until routes and permission set are added. ### Integration tests (full router, Mitglied = own_data) diff --git a/test/membership/join_request_approval_domain_test.exs b/test/membership/join_request_approval_domain_test.exs new file mode 100644 index 0000000..9578fea --- /dev/null +++ b/test/membership/join_request_approval_domain_test.exs @@ -0,0 +1,139 @@ +defmodule Mv.Membership.JoinRequestApprovalDomainTest do + @moduledoc """ + Domain tests for JoinRequest approval: approve/reject and promotion to Member (Step 2). + + Asserts that approve creates one Member with mapped data, reject does not create Member, + status rules, and idempotency. No User creation in MVP. + """ + use Mv.DataCase, async: true + + import Ash.Expr + require Ash.Query + + alias Mv.Fixtures + alias Mv.Helpers.SystemActor + alias Mv.Membership + alias Mv.Membership.Member + + defp member_count do + actor = SystemActor.get_system_actor() + {:ok, members} = Membership.list_members(actor: actor) + length(members) + end + + describe "approve_join_request/2 – promotion to Member" do + test "approve creates exactly one member with email, first_name, last_name from JoinRequest" do + request = + Fixtures.submitted_join_request_fixture(%{ + first_name: "Approved", + last_name: "User" + }) + + count_before = member_count() + user = Fixtures.user_with_role_fixture("normal_user") + + assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user) + assert approved.status == :approved + + assert member_count() == count_before + 1 + + request_email = request.email + [member] = + Member + |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) + |> Ash.read!(actor: SystemActor.get_system_actor(), domain: Membership) + + assert member.email == request.email + assert member.first_name == request.first_name + assert member.last_name == request.last_name + end + + test "approve does not create a User (MVP)" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("normal_user") + + assert {:ok, _} = Membership.approve_join_request(request.id, actor: user) + + # No User should exist with this email from the approval flow + request_email = request.email + users_with_email = + Mv.Accounts.User + |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) + |> Ash.read!(authorize?: false) + + assert users_with_email == [] + end + end + + describe "reject_join_request/2" do + test "reject does not create a member" do + request = Fixtures.submitted_join_request_fixture() + count_before = member_count() + user = Fixtures.user_with_role_fixture("normal_user") + + assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user) + assert rejected.status == :rejected + assert rejected.rejected_at != nil + + assert member_count() == count_before + end + end + + describe "approve_join_request/2 – status and idempotency" do + test "approve when status is already approved is idempotent or returns error" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("normal_user") + + assert {:ok, _} = Membership.approve_join_request(request.id, actor: user) + count_after_first = member_count() + + # Second approve: either {:ok, request} with no duplicate member, or {:error, _} + result = Membership.approve_join_request(request.id, actor: user) + + if match?({:ok, _}, result) do + assert member_count() == count_after_first + else + assert {:error, _} = result + end + end + + test "approve when status is pending_confirmation returns error" do + token = "pending-token-#{System.unique_integer([:positive])}" + attrs = %{ + email: "pending#{System.unique_integer([:positive])}@example.com", + confirmation_token: token + } + {:ok, request} = Membership.submit_join_request(attrs, actor: nil) + assert request.status == :pending_confirmation + + user = Fixtures.user_with_role_fixture("normal_user") + assert {:error, _} = Membership.approve_join_request(request.id, actor: user) + end + + test "approve when status is rejected returns error" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("normal_user") + assert {:ok, _} = Membership.reject_join_request(request.id, actor: user) + + assert {:error, _} = Membership.approve_join_request(request.id, actor: user) + end + end + + describe "approve_join_request/2 – defaults" do + test "created member has join_date and membership_fee_type when not in form_data" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("normal_user") + + assert {:ok, _} = Membership.approve_join_request(request.id, actor: user) + + request_email = request.email + [member] = + Member + |> Ash.Query.filter(expr(^ref(:email) == ^request_email)) + |> Ash.read!(actor: SystemActor.get_system_actor(), domain: Membership) + + assert member.join_date != nil + assert member.membership_fee_type_id != nil + end + end +end diff --git a/test/membership/join_request_approval_policy_test.exs b/test/membership/join_request_approval_policy_test.exs new file mode 100644 index 0000000..e658435 --- /dev/null +++ b/test/membership/join_request_approval_policy_test.exs @@ -0,0 +1,115 @@ +defmodule Mv.Membership.JoinRequestApprovalPolicyTest do + @moduledoc """ + Policy tests for JoinRequest approval UI (Step 2). + + Asserts that approve/reject and list are allowed for normal_user and admin, + and forbidden for read_only, own_data, and actor: nil. + No UI; domain and resource policies only. + """ + use Mv.DataCase, async: true + + alias Mv.Fixtures + alias Mv.Membership + + describe "list_join_requests/1" do + test "normal_user can list join requests" do + user = Fixtures.user_with_role_fixture("normal_user") + assert {:ok, _list} = Membership.list_join_requests(actor: user) + end + + test "admin can list join requests" do + user = Fixtures.user_with_role_fixture("admin") + assert {:ok, _list} = Membership.list_join_requests(actor: user) + end + + test "read_only cannot list join requests" do + user = Fixtures.user_with_role_fixture("read_only") + assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: user) + end + + test "own_data cannot list join requests" do + user = Fixtures.user_with_role_fixture("own_data") + assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: user) + end + + test "actor nil cannot list join requests" do + assert {:error, %Ash.Error.Forbidden{}} = Membership.list_join_requests(actor: nil) + end + end + + describe "approve_join_request/2" do + setup do + request = Fixtures.submitted_join_request_fixture() + %{request: request} + end + + test "normal_user can approve a submitted join request", %{request: request} do + user = Fixtures.user_with_role_fixture("normal_user") + assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user) + assert approved.status == :approved + assert approved.approved_at != nil + assert approved.reviewed_by_user_id == user.id + end + + test "admin can approve a submitted join request", %{request: request} do + user = Fixtures.user_with_role_fixture("admin") + assert {:ok, approved} = Membership.approve_join_request(request.id, actor: user) + assert approved.status == :approved + end + + test "read_only cannot approve", %{request: request} do + user = Fixtures.user_with_role_fixture("read_only") + assert {:error, %Ash.Error.Forbidden{}} = + Membership.approve_join_request(request.id, actor: user) + end + + test "own_data cannot approve", %{request: request} do + user = Fixtures.user_with_role_fixture("own_data") + assert {:error, %Ash.Error.Forbidden{}} = + Membership.approve_join_request(request.id, actor: user) + end + + test "actor nil cannot approve", %{request: request} do + assert {:error, %Ash.Error.Forbidden{}} = + Membership.approve_join_request(request.id, actor: nil) + end + end + + describe "reject_join_request/2" do + setup do + request = Fixtures.submitted_join_request_fixture() + %{request: request} + end + + test "normal_user can reject a submitted join request", %{request: request} do + user = Fixtures.user_with_role_fixture("normal_user") + assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user) + assert rejected.status == :rejected + assert rejected.rejected_at != nil + assert rejected.reviewed_by_user_id == user.id + end + + test "admin can reject a submitted join request", %{request: request} do + user = Fixtures.user_with_role_fixture("admin") + assert {:ok, rejected} = Membership.reject_join_request(request.id, actor: user) + assert rejected.status == :rejected + end + + test "read_only cannot reject", %{request: request} do + user = Fixtures.user_with_role_fixture("read_only") + assert {:error, %Ash.Error.Forbidden{}} = + Membership.reject_join_request(request.id, actor: user) + end + + test "own_data cannot reject", %{request: request} do + user = Fixtures.user_with_role_fixture("own_data") + assert {:error, %Ash.Error.Forbidden{}} = + Membership.reject_join_request(request.id, actor: user) + end + + test "actor nil cannot reject", %{request: request} do + assert {:error, %Ash.Error.Forbidden{}} = + Membership.reject_join_request(request.id, actor: nil) + end + end +end diff --git a/test/mv_web/plugs/check_page_permission_test.exs b/test/mv_web/plugs/check_page_permission_test.exs index 80aa95e..31922b0 100644 --- a/test/mv_web/plugs/check_page_permission_test.exs +++ b/test/mv_web/plugs/check_page_permission_test.exs @@ -212,6 +212,72 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do end end + describe "join_requests routes (approval UI, Step 2)" do + test "normal_user can access /join_requests" do + user = Fixtures.user_with_role_fixture("normal_user") + conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "normal_user can access /join_requests/:id" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("normal_user") + conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "read_only cannot access /join_requests" do + user = Fixtures.user_with_role_fixture("read_only") + conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "read_only cannot access /join_requests/:id" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("read_only") + conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "own_data cannot access /join_requests" do + user = Fixtures.user_with_role_fixture("own_data") + conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "own_data cannot access /join_requests/:id" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("own_data") + conn = conn_with_user("/join_requests/#{request.id}", user) |> CheckPagePermission.call([]) + + assert conn.halted + assert redirected_to(conn) == "/users/#{user.id}" + end + + test "admin can access /join_requests" do + user = Fixtures.user_with_role_fixture("admin") + conn = conn_with_user("/join_requests", user) |> CheckPagePermission.call([]) + + refute conn.halted + end + + test "admin can access /join_requests/:id" do + request = Fixtures.submitted_join_request_fixture() + user = Fixtures.user_with_role_fixture("admin") + conn = conn_with_user("/join_requests/#{request.id}", user) |> 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") @@ -429,6 +495,22 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do conn = get(conn, "/admin/roles/#{id}/edit") assert redirected_to(conn) == "/users/#{user.id}" end + + @tag role: :member + test "GET /join_requests redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/join_requests") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :member + test "GET /join_requests/:id redirects to user profile", %{ + conn: conn, + current_user: user + } do + request = Fixtures.submitted_join_request_fixture() + conn = get(conn, "/join_requests/#{request.id}") + assert redirected_to(conn) == "/users/#{user.id}" + end end describe "integration: Mitglied (own_data) can access allowed paths via full router" do @@ -713,15 +795,37 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do conn = get(conn, "/admin/roles/#{id}") assert redirected_to(conn) == "/users/#{user.id}" end + + @tag role: :read_only + test "GET /join_requests redirects to user profile", %{conn: conn, current_user: user} do + conn = get(conn, "/join_requests") + assert redirected_to(conn) == "/users/#{user.id}" + end + + @tag role: :read_only + test "GET /join_requests/:id redirects to user profile", %{ + conn: conn, + current_user: user + } do + request = Fixtures.submitted_join_request_fixture() + conn = get(conn, "/join_requests/#{request.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 + # normal_user (Kassenwart): allowed /, /members, /members/new, /members/:id, /members/:id/edit, /groups, /groups/:slug, /join_requests 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() + join_request = Fixtures.submitted_join_request_fixture() - {:ok, conn: conn, current_user: current_user, member_id: member.id, group_slug: group.slug} + {:ok, + conn: conn, + current_user: current_user, + member_id: member.id, + group_slug: group.slug, + join_request_id: join_request.id} end @tag role: :normal_user @@ -804,6 +908,18 @@ defmodule MvWeb.Plugs.CheckPagePermissionTest do conn = get(conn, "/users/#{user.id}/show/edit") assert conn.status == 200 end + + @tag role: :normal_user + test "GET /join_requests returns 200", %{conn: conn} do + conn = get(conn, "/join_requests") + assert conn.status == 200 + end + + @tag role: :normal_user + test "GET /join_requests/:id returns 200", %{conn: conn, join_request_id: id} do + conn = get(conn, "/join_requests/#{id}") + assert conn.status == 200 + end end describe "integration: normal_user denied paths via full router" do diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index d7cddda..56347c9 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -299,4 +299,38 @@ defmodule Mv.Fixtures do {:error, error} -> raise "Failed to create group: #{inspect(error)}" end end + + @doc """ + Creates a join request in status :submitted (for approval UI tests). + + Uses the public flow: submit_join_request then confirm_join_request with a known token. + Returns the JoinRequest struct so tests can use its id for approve/reject. + + ## Parameters + - `attrs` - Optional map: :email, :first_name, :last_name, :form_data, :schema_version. + Defaults: unique email; confirmation_token is generated and used internally. + + ## Returns + - JoinRequest struct with status :submitted + + ## Examples + + iex> request = submitted_join_request_fixture() + iex> request.status + :submitted + + iex> request = submitted_join_request_fixture(%{first_name: "Jane", last_name: "Doe"}) + """ + def submitted_join_request_fixture(attrs \\ %{}) do + token = "fixture-token-#{System.unique_integer([:positive])}" + base = %{ + email: "join#{System.unique_integer([:positive])}@example.com", + confirmation_token: token + } + attrs = base |> Map.merge(attrs) |> Map.put(:confirmation_token, token) + + {:ok, _} = Membership.submit_join_request(attrs, actor: nil) + {:ok, request} = Membership.confirm_join_request(token, actor: nil) + request + end end