//! Auth workflow: signup -> dashboard -> logout -> dashboard (redirect) use crate::harness::TestHarness; #[tokio::test] async fn multibyte_password_register_then_login() { // Regression (audit Run 22): signup capped password length by char count // (`chars().count()`) while login/OAuth/SyncKit capped by BYTE count // (`.len()`). A password of <=128 chars but >128 bytes, any multibyte // passphrase near the cap, registered fine, then was silently rejected at // every subsequent login: a permanent, undiagnosable self-lockout. // // 65 CJK chars = 65 chars (well under the 128-char signup cap) = 195 bytes // (over the old 128-byte login cap). Under the bug, signup succeeds but the // login below never establishes a session. let password = "\u{4f60}".repeat(65); // 你 x65 assert_eq!(password.chars().count(), 65); assert!(password.len() > 128, "test password must exceed 128 bytes"); let mut h = TestHarness::new().await; let _user_id = h.signup("mb_user", "mb@example.com", &password).await; h.client.post_form("/logout", "").await; // Log back in with the identical multibyte password. h.login("mb_user", &password).await; // A failed login renders 200 with an error but sets no session, so the // definitive check is that the authenticated dashboard is reachable. let resp = h.client.get("/dashboard").await; assert_eq!( resp.status, 200, "multibyte-password account must be able to log back in" ); } #[tokio::test] async fn signup_login_logout_flow() { let mut h = TestHarness::new().await; // Sign up let _user_id = h .signup("testuser", "test@example.com", "password123") .await; // Should be logged in, dashboard returns 200 let resp = h.client.get("/dashboard").await; assert_eq!( resp.status, 200, "Dashboard should be accessible after signup" ); let resp = h.client.post_form("/logout", "").await; assert!( resp.status.is_success() || resp.status.is_redirection(), "Logout should succeed" ); // Dashboard should now redirect (302) or return 401 let resp = h.client.get("/dashboard").await; assert!( resp.status == 302 || resp.status == 303 || resp.status == 401, "Dashboard should redirect after logout, got {}", resp.status ); } #[tokio::test] async fn signup_with_taken_email_does_not_reveal_or_create() { // m1 (ultra-fuzz Run 4): a signup attempt whose EMAIL is already registered // must not return "this email is already registered" (an account-existence // oracle for a private identifier). It returns the same step-2 response a // fresh signup returns and creates no second account; the real owner is // reached out-of-band by the "account exists" email. let mut h = TestHarness::new().await; h.signup("owner", "owner@example.com", "password123").await; h.client.post_form("/logout", "").await; // A new username, but the same (taken) email. h.client.fetch_csrf_token().await; let body = "username=intruder&email=owner@example.com&password=password123"; let resp = h.client.post_form("/join/step/account", body).await; assert!( resp.status.is_success(), "taken-email signup should not error: {}", resp.status ); let lower = resp.text.to_lowercase(); assert!( !lower.contains("already registered") && !lower.contains("already exists"), "response must not reveal the email is registered: {}", resp.text ); // No second account created for the probed email. let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = 'owner@example.com'") .fetch_one(&h.db) .await .unwrap(); assert_eq!( count, 1, "no duplicate account may be created for a taken email" ); // And the probed username was never registered. let intruder: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'intruder'") .fetch_one(&h.db) .await .unwrap(); assert_eq!( intruder, 0, "no account should be created on the taken-email path" ); } #[tokio::test] async fn login_with_existing_account() { let mut h = TestHarness::new().await; // Sign up and then log out let _user_id = h .signup("alice", "alice@example.com", "secure_pass99") .await; h.client.post_form("/logout", "").await; // Log back in h.login("alice", "secure_pass99").await; // Dashboard should be accessible let resp = h.client.get("/dashboard").await; assert_eq!( resp.status, 200, "Dashboard should be accessible after login" ); } #[tokio::test] async fn wrong_password_rejected() { let mut h = TestHarness::new().await; let _user_id = h.signup("wp_user", "wp@example.com", "correctpass1").await; h.client.post_form("/logout", "").await; let resp = h .client .post_form("/login", "login=wp_user&password=totallyWrong") .await; assert!( resp.status != 200 && resp.status != 303, "Wrong password should not yield 200 or 303, got {}", resp.status ); } #[tokio::test] async fn nonexistent_user_rejected() { let mut h = TestHarness::new().await; let resp = h .client .post_form("/login", "login=ghost_user_xyz&password=anypass123") .await; assert!( resp.status != 200 && resp.status != 303, "Nonexistent user login should not yield 200 or 303, got {}", resp.status ); } #[tokio::test] async fn duplicate_email_rejected() { let mut h = TestHarness::new().await; let _user_id = h .signup("orig_user", "dupe@example.com", "password123") .await; h.client.post_form("/logout", "").await; // Attempt signup with the same email but a different username let resp = h .client .post_form( "/join", "username=other_user&email=dupe@example.com&password=password123&password_confirm=password123", ) .await; assert!( resp.status.is_client_error() || resp.text.contains("already") || resp.text.contains("taken"), "Duplicate email signup should fail: {} {}", resp.status, resp.text ); } #[tokio::test] async fn duplicate_username_rejected() { let mut h = TestHarness::new().await; let _user_id = h .signup("taken_name", "first@example.com", "password123") .await; h.client.post_form("/logout", "").await; // Attempt signup with the same username but a different email let resp = h .client .post_form( "/join", "username=taken_name&email=second@example.com&password=password123&password_confirm=password123", ) .await; assert!( resp.status.is_client_error() || resp.text.contains("already") || resp.text.contains("taken"), "Duplicate username signup should fail: {} {}", resp.status, resp.text ); } #[tokio::test] async fn login_with_email() { let mut h = TestHarness::new().await; let _user_id = h .signup("emaillogin", "emaillogin@example.com", "password123") .await; h.client.post_form("/logout", "").await; // Login using email address instead of username h.login("emaillogin@example.com", "password123").await; let resp = h.client.get("/dashboard").await; assert_eq!( resp.status, 200, "Dashboard should be accessible after login with email" ); } #[tokio::test] async fn password_change_flow() { let mut h = TestHarness::new().await; let _user_id = h .signup("pwchange", "pwchange@example.com", "oldpass123") .await; // Change password via PUT form let resp = h .client .put_form( "/api/users/me/password", "current_password=oldpass123&new_password=newpass456", ) .await; assert!( resp.status.is_success(), "Password change should succeed: {} {}", resp.status, resp.text ); h.client.post_form("/logout", "").await; // Login with new password should succeed h.login("pwchange", "newpass456").await; let resp = h.client.get("/dashboard").await; assert_eq!( resp.status, 200, "New password should grant dashboard access" ); // Logout and try old password h.client.post_form("/logout", "").await; let resp = h .client .post_form("/login", "login=pwchange&password=oldpass123") .await; assert!( resp.status != 200 && resp.status != 303, "Old password should no longer work, got {}", resp.status ); } #[tokio::test] async fn lockout_after_failed_attempts() { let mut h = TestHarness::new().await; let _user_id = h.signup("lockme", "lockme@example.com", "rightpass1").await; h.client.post_form("/logout", "").await; // 5 failed login attempts. `/login` is Manual-CSRF now (Phase 2), so // each attempt must refresh the token, the helper handles that. for _ in 0..5 { h.failed_login_attempt("lockme", "wrongwrong").await; } // Now try with correct password, should be locked out. login_handler // returns 200 with the form re-rendered + inline "Account is locked" // message (same UX convention as the inline-error pattern); a successful // login would be a 303 redirect, so absence of redirect + body containing // "locked" together prove lockout. let resp = h.failed_login_attempt("lockme", "rightpass1").await; assert!( !resp.status.is_redirection(), "Lockout should not redirect to dashboard, got {}", resp.status ); assert!( resp.text.to_lowercase().contains("locked"), "Response should mention lockout: {}", resp.text ); }