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