Skip to main content

rustio_admin/admin/
routes.rs

1//! Admin route registration with permission checks.
2//!
3//! Every admin URL is gated by a specific permission:
4//!   GET  /admin/:model            → posts.view_post
5//!   GET  /admin/:model/new        → posts.add_post
6//!   POST /admin/:model/new        → posts.add_post
7//!   GET  /admin/:model/:id/edit   → posts.change_post
8//!   POST /admin/:model/:id/edit   → posts.change_post
9//!   GET  /admin/:model/:id/delete → posts.delete_post
10//!   POST /admin/:model/:id/delete → posts.delete_post
11//!
12//! Administrator + Developer bypass every check (see
13//! `Role::bypasses_group_checks`). Staff and Supervisor need the
14//! specific permission granted either directly or via a group.
15//!
16//! Slimmed for Tier 1: the legacy file's developer stub routes
17//! (`__schema__`, `__logs__`, `__sql_console__`) and the FK remote-
18//! search endpoint have been dropped. Everything else — `/static/admin.css`
19//! and `/static/admin.js` (P8), login/logout, dashboard,
20//! /admin/users/*, /admin/groups/*, /admin/history,
21//! /admin/password_change, /admin/:model/* CRUD,
22//! /admin/:model/:id/history — is wired below.
23
24use std::sync::Arc;
25
26use crate::auth::{self, Identity, Role};
27use crate::error::{Error, Result};
28use crate::http::{Request, Response};
29use crate::orm::Db;
30use crate::router::Router;
31use crate::templates::Templates;
32
33/// Embedded stylesheet baked into the binary. P8 ships a single
34/// hand-written CSS file; project overrides happen via
35/// `Admin::theme(...)` (CSS custom properties) rather than an asset
36/// override, so we don't expose a disk path here.
37const ADMIN_CSS: &str = include_str!("../../assets/static/admin.css");
38
39/// Embedded admin JS (theme toggle + sidebar drawer). ≤200 LOC, no
40/// build step.
41const ADMIN_JS: &str = include_str!("../../assets/static/admin.js");
42
43/// Self-hosted fonts (SIL OFL-1.1, see assets/static/fonts/LICENSE.txt).
44/// Bundling them as bytes keeps the single-binary deploy story intact
45/// and avoids the FOUT/CDN round-trip every consuming app would
46/// otherwise inherit from a Google Fonts <link>.
47///
48/// Latin: Geist (variable wght 100..900) + Geist Mono (variable wght
49/// 100..900). Arabic: Tajawal (UI surfaces — buttons, sidebar, tables)
50/// in 400/500/700, plus Noto Naskh Arabic (paragraph body, variable
51/// wght 400..700).
52const FONT_GEIST: &[u8] = include_bytes!("../../assets/static/fonts/Geist-Variable.woff2");
53const FONT_GEIST_MONO: &[u8] = include_bytes!("../../assets/static/fonts/GeistMono-Variable.woff2");
54const FONT_TAJAWAL_REG: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Regular.woff2");
55const FONT_TAJAWAL_MED: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Medium.woff2");
56const FONT_TAJAWAL_BOLD: &[u8] = include_bytes!("../../assets/static/fonts/Tajawal-Bold.woff2");
57const FONT_NOTO_NASKH_AR: &[u8] =
58    include_bytes!("../../assets/static/fonts/NotoNaskhArabic-Variable.woff2");
59
60use super::handlers::{self, AdminCtx};
61use super::render;
62use super::types::Admin;
63
64/// Either an identity + a permission check passed, or any non-Allow
65/// response the route closure should return as-is (a 303 redirect to
66/// /admin/login, a 403 forbidden body, etc.).
67enum Guard {
68    Allow(Identity),
69    Redirect(Response),
70}
71
72/// Paths a user with `must_change_password = TRUE` is allowed to
73/// reach without first completing the forced rotation.
74/// Locked-decision per `DESIGN_R2_ORGANISATIONAL.md` §12.
75///
76/// Exact-path match (no prefix matching). Sub-paths of
77/// `/admin/account/sessions` (e.g. `/admin/account/sessions/revoke`)
78/// are intentionally NOT whitelisted — a user being forced to
79/// rotate may view their active sessions but must finish the
80/// rotation before revoking siblings.
81const MUST_CHANGE_WHITELIST: &[&str] = &[
82    "/admin/must-change-password",
83    "/admin/logout",
84    "/admin/account/sessions",
85];
86
87/// Whether `path` is on the must-change-password whitelist.
88/// Pulled out as a free fn so the rule is unit-testable without a
89/// `Request`. See [`MUST_CHANGE_WHITELIST`] for the contract.
90fn is_must_change_whitelisted_path(path: &str) -> bool {
91    MUST_CHANGE_WHITELIST.contains(&path)
92}
93
94async fn login_guard(ctx: &AdminCtx, req: &Request) -> Result<Guard> {
95    let cookie = match req.header("cookie") {
96        Some(c) => c,
97        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
98    };
99    let token = match auth::session_token_from_cookie(cookie) {
100        Some(t) => t,
101        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
102    };
103    let ident = match auth::identity_from_session(&ctx.db, &token).await? {
104        Some(i) => i,
105        None => return Ok(Guard::Redirect(Response::redirect("/admin/login"))),
106    };
107    if !ident.is_active {
108        return Ok(Guard::Redirect(Response::redirect("/admin/login")));
109    }
110
111    // R2 forced-rotation gate (`DESIGN_R2_ORGANISATIONAL.md` §3.4 +
112    // §9.2). When the flag is set, every authenticated request EXCEPT
113    // the whitelist redirects to `/admin/must-change-password`. The
114    // check sits BEFORE any role gate so even Administrators /
115    // Developers with the flag set are funnelled through.
116    if ident.must_change_password && !is_must_change_whitelisted_path(req.path()) {
117        return Ok(Guard::Redirect(Response::redirect(
118            "/admin/must-change-password",
119        )));
120    }
121
122    Ok(Guard::Allow(ident))
123}
124
125async fn role_guard(ctx: &AdminCtx, req: &Request, min: Role) -> Result<Guard> {
126    match login_guard(ctx, req).await? {
127        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
128        Guard::Allow(ident) => {
129            if ident.role.includes(min) {
130                Ok(Guard::Allow(ident))
131            } else {
132                let body = render::render_forbidden_body(
133                    &ctx.admin,
134                    &ctx.templates,
135                    &ident,
136                    handlers::csrf_token(req),
137                    None,
138                    Some(min.label()),
139                )?;
140                Ok(Guard::Redirect(
141                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
142                ))
143            }
144        }
145    }
146}
147
148async fn perm_guard(ctx: &AdminCtx, req: &Request, perm: &str) -> Result<Guard> {
149    match role_guard(ctx, req, Role::Staff).await? {
150        Guard::Redirect(r) => Ok(Guard::Redirect(r)),
151        Guard::Allow(ident) => {
152            if ident.role.bypasses_group_checks() {
153                return Ok(Guard::Allow(ident));
154            }
155            if auth::check_permission(&ctx.db, &ident, perm).await? {
156                Ok(Guard::Allow(ident))
157            } else {
158                let body = render::render_forbidden_body(
159                    &ctx.admin,
160                    &ctx.templates,
161                    &ident,
162                    handlers::csrf_token(req),
163                    Some(perm.to_string()),
164                    None,
165                )?;
166                Ok(Guard::Redirect(
167                    Response::html(body).with_status(hyper::StatusCode::FORBIDDEN),
168                ))
169            }
170        }
171    }
172}
173
174/// Pure decision logic for `perm_guard`, factored out so it can be
175/// unit-tested without a `Db`.
176#[cfg(test)]
177fn perm_guard_verdict(ident: &Identity, perm_held: bool) -> bool {
178    if !ident.is_active {
179        return false;
180    }
181    if ident.role.bypasses_group_checks() {
182        return true;
183    }
184    perm_held
185}
186
187fn parse_id(raw: Option<&str>) -> Result<i64> {
188    raw.and_then(|s| s.parse().ok())
189        .ok_or_else(|| Error::BadRequest("invalid id".into()))
190}
191
192fn model_name_from_req(req: &Request) -> Result<String> {
193    req.param("admin_name")
194        .map(|s| s.to_string())
195        .ok_or_else(|| Error::BadRequest("missing model".into()))
196}
197
198fn perm_for(ctx: &AdminCtx, admin_name: &str, action: &str) -> Result<String> {
199    let entry = ctx
200        .admin
201        .find(admin_name)
202        .ok_or_else(|| Error::NotFound(format!("no admin model: {admin_name}")))?;
203    let singular = entry.singular_name.to_ascii_lowercase();
204    Ok(format!("{admin_name}.{action}_{singular}"))
205}
206
207/// Pure verdict for the R1 strict-mailer boot guard
208/// (`DESIGN_RECOVERY.md` §12.1). Returns an operator-facing error
209/// string when the policy demands a real mailer but `Admin::new()`'s
210/// default `LogMailer` is still in place; returns `Ok(())`
211/// otherwise.
212///
213/// Detection is deterministic and structural: it reads
214/// [`Admin::has_custom_mailer`] (set whenever
215/// [`Admin::mailer`] has been called). No `Arc::ptr_eq` against a
216/// freshly-constructed `LogMailer`; no environment heuristics; no
217/// hostname checks; no "production mode" guessing — the operator
218/// declares intent by calling `Admin::mailer(...)` (and opts the
219/// policy in via `RecoveryPolicy::strict_mailer_required(true)`).
220///
221/// The framework treats an explicit `Admin::mailer(...)` call as
222/// satisfying the guard even when the supplied mailer is itself a
223/// `LogMailer` — this is the documented escape hatch for projects
224/// that want to silence the guard during a migration window
225/// without yet wiring a real transport.
226fn strict_mailer_guard_check(admin: &Admin) -> std::result::Result<(), String> {
227    if admin.active_recovery_policy().strict_mailer_required() && !admin.has_custom_mailer() {
228        Err(
229            "rustio-admin: RecoveryPolicy::strict_mailer_required() = true but no mailer \
230             was registered via Admin::mailer(...).\n\n\
231             The framework's default LogMailer writes recovery emails to log::info! instead \
232             of sending them, which is unsuitable for production. Recovery routes are NOT \
233             registered with this configuration.\n\n\
234             To resolve, choose one:\n\
235              (a) register a real mailer before calling register_admin_routes:\n\
236                  Admin::mailer(Arc::new(MyProjectMailer::new(...)))\n\
237              (b) opt the policy out of strict mode (the framework default — dev / CI / \
238                  testing baseline):\n\
239                  RecoveryPolicy::strict_mailer_required(false)\n\n\
240             See DESIGN_RECOVERY.md §12.1 for the contract."
241                .to_string(),
242        )
243    } else {
244        Ok(())
245    }
246}
247
248pub fn register_admin_routes(
249    router: Router,
250    admin: Admin,
251    db: Db,
252    templates: Arc<Templates>,
253) -> Router {
254    // R1 commit #9 — strict-mailer boot guard. Runs BEFORE any
255    // route registration so a misconfigured deployment fails
256    // loudly at startup rather than registering recovery routes
257    // against a production-unsafe default mailer
258    // (`DESIGN_RECOVERY.md` §12.1). The check is structural: see
259    // [`strict_mailer_guard_check`] for why we don't do
260    // pointer-equality tricks against the default LogMailer.
261    if let Err(msg) = strict_mailer_guard_check(&admin) {
262        panic!("{msg}");
263    }
264
265    let ctx = Arc::new(AdminCtx::new(
266        Arc::new(admin),
267        db.clone(),
268        templates.clone(),
269    ));
270
271    // Bespoke user/group pages share the same DB / templates / Admin
272    // arc but live in their own ctx type with the same shape.
273    let auth_ctx = Arc::new(super::builtin::AuthAdminCtx {
274        admin: ctx.admin.clone(),
275        db,
276        templates,
277    });
278
279    // Render `Err(_)` from /admin/* handlers as styled HTML instead of
280    // the framework default `text/plain`. Non-admin paths bubble
281    // through unchanged so JSON / curl consumers still get the text
282    // body. `Error::Forbidden` (handled by `role_guard` via
283    // `admin/forbidden.html`) and login-required redirects come
284    // through as `Ok` responses and bypass this branch.
285    let err_admin = ctx.admin.clone();
286    let err_templates = ctx.templates.clone();
287    let router = router.middleware(move |req, next| {
288        let admin = err_admin.clone();
289        let templates = err_templates.clone();
290        Box::pin(async move {
291            let is_admin_path = req.path().starts_with("/admin");
292            let result = next.run(req).await;
293            match result {
294                Ok(resp) => Ok(resp),
295                Err(err) if is_admin_path => Ok(render::render_admin_error_response(
296                    &admin,
297                    &templates,
298                    None,
299                    err.status(),
300                    err.client_message().to_string(),
301                )),
302                Err(err) => Err(err),
303            }
304        })
305    });
306
307    // Embedded stylesheet + JS. The bytes are baked into the binary
308    // so single-binary deploy is preserved. CSS/JS use `no-cache`
309    // (revalidate every request) so theme + design tweaks roll out the
310    // moment the binary restarts; fonts (next block) keep their long
311    // immutable cache because their bytes never change per release.
312    let router = router.get("/static/admin.css", |_req| async move {
313        Ok(Response::new(
314            hyper::StatusCode::OK,
315            bytes::Bytes::from_static(ADMIN_CSS.as_bytes()),
316        )
317        .with_header("content-type", "text/css; charset=utf-8")
318        .with_header("cache-control", "no-cache, must-revalidate"))
319    });
320    let router = router.get("/static/admin.js", |_req| async move {
321        Ok(Response::new(
322            hyper::StatusCode::OK,
323            bytes::Bytes::from_static(ADMIN_JS.as_bytes()),
324        )
325        .with_header("content-type", "application/javascript; charset=utf-8")
326        .with_header("cache-control", "no-cache, must-revalidate"))
327    });
328
329    // Self-hosted fonts. Cache aggressively: file contents are
330    // immutable per build, so a 1-year cache is safe — the binary
331    // ships a fresh copy on the next release.
332    fn font_response(bytes: &'static [u8]) -> Response {
333        Response::new(hyper::StatusCode::OK, bytes::Bytes::from_static(bytes))
334            .with_header("content-type", "font/woff2")
335            .with_header("cache-control", "public, max-age=31536000, immutable")
336    }
337    let router = router.get("/static/fonts/Geist-Variable.woff2", |_req| async move {
338        Ok(font_response(FONT_GEIST))
339    });
340    let router = router.get(
341        "/static/fonts/GeistMono-Variable.woff2",
342        |_req| async move { Ok(font_response(FONT_GEIST_MONO)) },
343    );
344    let router = router.get("/static/fonts/Tajawal-Regular.woff2", |_req| async move {
345        Ok(font_response(FONT_TAJAWAL_REG))
346    });
347    let router = router.get("/static/fonts/Tajawal-Medium.woff2", |_req| async move {
348        Ok(font_response(FONT_TAJAWAL_MED))
349    });
350    let router = router.get("/static/fonts/Tajawal-Bold.woff2", |_req| async move {
351        Ok(font_response(FONT_TAJAWAL_BOLD))
352    });
353    let router = router.get(
354        "/static/fonts/NotoNaskhArabic-Variable.woff2",
355        |_req| async move { Ok(font_response(FONT_NOTO_NASKH_AR)) },
356    );
357
358    // Public: login/logout.
359    let c = ctx.clone();
360    let router = router.get("/admin/login", move |req| {
361        let c = c.clone();
362        async move { handlers::show_login(&c, req).await }
363    });
364
365    let c = ctx.clone();
366    let router = router.post("/admin/login", move |req| {
367        let c = c.clone();
368        async move { handlers::do_login(&c, req).await }
369    });
370
371    let c = ctx.clone();
372    let router = router.post("/admin/logout", move |req| {
373        let c = c.clone();
374        async move { handlers::do_logout(&c, req).await }
375    });
376
377    // === R1 recovery routes ====================================
378    //
379    // MUST be registered BEFORE the `/admin/:admin_name` model
380    // wildcards lower down — without that ordering, a request to
381    // `/admin/forgot-password` would match `:admin_name =
382    // "forgot-password"` and route into the model CRUD handler.
383    //
384    // Recovery state (the rate-limit buckets) is built once here
385    // and cloned into each route closure so the buckets persist
386    // for the process lifetime. No global / static / OnceLock —
387    // the Arc lives in the closures.
388    //
389    // Strict-mailer boot guard already ran at the top of this fn
390    // (would have panicked if misconfigured); reaching this block
391    // means we have the operator's blessing to wire recovery.
392
393    let recovery_state = Arc::new(super::recovery_handlers::RecoveryState::from_admin(
394        &ctx.admin,
395    ));
396
397    let c = ctx.clone();
398    let router = router.get("/admin/forgot-password", move |req| {
399        let c = c.clone();
400        async move { super::recovery_handlers::show_forgot_password(&c, &req).await }
401    });
402
403    let c = ctx.clone();
404    let rs = recovery_state.clone();
405    let router = router.post("/admin/forgot-password", move |req| {
406        let c = c.clone();
407        let rs = rs.clone();
408        async move { super::recovery_handlers::do_forgot_password(&c, &rs, req).await }
409    });
410
411    let c = ctx.clone();
412    let router = router.get("/admin/forgot-password/sent", move |req| {
413        let c = c.clone();
414        async move { super::recovery_handlers::show_forgot_password_sent(&c, &req).await }
415    });
416
417    let c = ctx.clone();
418    let router = router.get("/admin/reset-password/:token", move |req| {
419        let c = c.clone();
420        async move {
421            let token = req
422                .param("token")
423                .ok_or_else(|| Error::BadRequest("missing token".into()))?
424                .to_string();
425            super::recovery_handlers::show_reset_password(&c, &req, &token).await
426        }
427    });
428
429    let c = ctx.clone();
430    let rs = recovery_state.clone();
431    let router = router.post("/admin/reset-password/:token", move |req| {
432        let c = c.clone();
433        let rs = rs.clone();
434        async move {
435            let token = req
436                .param("token")
437                .ok_or_else(|| Error::BadRequest("missing token".into()))?
438                .to_string();
439            super::recovery_handlers::do_reset_password(&c, &rs, req, &token).await
440        }
441    });
442
443    // Dashboard — Staff floor. User-tier sees the forbidden page.
444    let c = ctx.clone();
445    let router = router.get("/admin", move |req| {
446        let c = c.clone();
447        async move {
448            match role_guard(&c, &req, Role::Staff).await? {
449                Guard::Redirect(r) => Ok(r),
450                Guard::Allow(ident) => handlers::dashboard(&c, ident, &req).await,
451            }
452        }
453    });
454
455    // Global history log (admin-only; high-signal page).
456    let c = ctx.clone();
457    let router = router.get("/admin/history", move |req| {
458        let c = c.clone();
459        async move {
460            match role_guard(&c, &req, Role::Administrator).await? {
461                Guard::Redirect(r) => Ok(r),
462                Guard::Allow(ident) => handlers::show_log_entries(&c, ident, &req).await,
463            }
464        }
465    });
466
467    // Self-service active-sessions listing (R0). Any logged-in user
468    // (User-tier and above) can see their own active sessions.
469    let c = ctx.clone();
470    let router = router.get("/admin/account/sessions", move |req| {
471        let c = c.clone();
472        async move {
473            match role_guard(&c, &req, Role::User).await? {
474                Guard::Redirect(r) => Ok(r),
475                Guard::Allow(ident) => handlers::show_account_sessions(&c, ident, &req).await,
476            }
477        }
478    });
479
480    // R1 commit #10 — active-sessions revoke buttons. All three
481    // POST routes go through `auth::invalidate_sessions` (Doctrine
482    // 22) and write `AuditEvent::SessionsRevokedSelf` per revoked
483    // id. The `/revoke-others` and `/revoke-all` literal segments
484    // sit at depth-4 while `:id/revoke` sits at depth-5, so segment
485    // count alone disambiguates them — no explicit ordering
486    // constraint between the three.
487    let c = ctx.clone();
488    let router = router.post("/admin/account/sessions/revoke-others", move |req| {
489        let c = c.clone();
490        async move {
491            match role_guard(&c, &req, Role::User).await? {
492                Guard::Redirect(r) => Ok(r),
493                Guard::Allow(ident) => handlers::do_revoke_other_sessions(&c, ident, req).await,
494            }
495        }
496    });
497
498    let c = ctx.clone();
499    let router = router.post("/admin/account/sessions/revoke-all", move |req| {
500        let c = c.clone();
501        async move {
502            match role_guard(&c, &req, Role::User).await? {
503                Guard::Redirect(r) => Ok(r),
504                Guard::Allow(ident) => handlers::do_revoke_all_sessions(&c, ident, req).await,
505            }
506        }
507    });
508
509    let c = ctx.clone();
510    let router = router.post("/admin/account/sessions/:id/revoke", move |req| {
511        let c = c.clone();
512        async move {
513            match role_guard(&c, &req, Role::User).await? {
514                Guard::Redirect(r) => Ok(r),
515                Guard::Allow(ident) => {
516                    let id = parse_id(req.param("id"))?;
517                    handlers::do_revoke_session(&c, ident, req, id).await
518                }
519            }
520        }
521    });
522
523    // Self-service password change. Any logged-in user (User-tier and
524    // above). User-tier can change their own password even though
525    // they can't access the dashboard.
526    let c = ctx.clone();
527    let router = router.get("/admin/password_change", move |req| {
528        let c = c.clone();
529        async move {
530            match role_guard(&c, &req, Role::User).await? {
531                Guard::Redirect(r) => Ok(r),
532                Guard::Allow(ident) => handlers::show_password_change(&c, ident, &req).await,
533            }
534        }
535    });
536    let c = ctx.clone();
537    let router = router.post("/admin/password_change", move |req| {
538        let c = c.clone();
539        async move {
540            match role_guard(&c, &req, Role::User).await? {
541                Guard::Redirect(r) => Ok(r),
542                Guard::Allow(ident) => handlers::do_password_change(&c, ident, req).await,
543            }
544        }
545    });
546
547    // === R2 re-auth wall (R2 commit #11) ====================================
548    //
549    // Standalone wall: any authenticated user can promote their own
550    // session into the elevated band by re-entering their password.
551    // The handler validates `return_to` strictly (only `/admin*`
552    // paths; see `admin_recovery_handlers::validate_return_to`).
553    // Any role from User-tier upward.
554
555    let c = ctx.clone();
556    let router = router.get("/admin/reauth", move |req| {
557        let c = c.clone();
558        async move {
559            match role_guard(&c, &req, Role::User).await? {
560                Guard::Redirect(r) => Ok(r),
561                Guard::Allow(ident) => {
562                    super::admin_recovery_handlers::show_reauth(&c, ident, &req).await
563                }
564            }
565        }
566    });
567
568    let c = ctx.clone();
569    let router = router.post("/admin/reauth", move |req| {
570        let c = c.clone();
571        async move {
572            match role_guard(&c, &req, Role::User).await? {
573                Guard::Redirect(r) => Ok(r),
574                Guard::Allow(ident) => {
575                    super::admin_recovery_handlers::do_reauth(&c, ident, req).await
576                }
577            }
578        }
579    });
580
581    // === R2 forced password rotation (R2 commit #12) ========================
582    //
583    // The `must_change_password` interstitial is the only writeable
584    // surface a user can reach while their flag is TRUE. The path is
585    // on `MUST_CHANGE_WHITELIST`; the `login_guard` redirect therefore
586    // skips it (otherwise the rotation would be unreachable). Role::User
587    // matches: any authenticated user can be forced to rotate, even a
588    // User-tier account that can't access the dashboard.
589
590    let c = ctx.clone();
591    let router = router.get("/admin/must-change-password", move |req| {
592        let c = c.clone();
593        async move {
594            match role_guard(&c, &req, Role::User).await? {
595                Guard::Redirect(r) => Ok(r),
596                Guard::Allow(ident) => {
597                    super::admin_recovery_handlers::show_must_change_password(&c, ident, &req).await
598                }
599            }
600        }
601    });
602
603    let c = ctx.clone();
604    let router = router.post("/admin/must-change-password", move |req| {
605        let c = c.clone();
606        async move {
607            match role_guard(&c, &req, Role::User).await? {
608                Guard::Redirect(r) => Ok(r),
609                Guard::Allow(ident) => {
610                    super::admin_recovery_handlers::do_must_change_password(&c, ident, req).await
611                }
612            }
613        }
614    });
615
616    // --- Built-in users admin (admin-only) ---
617    let c = ctx.clone();
618    let ac = auth_ctx.clone();
619    let router = router.get("/admin/users", move |req| {
620        let c = c.clone();
621        let ac = ac.clone();
622        async move {
623            match role_guard(&c, &req, Role::Administrator).await? {
624                Guard::Redirect(r) => Ok(r),
625                Guard::Allow(ident) => {
626                    super::builtin::list_users(&ac, ident, handlers::csrf_token(&req)).await
627                }
628            }
629        }
630    });
631
632    let c = ctx.clone();
633    let ac = auth_ctx.clone();
634    let router = router.get("/admin/users/new", move |req| {
635        let c = c.clone();
636        let ac = ac.clone();
637        async move {
638            match role_guard(&c, &req, Role::Administrator).await? {
639                Guard::Redirect(r) => Ok(r),
640                Guard::Allow(ident) => {
641                    super::builtin::show_new_user(&ac, ident, handlers::csrf_token(&req)).await
642                }
643            }
644        }
645    });
646
647    let c = ctx.clone();
648    let ac = auth_ctx.clone();
649    let router = router.post("/admin/users/new", move |req| {
650        let c = c.clone();
651        let ac = ac.clone();
652        async move {
653            match role_guard(&c, &req, Role::Administrator).await? {
654                Guard::Redirect(r) => Ok(r),
655                Guard::Allow(ident) => super::builtin::do_new_user(&ac, ident, req).await,
656            }
657        }
658    });
659
660    let c = ctx.clone();
661    let ac = auth_ctx.clone();
662    let router = router.get("/admin/users/:id/edit", move |req| {
663        let c = c.clone();
664        let ac = ac.clone();
665        async move {
666            match role_guard(&c, &req, Role::Administrator).await? {
667                Guard::Redirect(r) => Ok(r),
668                Guard::Allow(ident) => {
669                    let id = parse_id(req.param("id"))?;
670                    super::builtin::show_user_edit(&ac, ident, id, handlers::csrf_token(&req)).await
671                }
672            }
673        }
674    });
675
676    let c = ctx.clone();
677    let ac = auth_ctx.clone();
678    let router = router.post("/admin/users/:id/edit", move |req| {
679        let c = c.clone();
680        let ac = ac.clone();
681        async move {
682            match role_guard(&c, &req, Role::Administrator).await? {
683                Guard::Redirect(r) => Ok(r),
684                Guard::Allow(ident) => {
685                    let id = parse_id(req.param("id"))?;
686                    super::builtin::do_user_edit(&ac, ident, id, req).await
687                }
688            }
689        }
690    });
691
692    let c = ctx.clone();
693    let ac = auth_ctx.clone();
694    let router = router.get("/admin/users/:id/delete", move |req| {
695        let c = c.clone();
696        let ac = ac.clone();
697        async move {
698            match role_guard(&c, &req, Role::Administrator).await? {
699                Guard::Redirect(r) => Ok(r),
700                Guard::Allow(ident) => {
701                    let id = parse_id(req.param("id"))?;
702                    super::builtin::show_user_delete(&ac, ident, id, handlers::csrf_token(&req))
703                        .await
704                }
705            }
706        }
707    });
708
709    let c = ctx.clone();
710    let ac = auth_ctx.clone();
711    let router = router.post("/admin/users/:id/delete", move |req| {
712        let c = c.clone();
713        let ac = ac.clone();
714        async move {
715            match role_guard(&c, &req, Role::Administrator).await? {
716                Guard::Redirect(r) => Ok(r),
717                Guard::Allow(ident) => {
718                    let id = parse_id(req.param("id"))?;
719                    super::builtin::do_user_delete(&ac, ident, id, req).await
720                }
721            }
722        }
723    });
724
725    // === R2 admin-driven recovery routes ====================================
726    //
727    // Registered alongside the existing `/admin/users/:id/...` cluster
728    // (per `DESIGN_R2_ORGANISATIONAL.md` §7.2 — user-related cluster
729    // contiguous). All gated `Role::Administrator`; the cross-rank
730    // safety check + the re-auth wall are enforced INSIDE the
731    // handlers (commits #15 / #16) so a Supervisor probe doesn't even
732    // reach the form.
733    //
734    // Insertion-order note: these are 4-segment routes, so the
735    // 3-segment `/admin/users/:id` read-only view further down doesn't
736    // conflict regardless of order. Placing them before the 3-segment
737    // view keeps the user routes lexically clustered.
738
739    // GET /admin/users/:id/reset-password — admin reset form (R2 #15).
740    let c = ctx.clone();
741    let router = router.get("/admin/users/:id/reset-password", move |req| {
742        let c = c.clone();
743        async move {
744            match role_guard(&c, &req, Role::Administrator).await? {
745                Guard::Redirect(r) => Ok(r),
746                Guard::Allow(ident) => {
747                    let id = parse_id(req.param("id"))?;
748                    super::admin_recovery_handlers::show_admin_reset_password(&c, ident, id, &req)
749                        .await
750                }
751            }
752        }
753    });
754
755    // POST /admin/users/:id/reset-password — apply admin reset (R2 #15).
756    let c = ctx.clone();
757    let router = router.post("/admin/users/:id/reset-password", move |req| {
758        let c = c.clone();
759        async move {
760            match role_guard(&c, &req, Role::Administrator).await? {
761                Guard::Redirect(r) => Ok(r),
762                Guard::Allow(ident) => {
763                    let id = parse_id(req.param("id"))?;
764                    super::admin_recovery_handlers::do_admin_reset_password(&c, ident, id, req)
765                        .await
766                }
767            }
768        }
769    });
770
771    // GET /admin/users/:id/lock — lock confirmation form (R2 #16).
772    let c = ctx.clone();
773    let router = router.get("/admin/users/:id/lock", move |req| {
774        let c = c.clone();
775        async move {
776            match role_guard(&c, &req, Role::Administrator).await? {
777                Guard::Redirect(r) => Ok(r),
778                Guard::Allow(ident) => {
779                    let id = parse_id(req.param("id"))?;
780                    super::admin_recovery_handlers::show_lock_user(&c, ident, id, &req).await
781                }
782            }
783        }
784    });
785
786    // POST /admin/users/:id/lock — apply manual lock (R2 #16).
787    let c = ctx.clone();
788    let router = router.post("/admin/users/:id/lock", move |req| {
789        let c = c.clone();
790        async move {
791            match role_guard(&c, &req, Role::Administrator).await? {
792                Guard::Redirect(r) => Ok(r),
793                Guard::Allow(ident) => {
794                    let id = parse_id(req.param("id"))?;
795                    super::admin_recovery_handlers::do_lock_user(&c, ident, id, req).await
796                }
797            }
798        }
799    });
800
801    // GET /admin/users/:id/unlock — unlock confirmation form (R2 #16).
802    let c = ctx.clone();
803    let router = router.get("/admin/users/:id/unlock", move |req| {
804        let c = c.clone();
805        async move {
806            match role_guard(&c, &req, Role::Administrator).await? {
807                Guard::Redirect(r) => Ok(r),
808                Guard::Allow(ident) => {
809                    let id = parse_id(req.param("id"))?;
810                    super::admin_recovery_handlers::show_unlock_user(&c, ident, id, &req).await
811                }
812            }
813        }
814    });
815
816    // POST /admin/users/:id/unlock — clear lock (R2 #16).
817    let c = ctx.clone();
818    let router = router.post("/admin/users/:id/unlock", move |req| {
819        let c = c.clone();
820        async move {
821            match role_guard(&c, &req, Role::Administrator).await? {
822                Guard::Redirect(r) => Ok(r),
823                Guard::Allow(ident) => {
824                    let id = parse_id(req.param("id"))?;
825                    super::admin_recovery_handlers::do_unlock_user(&c, ident, id, req).await
826                }
827            }
828        }
829    });
830
831    // GET /admin/users/:id/revoke-sessions — revoke confirmation form
832    // (R2 #16).
833    let c = ctx.clone();
834    let router = router.get("/admin/users/:id/revoke-sessions", move |req| {
835        let c = c.clone();
836        async move {
837            match role_guard(&c, &req, Role::Administrator).await? {
838                Guard::Redirect(r) => Ok(r),
839                Guard::Allow(ident) => {
840                    let id = parse_id(req.param("id"))?;
841                    super::admin_recovery_handlers::show_admin_revoke_sessions(&c, ident, id, &req)
842                        .await
843                }
844            }
845        }
846    });
847
848    // POST /admin/users/:id/revoke-sessions — revoke all sessions (R2 #16).
849    let c = ctx.clone();
850    let router = router.post("/admin/users/:id/revoke-sessions", move |req| {
851        let c = c.clone();
852        async move {
853            match role_guard(&c, &req, Role::Administrator).await? {
854                Guard::Redirect(r) => Ok(r),
855                Guard::Allow(ident) => {
856                    let id = parse_id(req.param("id"))?;
857                    super::admin_recovery_handlers::do_admin_revoke_sessions(&c, ident, id, req)
858                        .await
859                }
860            }
861        }
862    });
863
864    // Read-only user profile view. MUST be registered AFTER
865    // `/admin/users/new` and the `:id/edit` + `:id/delete` routes
866    // above: the router matches in insertion order, and `:id` is a
867    // wildcard that would happily swallow "new" or extra path
868    // segments. Putting this last preserves the more-specific routes'
869    // priority.
870    let c = ctx.clone();
871    let ac = auth_ctx.clone();
872    let router = router.get("/admin/users/:id", move |req| {
873        let c = c.clone();
874        let ac = ac.clone();
875        async move {
876            match role_guard(&c, &req, Role::Administrator).await? {
877                Guard::Redirect(r) => Ok(r),
878                Guard::Allow(ident) => {
879                    let id = parse_id(req.param("id"))?;
880                    let q = req.query();
881                    let tab = q.get("tab").map(|s| s.to_string());
882                    let page: i64 = q.get("page").and_then(|s| s.parse().ok()).unwrap_or(1);
883                    super::builtin::show_user_view(
884                        &ac,
885                        ident,
886                        id,
887                        handlers::csrf_token(&req),
888                        tab,
889                        page,
890                    )
891                    .await
892                }
893            }
894        }
895    });
896
897    // --- Built-in groups admin (admin-only) ---
898    let c = ctx.clone();
899    let ac = auth_ctx.clone();
900    let router = router.get("/admin/groups", move |req| {
901        let c = c.clone();
902        let ac = ac.clone();
903        async move {
904            match role_guard(&c, &req, Role::Administrator).await? {
905                Guard::Redirect(r) => Ok(r),
906                Guard::Allow(ident) => {
907                    super::builtin::list_groups(&ac, ident, handlers::csrf_token(&req)).await
908                }
909            }
910        }
911    });
912
913    let c = ctx.clone();
914    let ac = auth_ctx.clone();
915    let router = router.get("/admin/groups/new", move |req| {
916        let c = c.clone();
917        let ac = ac.clone();
918        async move {
919            match role_guard(&c, &req, Role::Administrator).await? {
920                Guard::Redirect(r) => Ok(r),
921                Guard::Allow(ident) => {
922                    super::builtin::show_new_group(&ac, ident, handlers::csrf_token(&req)).await
923                }
924            }
925        }
926    });
927
928    let c = ctx.clone();
929    let ac = auth_ctx.clone();
930    let router = router.post("/admin/groups/new", move |req| {
931        let c = c.clone();
932        let ac = ac.clone();
933        async move {
934            match role_guard(&c, &req, Role::Administrator).await? {
935                Guard::Redirect(r) => Ok(r),
936                Guard::Allow(ident) => super::builtin::do_new_group(&ac, ident, req).await,
937            }
938        }
939    });
940
941    let c = ctx.clone();
942    let ac = auth_ctx.clone();
943    let router = router.get("/admin/groups/:id/edit", move |req| {
944        let c = c.clone();
945        let ac = ac.clone();
946        async move {
947            match role_guard(&c, &req, Role::Administrator).await? {
948                Guard::Redirect(r) => Ok(r),
949                Guard::Allow(ident) => {
950                    let id = parse_id(req.param("id"))?;
951                    super::builtin::show_group_edit(&ac, ident, id, handlers::csrf_token(&req))
952                        .await
953                }
954            }
955        }
956    });
957
958    let c = ctx.clone();
959    let ac = auth_ctx.clone();
960    let router = router.post("/admin/groups/:id/edit", move |req| {
961        let c = c.clone();
962        let ac = ac.clone();
963        async move {
964            match role_guard(&c, &req, Role::Administrator).await? {
965                Guard::Redirect(r) => Ok(r),
966                Guard::Allow(ident) => {
967                    let id = parse_id(req.param("id"))?;
968                    super::builtin::do_group_edit(&ac, ident, id, req).await
969                }
970            }
971        }
972    });
973
974    let c = ctx.clone();
975    let ac = auth_ctx.clone();
976    let router = router.get("/admin/groups/:id/delete", move |req| {
977        let c = c.clone();
978        let ac = ac.clone();
979        async move {
980            match role_guard(&c, &req, Role::Administrator).await? {
981                Guard::Redirect(r) => Ok(r),
982                Guard::Allow(ident) => {
983                    let id = parse_id(req.param("id"))?;
984                    super::builtin::show_group_delete(&ac, ident, id, handlers::csrf_token(&req))
985                        .await
986                }
987            }
988        }
989    });
990
991    let c = ctx.clone();
992    let ac = auth_ctx.clone();
993    let router = router.post("/admin/groups/:id/delete", move |req| {
994        let c = c.clone();
995        let ac = ac.clone();
996        async move {
997            match role_guard(&c, &req, Role::Administrator).await? {
998                Guard::Redirect(r) => Ok(r),
999                Guard::Allow(ident) => {
1000                    let id = parse_id(req.param("id"))?;
1001                    super::builtin::do_group_delete(&ac, ident, id, req).await
1002                }
1003            }
1004        }
1005    });
1006
1007    // Per-model list — needs `view` permission.
1008    let c = ctx.clone();
1009    let router = router.get("/admin/:admin_name", move |req| {
1010        let c = c.clone();
1011        async move {
1012            let name = model_name_from_req(&req)?;
1013            let perm = perm_for(&c, &name, "view")?;
1014            match perm_guard(&c, &req, &perm).await? {
1015                Guard::Redirect(r) => Ok(r),
1016                Guard::Allow(ident) => handlers::list_model(&c, ident, &name, &req).await,
1017            }
1018        }
1019    });
1020
1021    // Create.
1022    let c = ctx.clone();
1023    let router = router.get("/admin/:admin_name/new", move |req| {
1024        let c = c.clone();
1025        async move {
1026            let name = model_name_from_req(&req)?;
1027            let perm = perm_for(&c, &name, "add")?;
1028            match perm_guard(&c, &req, &perm).await? {
1029                Guard::Redirect(r) => Ok(r),
1030                Guard::Allow(ident) => handlers::show_new_form(&c, ident, &name, &req).await,
1031            }
1032        }
1033    });
1034    let c = ctx.clone();
1035    let router = router.post("/admin/:admin_name/new", move |req| {
1036        let c = c.clone();
1037        async move {
1038            let name = model_name_from_req(&req)?;
1039            let perm = perm_for(&c, &name, "add")?;
1040            match perm_guard(&c, &req, &perm).await? {
1041                Guard::Redirect(r) => Ok(r),
1042                Guard::Allow(ident) => handlers::do_create(&c, ident, &name, req).await,
1043            }
1044        }
1045    });
1046
1047    // Edit.
1048    let c = ctx.clone();
1049    let router = router.get("/admin/:admin_name/:id/edit", move |req| {
1050        let c = c.clone();
1051        async move {
1052            let name = model_name_from_req(&req)?;
1053            let perm = perm_for(&c, &name, "change")?;
1054            match perm_guard(&c, &req, &perm).await? {
1055                Guard::Redirect(r) => Ok(r),
1056                Guard::Allow(ident) => {
1057                    let id = parse_id(req.param("id"))?;
1058                    handlers::show_edit_form(&c, ident, &name, id, &req).await
1059                }
1060            }
1061        }
1062    });
1063    let c = ctx.clone();
1064    let router = router.post("/admin/:admin_name/:id/edit", move |req| {
1065        let c = c.clone();
1066        async move {
1067            let name = model_name_from_req(&req)?;
1068            let perm = perm_for(&c, &name, "change")?;
1069            match perm_guard(&c, &req, &perm).await? {
1070                Guard::Redirect(r) => Ok(r),
1071                Guard::Allow(ident) => {
1072                    let id = parse_id(req.param("id"))?;
1073                    handlers::do_update(&c, ident, &name, id, req).await
1074                }
1075            }
1076        }
1077    });
1078
1079    // Per-object history. Read-only; same `view` permission as the
1080    // changelist (if you can list, you can read the audit trail).
1081    let c = ctx.clone();
1082    let router = router.get("/admin/:admin_name/:id/history", move |req| {
1083        let c = c.clone();
1084        async move {
1085            let name = model_name_from_req(&req)?;
1086            let perm = perm_for(&c, &name, "view")?;
1087            match perm_guard(&c, &req, &perm).await? {
1088                Guard::Redirect(r) => Ok(r),
1089                Guard::Allow(ident) => {
1090                    let id = parse_id(req.param("id"))?;
1091                    handlers::show_object_history(&c, ident, &name, id, &req).await
1092                }
1093            }
1094        }
1095    });
1096
1097    // Delete.
1098    let c = ctx.clone();
1099    let router = router.get("/admin/:admin_name/:id/delete", move |req| {
1100        let c = c.clone();
1101        async move {
1102            let name = model_name_from_req(&req)?;
1103            let perm = perm_for(&c, &name, "delete")?;
1104            match perm_guard(&c, &req, &perm).await? {
1105                Guard::Redirect(r) => Ok(r),
1106                Guard::Allow(ident) => {
1107                    let id = parse_id(req.param("id"))?;
1108                    handlers::show_delete_confirm(&c, ident, &name, id, &req).await
1109                }
1110            }
1111        }
1112    });
1113    let c = ctx.clone();
1114    let router = router.post("/admin/:admin_name/:id/delete", move |req| {
1115        let c = c.clone();
1116        async move {
1117            let name = model_name_from_req(&req)?;
1118            let perm = perm_for(&c, &name, "delete")?;
1119            match perm_guard(&c, &req, &perm).await? {
1120                Guard::Redirect(r) => Ok(r),
1121                Guard::Allow(ident) => {
1122                    let id = parse_id(req.param("id"))?;
1123                    handlers::do_delete(&c, ident, &name, id).await
1124                }
1125            }
1126        }
1127    });
1128
1129    // Bulk delete — same permission gate as the per-row delete.
1130    // Two-step flow: first POST renders the confirm page, second POST
1131    // (with `_confirmed=1`) executes. See `handlers::handle_bulk_delete`
1132    // for the full contract.
1133    let c = ctx.clone();
1134    let router = router.post("/admin/:admin_name/bulk_delete", move |req| {
1135        let c = c.clone();
1136        async move {
1137            let name = model_name_from_req(&req)?;
1138            let perm = perm_for(&c, &name, "delete")?;
1139            match perm_guard(&c, &req, &perm).await? {
1140                Guard::Redirect(r) => Ok(r),
1141                Guard::Allow(ident) => handlers::handle_bulk_delete(&c, ident, &name, &req).await,
1142            }
1143        }
1144    });
1145
1146    // Project-defined bulk actions. Permission gated on `change` —
1147    // bulk actions modify rows but don't delete them (delete has its
1148    // own route). Project-side guard against further write-vs-read
1149    // distinctions belongs inside `execute_bulk_action`.
1150    let c = ctx.clone();
1151    router.post("/admin/:admin_name/bulk/:action", move |req| {
1152        let c = c.clone();
1153        async move {
1154            let name = model_name_from_req(&req)?;
1155            let action = req
1156                .param("action")
1157                .ok_or_else(|| Error::BadRequest("missing bulk action name".into()))?
1158                .to_string();
1159            let perm = perm_for(&c, &name, "change")?;
1160            match perm_guard(&c, &req, &perm).await? {
1161                Guard::Redirect(r) => Ok(r),
1162                Guard::Allow(ident) => {
1163                    handlers::handle_bulk_action(&c, ident, &name, &action, &req).await
1164                }
1165            }
1166        }
1167    })
1168}
1169
1170#[cfg(test)]
1171mod tests {
1172    use super::*;
1173
1174    fn make_identity(role: Role, is_active: bool) -> Identity {
1175        Identity {
1176            user_id: 42,
1177            email: "test@example.com".into(),
1178            role,
1179            is_active,
1180            is_demo: false,
1181            demo_label: None,
1182            must_change_password: false,
1183        }
1184    }
1185
1186    // role_guard's decision is `Role::includes(min)`. The 25-case
1187    // matrix lives in `auth::role::tests::includes_matrix_…`; the
1188    // cases below pin the most operator-relevant pairings.
1189
1190    #[test]
1191    fn role_guard_decision_admin_meets_staff_floor() {
1192        let id = make_identity(Role::Administrator, true);
1193        assert!(id.role.includes(Role::Staff));
1194    }
1195
1196    #[test]
1197    fn role_guard_decision_user_does_not_meet_staff() {
1198        let id = make_identity(Role::User, true);
1199        assert!(!id.role.includes(Role::Staff));
1200    }
1201
1202    #[test]
1203    fn role_guard_decision_administrator_does_not_meet_developer() {
1204        let id = make_identity(Role::Administrator, true);
1205        assert!(!id.role.includes(Role::Developer));
1206    }
1207
1208    #[test]
1209    fn role_guard_decision_developer_meets_everything() {
1210        let id = make_identity(Role::Developer, true);
1211        for &min in &[
1212            Role::User,
1213            Role::Staff,
1214            Role::Supervisor,
1215            Role::Administrator,
1216            Role::Developer,
1217        ] {
1218            assert!(id.role.includes(min), "Developer should meet {min:?}");
1219        }
1220    }
1221
1222    // ---- perm_guard_verdict matrix --------------------------------------
1223
1224    #[test]
1225    fn perm_guard_admin_short_circuits_without_perm() {
1226        let id = make_identity(Role::Administrator, true);
1227        assert!(perm_guard_verdict(&id, false));
1228    }
1229
1230    #[test]
1231    fn perm_guard_developer_short_circuits_without_perm() {
1232        let id = make_identity(Role::Developer, true);
1233        assert!(perm_guard_verdict(&id, false));
1234    }
1235
1236    #[test]
1237    fn perm_guard_staff_with_perm_passes() {
1238        let id = make_identity(Role::Staff, true);
1239        assert!(perm_guard_verdict(&id, true));
1240    }
1241
1242    #[test]
1243    fn perm_guard_staff_without_perm_denies() {
1244        let id = make_identity(Role::Staff, true);
1245        assert!(!perm_guard_verdict(&id, false));
1246    }
1247
1248    #[test]
1249    fn perm_guard_inactive_admin_denies_even_with_bypass() {
1250        // Defense-in-depth invariant.
1251        let id = make_identity(Role::Administrator, false);
1252        assert!(!perm_guard_verdict(&id, true));
1253    }
1254
1255    #[test]
1256    fn perm_guard_supervisor_without_perm_denies() {
1257        // Supervisor doesn't bypass; needs the per-model perm.
1258        let id = make_identity(Role::Supervisor, true);
1259        assert!(!perm_guard_verdict(&id, false));
1260    }
1261
1262    // ---- strict_mailer_guard_check ----------------------------------------
1263
1264    /// Default `Admin::new()` doesn't override the mailer AND
1265    /// doesn't enable strict mode — the guard passes.
1266    #[test]
1267    fn strict_mailer_guard_passes_for_default_admin() {
1268        let admin = super::super::types::Admin::new();
1269        assert!(strict_mailer_guard_check(&admin).is_ok());
1270    }
1271
1272    /// Strict-mailer mode + default LogMailer = boot guard fires.
1273    /// The error message is operator-actionable.
1274    #[test]
1275    fn strict_mailer_guard_fails_when_required_but_default_mailer() {
1276        use crate::auth::DefaultRecoveryPolicy;
1277        let admin = super::super::types::Admin::new().recovery_policy(std::sync::Arc::new(
1278            DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1279        ));
1280        let err = strict_mailer_guard_check(&admin).expect_err("guard should fail");
1281        assert!(
1282            err.contains("strict_mailer_required"),
1283            "error message must name the policy method: {err}"
1284        );
1285        assert!(
1286            err.contains("Admin::mailer"),
1287            "error message must direct the operator to the fix: {err}"
1288        );
1289    }
1290
1291    /// Strict-mailer mode + project-supplied mailer = guard passes.
1292    /// Note: the explicit override flips the flag even when the
1293    /// supplied value happens to be another LogMailer — the
1294    /// operator's intent is what matters, not the concrete type.
1295    #[test]
1296    fn strict_mailer_guard_passes_when_mailer_was_explicitly_overridden() {
1297        use crate::auth::DefaultRecoveryPolicy;
1298        use crate::email::LogMailer;
1299        let admin = super::super::types::Admin::new()
1300            .recovery_policy(std::sync::Arc::new(
1301                DefaultRecoveryPolicy::new().with_strict_mailer_required(true),
1302            ))
1303            .mailer(std::sync::Arc::new(LogMailer));
1304        assert!(strict_mailer_guard_check(&admin).is_ok());
1305    }
1306
1307    /// Project NOT in strict mode + default LogMailer = passes
1308    /// (dev / CI / testing baseline).
1309    #[test]
1310    fn strict_mailer_guard_passes_when_strict_mode_disabled() {
1311        let admin = super::super::types::Admin::new();
1312        assert!(strict_mailer_guard_check(&admin).is_ok());
1313    }
1314
1315    // ---- must-change-password whitelist (R2 commit #13) --------------------
1316
1317    #[test]
1318    fn whitelist_accepts_the_three_locked_paths() {
1319        // Locked-decision per DESIGN_R2_ORGANISATIONAL.md §12.
1320        assert!(super::is_must_change_whitelisted_path(
1321            "/admin/must-change-password"
1322        ));
1323        assert!(super::is_must_change_whitelisted_path("/admin/logout"));
1324        assert!(super::is_must_change_whitelisted_path(
1325            "/admin/account/sessions"
1326        ));
1327    }
1328
1329    #[test]
1330    fn whitelist_rejects_subpaths_of_account_sessions() {
1331        // Sub-paths of /admin/account/sessions (revoke buttons) are
1332        // intentionally NOT whitelisted — a user being forced to
1333        // rotate may VIEW their sessions but must finish the
1334        // rotation before revoking siblings.
1335        assert!(!super::is_must_change_whitelisted_path(
1336            "/admin/account/sessions/revoke"
1337        ));
1338        assert!(!super::is_must_change_whitelisted_path(
1339            "/admin/account/sessions/revoke-others"
1340        ));
1341        assert!(!super::is_must_change_whitelisted_path(
1342            "/admin/account/sessions/"
1343        ));
1344    }
1345
1346    #[test]
1347    fn whitelist_rejects_other_admin_paths() {
1348        for path in [
1349            "/admin",
1350            "/admin/",
1351            "/admin/users",
1352            "/admin/users/42",
1353            "/admin/login",
1354            "/admin/password_change",
1355            "/admin/forgot-password",
1356            "/admin/reauth",
1357            "/admin/must-change-password/", // trailing slash → not exact
1358        ] {
1359            assert!(
1360                !super::is_must_change_whitelisted_path(path),
1361                "expected reject for {path:?}"
1362            );
1363        }
1364    }
1365
1366    #[test]
1367    fn whitelist_rejects_paths_outside_admin_surface() {
1368        for path in ["/", "/login", "/static/admin.css", "/api"] {
1369            assert!(
1370                !super::is_must_change_whitelisted_path(path),
1371                "expected reject for {path:?}"
1372            );
1373        }
1374    }
1375}