Skip to main content

allowthem_server/
password_reset_page_routes.rs

1use std::sync::Arc;
2
3use axum::Form;
4use axum::Router;
5use axum::extract::{Extension, Query};
6use axum::http::HeaderMap;
7use axum::http::StatusCode;
8use axum::http::header::COOKIE;
9use axum::response::{Html, IntoResponse, Response};
10use axum::routing::get;
11use axum_htmx::{HxBoosted, HxRequest};
12use serde::Deserialize;
13
14use allowthem_core::applications::BrandingConfig;
15use allowthem_core::{AllowThem, Email};
16
17use crate::auth_views::{
18    ForgotPasswordView, ResetPasswordView, forgot_password_fragment, forgot_password_page,
19    reset_password_fragment, reset_password_page,
20};
21use crate::branding::{DefaultBranding, default_branding_ref, resolve_branding};
22use crate::browser_error::BrowserError;
23use crate::csrf::CsrfToken;
24
25const MIN_PASSWORD_LEN: usize = 8;
26
27#[derive(Clone)]
28struct PasswordResetPageConfig {
29    is_production: bool,
30}
31
32#[derive(Deserialize)]
33pub struct ResetTokenQuery {
34    token: Option<String>,
35}
36
37#[derive(Deserialize)]
38pub struct ForgotPasswordForm {
39    email: String,
40    #[allow(dead_code)]
41    csrf_token: String,
42}
43
44#[derive(Deserialize)]
45pub struct ResetPasswordForm {
46    token: String,
47    new_password: String,
48    confirm_password: String,
49    #[allow(dead_code)]
50    csrf_token: String,
51}
52
53/// Render just the `_auth_main_forgot_password.html` partial plus the
54/// `_auth_oob_head.html` OOB head swap, for HTMX fragment responses.
55fn render_forgot_password_fragment(
56    config: &PasswordResetPageConfig,
57    csrf_token: &str,
58    error: &str,
59    success: bool,
60    branding: Option<&BrandingConfig>,
61) -> Result<Html<String>, BrowserError> {
62    forgot_password_fragment(&ForgotPasswordView {
63        csrf_token,
64        error,
65        success,
66        branding,
67        is_production: config.is_production,
68    })
69}
70
71/// Render just the `_auth_main_reset_password.html` partial plus the
72/// `_auth_oob_head.html` OOB head swap, for HTMX fragment responses.
73fn render_reset_password_fragment(
74    config: &PasswordResetPageConfig,
75    csrf_token: &str,
76    token: &str,
77    invalid_token: bool,
78    success: bool,
79    error: &str,
80    branding: Option<&BrandingConfig>,
81) -> Result<Html<String>, BrowserError> {
82    reset_password_fragment(&ResetPasswordView {
83        csrf_token,
84        token,
85        invalid_token,
86        success,
87        error,
88        branding,
89        is_production: config.is_production,
90    })
91}
92
93/// GET /forgot-password — render the email input form.
94async fn get_forgot_password(
95    Extension(ath): Extension<AllowThem>,
96    Extension(config): Extension<PasswordResetPageConfig>,
97    default_branding: Option<Extension<Arc<DefaultBranding>>>,
98    headers: HeaderMap,
99    csrf: CsrfToken,
100    HxBoosted(boosted): HxBoosted,
101    HxRequest(request): HxRequest,
102) -> Result<Response, BrowserError> {
103    if is_authenticated(&ath, &headers).await {
104        return Ok((StatusCode::SEE_OTHER, [(axum::http::header::LOCATION, "/")]).into_response());
105    }
106
107    let default = default_branding_ref(&default_branding);
108    let branding = resolve_branding(&ath, None, default).await;
109
110    if request && !boosted {
111        let html =
112            render_forgot_password_fragment(&config, csrf.as_str(), "", false, branding.as_ref())?;
113        return Ok(html.into_response());
114    }
115
116    let html = forgot_password_page(&ForgotPasswordView {
117        csrf_token: csrf.as_str(),
118        error: "",
119        success: false,
120        branding: branding.as_ref(),
121        is_production: config.is_production,
122    })?;
123    Ok(html.into_response())
124}
125
126/// POST /forgot-password — initiate reset; always render success to prevent enumeration.
127async fn post_forgot_password(
128    Extension(ath): Extension<AllowThem>,
129    Extension(config): Extension<PasswordResetPageConfig>,
130    default_branding: Option<Extension<Arc<DefaultBranding>>>,
131    csrf: CsrfToken,
132    Form(form): Form<ForgotPasswordForm>,
133) -> Result<Response, BrowserError> {
134    let default = default_branding_ref(&default_branding);
135    let branding = resolve_branding(&ath, None, default).await;
136
137    let email = match Email::new(form.email.clone()) {
138        Ok(e) => e,
139        Err(_) => {
140            let html = forgot_password_page(&ForgotPasswordView {
141                csrf_token: csrf.as_str(),
142                error: "Please enter a valid email address.",
143                success: false,
144                branding: branding.as_ref(),
145                is_production: config.is_production,
146            })?;
147            return Ok(html.into_response());
148        }
149    };
150
151    if let Err(err) = ath.send_password_reset_email(&email).await {
152        tracing::error!("password reset email error: {err}");
153    }
154
155    let html = forgot_password_page(&ForgotPasswordView {
156        csrf_token: csrf.as_str(),
157        error: "",
158        success: true,
159        branding: branding.as_ref(),
160        is_production: config.is_production,
161    })?;
162    Ok(html.into_response())
163}
164
165/// GET /auth/reset-password?token=... — validate token and render form or error state.
166async fn get_reset_password(
167    Extension(ath): Extension<AllowThem>,
168    Extension(config): Extension<PasswordResetPageConfig>,
169    default_branding: Option<Extension<Arc<DefaultBranding>>>,
170    csrf: CsrfToken,
171    Query(query): Query<ResetTokenQuery>,
172    HxBoosted(boosted): HxBoosted,
173    HxRequest(request): HxRequest,
174) -> Result<Response, BrowserError> {
175    let default = default_branding_ref(&default_branding);
176    let branding = resolve_branding(&ath, None, default).await;
177
178    let token = match query.token {
179        Some(ref t) if !t.is_empty() => t.clone(),
180        _ => {
181            if request && !boosted {
182                let html = render_reset_password_fragment(
183                    &config,
184                    csrf.as_str(),
185                    "",
186                    true,
187                    false,
188                    "",
189                    branding.as_ref(),
190                )?;
191                return Ok(html.into_response());
192            }
193            let html = reset_password_page(&ResetPasswordView {
194                csrf_token: csrf.as_str(),
195                token: "",
196                invalid_token: true,
197                success: false,
198                error: "",
199                branding: branding.as_ref(),
200                is_production: config.is_production,
201            })?;
202            return Ok(html.into_response());
203        }
204    };
205
206    let valid = match ath.db().validate_reset_token(&token).await {
207        Ok(v) => v,
208        Err(e) => {
209            tracing::warn!(error = %e, "reset token validation failed");
210            None
211        }
212    };
213
214    if valid.is_some() {
215        if request && !boosted {
216            let html = render_reset_password_fragment(
217                &config,
218                csrf.as_str(),
219                &token,
220                false,
221                false,
222                "",
223                branding.as_ref(),
224            )?;
225            return Ok(html.into_response());
226        }
227        let html = reset_password_page(&ResetPasswordView {
228            csrf_token: csrf.as_str(),
229            token: &token,
230            invalid_token: false,
231            success: false,
232            error: "",
233            branding: branding.as_ref(),
234            is_production: config.is_production,
235        })?;
236        Ok(html.into_response())
237    } else {
238        if request && !boosted {
239            let html = render_reset_password_fragment(
240                &config,
241                csrf.as_str(),
242                "",
243                true,
244                false,
245                "",
246                branding.as_ref(),
247            )?;
248            return Ok(html.into_response());
249        }
250        let html = reset_password_page(&ResetPasswordView {
251            csrf_token: csrf.as_str(),
252            token: "",
253            invalid_token: true,
254            success: false,
255            error: "",
256            branding: branding.as_ref(),
257            is_production: config.is_production,
258        })?;
259        Ok(html.into_response())
260    }
261}
262
263/// POST /auth/reset-password — execute the password reset.
264async fn post_reset_password(
265    Extension(ath): Extension<AllowThem>,
266    Extension(config): Extension<PasswordResetPageConfig>,
267    default_branding: Option<Extension<Arc<DefaultBranding>>>,
268    csrf: CsrfToken,
269    Form(form): Form<ResetPasswordForm>,
270) -> Result<Response, BrowserError> {
271    let default = default_branding_ref(&default_branding);
272    let branding = resolve_branding(&ath, None, default).await;
273
274    // Validate: passwords match
275    if form.new_password != form.confirm_password {
276        let html = reset_password_page(&ResetPasswordView {
277            csrf_token: csrf.as_str(),
278            token: &form.token,
279            invalid_token: false,
280            success: false,
281            error: "Passwords do not match",
282            branding: branding.as_ref(),
283            is_production: config.is_production,
284        })?;
285        return Ok(html.into_response());
286    }
287
288    // Validate: password length
289    if form.new_password.len() < MIN_PASSWORD_LEN {
290        let html = reset_password_page(&ResetPasswordView {
291            csrf_token: csrf.as_str(),
292            token: &form.token,
293            invalid_token: false,
294            success: false,
295            error: "Password must be at least 8 characters",
296            branding: branding.as_ref(),
297            is_production: config.is_production,
298        })?;
299        return Ok(html.into_response());
300    }
301
302    let reset_result = match ath
303        .consume_password_reset(&form.token, &form.new_password)
304        .await
305    {
306        Ok(v) => v,
307        Err(e) => {
308            tracing::warn!(error = %e, "password reset execution failed");
309            false
310        }
311    };
312
313    match reset_result {
314        true => {
315            let html = reset_password_page(&ResetPasswordView {
316                csrf_token: csrf.as_str(),
317                token: "",
318                invalid_token: false,
319                success: true,
320                error: "",
321                branding: branding.as_ref(),
322                is_production: config.is_production,
323            })?;
324            Ok(html.into_response())
325        }
326        false => {
327            let html = reset_password_page(&ResetPasswordView {
328                csrf_token: csrf.as_str(),
329                token: "",
330                invalid_token: true,
331                success: false,
332                error: "",
333                branding: branding.as_ref(),
334                is_production: config.is_production,
335            })?;
336            Ok(html.into_response())
337        }
338    }
339}
340
341/// Returns true if the request carries a valid session cookie.
342async fn is_authenticated(ath: &AllowThem, headers: &HeaderMap) -> bool {
343    let Some(cookie_header) = headers.get(COOKIE).and_then(|v| v.to_str().ok()) else {
344        return false;
345    };
346    let Some(token) = ath.parse_session_cookie(cookie_header) else {
347        return false;
348    };
349    let ttl = ath.session_config().ttl;
350    ath.db()
351        .validate_session(&token, ttl)
352        .await
353        .unwrap_or(None)
354        .is_some()
355}
356
357pub fn password_reset_page_routes(is_production: bool) -> Router<()> {
358    let cfg = PasswordResetPageConfig { is_production };
359    Router::new()
360        .route(
361            "/forgot-password",
362            get(get_forgot_password).post(post_forgot_password),
363        )
364        .route(
365            "/auth/reset-password",
366            get(get_reset_password).post(post_reset_password),
367        )
368        .layer(Extension(cfg))
369}
370
371#[cfg(test)]
372mod tests {
373    use axum::Router;
374    use axum::body::Body;
375    use axum::http::{Request, StatusCode, header};
376    use tower::ServiceExt;
377
378    use allowthem_core::{AllowThem, AllowThemBuilder, Email, LogEmailSender};
379
380    use super::{
381        PasswordResetPageConfig, password_reset_page_routes, render_forgot_password_fragment,
382        render_reset_password_fragment,
383    };
384
385    async fn setup() -> (AllowThem, PasswordResetPageConfig) {
386        let ath = AllowThemBuilder::new("sqlite::memory:")
387            .cookie_secure(false)
388            .csrf_key(*b"test-csrf-key-for-binary-tests!!")
389            .base_url("http://localhost:3000")
390            .email_sender(Box::new(LogEmailSender))
391            .build()
392            .await
393            .unwrap();
394        let config = PasswordResetPageConfig {
395            is_production: false,
396        };
397        (ath, config)
398    }
399
400    fn test_app(ath: AllowThem, config: PasswordResetPageConfig) -> Router {
401        password_reset_page_routes(config.is_production)
402            .layer(axum::middleware::from_fn(crate::csrf::csrf_middleware))
403            .layer(axum::middleware::from_fn_with_state(
404                ath.clone(),
405                crate::cors::inject_ath_into_extensions,
406            ))
407    }
408
409    async fn get_csrf_token(app: &Router, path: &str) -> String {
410        let req = Request::builder().uri(path).body(Body::empty()).unwrap();
411        let resp = app.clone().oneshot(req).await.unwrap();
412        let set_cookie = resp
413            .headers()
414            .get(header::SET_COOKIE)
415            .unwrap()
416            .to_str()
417            .unwrap()
418            .to_string();
419        set_cookie
420            .split(';')
421            .next()
422            .unwrap()
423            .split('=')
424            .nth(1)
425            .unwrap()
426            .to_string()
427    }
428
429    async fn body_string(resp: axum::http::Response<Body>) -> String {
430        let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
431            .await
432            .unwrap();
433        String::from_utf8(bytes.to_vec()).unwrap()
434    }
435
436    async fn create_user_and_token(ath: &AllowThem, email_str: &str) -> String {
437        let email = Email::new(email_str.into()).unwrap();
438        ath.db()
439            .create_user(email.clone(), "OldPass123!", None, None)
440            .await
441            .unwrap();
442        ath.db()
443            .create_password_reset(&email)
444            .await
445            .unwrap()
446            .unwrap()
447    }
448
449    #[tokio::test]
450    async fn get_forgot_password_renders_form() {
451        let (ath, config) = setup().await;
452        let app = test_app(ath, config);
453        let resp = app
454            .oneshot(
455                Request::builder()
456                    .uri("/forgot-password")
457                    .body(Body::empty())
458                    .unwrap(),
459            )
460            .await
461            .unwrap();
462        assert_eq!(resp.status(), StatusCode::OK);
463        let html = body_string(resp).await;
464        assert!(html.contains("<form"));
465        assert!(html.contains("name=\"email\""));
466    }
467
468    #[tokio::test]
469    async fn post_forgot_password_valid_email_shows_success() {
470        let (ath, config) = setup().await;
471        let email = Email::new("reset@example.com".into()).unwrap();
472        ath.db()
473            .create_user(email, "Pass123!", None, None)
474            .await
475            .unwrap();
476        let app = test_app(ath, config);
477        let csrf = get_csrf_token(&app, "/forgot-password").await;
478
479        let req = Request::builder()
480            .method("POST")
481            .uri("/forgot-password")
482            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
483            .header(header::COOKIE, format!("csrf_pre={csrf}"))
484            .body(Body::from(format!(
485                "email=reset%40example.com&csrf_token={csrf}"
486            )))
487            .unwrap();
488        let resp = app.oneshot(req).await.unwrap();
489        assert_eq!(resp.status(), StatusCode::OK);
490        let html = body_string(resp).await;
491        assert!(html.contains("If an account with that email exists"));
492    }
493
494    #[tokio::test]
495    async fn post_forgot_password_unknown_email_shows_success() {
496        let (ath, config) = setup().await;
497        let app = test_app(ath, config);
498        let csrf = get_csrf_token(&app, "/forgot-password").await;
499
500        let req = Request::builder()
501            .method("POST")
502            .uri("/forgot-password")
503            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
504            .header(header::COOKIE, format!("csrf_pre={csrf}"))
505            .body(Body::from(format!(
506                "email=nobody%40example.com&csrf_token={csrf}"
507            )))
508            .unwrap();
509        let resp = app.oneshot(req).await.unwrap();
510        assert_eq!(resp.status(), StatusCode::OK);
511        let html = body_string(resp).await;
512        assert!(html.contains("If an account with that email exists"));
513    }
514
515    #[tokio::test]
516    async fn post_forgot_password_invalid_email_shows_error() {
517        let (ath, config) = setup().await;
518        let app = test_app(ath, config);
519        let csrf = get_csrf_token(&app, "/forgot-password").await;
520
521        let req = Request::builder()
522            .method("POST")
523            .uri("/forgot-password")
524            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
525            .header(header::COOKIE, format!("csrf_pre={csrf}"))
526            .body(Body::from(format!("email=notanemail&csrf_token={csrf}")))
527            .unwrap();
528        let resp = app.oneshot(req).await.unwrap();
529        assert_eq!(resp.status(), StatusCode::OK);
530        let html = body_string(resp).await;
531        assert!(html.contains("Please enter a valid email address."));
532    }
533
534    #[tokio::test]
535    async fn get_reset_password_valid_token_renders_form() {
536        let (ath, config) = setup().await;
537        let token = create_user_and_token(&ath, "tok@example.com").await;
538        let app = test_app(ath, config);
539
540        let resp = app
541            .oneshot(
542                Request::builder()
543                    .uri(format!("/auth/reset-password?token={token}"))
544                    .body(Body::empty())
545                    .unwrap(),
546            )
547            .await
548            .unwrap();
549        assert_eq!(resp.status(), StatusCode::OK);
550        let html = body_string(resp).await;
551        assert!(html.contains("name=\"new_password\""));
552        assert!(html.contains("name=\"confirm_password\""));
553    }
554
555    #[tokio::test]
556    async fn get_reset_password_invalid_token_shows_error() {
557        let (ath, config) = setup().await;
558        let app = test_app(ath, config);
559
560        let resp = app
561            .oneshot(
562                Request::builder()
563                    .uri("/auth/reset-password?token=invalidtoken")
564                    .body(Body::empty())
565                    .unwrap(),
566            )
567            .await
568            .unwrap();
569        assert_eq!(resp.status(), StatusCode::OK);
570        let html = body_string(resp).await;
571        assert!(html.contains("invalid or has expired"));
572        assert!(!html.contains("name=\"new_password\""));
573    }
574
575    #[tokio::test]
576    async fn post_reset_password_passwords_mismatch_shows_error() {
577        let (ath, config) = setup().await;
578        let token = create_user_and_token(&ath, "mismatch@example.com").await;
579        let app = test_app(ath, config);
580        let csrf = get_csrf_token(&app, &format!("/auth/reset-password?token={token}")).await;
581
582        let req = Request::builder()
583            .method("POST")
584            .uri("/auth/reset-password")
585            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
586            .header(header::COOKIE, format!("csrf_pre={csrf}"))
587            .body(Body::from(format!(
588                "token={token}&new_password=NewPass999!&confirm_password=Different1!&csrf_token={csrf}"
589            )))
590            .unwrap();
591        let resp = app.oneshot(req).await.unwrap();
592        assert_eq!(resp.status(), StatusCode::OK);
593        let html = body_string(resp).await;
594        assert!(html.contains("Passwords do not match"));
595    }
596
597    #[tokio::test]
598    async fn post_reset_password_too_short_shows_error() {
599        let (ath, config) = setup().await;
600        let token = create_user_and_token(&ath, "short@example.com").await;
601        let app = test_app(ath, config);
602        let csrf = get_csrf_token(&app, &format!("/auth/reset-password?token={token}")).await;
603
604        let req = Request::builder()
605            .method("POST")
606            .uri("/auth/reset-password")
607            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
608            .header(header::COOKIE, format!("csrf_pre={csrf}"))
609            .body(Body::from(format!(
610                "token={token}&new_password=short&confirm_password=short&csrf_token={csrf}"
611            )))
612            .unwrap();
613        let resp = app.oneshot(req).await.unwrap();
614        assert_eq!(resp.status(), StatusCode::OK);
615        let html = body_string(resp).await;
616        assert!(html.contains("Password must be at least 8 characters"));
617    }
618
619    #[tokio::test]
620    async fn post_reset_password_success_shows_confirmation() {
621        let (ath, config) = setup().await;
622        let token = create_user_and_token(&ath, "success@example.com").await;
623        let app = test_app(ath, config);
624        let csrf = get_csrf_token(&app, &format!("/auth/reset-password?token={token}")).await;
625
626        let req = Request::builder()
627            .method("POST")
628            .uri("/auth/reset-password")
629            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
630            .header(header::COOKIE, format!("csrf_pre={csrf}"))
631            .body(Body::from(format!(
632                "token={token}&new_password=NewPass999!&confirm_password=NewPass999!&csrf_token={csrf}"
633            )))
634            .unwrap();
635        let resp = app.oneshot(req).await.unwrap();
636        assert_eq!(resp.status(), StatusCode::OK);
637        let html = body_string(resp).await;
638        assert!(html.contains("Your password has been reset"));
639    }
640
641    #[tokio::test]
642    async fn post_reset_password_used_token_shows_invalid() {
643        let (ath, config) = setup().await;
644        let token = create_user_and_token(&ath, "used@example.com").await;
645        // Consume the token directly via DB
646        ath.db()
647            .execute_reset(&token, "AlreadyUsed1!")
648            .await
649            .unwrap();
650
651        let app = test_app(ath, config);
652        let csrf = get_csrf_token(&app, "/forgot-password").await;
653
654        let req = Request::builder()
655            .method("POST")
656            .uri("/auth/reset-password")
657            .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
658            .header(header::COOKIE, format!("csrf_pre={csrf}"))
659            .body(Body::from(format!(
660                "token={token}&new_password=NewPass999!&confirm_password=NewPass999!&csrf_token={csrf}"
661            )))
662            .unwrap();
663        let resp = app.oneshot(req).await.unwrap();
664        assert_eq!(resp.status(), StatusCode::OK);
665        let html = body_string(resp).await;
666        assert!(html.contains("invalid or has expired"));
667    }
668
669    #[tokio::test]
670    async fn get_forgot_password_logged_in_redirects_to_root() {
671        use allowthem_core::{generate_token, hash_token};
672        use chrono::{Duration, Utc};
673
674        let (ath, config) = setup().await;
675
676        // Create a user and an active session
677        let email = Email::new("loggedin@example.com".into()).unwrap();
678        let user = ath
679            .db()
680            .create_user(email, "password123", None, None)
681            .await
682            .unwrap();
683        let token = generate_token();
684        let token_hash = hash_token(&token);
685        ath.db()
686            .create_session(
687                user.id,
688                token_hash,
689                None,
690                None,
691                Utc::now() + Duration::hours(24),
692            )
693            .await
694            .unwrap();
695        let session_cookie = ath.session_cookie(&token);
696        let cookie_value = session_cookie.split(';').next().unwrap().to_string();
697
698        let app = test_app(ath, config);
699        let req = Request::builder()
700            .uri("/forgot-password")
701            .header(header::COOKIE, cookie_value)
702            .body(Body::empty())
703            .unwrap();
704        let resp = app.oneshot(req).await.unwrap();
705
706        assert_eq!(resp.status(), StatusCode::SEE_OTHER);
707        assert_eq!(resp.headers().get("location").unwrap(), "/");
708    }
709
710    #[tokio::test]
711    async fn get_forgot_password_hx_request_returns_fragment() {
712        let (ath, config) = setup().await;
713        let app = test_app(ath, config);
714        let resp = app
715            .oneshot(
716                Request::builder()
717                    .uri("/forgot-password")
718                    .header("HX-Request", "true")
719                    .body(Body::empty())
720                    .unwrap(),
721            )
722            .await
723            .unwrap();
724        assert_eq!(resp.status(), StatusCode::OK);
725        let html = body_string(resp).await;
726        assert!(
727            html.contains("<main class=\"wf-auth-form\">"),
728            "HX response must be a fragment starting at <main>"
729        );
730        assert!(
731            !html.contains("<html"),
732            "HX response must not render the full shell"
733        );
734    }
735
736    #[tokio::test]
737    async fn render_forgot_password_fragment_composes_main_and_oob_head() {
738        let (_ath, config) = setup().await;
739        let html = render_forgot_password_fragment(&config, "tok", "", false, None)
740            .unwrap()
741            .0;
742        assert!(
743            html.contains("<main class=\"wf-auth-form\">"),
744            "fragment must include the <main> root"
745        );
746        assert!(
747            html.contains("<title hx-swap-oob=\"true\">"),
748            "fragment must include the OOB <title> tag"
749        );
750        assert!(
751            html.contains("id=\"wf-screen-label\""),
752            "fragment must include the OOB #wf-screen-label span"
753        );
754    }
755
756    #[tokio::test]
757    async fn render_reset_password_fragment_composes_main_and_oob_head() {
758        let (_ath, config) = setup().await;
759        let html = render_reset_password_fragment(
760            &config,
761            "tok",
762            "reset-token-abc",
763            false,
764            false,
765            "",
766            None,
767        )
768        .unwrap()
769        .0;
770        assert!(
771            html.contains("<main class=\"wf-auth-form\">"),
772            "fragment must include the <main> root"
773        );
774        assert!(
775            html.contains("<title hx-swap-oob=\"true\">"),
776            "fragment must include the OOB <title> tag"
777        );
778        assert!(
779            html.contains("id=\"wf-screen-label\""),
780            "fragment must include the OOB #wf-screen-label span"
781        );
782        assert!(
783            html.contains("RESET PASSWORD"),
784            "fragment must include the RESET PASSWORD status hint"
785        );
786    }
787
788    #[tokio::test]
789    async fn get_reset_password_hx_request_returns_fragment() {
790        let (ath, config) = setup().await;
791        let token = create_user_and_token(&ath, "hx@example.com").await;
792        let app = test_app(ath, config);
793        let resp = app
794            .oneshot(
795                Request::builder()
796                    .uri(format!("/auth/reset-password?token={token}"))
797                    .header("HX-Request", "true")
798                    .body(Body::empty())
799                    .unwrap(),
800            )
801            .await
802            .unwrap();
803        assert_eq!(resp.status(), StatusCode::OK);
804        let html = body_string(resp).await;
805        assert!(
806            html.contains("<main class=\"wf-auth-form\">"),
807            "HX response must be a fragment starting at <main>"
808        );
809        assert!(
810            !html.contains("<html"),
811            "HX response must not render the full shell"
812        );
813    }
814}