add approval ui for join requests #468
6 changed files with 466 additions and 11 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
139
test/membership/join_request_approval_domain_test.exs
Normal file
139
test/membership/join_request_approval_domain_test.exs
Normal file
|
|
@ -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
|
||||
115
test/membership/join_request_approval_policy_test.exs
Normal file
115
test/membership/join_request_approval_policy_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue