Skip to main content

allowthem_server/
settings_routes.rs

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
109/// Validate session cookie and return the authenticated user.
110///
111/// On failure, returns a 303 redirect to `/login?next={path}` — matching
112/// `BrowserAuthUser` rejection semantics without requiring `Arc<dyn AuthClient>`
113/// in the router state.
114async 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
150/// GET /settings — render the settings page for the authenticated user.
151async 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
187/// POST /settings — update email and/or username.
188async 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    // 1. Parse email
204    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    // 2. Parse username
226    let trimmed = form.username.trim();
227    let username = if trimmed.is_empty() {
228        None
229    } else {
230        Some(Username::new(trimmed))
231    };
232
233    // 3. Update email if changed
234    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    // 4. Update username if changed
259    // Note: if email update succeeded but username update fails, the email change
260    // is already persisted. This non-atomicity is acceptable for M34 — SQLite does
261    // not easily support transactional conflict handling across separate UPDATEs.
262    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    // 5. Audit log
289    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    // 6. Re-render with success — use form values for display (they reflect the new state)
304    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
321/// POST /settings/password — change password with session rotation.
322async 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    // 1. Validate new password length
341    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    // 2. Validate passwords match
363    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    // 3. Verify current password
385    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    // 4. Update password
416    ath.update_user_password(user.id, &form.new_password)
417        .await?;
418
419    // 5. Invalidate all sessions + create fresh one
420    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    // 6. Audit log
431    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    // 7. Render success page with Set-Cookie header for new session
444    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}&current_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    // --- GET /settings tests ---
608
609    #[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("&#x2f;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        // Grant admin role to the seeded user
636        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("&#x2f;admin&#x2f;applications"));
652        assert!(html.contains("&#x2f;admin&#x2f;sessions"));
653        assert!(html.contains("&#x2f;admin&#x2f;audit"));
654        // Users admin route is not yet wired; must not appear.
655        assert!(!html.contains("&#x2f;admin&#x2f;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("&#x2f;admin&#x2f;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    // --- POST /settings (profile) tests ---
733
734    #[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    // --- POST /settings/password tests ---
874
875    #[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        // Verify new password works
893        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        // Create a second session
954        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        // The old second session should be gone
977        let session2 = ath.db().lookup_session(&token2).await.unwrap();
978        assert!(session2.is_none(), "old session should be invalidated");
979
980        // The response should have a new session cookie
981        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        // Extract the new session cookie
1005        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        // Use the new cookie to access GET /settings on a fresh router with same state
1016        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        // OAuth-only users have no password_hash — attempting to change password
1071        // must return "Current password is incorrect", not a crash or 500.
1072        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}