//! Password reset: single-use token generation, full flow, expired/forged/ //! mismatched cases, and the replay guard (the token is spent exactly once). use crate::harness::TestHarness; /// Mint a reset token for `user_id`, persist its hash with the given expiry, and /// return the raw token (the value that would be emailed). Mirrors what /// `forgot_password_handler` does, using only the public token API + a direct /// insert so the test doesn't depend on `pub(crate)` db functions. async fn issue_reset_token( pool: &sqlx::PgPool, user_id: makenotwork::db::UserId, expires_at: chrono::DateTime, ) -> String { let (token, token_hash) = makenotwork::email::generate_password_reset_token(); sqlx::query( "INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)", ) .bind(user_id) .bind(&token_hash) .bind(expires_at) .execute(pool) .await .expect("insert reset token"); token } fn in_one_hour() -> chrono::DateTime { chrono::Utc::now() + chrono::Duration::hours(1) } #[tokio::test] async fn password_reset_full_flow() { let mut h = TestHarness::new().await; let user_id = h .signup("resetuser", "reset@test.com", "oldpassword1") .await; // The forgot-password endpoint always returns success (no enumeration). let resp = h .client .post_form("/forgot-password", "email=reset%40test.com") .await; assert_eq!( resp.status, 303, "Forgot password failed: {} {}", resp.status, resp.text ); let token = issue_reset_token(&h.db, user_id, in_one_hour()).await; let url = format!("/reset-password?token={token}"); // GET the reset page, valid token shows the form. let resp = h.client.get(&url).await; assert_eq!( resp.status, 200, "Reset page failed: {} {}", resp.status, resp.text ); // POST the new password. let body = format!( "token={}&password=newpassword1&password_confirm=newpassword1", urlencoding::encode(&token), ); let resp = h.client.post_form("/reset-password", &body).await; assert_eq!( resp.status, 303, "Reset password POST failed: {} {}", resp.status, resp.text ); // Logout and log in with the new password. h.client.post_form("/logout", "").await; h.login("resetuser", "newpassword1").await; let resp = h.client.get("/dashboard").await; assert_eq!( resp.status, 200, "Should access dashboard after password reset" ); } /// The fix for the SERIOUS replay finding: a reset token works exactly once. #[tokio::test] async fn password_reset_token_is_single_use() { let mut h = TestHarness::new().await; let user_id = h .signup("replayuser", "replay@test.com", "oldpassword1") .await; let token = issue_reset_token(&h.db, user_id, in_one_hour()).await; let body = format!( "token={}&password=newpassword1&password_confirm=newpassword1", urlencoding::encode(&token), ); // First use succeeds. let resp = h.client.post_form("/reset-password", &body).await; assert_eq!( resp.status, 303, "First reset should succeed: {} {}", resp.status, resp.text ); // Second use of the SAME token must be rejected, the row is consumed. let resp = h.client.post_form("/reset-password", &body).await; assert!( !resp.status.is_redirection(), "Replayed token must not redirect to login, got {}", resp.status ); assert!( resp.text.to_lowercase().contains("expired") || resp.text.to_lowercase().contains("used") || resp.text.to_lowercase().contains("invalid"), "Replayed token should be reported expired/used/invalid: {}", resp.text ); // And changing the password a third way (to prove the second POST really // didn't take), logging in with the *second* attempt's password fails is // implicit; here we confirm the first password still works. h.client.post_form("/logout", "").await; h.login("replayuser", "newpassword1").await; let resp = h.client.get("/dashboard").await; assert_eq!( resp.status, 200, "First reset's password must be the one in effect" ); } #[tokio::test] async fn password_reset_expired_link() { let mut h = TestHarness::new().await; let user_id = h.signup("expuser", "exp@test.com", "password123").await; // Token whose stored expiry is already in the past. let token = issue_reset_token( &h.db, user_id, chrono::Utc::now() - chrono::Duration::hours(1), ) .await; // GET shows the invalid/expired state (still 200). let resp = h .client .get(&format!("/reset-password?token={token}")) .await; assert_eq!( resp.status, 200, "Reset page should return 200 with invalid state: {} {}", resp.status, resp.text ); // POST with the expired token is rejected. let body = format!( "token={}&password=newpassword1&password_confirm=newpassword1", urlencoding::encode(&token), ); let resp = h.client.post_form("/reset-password", &body).await; assert!( !resp.status.is_redirection(), "Expired link should not redirect to login, got {}", resp.status ); assert!( resp.text.to_lowercase().contains("expired") || resp.text.to_lowercase().contains("invalid"), "Response should mention expired/invalid: {}", resp.text ); } #[tokio::test] async fn password_reset_forged_token() { let mut h = TestHarness::new().await; h.signup("forgeuser", "forge@test.com", "password123").await; // A token that was never issued. let forged = "0".repeat(64); let body = format!("token={forged}&password=newpassword1&password_confirm=newpassword1"); let resp = h.client.post_form("/reset-password", &body).await; assert!( !resp.status.is_redirection(), "Forged token should not redirect to login, got {}", resp.status ); assert!( resp.text.to_lowercase().contains("expired") || resp.text.to_lowercase().contains("invalid") || resp.text.to_lowercase().contains("used"), "Response should reject the forged token: {}", resp.text ); } #[tokio::test] async fn password_reset_passwords_must_match() { let mut h = TestHarness::new().await; let user_id = h .signup("mismatch", "mismatch@test.com", "password123") .await; let token = issue_reset_token(&h.db, user_id, in_one_hour()).await; // Mismatched confirmation is rejected *before* the token is consumed. let body = format!( "token={}&password=newpassword1&password_confirm=differentpassword", urlencoding::encode(&token), ); let resp = h.client.post_form("/reset-password", &body).await; assert!( !resp.status.is_redirection(), "Mismatched passwords should not redirect to login, got {}", resp.status ); assert!( resp.text.to_lowercase().contains("match") || resp.text.to_lowercase().contains("do not"), "Response should mention password mismatch: {}", resp.text ); // The token survived the mismatch (validation precedes consumption): a // correct retry now succeeds. let body = format!( "token={}&password=newpassword1&password_confirm=newpassword1", urlencoding::encode(&token), ); let resp = h.client.post_form("/reset-password", &body).await; assert_eq!( resp.status, 303, "Retry after mismatch should succeed: {} {}", resp.status, resp.text ); }