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