Skip to main content

allowthem_server/
settings_routes.rs

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