Skip to main content

max / makenotwork

7.5 KB · 216 lines History Blame Raw
1 //! Password reset: signed link generation, full flow, expired/tampered/mismatched cases.
2
3 use crate::harness::TestHarness;
4
5 const SIGNING_SECRET: &str = "test-signing-secret-for-integration-tests";
6
7 /// Get the password_hash from the DB for a user (needed to generate signed reset URLs).
8 async fn get_password_hash(pool: &sqlx::PgPool, user_id: makenotwork::db::UserId) -> String {
9 sqlx::query_scalar::<_, String>("SELECT password_hash FROM users WHERE id = $1")
10 .bind(user_id)
11 .fetch_one(pool)
12 .await
13 .expect("User not found")
14 }
15
16 #[tokio::test]
17 async fn password_reset_full_flow() {
18 let mut h = TestHarness::new().await;
19 let user_id = h
20 .signup("resetuser", "reset@test.com", "oldpassword1")
21 .await;
22
23 // Call forgot-password endpoint (always returns success)
24 let resp = h
25 .client
26 .post_form("/forgot-password", "email=reset%40test.com")
27 .await;
28 assert!(
29 resp.status.is_success() || resp.status.is_redirection(),
30 "Forgot password failed: {} {}",
31 resp.status,
32 resp.text
33 );
34
35 // Generate the signed URL the same way the app does
36 let password_hash = get_password_hash(&h.db, user_id).await;
37 let url = makenotwork::email::generate_password_reset_url(
38 "",
39 user_id,
40 &password_hash,
41 SIGNING_SECRET,
42 );
43
44 // GET the reset page — should show a valid form
45 let resp = h.client.get(&url).await;
46 assert!(
47 resp.status.is_success(),
48 "Reset page failed: {} {}",
49 resp.status,
50 resp.text
51 );
52
53 // Extract query params from the URL for the POST
54 let parsed = url::Url::parse(&format!("http://localhost{}", url)).unwrap();
55 let user_param = parsed.query_pairs().find(|(k, _)| k == "user").unwrap().1.to_string();
56 let expires_param = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.to_string();
57 let sig_param = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
58
59 // POST the new password
60 let body = format!(
61 "user={}&expires={}&sig={}&password=newpassword1&password_confirm=newpassword1",
62 urlencoding::encode(&user_param),
63 urlencoding::encode(&expires_param),
64 urlencoding::encode(&sig_param),
65 );
66 let resp = h.client.post_form("/reset-password", &body).await;
67 assert!(
68 resp.status.is_success() || resp.status.is_redirection(),
69 "Reset password POST failed: {} {}",
70 resp.status,
71 resp.text
72 );
73
74 // Logout and login with new password
75 h.client.post_form("/logout", "").await;
76 h.login("resetuser", "newpassword1").await;
77 let resp = h.client.get("/dashboard").await;
78 assert_eq!(
79 resp.status, 200,
80 "Should access dashboard after password reset"
81 );
82 }
83
84 #[tokio::test]
85 async fn password_reset_expired_link() {
86 let mut h = TestHarness::new().await;
87 let user_id = h
88 .signup("expuser", "exp@test.com", "password123")
89 .await;
90
91 let password_hash = get_password_hash(&h.db, user_id).await;
92
93 // Manually construct an expired signed URL (1 hour in the past)
94 let expires = chrono::Utc::now().timestamp() - 3600;
95 let message = format!("reset:{}:{}:{}", user_id, expires, password_hash);
96 let sig = {
97 use hmac::{Hmac, Mac};
98 use sha2::Sha256;
99 let mut mac =
100 Hmac::<Sha256>::new_from_slice(SIGNING_SECRET.as_bytes()).unwrap();
101 mac.update(message.as_bytes());
102 hex::encode(mac.finalize().into_bytes())
103 };
104
105 // GET the reset page — should show invalid/expired
106 let url = format!(
107 "/reset-password?user={}&expires={}&sig={}",
108 user_id, expires, sig
109 );
110 let resp = h.client.get(&url).await;
111 assert!(
112 resp.status.is_success(),
113 "Reset page should still return 200 with invalid state: {} {}",
114 resp.status,
115 resp.text
116 );
117
118 // POST with the expired link — should fail
119 let body = format!(
120 "user={}&expires={}&sig={}&password=newpassword1&password_confirm=newpassword1",
121 user_id, expires, sig,
122 );
123 let resp = h.client.post_form("/reset-password", &body).await;
124 // Handler returns 200 with the form re-rendered + inline error (same
125 // UX convention as login: don't lose context on rejection). Reject is
126 // proven by the absence of a 303 to /login + the error message in body.
127 assert!(
128 !resp.status.is_redirection(),
129 "Expired link should not redirect to login, got {}", resp.status
130 );
131 assert!(
132 resp.text.to_lowercase().contains("expired") || resp.text.to_lowercase().contains("invalid"),
133 "Response should mention expired/invalid: {}", resp.text
134 );
135 }
136
137 #[tokio::test]
138 async fn password_reset_tampered_signature() {
139 let mut h = TestHarness::new().await;
140 let user_id = h
141 .signup("tamperuser", "tamper@test.com", "password123")
142 .await;
143
144 let password_hash = get_password_hash(&h.db, user_id).await;
145
146 // Generate a valid URL, then tamper with the signature
147 let url = makenotwork::email::generate_password_reset_url(
148 "",
149 user_id,
150 &password_hash,
151 SIGNING_SECRET,
152 );
153
154 let parsed = url::Url::parse(&format!("http://localhost{}", url)).unwrap();
155 let user_param = parsed.query_pairs().find(|(k, _)| k == "user").unwrap().1.to_string();
156 let expires_param = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.to_string();
157
158 // Use a tampered signature
159 let tampered_sig = "0000000000000000000000000000000000000000000000000000000000000000";
160
161 let body = format!(
162 "user={}&expires={}&sig={}&password=newpassword1&password_confirm=newpassword1",
163 urlencoding::encode(&user_param),
164 urlencoding::encode(&expires_param),
165 tampered_sig,
166 );
167 let resp = h.client.post_form("/reset-password", &body).await;
168 assert!(
169 !resp.status.is_redirection(),
170 "Tampered signature should not redirect to login, got {}", resp.status
171 );
172 assert!(
173 resp.text.to_lowercase().contains("expired") || resp.text.to_lowercase().contains("invalid"),
174 "Response should mention expired/invalid: {}", resp.text
175 );
176 }
177
178 #[tokio::test]
179 async fn password_reset_passwords_must_match() {
180 let mut h = TestHarness::new().await;
181 let user_id = h
182 .signup("mismatch", "mismatch@test.com", "password123")
183 .await;
184
185 let password_hash = get_password_hash(&h.db, user_id).await;
186
187 let url = makenotwork::email::generate_password_reset_url(
188 "",
189 user_id,
190 &password_hash,
191 SIGNING_SECRET,
192 );
193
194 let parsed = url::Url::parse(&format!("http://localhost{}", url)).unwrap();
195 let user_param = parsed.query_pairs().find(|(k, _)| k == "user").unwrap().1.to_string();
196 let expires_param = parsed.query_pairs().find(|(k, _)| k == "expires").unwrap().1.to_string();
197 let sig_param = parsed.query_pairs().find(|(k, _)| k == "sig").unwrap().1.to_string();
198
199 // POST with mismatched passwords
200 let body = format!(
201 "user={}&expires={}&sig={}&password=newpassword1&password_confirm=differentpassword",
202 urlencoding::encode(&user_param),
203 urlencoding::encode(&expires_param),
204 urlencoding::encode(&sig_param),
205 );
206 let resp = h.client.post_form("/reset-password", &body).await;
207 assert!(
208 !resp.status.is_redirection(),
209 "Mismatched passwords should not redirect to login, got {}", resp.status
210 );
211 assert!(
212 resp.text.to_lowercase().contains("match") || resp.text.to_lowercase().contains("do not"),
213 "Response should mention password mismatch: {}", resp.text
214 );
215 }
216