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
51fn 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
80fn 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
113async 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
150async 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
197async 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
307async 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 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 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
401async 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 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 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}