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
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 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
82fn 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
115async 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
152async 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
204async 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
308async 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 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 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
395async 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 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 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}