1use axum::Router;
2use axum::extract::Extension;
3use axum::http::HeaderMap;
4use axum::http::Uri;
5use axum::http::header::USER_AGENT;
6use axum::response::{Html, IntoResponse, Response};
7use axum::routing::{get, post};
8use serde::Deserialize;
9
10use allowthem_core::types::{RoleName, User};
11use allowthem_core::{AllowThem, AuditEvent, AuthError, Email, OAuthAccountInfo, Username};
12
13use crate::auth_views::SettingsView;
14use crate::browser_error::BrowserError;
15use crate::csrf::CsrfToken;
16use crate::error::BrowserAuthRedirect;
17use crate::shell_context::ShellContext;
18
19const MIN_PASSWORD_LEN: usize = 8;
20
21#[derive(Clone)]
22struct SettingsConfig {
23 is_production: bool,
24}
25
26#[derive(Deserialize)]
27pub struct ProfileForm {
28 email: String,
29 #[serde(default)]
30 username: String,
31 #[allow(dead_code)]
32 csrf_token: String,
33}
34
35#[derive(Deserialize)]
36pub struct PasswordForm {
37 current_password: String,
38 new_password: String,
39 new_password_confirm: String,
40 #[allow(dead_code)]
41 csrf_token: String,
42}
43
44struct SettingsContext {
45 email: String,
46 username: String,
47 profile_error: String,
48 profile_success: String,
49 password_error: String,
50 password_success: String,
51 oauth_accounts: Vec<OAuthAccountInfo>,
52 mfa_enabled: bool,
53 mfa_recovery_remaining: i64,
54 is_admin: bool,
55}
56
57fn render_settings(
58 config: &SettingsConfig,
59 csrf_token: &str,
60 ctx: &SettingsContext,
61) -> Result<Html<String>, BrowserError> {
62 let shell = ShellContext::new(ctx.is_admin, "/settings", "allowthem").with_session(&ctx.email);
63 crate::auth_views::settings_page(&SettingsView {
64 csrf_token,
65 shell: &shell,
66 email: &ctx.email,
67 username: &ctx.username,
68 profile_error: &ctx.profile_error,
69 profile_success: &ctx.profile_success,
70 password_error: &ctx.password_error,
71 password_success: &ctx.password_success,
72 oauth_accounts: &ctx.oauth_accounts,
73 mfa_enabled: ctx.mfa_enabled,
74 mfa_recovery_remaining: ctx.mfa_recovery_remaining,
75 is_production: config.is_production,
76 })
77}
78
79async fn fetch_account_data(
80 ath: &AllowThem,
81 user_id: allowthem_core::types::UserId,
82) -> Result<(Vec<OAuthAccountInfo>, bool, i64), BrowserError> {
83 let oauth_accounts = ath.db().get_user_oauth_accounts(user_id).await?;
84 let mfa_enabled = ath.db().has_mfa_enabled(user_id).await?;
85 let mfa_recovery_remaining = if mfa_enabled {
86 ath.db().remaining_recovery_codes(user_id).await?
87 } else {
88 0
89 };
90 Ok((oauth_accounts, mfa_enabled, mfa_recovery_remaining))
91}
92
93fn client_ip(headers: &HeaderMap) -> Option<String> {
94 headers
95 .get("x-forwarded-for")
96 .and_then(|v| v.to_str().ok())
97 .and_then(|s| s.split(',').next())
98 .map(|s| s.trim().to_string())
99}
100
101async fn require_browser_user(
107 ath: &AllowThem,
108 headers: &HeaderMap,
109 path: &str,
110) -> Result<User, Response> {
111 let cookie_header = headers
112 .get(axum::http::header::COOKIE)
113 .and_then(|v| v.to_str().ok())
114 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
115
116 let token = ath
117 .parse_session_cookie(cookie_header)
118 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
119
120 let ttl = ath.session_config().ttl;
121 let session = ath
122 .db()
123 .validate_session(&token, ttl)
124 .await
125 .map_err(|err| {
126 tracing::error!("session validation error: {err}");
127 BrowserAuthRedirect::new(path).into_response()
128 })?
129 .ok_or_else(|| BrowserAuthRedirect::new(path).into_response())?;
130
131 match ath.db().get_user(session.user_id).await {
132 Ok(user) if user.is_active => Ok(user),
133 Ok(_) => Err(BrowserAuthRedirect::new(path).into_response()),
134 Err(AuthError::NotFound) => Err(BrowserAuthRedirect::new(path).into_response()),
135 Err(err) => {
136 tracing::error!("user lookup error: {err}");
137 Err(BrowserAuthRedirect::new(path).into_response())
138 }
139 }
140}
141
142async fn get_settings(
144 Extension(ath): Extension<AllowThem>,
145 Extension(config): Extension<SettingsConfig>,
146 uri: Uri,
147 csrf: CsrfToken,
148 headers: HeaderMap,
149) -> Result<Response, BrowserError> {
150 let user = match require_browser_user(&ath, &headers, uri.path()).await {
151 Ok(u) => u,
152 Err(redirect) => return Ok(redirect),
153 };
154
155 let is_admin = ath.db().has_role(&user.id, &RoleName::new("admin")).await?;
156
157 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
158 fetch_account_data(&ath, user.id).await?;
159
160 let ctx = SettingsContext {
161 email: user.email.as_str().to_string(),
162 username: user
163 .username
164 .as_ref()
165 .map_or(String::new(), |u| u.as_str().to_string()),
166 profile_error: String::new(),
167 profile_success: String::new(),
168 password_error: String::new(),
169 password_success: String::new(),
170 oauth_accounts,
171 mfa_enabled,
172 mfa_recovery_remaining,
173 is_admin,
174 };
175 let html = render_settings(&config, csrf.as_str(), &ctx)?;
176 Ok(html.into_response())
177}
178
179async fn post_settings(
181 Extension(ath): Extension<AllowThem>,
182 Extension(config): Extension<SettingsConfig>,
183 uri: Uri,
184 csrf: CsrfToken,
185 headers: HeaderMap,
186 axum::Form(form): axum::Form<ProfileForm>,
187) -> Result<Response, BrowserError> {
188 let user = match require_browser_user(&ath, &headers, uri.path()).await {
189 Ok(u) => u,
190 Err(redirect) => return Ok(redirect),
191 };
192
193 let is_admin = ath.db().has_role(&user.id, &RoleName::new("admin")).await?;
194
195 let email = match Email::new(form.email.clone()) {
197 Ok(e) => e,
198 Err(_) => {
199 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
200 fetch_account_data(&ath, user.id).await?;
201 let ctx = SettingsContext {
202 email: form.email,
203 username: form.username,
204 profile_error: "Invalid email address".into(),
205 profile_success: String::new(),
206 password_error: String::new(),
207 password_success: String::new(),
208 oauth_accounts,
209 mfa_enabled,
210 mfa_recovery_remaining,
211 is_admin,
212 };
213 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
214 }
215 };
216
217 let trimmed = form.username.trim();
219 let username = if trimmed.is_empty() {
220 None
221 } else {
222 Some(Username::new(trimmed))
223 };
224
225 if email != user.email {
227 match ath.update_user_email(user.id, email).await {
228 Ok(()) => {}
229 Err(AuthError::Conflict(ref msg)) if msg.contains("email") => {
230 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
231 fetch_account_data(&ath, user.id).await?;
232 let ctx = SettingsContext {
233 email: form.email,
234 username: form.username,
235 profile_error: "An account with this email already exists".into(),
236 profile_success: String::new(),
237 password_error: String::new(),
238 password_success: String::new(),
239 oauth_accounts,
240 mfa_enabled,
241 mfa_recovery_remaining,
242 is_admin,
243 };
244 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
245 }
246 Err(e) => return Err(BrowserError::Auth(e)),
247 }
248 }
249
250 let current_username = user.username.as_ref().map(|u| u.as_str());
255 let new_username = username.as_ref().map(|u| u.as_str());
256 if current_username != new_username {
257 match ath.update_user_username(user.id, username).await {
258 Ok(()) => {}
259 Err(AuthError::Conflict(ref msg)) if msg.contains("username") => {
260 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
261 fetch_account_data(&ath, user.id).await?;
262 let ctx = SettingsContext {
263 email: form.email,
264 username: form.username,
265 profile_error: "This username is already taken".into(),
266 profile_success: String::new(),
267 password_error: String::new(),
268 password_success: String::new(),
269 oauth_accounts,
270 mfa_enabled,
271 mfa_recovery_remaining,
272 is_admin,
273 };
274 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
275 }
276 Err(e) => return Err(BrowserError::Auth(e)),
277 }
278 }
279
280 let ip = client_ip(&headers);
282 let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
283 let _ = ath
284 .db()
285 .log_audit(
286 AuditEvent::UserUpdated,
287 Some(&user.id),
288 None,
289 ip.as_deref(),
290 ua,
291 None,
292 )
293 .await;
294
295 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
297 fetch_account_data(&ath, user.id).await?;
298 let ctx = SettingsContext {
299 email: form.email,
300 username: form.username,
301 profile_error: String::new(),
302 profile_success: "Profile updated".into(),
303 password_error: String::new(),
304 password_success: String::new(),
305 oauth_accounts,
306 mfa_enabled,
307 mfa_recovery_remaining,
308 is_admin,
309 };
310 Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response())
311}
312
313async fn post_change_password(
315 Extension(ath): Extension<AllowThem>,
316 Extension(config): Extension<SettingsConfig>,
317 uri: Uri,
318 csrf: CsrfToken,
319 headers: HeaderMap,
320 axum::Form(form): axum::Form<PasswordForm>,
321) -> Result<Response, BrowserError> {
322 let user = match require_browser_user(&ath, &headers, uri.path()).await {
323 Ok(u) => u,
324 Err(redirect) => return Ok(redirect),
325 };
326
327 let is_admin = ath.db().has_role(&user.id, &RoleName::new("admin")).await?;
328
329 let ip = client_ip(&headers);
330 let ua = headers.get(USER_AGENT).and_then(|v| v.to_str().ok());
331
332 if form.new_password.len() < MIN_PASSWORD_LEN {
334 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
335 fetch_account_data(&ath, user.id).await?;
336 let ctx = SettingsContext {
337 email: user.email.as_str().to_string(),
338 username: user
339 .username
340 .as_ref()
341 .map_or(String::new(), |u| u.as_str().to_string()),
342 profile_error: String::new(),
343 profile_success: String::new(),
344 password_error: "New password must be at least 8 characters".into(),
345 password_success: String::new(),
346 oauth_accounts,
347 mfa_enabled,
348 mfa_recovery_remaining,
349 is_admin,
350 };
351 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
352 }
353
354 if form.new_password != form.new_password_confirm {
356 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
357 fetch_account_data(&ath, user.id).await?;
358 let ctx = SettingsContext {
359 email: user.email.as_str().to_string(),
360 username: user
361 .username
362 .as_ref()
363 .map_or(String::new(), |u| u.as_str().to_string()),
364 profile_error: String::new(),
365 profile_success: String::new(),
366 password_error: "New passwords do not match".into(),
367 password_success: String::new(),
368 oauth_accounts,
369 mfa_enabled,
370 mfa_recovery_remaining,
371 is_admin,
372 };
373 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
374 }
375
376 let fetched_user = ath.db().find_for_login(user.email.as_str()).await?;
378
379 let password_ok = match fetched_user.password_hash {
380 Some(ref h) => {
381 allowthem_core::password::verify_password(&form.current_password, h).unwrap_or(false)
382 }
383 None => false,
384 };
385
386 if !password_ok {
387 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
388 fetch_account_data(&ath, user.id).await?;
389 let ctx = SettingsContext {
390 email: user.email.as_str().to_string(),
391 username: user
392 .username
393 .as_ref()
394 .map_or(String::new(), |u| u.as_str().to_string()),
395 profile_error: String::new(),
396 profile_success: String::new(),
397 password_error: "Current password is incorrect".into(),
398 password_success: String::new(),
399 oauth_accounts,
400 mfa_enabled,
401 mfa_recovery_remaining,
402 is_admin,
403 };
404 return Ok(render_settings(&config, csrf.as_str(), &ctx)?.into_response());
405 }
406
407 ath.update_user_password(user.id, &form.new_password)
409 .await?;
410
411 ath.delete_user_sessions(&user.id).await?;
413
414 let token = allowthem_core::generate_token();
415 let token_hash = allowthem_core::hash_token(&token);
416 let expires_at = chrono::Utc::now() + ath.session_config().ttl;
417 ath.db()
418 .create_session(user.id, token_hash, ip.as_deref(), ua, expires_at)
419 .await?;
420 let cookie = ath.session_cookie(&token);
421
422 let _ = ath
424 .db()
425 .log_audit(
426 AuditEvent::PasswordChange,
427 Some(&user.id),
428 None,
429 ip.as_deref(),
430 ua,
431 None,
432 )
433 .await;
434
435 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
437 fetch_account_data(&ath, user.id).await?;
438 let ctx = SettingsContext {
439 email: user.email.as_str().to_string(),
440 username: user
441 .username
442 .as_ref()
443 .map_or(String::new(), |u| u.as_str().to_string()),
444 profile_error: String::new(),
445 profile_success: String::new(),
446 password_error: String::new(),
447 password_success: "Password changed successfully".into(),
448 oauth_accounts,
449 mfa_enabled,
450 mfa_recovery_remaining,
451 is_admin,
452 };
453 let html = render_settings(&config, csrf.as_str(), &ctx)?;
454
455 Ok(([(axum::http::header::SET_COOKIE, cookie)], html).into_response())
456}
457
458pub fn settings_routes(is_production: bool) -> Router<()> {
459 let cfg = SettingsConfig { is_production };
460 Router::new()
461 .route("/settings", get(get_settings).post(post_settings))
462 .route("/settings/password", post(post_change_password))
463 .layer(Extension(cfg))
464}
465
466#[cfg(test)]
467mod tests {
468 use axum::Router;
469 use axum::body::Body;
470 use axum::http::{Request, StatusCode, header};
471 use tower::ServiceExt;
472
473 use allowthem_core::types::RoleName;
474 use allowthem_core::{
475 AllowThem, AllowThemBuilder, AuditEvent, Email, Username, generate_token, hash_token,
476 parse_session_cookie,
477 };
478
479 use super::{SettingsConfig, settings_routes};
480
481 async fn setup() -> (AllowThem, SettingsConfig, String) {
482 let ath = AllowThemBuilder::new("sqlite::memory:")
483 .cookie_secure(false)
484 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
485 .build()
486 .await
487 .unwrap();
488
489 let email = Email::new("user@example.com".into()).unwrap();
490 let user = ath
491 .db()
492 .create_user(email, "password123", Some(Username::new("testuser")), None)
493 .await
494 .unwrap();
495
496 let token = generate_token();
497 let token_hash = hash_token(&token);
498 let expires = chrono::Utc::now() + chrono::Duration::hours(24);
499 ath.db()
500 .create_session(user.id, token_hash, None, None, expires)
501 .await
502 .unwrap();
503 let set_cookie = ath.session_cookie(&token);
504 let cookie_value = set_cookie.split(';').next().unwrap().to_string();
505
506 let config = SettingsConfig {
507 is_production: false,
508 };
509
510 (ath, config, cookie_value)
511 }
512
513 fn test_app(ath: AllowThem, config: SettingsConfig) -> Router {
514 settings_routes(config.is_production)
515 .layer(axum::middleware::from_fn(crate::csrf::csrf_middleware))
516 .layer(axum::middleware::from_fn_with_state(
517 ath.clone(),
518 crate::cors::inject_ath_into_extensions,
519 ))
520 }
521
522 async fn get_csrf_token(app: &Router, cookie: &str) -> String {
523 let req = Request::builder()
524 .uri("/settings")
525 .header(header::COOKIE, cookie)
526 .body(Body::empty())
527 .unwrap();
528 let resp = app.clone().oneshot(req).await.unwrap();
529 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
530 .await
531 .unwrap();
532 let html = String::from_utf8(bytes.to_vec()).unwrap();
533 let marker = "name=\"csrf_token\" value=\"";
534 let start = html.find(marker).expect("csrf_token not found in HTML") + marker.len();
535 let end = html[start..].find('"').unwrap() + start;
536 html[start..end].to_string()
537 }
538
539 async fn body_string(resp: axum::http::Response<Body>) -> String {
540 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
541 .await
542 .unwrap();
543 String::from_utf8(bytes.to_vec()).unwrap()
544 }
545
546 fn profile_request(
547 csrf: &str,
548 session_cookie: &str,
549 email: &str,
550 username: &str,
551 ) -> Request<Body> {
552 let enc = |s: &str| s.replace('@', "%40");
553 let body = format!(
554 "csrf_token={}&email={}&username={}",
555 csrf,
556 enc(email),
557 enc(username),
558 );
559 Request::builder()
560 .method("POST")
561 .uri("/settings")
562 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
563 .header(
564 header::COOKIE,
565 format!("{session_cookie}; csrf_token={csrf}"),
566 )
567 .body(Body::from(body))
568 .unwrap()
569 }
570
571 fn password_request(
572 csrf: &str,
573 session_cookie: &str,
574 current: &str,
575 new: &str,
576 confirm: &str,
577 ) -> Request<Body> {
578 let body = format!(
579 "csrf_token={csrf}¤t_password={current}&new_password={new}&new_password_confirm={confirm}",
580 );
581 Request::builder()
582 .method("POST")
583 .uri("/settings/password")
584 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
585 .header(
586 header::COOKIE,
587 format!("{session_cookie}; csrf_token={csrf}"),
588 )
589 .body(Body::from(body))
590 .unwrap()
591 }
592
593 #[tokio::test]
596 async fn get_settings_renders_page() {
597 let (ath, config, cookie) = setup().await;
598 let app = test_app(ath, config);
599 let req = Request::builder()
600 .uri("/settings")
601 .header(header::COOKIE, &cookie)
602 .body(Body::empty())
603 .unwrap();
604 let resp = app.oneshot(req).await.unwrap();
605 assert_eq!(resp.status(), StatusCode::OK);
606 let html = body_string(resp).await;
607 assert!(html.contains("user@example.com"));
608 assert!(html.contains("testuser"));
609 assert!(html.contains("Settings"));
610 assert!(html.contains("class=\"wf-app\"") || html.contains("class=\"wf-app "));
611 assert!(
612 !html.contains("class=\"at-app-shell\"") && !html.contains("class=\"at-app-shell ")
613 );
614 assert!(html.contains("href=\"/logout\""));
615 }
616
617 #[tokio::test]
618 async fn get_settings_admin_user_sees_admin_nav() {
619 let (ath, config, cookie) = setup().await;
620
621 let email = Email::new("user@example.com".into()).unwrap();
623 let user = ath.db().get_user_by_email(&email).await.unwrap();
624 let role_name = RoleName::new("admin");
625 let role = ath.db().create_role(&role_name, None).await.unwrap();
626 ath.db().assign_role(&user.id, &role.id).await.unwrap();
627
628 let app = test_app(ath, config);
629 let req = Request::builder()
630 .uri("/settings")
631 .header(header::COOKIE, &cookie)
632 .body(Body::empty())
633 .unwrap();
634 let resp = app.oneshot(req).await.unwrap();
635 assert_eq!(resp.status(), StatusCode::OK);
636 let html = body_string(resp).await;
637 assert!(html.contains("href=\"/admin/applications\""));
638 assert!(html.contains("href=\"/admin/users\""));
639 assert!(html.contains("href=\"/admin/sessions\""));
640 assert!(html.contains("href=\"/admin/audit\""));
641 }
642
643 #[tokio::test]
644 async fn get_settings_non_admin_user_has_no_admin_nav() {
645 let (ath, config, cookie) = setup().await;
646 let app = test_app(ath, config);
647 let req = Request::builder()
648 .uri("/settings")
649 .header(header::COOKIE, &cookie)
650 .body(Body::empty())
651 .unwrap();
652 let resp = app.oneshot(req).await.unwrap();
653 let html = body_string(resp).await;
654 assert!(!html.contains("/admin/applications"));
655 }
656
657 #[tokio::test]
658 async fn get_settings_unauthenticated_redirects() {
659 let (ath, config, _) = setup().await;
660 let app = test_app(ath, config);
661 let req = Request::builder()
662 .uri("/settings")
663 .body(Body::empty())
664 .unwrap();
665 let resp = app.oneshot(req).await.unwrap();
666 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
667 assert_eq!(
668 resp.headers().get("location").unwrap(),
669 "/login?next=/settings"
670 );
671 }
672
673 #[tokio::test]
674 async fn get_settings_shows_csrf_token() {
675 let (ath, config, cookie) = setup().await;
676 let app = test_app(ath, config);
677 let req = Request::builder()
678 .uri("/settings")
679 .header(header::COOKIE, &cookie)
680 .body(Body::empty())
681 .unwrap();
682 let resp = app.oneshot(req).await.unwrap();
683 let html = body_string(resp).await;
684 assert!(html.contains("name=\"csrf_token\""));
685 }
686
687 #[tokio::test]
688 async fn get_settings_shows_oauth_section() {
689 let (ath, config, cookie) = setup().await;
690 let app = test_app(ath, config);
691 let req = Request::builder()
692 .uri("/settings")
693 .header(header::COOKIE, &cookie)
694 .body(Body::empty())
695 .unwrap();
696 let resp = app.oneshot(req).await.unwrap();
697 let html = body_string(resp).await;
698 assert!(html.contains("Linked accounts"));
699 assert!(html.contains("No linked accounts"));
700 }
701
702 #[tokio::test]
703 async fn get_settings_shows_mfa_section() {
704 let (ath, config, cookie) = setup().await;
705 let app = test_app(ath, config);
706 let req = Request::builder()
707 .uri("/settings")
708 .header(header::COOKIE, &cookie)
709 .body(Body::empty())
710 .unwrap();
711 let resp = app.oneshot(req).await.unwrap();
712 let html = body_string(resp).await;
713 assert!(html.contains("Two-factor authentication"));
714 assert!(html.contains("Not configured"));
715 }
716
717 #[tokio::test]
720 async fn post_settings_updates_email() {
721 let (ath, config, cookie) = setup().await;
722 let app = test_app(ath.clone(), config);
723 let csrf = get_csrf_token(&app, &cookie).await;
724 let req = profile_request(&csrf, &cookie, "new@example.com", "testuser");
725 let resp = app.oneshot(req).await.unwrap();
726 assert_eq!(resp.status(), StatusCode::OK);
727 let html = body_string(resp).await;
728 assert!(html.contains("Profile updated"));
729
730 let email = Email::new("new@example.com".into()).unwrap();
731 let user = ath.db().get_user_by_email(&email).await;
732 assert!(user.is_ok());
733 }
734
735 #[tokio::test]
736 async fn post_settings_updates_username() {
737 let (ath, config, cookie) = setup().await;
738 let app = test_app(ath.clone(), config);
739 let csrf = get_csrf_token(&app, &cookie).await;
740 let req = profile_request(&csrf, &cookie, "user@example.com", "newname");
741 let resp = app.oneshot(req).await.unwrap();
742 assert_eq!(resp.status(), StatusCode::OK);
743
744 let username = Username::new("newname");
745 let user = ath.db().get_user_by_username(&username).await;
746 assert!(user.is_ok());
747 }
748
749 #[tokio::test]
750 async fn post_settings_clears_username() {
751 let (ath, config, cookie) = setup().await;
752 let app = test_app(ath.clone(), config);
753 let csrf = get_csrf_token(&app, &cookie).await;
754 let req = profile_request(&csrf, &cookie, "user@example.com", "");
755 let resp = app.oneshot(req).await.unwrap();
756 assert_eq!(resp.status(), StatusCode::OK);
757
758 let email = Email::new("user@example.com".into()).unwrap();
759 let user = ath.db().get_user_by_email(&email).await.unwrap();
760 assert!(user.username.is_none());
761 }
762
763 #[tokio::test]
764 async fn post_settings_duplicate_email_shows_error() {
765 let (ath, config, cookie) = setup().await;
766 let other_email = Email::new("other@example.com".into()).unwrap();
767 ath.db()
768 .create_user(other_email, "password123", None, None)
769 .await
770 .unwrap();
771
772 let app = test_app(ath, config);
773 let csrf = get_csrf_token(&app, &cookie).await;
774 let req = profile_request(&csrf, &cookie, "other@example.com", "testuser");
775 let resp = app.oneshot(req).await.unwrap();
776 let html = body_string(resp).await;
777 assert!(html.contains("An account with this email already exists"));
778 }
779
780 #[tokio::test]
781 async fn post_settings_duplicate_username_shows_error() {
782 let (ath, config, cookie) = setup().await;
783 let other_email = Email::new("other@example.com".into()).unwrap();
784 ath.db()
785 .create_user(
786 other_email,
787 "password123",
788 Some(Username::new("taken")),
789 None,
790 )
791 .await
792 .unwrap();
793
794 let app = test_app(ath, config);
795 let csrf = get_csrf_token(&app, &cookie).await;
796 let req = profile_request(&csrf, &cookie, "user@example.com", "taken");
797 let resp = app.oneshot(req).await.unwrap();
798 let html = body_string(resp).await;
799 assert!(html.contains("This username is already taken"));
800 }
801
802 #[tokio::test]
803 async fn post_settings_invalid_email_shows_error() {
804 let (ath, config, cookie) = setup().await;
805 let app = test_app(ath, config);
806 let csrf = get_csrf_token(&app, &cookie).await;
807 let req = profile_request(&csrf, &cookie, "not-an-email", "testuser");
808 let resp = app.oneshot(req).await.unwrap();
809 let html = body_string(resp).await;
810 assert!(html.contains("Invalid email address"));
811 }
812
813 #[tokio::test]
814 async fn post_settings_no_changes_succeeds() {
815 let (ath, config, cookie) = setup().await;
816 let app = test_app(ath, config);
817 let csrf = get_csrf_token(&app, &cookie).await;
818 let req = profile_request(&csrf, &cookie, "user@example.com", "testuser");
819 let resp = app.oneshot(req).await.unwrap();
820 let html = body_string(resp).await;
821 assert!(html.contains("Profile updated"));
822 }
823
824 #[tokio::test]
825 async fn post_settings_logs_audit() {
826 let (ath, config, cookie) = setup().await;
827 let app = test_app(ath.clone(), config);
828 let csrf = get_csrf_token(&app, &cookie).await;
829 let req = profile_request(&csrf, &cookie, "user@example.com", "testuser");
830 app.oneshot(req).await.unwrap();
831
832 let entries = ath.db().get_audit_log(None, 10, 0).await.unwrap();
833 let updated = entries
834 .iter()
835 .find(|e| e.event_type == AuditEvent::UserUpdated);
836 assert!(
837 updated.is_some(),
838 "UserUpdated audit event should be recorded"
839 );
840 }
841
842 #[tokio::test]
843 async fn post_settings_requires_csrf() {
844 let (ath, config, cookie) = setup().await;
845 let app = test_app(ath, config);
846 let body = "email=user%40example.com&username=testuser";
847 let req = Request::builder()
848 .method("POST")
849 .uri("/settings")
850 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
851 .header(header::COOKIE, &cookie)
852 .body(Body::from(body))
853 .unwrap();
854 let resp = app.oneshot(req).await.unwrap();
855 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
856 }
857
858 #[tokio::test]
861 async fn post_password_change_success() {
862 let (ath, config, cookie) = setup().await;
863 let app = test_app(ath.clone(), config);
864 let csrf = get_csrf_token(&app, &cookie).await;
865 let req = password_request(
866 &csrf,
867 &cookie,
868 "password123",
869 "newpassword456",
870 "newpassword456",
871 );
872 let resp = app.oneshot(req).await.unwrap();
873 assert_eq!(resp.status(), StatusCode::OK);
874 let html = body_string(resp).await;
875 assert!(html.contains("Password changed successfully"));
876
877 let email = Email::new("user@example.com".into()).unwrap();
879 let user = ath.db().get_user_by_email(&email).await.unwrap();
880 let user_with_hash = ath.db().find_for_login(user.email.as_str()).await.unwrap();
881 let ok = allowthem_core::password::verify_password(
882 "newpassword456",
883 user_with_hash.password_hash.as_ref().unwrap(),
884 )
885 .unwrap();
886 assert!(ok, "new password should verify");
887 }
888
889 #[tokio::test]
890 async fn post_password_wrong_current() {
891 let (ath, config, cookie) = setup().await;
892 let app = test_app(ath, config);
893 let csrf = get_csrf_token(&app, &cookie).await;
894 let req = password_request(
895 &csrf,
896 &cookie,
897 "wrongpassword",
898 "newpassword456",
899 "newpassword456",
900 );
901 let resp = app.oneshot(req).await.unwrap();
902 let html = body_string(resp).await;
903 assert!(html.contains("Current password is incorrect"));
904 }
905
906 #[tokio::test]
907 async fn post_password_too_short() {
908 let (ath, config, cookie) = setup().await;
909 let app = test_app(ath, config);
910 let csrf = get_csrf_token(&app, &cookie).await;
911 let req = password_request(&csrf, &cookie, "password123", "abc", "abc");
912 let resp = app.oneshot(req).await.unwrap();
913 let html = body_string(resp).await;
914 assert!(html.contains("New password must be at least 8 characters"));
915 }
916
917 #[tokio::test]
918 async fn post_password_mismatch() {
919 let (ath, config, cookie) = setup().await;
920 let app = test_app(ath, config);
921 let csrf = get_csrf_token(&app, &cookie).await;
922 let req = password_request(
923 &csrf,
924 &cookie,
925 "password123",
926 "newpassword1",
927 "newpassword2",
928 );
929 let resp = app.oneshot(req).await.unwrap();
930 let html = body_string(resp).await;
931 assert!(html.contains("New passwords do not match"));
932 }
933
934 #[tokio::test]
935 async fn post_password_invalidates_other_sessions() {
936 let (ath, config, cookie) = setup().await;
937
938 let email = Email::new("user@example.com".into()).unwrap();
940 let user = ath.db().get_user_by_email(&email).await.unwrap();
941 let token2 = generate_token();
942 let token2_hash = hash_token(&token2);
943 let expires = chrono::Utc::now() + chrono::Duration::hours(24);
944 ath.db()
945 .create_session(user.id, token2_hash, None, None, expires)
946 .await
947 .unwrap();
948
949 let app = test_app(ath.clone(), config);
950 let csrf = get_csrf_token(&app, &cookie).await;
951 let req = password_request(
952 &csrf,
953 &cookie,
954 "password123",
955 "newpassword456",
956 "newpassword456",
957 );
958 let resp = app.oneshot(req).await.unwrap();
959 assert_eq!(resp.status(), StatusCode::OK);
960
961 let session2 = ath.db().lookup_session(&token2).await.unwrap();
963 assert!(session2.is_none(), "old session should be invalidated");
964
965 let set_cookie = resp
967 .headers()
968 .get(header::SET_COOKIE)
969 .unwrap()
970 .to_str()
971 .unwrap();
972 assert!(set_cookie.contains("allowthem_session"));
973 }
974
975 #[tokio::test]
976 async fn post_password_new_cookie_authenticates() {
977 let (ath, config, cookie) = setup().await;
978 let app = test_app(ath.clone(), config.clone());
979 let csrf = get_csrf_token(&app, &cookie).await;
980 let req = password_request(
981 &csrf,
982 &cookie,
983 "password123",
984 "newpassword456",
985 "newpassword456",
986 );
987 let resp = app.oneshot(req).await.unwrap();
988
989 let set_cookie = resp
991 .headers()
992 .get(header::SET_COOKIE)
993 .unwrap()
994 .to_str()
995 .unwrap();
996 let new_token = parse_session_cookie(set_cookie, "allowthem_session")
997 .expect("new session cookie should be present");
998 let new_cookie = format!("allowthem_session={}", new_token.as_str());
999
1000 let app2 = test_app(ath, config);
1002 let req = Request::builder()
1003 .uri("/settings")
1004 .header(header::COOKIE, &new_cookie)
1005 .body(Body::empty())
1006 .unwrap();
1007 let resp = app2.oneshot(req).await.unwrap();
1008 assert_eq!(resp.status(), StatusCode::OK);
1009 let html = body_string(resp).await;
1010 assert!(html.contains("user@example.com"));
1011 }
1012
1013 #[tokio::test]
1014 async fn post_password_logs_audit() {
1015 let (ath, config, cookie) = setup().await;
1016 let app = test_app(ath.clone(), config);
1017 let csrf = get_csrf_token(&app, &cookie).await;
1018 let req = password_request(
1019 &csrf,
1020 &cookie,
1021 "password123",
1022 "newpassword456",
1023 "newpassword456",
1024 );
1025 app.oneshot(req).await.unwrap();
1026
1027 let entries = ath.db().get_audit_log(None, 10, 0).await.unwrap();
1028 let pw_change = entries
1029 .iter()
1030 .find(|e| e.event_type == AuditEvent::PasswordChange);
1031 assert!(
1032 pw_change.is_some(),
1033 "PasswordChange audit event should be recorded"
1034 );
1035 }
1036
1037 #[tokio::test]
1038 async fn post_password_requires_csrf() {
1039 let (ath, config, cookie) = setup().await;
1040 let app = test_app(ath, config);
1041 let body = "current_password=pass&new_password=newpass123&new_password_confirm=newpass123";
1042 let req = Request::builder()
1043 .method("POST")
1044 .uri("/settings/password")
1045 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1046 .header(header::COOKIE, &cookie)
1047 .body(Body::from(body))
1048 .unwrap();
1049 let resp = app.oneshot(req).await.unwrap();
1050 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1051 }
1052
1053 #[tokio::test]
1054 async fn post_password_oauth_only_user_shows_error() {
1055 let ath = AllowThemBuilder::new("sqlite::memory:")
1058 .cookie_secure(false)
1059 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
1060 .build()
1061 .await
1062 .unwrap();
1063 let email = Email::new("oauth@example.com".into()).unwrap();
1064 let user = ath
1065 .db()
1066 .create_oauth_user(email, "google", "google-uid-123")
1067 .await
1068 .unwrap();
1069
1070 let token = generate_token();
1071 let token_hash = hash_token(&token);
1072 let expires = chrono::Utc::now() + chrono::Duration::hours(24);
1073 ath.db()
1074 .create_session(user.id, token_hash, None, None, expires)
1075 .await
1076 .unwrap();
1077 let set_cookie = ath.session_cookie(&token);
1078 let cookie = set_cookie.split(';').next().unwrap().to_string();
1079
1080 let config = SettingsConfig {
1081 is_production: false,
1082 };
1083
1084 let app = test_app(ath, config);
1085 let csrf = get_csrf_token(&app, &cookie).await;
1086 let req = password_request(
1087 &csrf,
1088 &cookie,
1089 "anypassword",
1090 "newpassword456",
1091 "newpassword456",
1092 );
1093 let resp = app.oneshot(req).await.unwrap();
1094 let html = body_string(resp).await;
1095 assert!(html.contains("Current password is incorrect"));
1096 }
1097}