//! Password reset: signed link generation, full flow, expired/tampered/mismatched cases. use crate::harness::TestHarness; const SIGNING_SECRET: &str = "test-signing-secret-for-integration-tests"; /// Get the password_hash from the DB for a user (needed to generate signed reset URLs). async fn get_password_hash(pool: &sqlx::PgPool, user_id: makenotwork::db::UserId) -> String { sqlx::query_scalar::<_, String>("SELECT password_hash FROM users WHERE id = $1") .bind(user_id) .fetch_one(pool) .await .expect("User not found") } #[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; // Call forgot-password endpoint (always returns success) let resp = h .client .post_form("/forgot-password", "email=reset%40test.com") .await; assert!( resp.status.is_success() || resp.status.is_redirection(), "Forgot password failed: {} {}", resp.status, resp.text ); // Generate the signed URL the same way the app does let password_hash = get_password_hash(&h.db, user_id).await; let url = makenotwork::email::generate_password_reset_url( "", user_id, &password_hash, SIGNING_SECRET, ); // GET the reset page — should show a valid form let resp = h.client.get(&url).await; assert!( resp.status.is_success(), "Reset page failed: {} {}", resp.status, resp.text ); // Extract query params from the URL for the POST let parsed = url::Url::parse(&format!("http://localhost{}", url)).unwrap(); let user_param = parsed.query_pairs().find(|(k, _)| k == "user").unwrap().1.to_string(); let expires_param = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.to_string(); let sig_param = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); // POST the new password let body = format!( "user={}&expires={}&sig={}&password=newpassword1&password_confirm=newpassword1", urlencoding::encode(&user_param), urlencoding::encode(&expires_param), urlencoding::encode(&sig_param), ); let resp = h.client.post_form("/reset-password", &body).await; assert!( resp.status.is_success() || resp.status.is_redirection(), "Reset password POST failed: {} {}", resp.status, resp.text ); // Logout and login with 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" ); } #[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; let password_hash = get_password_hash(&h.db, user_id).await; // Manually construct an expired signed URL (1 hour in the past) let expires = chrono::Utc::now().timestamp() - 3600; let message = format!("reset:{}:{}:{}", user_id, expires, password_hash); let sig = { use hmac::{Hmac, Mac}; use sha2::Sha256; let mut mac = Hmac::::new_from_slice(SIGNING_SECRET.as_bytes()).unwrap(); mac.update(message.as_bytes()); hex::encode(mac.finalize().into_bytes()) }; // GET the reset page — should show invalid/expired let url = format!( "/reset-password?user={}&expires={}&sig={}", user_id, expires, sig ); let resp = h.client.get(&url).await; assert!( resp.status.is_success(), "Reset page should still return 200 with invalid state: {} {}", resp.status, resp.text ); // POST with the expired link — should fail let body = format!( "user={}&expires={}&sig={}&password=newpassword1&password_confirm=newpassword1", user_id, expires, sig, ); let resp = h.client.post_form("/reset-password", &body).await; // Handler returns 200 with the form re-rendered + inline error (same // UX convention as login: don't lose context on rejection). Reject is // proven by the absence of a 303 to /login + the error message in body. 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_tampered_signature() { let mut h = TestHarness::new().await; let user_id = h .signup("tamperuser", "tamper@test.com", "password123") .await; let password_hash = get_password_hash(&h.db, user_id).await; // Generate a valid URL, then tamper with the signature let url = makenotwork::email::generate_password_reset_url( "", user_id, &password_hash, SIGNING_SECRET, ); let parsed = url::Url::parse(&format!("http://localhost{}", url)).unwrap(); let user_param = parsed.query_pairs().find(|(k, _)| k == "user").unwrap().1.to_string(); let expires_param = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.to_string(); // Use a tampered signature let tampered_sig = "0000000000000000000000000000000000000000000000000000000000000000"; let body = format!( "user={}&expires={}&sig={}&password=newpassword1&password_confirm=newpassword1", urlencoding::encode(&user_param), urlencoding::encode(&expires_param), tampered_sig, ); let resp = h.client.post_form("/reset-password", &body).await; assert!( !resp.status.is_redirection(), "Tampered signature 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_passwords_must_match() { let mut h = TestHarness::new().await; let user_id = h .signup("mismatch", "mismatch@test.com", "password123") .await; let password_hash = get_password_hash(&h.db, user_id).await; let url = makenotwork::email::generate_password_reset_url( "", user_id, &password_hash, SIGNING_SECRET, ); let parsed = url::Url::parse(&format!("http://localhost{}", url)).unwrap(); let user_param = parsed.query_pairs().find(|(k, _)| k == "user").unwrap().1.to_string(); let expires_param = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.to_string(); let sig_param = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string(); // POST with mismatched passwords let body = format!( "user={}&expires={}&sig={}&password=newpassword1&password_confirm=differentpassword", urlencoding::encode(&user_param), urlencoding::encode(&expires_param), urlencoding::encode(&sig_param), ); 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 ); }