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");
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.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    // 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.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    // 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.db()
417        .update_user_password(user.id, &form.new_password)
418        .await?;
419
420    // 5. Invalidate all sessions + create fresh one
421    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    // 6. Audit log
432    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    // 7. Render success page with Set-Cookie header for new session
445    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}&current_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    // --- GET /settings tests ---
609
610    #[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("&#x2f;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        // Grant admin role to the seeded user
637        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("&#x2f;admin&#x2f;applications"));
653        assert!(html.contains("&#x2f;admin&#x2f;sessions"));
654        assert!(html.contains("&#x2f;admin&#x2f;audit"));
655        // Users admin route is not yet wired; must not appear.
656        assert!(!html.contains("&#x2f;admin&#x2f;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("&#x2f;admin&#x2f;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    // --- POST /settings (profile) tests ---
734
735    #[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    // --- POST /settings/password tests ---
875
876    #[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        // Verify new password works
894        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        // Create a second session
955        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        // The old second session should be gone
978        let session2 = ath.db().lookup_session(&token2).await.unwrap();
979        assert!(session2.is_none(), "old session should be invalidated");
980
981        // The response should have a new session cookie
982        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        // Extract the new session cookie
1006        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        // Use the new cookie to access GET /settings on a fresh router with same state
1017        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        // OAuth-only users have no password_hash — attempting to change password
1072        // must return "Current password is incorrect", not a crash or 500.
1073        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}