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