//! 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_eq!(resp.status, 303, "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_eq!( resp.status, 200, "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 ); } /// A duplicate email must answer exactly like a fresh signup. /// /// Saying "this email is already registered" turns the signup form into an /// account-existence oracle for an identifier the owner did not make public, so /// `join_wizard::step_account_create` returns the same step-2 partial a fresh /// signup gets and tells the real owner out of band instead. `WizardJoinProfileTemplate` /// carries nothing but the step nav, which is why the two responses can be /// compared byte for byte rather than probed for the absence of a phrase. /// /// This test spent its life POSTing `/join`, which is GET-only, and passing on /// the 405. Named `duplicate_email_rejected` then, which is the opposite of the /// behavior the handler is careful to have. #[tokio::test] async fn duplicate_email_answers_like_a_fresh_signup() { let mut h = TestHarness::new().await; let _user_id = h .signup("orig_user", "dupe@example.com", "password123") .await; h.client.post_form("/logout", "").await; // The baseline: a signup with no collision at all. h.client.fetch_csrf_token().await; let fresh = h .client .post_form( "/join/step/account", "username=fresh_user&email=fresh@example.com&password=password123", ) .await; assert_eq!(fresh.status, 200, "{}", fresh.text); // Pin what the baseline IS, so the comparison below cannot be satisfied by // two identical failures. assert!( fresh.text.contains(r#"hx-post="/join/step/profile""#), "a clean signup should advance to the profile step: {}", fresh.text ); h.client.post_form("/logout", "").await; // Same request, but the email is taken. h.client.fetch_csrf_token().await; let resp = h .client .post_form( "/join/step/account", "username=other_user&email=dupe@example.com&password=password123", ) .await; assert_eq!(resp.status, 200, "{}", resp.text); assert_eq!( resp.text, fresh.text, "a taken email must be indistinguishable from a free one", ); // The collision is concealed, not ignored: no second account exists. let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = 'dupe@example.com'") .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "the duplicate signup must not create an account"); } /// A duplicate username is revealed, unlike a duplicate email: usernames are /// public handles that appear in profile URLs, and the user has to be told to /// pick another one. Re-renders the account step at 200 with the field flagged. /// /// Was POSTing GET-only `/join` and passing on the 405. `adversarial_input.rs` /// has a sibling covering the same route from the "did it create a second row" /// angle. #[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; h.client.fetch_csrf_token().await; let resp = h .client .post_form( "/join/step/account", "username=taken_name&email=second@example.com&password=password123", ) .await; assert_eq!(resp.status, 200, "{}", resp.text); assert!( resp.text.contains("already taken"), "the account step must come back saying the username is taken: {}", resp.text ); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = 'second@example.com'") .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "the rejected signup must not create an account"); } #[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_eq!( resp.status, 204, "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 ); }