Skip to main content

allowthem_server/
settings_routes.rs

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