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");
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.db().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.db().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.db()
417 .update_user_password(user.id, &form.new_password)
418 .await?;
419
420 ath.db().delete_user_sessions(&user.id).await?;
422
423 let token = allowthem_core::generate_token();
424 let token_hash = allowthem_core::hash_token(&token);
425 let expires_at = chrono::Utc::now() + ath.session_config().ttl;
426 ath.db()
427 .create_session(user.id, token_hash, ip.as_deref(), ua, expires_at)
428 .await?;
429 let cookie = ath.session_cookie(&token);
430
431 let _ = ath
433 .db()
434 .log_audit(
435 AuditEvent::PasswordChange,
436 Some(&user.id),
437 None,
438 ip.as_deref(),
439 ua,
440 None,
441 )
442 .await;
443
444 let (oauth_accounts, mfa_enabled, mfa_recovery_remaining) =
446 fetch_account_data(&ath, user.id).await?;
447 let ctx = SettingsContext {
448 email: user.email.as_str().to_string(),
449 username: user
450 .username
451 .as_ref()
452 .map_or(String::new(), |u| u.as_str().to_string()),
453 profile_error: String::new(),
454 profile_success: String::new(),
455 password_error: String::new(),
456 password_success: "Password changed successfully".into(),
457 oauth_accounts,
458 mfa_enabled,
459 mfa_recovery_remaining,
460 is_admin,
461 };
462 let html = render_settings(&config, csrf.as_str(), &ctx)?;
463
464 Ok(([(axum::http::header::SET_COOKIE, cookie)], html).into_response())
465}
466
467pub fn settings_routes(templates: Arc<Environment<'static>>, is_production: bool) -> Router<()> {
468 let cfg = SettingsConfig {
469 templates,
470 is_production,
471 };
472 Router::new()
473 .route("/settings", get(get_settings).post(post_settings))
474 .route("/settings/password", post(post_change_password))
475 .layer(Extension(cfg))
476}
477
478#[cfg(test)]
479mod tests {
480 use axum::Router;
481 use axum::body::Body;
482 use axum::http::{Request, StatusCode, header};
483 use tower::ServiceExt;
484
485 use allowthem_core::types::RoleName;
486 use allowthem_core::{
487 AllowThem, AllowThemBuilder, AuditEvent, Email, Username, generate_token, hash_token,
488 parse_session_cookie,
489 };
490
491 use super::{SettingsConfig, settings_routes};
492
493 async fn setup() -> (AllowThem, SettingsConfig, String) {
494 let ath = AllowThemBuilder::new("sqlite::memory:")
495 .cookie_secure(false)
496 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
497 .build()
498 .await
499 .unwrap();
500
501 let templates = crate::browser_templates::build_default_browser_env();
502
503 let email = Email::new("user@example.com".into()).unwrap();
504 let user = ath
505 .db()
506 .create_user(email, "password123", Some(Username::new("testuser")), None)
507 .await
508 .unwrap();
509
510 let token = generate_token();
511 let token_hash = hash_token(&token);
512 let expires = chrono::Utc::now() + chrono::Duration::hours(24);
513 ath.db()
514 .create_session(user.id, token_hash, None, None, expires)
515 .await
516 .unwrap();
517 let set_cookie = ath.session_cookie(&token);
518 let cookie_value = set_cookie.split(';').next().unwrap().to_string();
519
520 let config = SettingsConfig {
521 templates,
522 is_production: false,
523 };
524
525 (ath, config, cookie_value)
526 }
527
528 fn test_app(ath: AllowThem, config: SettingsConfig) -> Router {
529 settings_routes(config.templates.clone(), config.is_production)
530 .layer(axum::middleware::from_fn(crate::csrf::csrf_middleware))
531 .layer(axum::middleware::from_fn_with_state(
532 ath.clone(),
533 crate::cors::inject_ath_into_extensions,
534 ))
535 }
536
537 async fn get_csrf_token(app: &Router, cookie: &str) -> String {
538 let req = Request::builder()
539 .uri("/settings")
540 .header(header::COOKIE, cookie)
541 .body(Body::empty())
542 .unwrap();
543 let resp = app.clone().oneshot(req).await.unwrap();
544 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
545 .await
546 .unwrap();
547 let html = String::from_utf8(bytes.to_vec()).unwrap();
548 let marker = "name=\"csrf_token\" value=\"";
549 let start = html.find(marker).expect("csrf_token not found in HTML") + marker.len();
550 let end = html[start..].find('"').unwrap() + start;
551 html[start..end].to_string()
552 }
553
554 async fn body_string(resp: axum::http::Response<Body>) -> String {
555 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
556 .await
557 .unwrap();
558 String::from_utf8(bytes.to_vec()).unwrap()
559 }
560
561 fn profile_request(
562 csrf: &str,
563 session_cookie: &str,
564 email: &str,
565 username: &str,
566 ) -> Request<Body> {
567 let enc = |s: &str| s.replace('@', "%40");
568 let body = format!(
569 "csrf_token={}&email={}&username={}",
570 csrf,
571 enc(email),
572 enc(username),
573 );
574 Request::builder()
575 .method("POST")
576 .uri("/settings")
577 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
578 .header(
579 header::COOKIE,
580 format!("{session_cookie}; csrf_token={csrf}"),
581 )
582 .body(Body::from(body))
583 .unwrap()
584 }
585
586 fn password_request(
587 csrf: &str,
588 session_cookie: &str,
589 current: &str,
590 new: &str,
591 confirm: &str,
592 ) -> Request<Body> {
593 let body = format!(
594 "csrf_token={csrf}¤t_password={current}&new_password={new}&new_password_confirm={confirm}",
595 );
596 Request::builder()
597 .method("POST")
598 .uri("/settings/password")
599 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
600 .header(
601 header::COOKIE,
602 format!("{session_cookie}; csrf_token={csrf}"),
603 )
604 .body(Body::from(body))
605 .unwrap()
606 }
607
608 #[tokio::test]
611 async fn get_settings_renders_page() {
612 let (ath, config, cookie) = setup().await;
613 let app = test_app(ath, config);
614 let req = Request::builder()
615 .uri("/settings")
616 .header(header::COOKIE, &cookie)
617 .body(Body::empty())
618 .unwrap();
619 let resp = app.oneshot(req).await.unwrap();
620 assert_eq!(resp.status(), StatusCode::OK);
621 let html = body_string(resp).await;
622 assert!(html.contains("user@example.com"));
623 assert!(html.contains("testuser"));
624 assert!(html.contains("Settings"));
625 assert!(html.contains("class=\"wf-app\"") || html.contains("class=\"wf-app "));
626 assert!(
627 !html.contains("class=\"at-app-shell\"") && !html.contains("class=\"at-app-shell ")
628 );
629 assert!(html.contains("/logout"));
630 }
631
632 #[tokio::test]
633 async fn get_settings_admin_user_sees_admin_nav() {
634 let (ath, config, cookie) = setup().await;
635
636 let email = Email::new("user@example.com".into()).unwrap();
638 let user = ath.db().get_user_by_email(&email).await.unwrap();
639 let role_name = RoleName::new("admin");
640 let role = ath.db().create_role(&role_name, None).await.unwrap();
641 ath.db().assign_role(&user.id, &role.id).await.unwrap();
642
643 let app = test_app(ath, config);
644 let req = Request::builder()
645 .uri("/settings")
646 .header(header::COOKIE, &cookie)
647 .body(Body::empty())
648 .unwrap();
649 let resp = app.oneshot(req).await.unwrap();
650 assert_eq!(resp.status(), StatusCode::OK);
651 let html = body_string(resp).await;
652 assert!(html.contains("/admin/applications"));
653 assert!(html.contains("/admin/sessions"));
654 assert!(html.contains("/admin/audit"));
655 assert!(!html.contains("/admin/users"));
657 }
658
659 #[tokio::test]
660 async fn get_settings_non_admin_user_has_no_admin_nav() {
661 let (ath, config, cookie) = setup().await;
662 let app = test_app(ath, config);
663 let req = Request::builder()
664 .uri("/settings")
665 .header(header::COOKIE, &cookie)
666 .body(Body::empty())
667 .unwrap();
668 let resp = app.oneshot(req).await.unwrap();
669 let html = body_string(resp).await;
670 assert!(!html.contains("/admin/applications"));
671 }
672
673 #[tokio::test]
674 async fn get_settings_unauthenticated_redirects() {
675 let (ath, config, _) = setup().await;
676 let app = test_app(ath, config);
677 let req = Request::builder()
678 .uri("/settings")
679 .body(Body::empty())
680 .unwrap();
681 let resp = app.oneshot(req).await.unwrap();
682 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
683 assert_eq!(
684 resp.headers().get("location").unwrap(),
685 "/login?next=/settings"
686 );
687 }
688
689 #[tokio::test]
690 async fn get_settings_shows_csrf_token() {
691 let (ath, config, cookie) = setup().await;
692 let app = test_app(ath, config);
693 let req = Request::builder()
694 .uri("/settings")
695 .header(header::COOKIE, &cookie)
696 .body(Body::empty())
697 .unwrap();
698 let resp = app.oneshot(req).await.unwrap();
699 let html = body_string(resp).await;
700 assert!(html.contains("name=\"csrf_token\""));
701 }
702
703 #[tokio::test]
704 async fn get_settings_shows_oauth_section() {
705 let (ath, config, cookie) = setup().await;
706 let app = test_app(ath, config);
707 let req = Request::builder()
708 .uri("/settings")
709 .header(header::COOKIE, &cookie)
710 .body(Body::empty())
711 .unwrap();
712 let resp = app.oneshot(req).await.unwrap();
713 let html = body_string(resp).await;
714 assert!(html.contains("Linked accounts"));
715 assert!(html.contains("No linked accounts"));
716 }
717
718 #[tokio::test]
719 async fn get_settings_shows_mfa_section() {
720 let (ath, config, cookie) = setup().await;
721 let app = test_app(ath, config);
722 let req = Request::builder()
723 .uri("/settings")
724 .header(header::COOKIE, &cookie)
725 .body(Body::empty())
726 .unwrap();
727 let resp = app.oneshot(req).await.unwrap();
728 let html = body_string(resp).await;
729 assert!(html.contains("Two-factor authentication"));
730 assert!(html.contains("Not configured"));
731 }
732
733 #[tokio::test]
736 async fn post_settings_updates_email() {
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, "new@example.com", "testuser");
741 let resp = app.oneshot(req).await.unwrap();
742 assert_eq!(resp.status(), StatusCode::OK);
743 let html = body_string(resp).await;
744 assert!(html.contains("Profile updated"));
745
746 let email = Email::new("new@example.com".into()).unwrap();
747 let user = ath.db().get_user_by_email(&email).await;
748 assert!(user.is_ok());
749 }
750
751 #[tokio::test]
752 async fn post_settings_updates_username() {
753 let (ath, config, cookie) = setup().await;
754 let app = test_app(ath.clone(), config);
755 let csrf = get_csrf_token(&app, &cookie).await;
756 let req = profile_request(&csrf, &cookie, "user@example.com", "newname");
757 let resp = app.oneshot(req).await.unwrap();
758 assert_eq!(resp.status(), StatusCode::OK);
759
760 let username = Username::new("newname");
761 let user = ath.db().get_user_by_username(&username).await;
762 assert!(user.is_ok());
763 }
764
765 #[tokio::test]
766 async fn post_settings_clears_username() {
767 let (ath, config, cookie) = setup().await;
768 let app = test_app(ath.clone(), config);
769 let csrf = get_csrf_token(&app, &cookie).await;
770 let req = profile_request(&csrf, &cookie, "user@example.com", "");
771 let resp = app.oneshot(req).await.unwrap();
772 assert_eq!(resp.status(), StatusCode::OK);
773
774 let email = Email::new("user@example.com".into()).unwrap();
775 let user = ath.db().get_user_by_email(&email).await.unwrap();
776 assert!(user.username.is_none());
777 }
778
779 #[tokio::test]
780 async fn post_settings_duplicate_email_shows_error() {
781 let (ath, config, cookie) = setup().await;
782 let other_email = Email::new("other@example.com".into()).unwrap();
783 ath.db()
784 .create_user(other_email, "password123", None, None)
785 .await
786 .unwrap();
787
788 let app = test_app(ath, config);
789 let csrf = get_csrf_token(&app, &cookie).await;
790 let req = profile_request(&csrf, &cookie, "other@example.com", "testuser");
791 let resp = app.oneshot(req).await.unwrap();
792 let html = body_string(resp).await;
793 assert!(html.contains("An account with this email already exists"));
794 }
795
796 #[tokio::test]
797 async fn post_settings_duplicate_username_shows_error() {
798 let (ath, config, cookie) = setup().await;
799 let other_email = Email::new("other@example.com".into()).unwrap();
800 ath.db()
801 .create_user(
802 other_email,
803 "password123",
804 Some(Username::new("taken")),
805 None,
806 )
807 .await
808 .unwrap();
809
810 let app = test_app(ath, config);
811 let csrf = get_csrf_token(&app, &cookie).await;
812 let req = profile_request(&csrf, &cookie, "user@example.com", "taken");
813 let resp = app.oneshot(req).await.unwrap();
814 let html = body_string(resp).await;
815 assert!(html.contains("This username is already taken"));
816 }
817
818 #[tokio::test]
819 async fn post_settings_invalid_email_shows_error() {
820 let (ath, config, cookie) = setup().await;
821 let app = test_app(ath, config);
822 let csrf = get_csrf_token(&app, &cookie).await;
823 let req = profile_request(&csrf, &cookie, "not-an-email", "testuser");
824 let resp = app.oneshot(req).await.unwrap();
825 let html = body_string(resp).await;
826 assert!(html.contains("Invalid email address"));
827 }
828
829 #[tokio::test]
830 async fn post_settings_no_changes_succeeds() {
831 let (ath, config, cookie) = setup().await;
832 let app = test_app(ath, config);
833 let csrf = get_csrf_token(&app, &cookie).await;
834 let req = profile_request(&csrf, &cookie, "user@example.com", "testuser");
835 let resp = app.oneshot(req).await.unwrap();
836 let html = body_string(resp).await;
837 assert!(html.contains("Profile updated"));
838 }
839
840 #[tokio::test]
841 async fn post_settings_logs_audit() {
842 let (ath, config, cookie) = setup().await;
843 let app = test_app(ath.clone(), config);
844 let csrf = get_csrf_token(&app, &cookie).await;
845 let req = profile_request(&csrf, &cookie, "user@example.com", "testuser");
846 app.oneshot(req).await.unwrap();
847
848 let entries = ath.db().get_audit_log(None, 10, 0).await.unwrap();
849 let updated = entries
850 .iter()
851 .find(|e| e.event_type == AuditEvent::UserUpdated);
852 assert!(
853 updated.is_some(),
854 "UserUpdated audit event should be recorded"
855 );
856 }
857
858 #[tokio::test]
859 async fn post_settings_requires_csrf() {
860 let (ath, config, cookie) = setup().await;
861 let app = test_app(ath, config);
862 let body = "email=user%40example.com&username=testuser";
863 let req = Request::builder()
864 .method("POST")
865 .uri("/settings")
866 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
867 .header(header::COOKIE, &cookie)
868 .body(Body::from(body))
869 .unwrap();
870 let resp = app.oneshot(req).await.unwrap();
871 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
872 }
873
874 #[tokio::test]
877 async fn post_password_change_success() {
878 let (ath, config, cookie) = setup().await;
879 let app = test_app(ath.clone(), config);
880 let csrf = get_csrf_token(&app, &cookie).await;
881 let req = password_request(
882 &csrf,
883 &cookie,
884 "password123",
885 "newpassword456",
886 "newpassword456",
887 );
888 let resp = app.oneshot(req).await.unwrap();
889 assert_eq!(resp.status(), StatusCode::OK);
890 let html = body_string(resp).await;
891 assert!(html.contains("Password changed successfully"));
892
893 let email = Email::new("user@example.com".into()).unwrap();
895 let user = ath.db().get_user_by_email(&email).await.unwrap();
896 let user_with_hash = ath.db().find_for_login(user.email.as_str()).await.unwrap();
897 let ok = allowthem_core::password::verify_password(
898 "newpassword456",
899 user_with_hash.password_hash.as_ref().unwrap(),
900 )
901 .unwrap();
902 assert!(ok, "new password should verify");
903 }
904
905 #[tokio::test]
906 async fn post_password_wrong_current() {
907 let (ath, config, cookie) = setup().await;
908 let app = test_app(ath, config);
909 let csrf = get_csrf_token(&app, &cookie).await;
910 let req = password_request(
911 &csrf,
912 &cookie,
913 "wrongpassword",
914 "newpassword456",
915 "newpassword456",
916 );
917 let resp = app.oneshot(req).await.unwrap();
918 let html = body_string(resp).await;
919 assert!(html.contains("Current password is incorrect"));
920 }
921
922 #[tokio::test]
923 async fn post_password_too_short() {
924 let (ath, config, cookie) = setup().await;
925 let app = test_app(ath, config);
926 let csrf = get_csrf_token(&app, &cookie).await;
927 let req = password_request(&csrf, &cookie, "password123", "abc", "abc");
928 let resp = app.oneshot(req).await.unwrap();
929 let html = body_string(resp).await;
930 assert!(html.contains("New password must be at least 8 characters"));
931 }
932
933 #[tokio::test]
934 async fn post_password_mismatch() {
935 let (ath, config, cookie) = setup().await;
936 let app = test_app(ath, config);
937 let csrf = get_csrf_token(&app, &cookie).await;
938 let req = password_request(
939 &csrf,
940 &cookie,
941 "password123",
942 "newpassword1",
943 "newpassword2",
944 );
945 let resp = app.oneshot(req).await.unwrap();
946 let html = body_string(resp).await;
947 assert!(html.contains("New passwords do not match"));
948 }
949
950 #[tokio::test]
951 async fn post_password_invalidates_other_sessions() {
952 let (ath, config, cookie) = setup().await;
953
954 let email = Email::new("user@example.com".into()).unwrap();
956 let user = ath.db().get_user_by_email(&email).await.unwrap();
957 let token2 = generate_token();
958 let token2_hash = hash_token(&token2);
959 let expires = chrono::Utc::now() + chrono::Duration::hours(24);
960 ath.db()
961 .create_session(user.id, token2_hash, None, None, expires)
962 .await
963 .unwrap();
964
965 let app = test_app(ath.clone(), config);
966 let csrf = get_csrf_token(&app, &cookie).await;
967 let req = password_request(
968 &csrf,
969 &cookie,
970 "password123",
971 "newpassword456",
972 "newpassword456",
973 );
974 let resp = app.oneshot(req).await.unwrap();
975 assert_eq!(resp.status(), StatusCode::OK);
976
977 let session2 = ath.db().lookup_session(&token2).await.unwrap();
979 assert!(session2.is_none(), "old session should be invalidated");
980
981 let set_cookie = resp
983 .headers()
984 .get(header::SET_COOKIE)
985 .unwrap()
986 .to_str()
987 .unwrap();
988 assert!(set_cookie.contains("allowthem_session"));
989 }
990
991 #[tokio::test]
992 async fn post_password_new_cookie_authenticates() {
993 let (ath, config, cookie) = setup().await;
994 let app = test_app(ath.clone(), config.clone());
995 let csrf = get_csrf_token(&app, &cookie).await;
996 let req = password_request(
997 &csrf,
998 &cookie,
999 "password123",
1000 "newpassword456",
1001 "newpassword456",
1002 );
1003 let resp = app.oneshot(req).await.unwrap();
1004
1005 let set_cookie = resp
1007 .headers()
1008 .get(header::SET_COOKIE)
1009 .unwrap()
1010 .to_str()
1011 .unwrap();
1012 let new_token = parse_session_cookie(set_cookie, "allowthem_session")
1013 .expect("new session cookie should be present");
1014 let new_cookie = format!("allowthem_session={}", new_token.as_str());
1015
1016 let app2 = test_app(ath, config);
1018 let req = Request::builder()
1019 .uri("/settings")
1020 .header(header::COOKIE, &new_cookie)
1021 .body(Body::empty())
1022 .unwrap();
1023 let resp = app2.oneshot(req).await.unwrap();
1024 assert_eq!(resp.status(), StatusCode::OK);
1025 let html = body_string(resp).await;
1026 assert!(html.contains("user@example.com"));
1027 }
1028
1029 #[tokio::test]
1030 async fn post_password_logs_audit() {
1031 let (ath, config, cookie) = setup().await;
1032 let app = test_app(ath.clone(), config);
1033 let csrf = get_csrf_token(&app, &cookie).await;
1034 let req = password_request(
1035 &csrf,
1036 &cookie,
1037 "password123",
1038 "newpassword456",
1039 "newpassword456",
1040 );
1041 app.oneshot(req).await.unwrap();
1042
1043 let entries = ath.db().get_audit_log(None, 10, 0).await.unwrap();
1044 let pw_change = entries
1045 .iter()
1046 .find(|e| e.event_type == AuditEvent::PasswordChange);
1047 assert!(
1048 pw_change.is_some(),
1049 "PasswordChange audit event should be recorded"
1050 );
1051 }
1052
1053 #[tokio::test]
1054 async fn post_password_requires_csrf() {
1055 let (ath, config, cookie) = setup().await;
1056 let app = test_app(ath, config);
1057 let body = "current_password=pass&new_password=newpass123&new_password_confirm=newpass123";
1058 let req = Request::builder()
1059 .method("POST")
1060 .uri("/settings/password")
1061 .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1062 .header(header::COOKIE, &cookie)
1063 .body(Body::from(body))
1064 .unwrap();
1065 let resp = app.oneshot(req).await.unwrap();
1066 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1067 }
1068
1069 #[tokio::test]
1070 async fn post_password_oauth_only_user_shows_error() {
1071 let ath = AllowThemBuilder::new("sqlite::memory:")
1074 .cookie_secure(false)
1075 .csrf_key(*b"test-csrf-key-for-binary-tests!!")
1076 .build()
1077 .await
1078 .unwrap();
1079 let templates = crate::browser_templates::build_default_browser_env();
1080
1081 let email = Email::new("oauth@example.com".into()).unwrap();
1082 let user = ath
1083 .db()
1084 .create_oauth_user(email, "google", "google-uid-123")
1085 .await
1086 .unwrap();
1087
1088 let token = generate_token();
1089 let token_hash = hash_token(&token);
1090 let expires = chrono::Utc::now() + chrono::Duration::hours(24);
1091 ath.db()
1092 .create_session(user.id, token_hash, None, None, expires)
1093 .await
1094 .unwrap();
1095 let set_cookie = ath.session_cookie(&token);
1096 let cookie = set_cookie.split(';').next().unwrap().to_string();
1097
1098 let config = SettingsConfig {
1099 templates,
1100 is_production: false,
1101 };
1102
1103 let app = test_app(ath, config);
1104 let csrf = get_csrf_token(&app, &cookie).await;
1105 let req = password_request(
1106 &csrf,
1107 &cookie,
1108 "anypassword",
1109 "newpassword456",
1110 "newpassword456",
1111 );
1112 let resp = app.oneshot(req).await.unwrap();
1113 let html = body_string(resp).await;
1114 assert!(html.contains("Current password is incorrect"));
1115 }
1116}