Skip to main content

max / makenotwork

12.0 KB · 359 lines History Blame Raw
1 //! Auth workflow: signup -> dashboard -> logout -> dashboard (redirect)
2
3 use crate::harness::TestHarness;
4
5 #[tokio::test]
6 async fn multibyte_password_register_then_login() {
7 // Regression (audit Run 22): signup capped password length by char count
8 // (`chars().count()`) while login/OAuth/SyncKit capped by BYTE count
9 // (`.len()`). A password of <=128 chars but >128 bytes, any multibyte
10 // passphrase near the cap, registered fine, then was silently rejected at
11 // every subsequent login: a permanent, undiagnosable self-lockout.
12 //
13 // 65 CJK chars = 65 chars (well under the 128-char signup cap) = 195 bytes
14 // (over the old 128-byte login cap). Under the bug, signup succeeds but the
15 // login below never establishes a session.
16 let password = "\u{4f60}".repeat(65); // 你 x65
17 assert_eq!(password.chars().count(), 65);
18 assert!(password.len() > 128, "test password must exceed 128 bytes");
19
20 let mut h = TestHarness::new().await;
21 let _user_id = h.signup("mb_user", "mb@example.com", &password).await;
22 h.client.post_form("/logout", "").await;
23
24 // Log back in with the identical multibyte password.
25 h.login("mb_user", &password).await;
26
27 // A failed login renders 200 with an error but sets no session, so the
28 // definitive check is that the authenticated dashboard is reachable.
29 let resp = h.client.get("/dashboard").await;
30 assert_eq!(
31 resp.status, 200,
32 "multibyte-password account must be able to log back in"
33 );
34 }
35
36 #[tokio::test]
37 async fn signup_login_logout_flow() {
38 let mut h = TestHarness::new().await;
39
40 // Sign up
41 let _user_id = h
42 .signup("testuser", "test@example.com", "password123")
43 .await;
44
45 // Should be logged in, dashboard returns 200
46 let resp = h.client.get("/dashboard").await;
47 assert_eq!(
48 resp.status, 200,
49 "Dashboard should be accessible after signup"
50 );
51
52 let resp = h.client.post_form("/logout", "").await;
53 assert_eq!(resp.status, 303, "Logout should succeed");
54
55 // Dashboard should now redirect (302) or return 401
56 let resp = h.client.get("/dashboard").await;
57 assert!(
58 resp.status == 302 || resp.status == 303 || resp.status == 401,
59 "Dashboard should redirect after logout, got {}",
60 resp.status
61 );
62 }
63
64 #[tokio::test]
65 async fn signup_with_taken_email_does_not_reveal_or_create() {
66 // m1 (ultra-fuzz Run 4): a signup attempt whose EMAIL is already registered
67 // must not return "this email is already registered" (an account-existence
68 // oracle for a private identifier). It returns the same step-2 response a
69 // fresh signup returns and creates no second account; the real owner is
70 // reached out-of-band by the "account exists" email.
71 let mut h = TestHarness::new().await;
72 h.signup("owner", "owner@example.com", "password123").await;
73 h.client.post_form("/logout", "").await;
74
75 // A new username, but the same (taken) email.
76 h.client.fetch_csrf_token().await;
77 let body = "username=intruder&email=owner@example.com&password=password123";
78 let resp = h.client.post_form("/join/step/account", body).await;
79
80 assert_eq!(
81 resp.status, 200,
82 "taken-email signup should not error: {}",
83 resp.status
84 );
85 let lower = resp.text.to_lowercase();
86 assert!(
87 !lower.contains("already registered") && !lower.contains("already exists"),
88 "response must not reveal the email is registered: {}",
89 resp.text
90 );
91
92 // No second account created for the probed email.
93 let count: i64 =
94 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = 'owner@example.com'")
95 .fetch_one(&h.db)
96 .await
97 .unwrap();
98 assert_eq!(
99 count, 1,
100 "no duplicate account may be created for a taken email"
101 );
102 // And the probed username was never registered.
103 let intruder: i64 =
104 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = 'intruder'")
105 .fetch_one(&h.db)
106 .await
107 .unwrap();
108 assert_eq!(
109 intruder, 0,
110 "no account should be created on the taken-email path"
111 );
112 }
113
114 #[tokio::test]
115 async fn login_with_existing_account() {
116 let mut h = TestHarness::new().await;
117
118 // Sign up and then log out
119 let _user_id = h
120 .signup("alice", "alice@example.com", "secure_pass99")
121 .await;
122 h.client.post_form("/logout", "").await;
123
124 // Log back in
125 h.login("alice", "secure_pass99").await;
126
127 // Dashboard should be accessible
128 let resp = h.client.get("/dashboard").await;
129 assert_eq!(
130 resp.status, 200,
131 "Dashboard should be accessible after login"
132 );
133 }
134
135 #[tokio::test]
136 async fn wrong_password_rejected() {
137 let mut h = TestHarness::new().await;
138 let _user_id = h.signup("wp_user", "wp@example.com", "correctpass1").await;
139 h.client.post_form("/logout", "").await;
140
141 let resp = h
142 .client
143 .post_form("/login", "login=wp_user&password=totallyWrong")
144 .await;
145 assert!(
146 resp.status != 200 && resp.status != 303,
147 "Wrong password should not yield 200 or 303, got {}",
148 resp.status
149 );
150 }
151
152 #[tokio::test]
153 async fn nonexistent_user_rejected() {
154 let mut h = TestHarness::new().await;
155
156 let resp = h
157 .client
158 .post_form("/login", "login=ghost_user_xyz&password=anypass123")
159 .await;
160 assert!(
161 resp.status != 200 && resp.status != 303,
162 "Nonexistent user login should not yield 200 or 303, got {}",
163 resp.status
164 );
165 }
166
167 /// A duplicate email must answer exactly like a fresh signup.
168 ///
169 /// Saying "this email is already registered" turns the signup form into an
170 /// account-existence oracle for an identifier the owner did not make public, so
171 /// `join_wizard::step_account_create` returns the same step-2 partial a fresh
172 /// signup gets and tells the real owner out of band instead. `WizardJoinProfileTemplate`
173 /// carries nothing but the step nav, which is why the two responses can be
174 /// compared byte for byte rather than probed for the absence of a phrase.
175 ///
176 /// This test spent its life POSTing `/join`, which is GET-only, and passing on
177 /// the 405. Named `duplicate_email_rejected` then, which is the opposite of the
178 /// behavior the handler is careful to have.
179 #[tokio::test]
180 async fn duplicate_email_answers_like_a_fresh_signup() {
181 let mut h = TestHarness::new().await;
182 let _user_id = h
183 .signup("orig_user", "dupe@example.com", "password123")
184 .await;
185 h.client.post_form("/logout", "").await;
186
187 // The baseline: a signup with no collision at all.
188 h.client.fetch_csrf_token().await;
189 let fresh = h
190 .client
191 .post_form(
192 "/join/step/account",
193 "username=fresh_user&email=fresh@example.com&password=password123",
194 )
195 .await;
196 assert_eq!(fresh.status, 200, "{}", fresh.text);
197 // Pin what the baseline IS, so the comparison below cannot be satisfied by
198 // two identical failures.
199 assert!(
200 fresh.text.contains(r#"hx-post="/join/step/profile""#),
201 "a clean signup should advance to the profile step: {}",
202 fresh.text
203 );
204 h.client.post_form("/logout", "").await;
205
206 // Same request, but the email is taken.
207 h.client.fetch_csrf_token().await;
208 let resp = h
209 .client
210 .post_form(
211 "/join/step/account",
212 "username=other_user&email=dupe@example.com&password=password123",
213 )
214 .await;
215 assert_eq!(resp.status, 200, "{}", resp.text);
216 assert_eq!(
217 resp.text, fresh.text,
218 "a taken email must be indistinguishable from a free one",
219 );
220
221 // The collision is concealed, not ignored: no second account exists.
222 let count: i64 =
223 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = 'dupe@example.com'")
224 .fetch_one(&h.db)
225 .await
226 .unwrap();
227 assert_eq!(count, 1, "the duplicate signup must not create an account");
228 }
229
230 /// A duplicate username is revealed, unlike a duplicate email: usernames are
231 /// public handles that appear in profile URLs, and the user has to be told to
232 /// pick another one. Re-renders the account step at 200 with the field flagged.
233 ///
234 /// Was POSTing GET-only `/join` and passing on the 405. `adversarial_input.rs`
235 /// has a sibling covering the same route from the "did it create a second row"
236 /// angle.
237 #[tokio::test]
238 async fn duplicate_username_rejected() {
239 let mut h = TestHarness::new().await;
240 let _user_id = h
241 .signup("taken_name", "first@example.com", "password123")
242 .await;
243 h.client.post_form("/logout", "").await;
244
245 h.client.fetch_csrf_token().await;
246 let resp = h
247 .client
248 .post_form(
249 "/join/step/account",
250 "username=taken_name&email=second@example.com&password=password123",
251 )
252 .await;
253 assert_eq!(resp.status, 200, "{}", resp.text);
254 assert!(
255 resp.text.contains("already taken"),
256 "the account step must come back saying the username is taken: {}",
257 resp.text
258 );
259
260 let count: i64 =
261 sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE email = 'second@example.com'")
262 .fetch_one(&h.db)
263 .await
264 .unwrap();
265 assert_eq!(count, 0, "the rejected signup must not create an account");
266 }
267
268 #[tokio::test]
269 async fn login_with_email() {
270 let mut h = TestHarness::new().await;
271 let _user_id = h
272 .signup("emaillogin", "emaillogin@example.com", "password123")
273 .await;
274 h.client.post_form("/logout", "").await;
275
276 // Login using email address instead of username
277 h.login("emaillogin@example.com", "password123").await;
278
279 let resp = h.client.get("/dashboard").await;
280 assert_eq!(
281 resp.status, 200,
282 "Dashboard should be accessible after login with email"
283 );
284 }
285
286 #[tokio::test]
287 async fn password_change_flow() {
288 let mut h = TestHarness::new().await;
289 let _user_id = h
290 .signup("pwchange", "pwchange@example.com", "oldpass123")
291 .await;
292
293 // Change password via PUT form
294 let resp = h
295 .client
296 .put_form(
297 "/api/users/me/password",
298 "current_password=oldpass123&new_password=newpass456",
299 )
300 .await;
301 assert_eq!(
302 resp.status, 204,
303 "Password change should succeed: {} {}",
304 resp.status, resp.text
305 );
306
307 h.client.post_form("/logout", "").await;
308
309 // Login with new password should succeed
310 h.login("pwchange", "newpass456").await;
311 let resp = h.client.get("/dashboard").await;
312 assert_eq!(
313 resp.status, 200,
314 "New password should grant dashboard access"
315 );
316
317 // Logout and try old password
318 h.client.post_form("/logout", "").await;
319 let resp = h
320 .client
321 .post_form("/login", "login=pwchange&password=oldpass123")
322 .await;
323 assert!(
324 resp.status != 200 && resp.status != 303,
325 "Old password should no longer work, got {}",
326 resp.status
327 );
328 }
329
330 #[tokio::test]
331 async fn lockout_after_failed_attempts() {
332 let mut h = TestHarness::new().await;
333 let _user_id = h.signup("lockme", "lockme@example.com", "rightpass1").await;
334 h.client.post_form("/logout", "").await;
335
336 // 5 failed login attempts. `/login` is Manual-CSRF now (Phase 2), so
337 // each attempt must refresh the token, the helper handles that.
338 for _ in 0..5 {
339 h.failed_login_attempt("lockme", "wrongwrong").await;
340 }
341
342 // Now try with correct password, should be locked out. login_handler
343 // returns 200 with the form re-rendered + inline "Account is locked"
344 // message (same UX convention as the inline-error pattern); a successful
345 // login would be a 303 redirect, so absence of redirect + body containing
346 // "locked" together prove lockout.
347 let resp = h.failed_login_attempt("lockme", "rightpass1").await;
348 assert!(
349 !resp.status.is_redirection(),
350 "Lockout should not redirect to dashboard, got {}",
351 resp.status
352 );
353 assert!(
354 resp.text.to_lowercase().contains("locked"),
355 "Response should mention lockout: {}",
356 resp.text
357 );
358 }
359