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
53fn 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
71fn 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
93async 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
126async 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
165async 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
263async 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 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 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
341async 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 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 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}