Skip to main content

max / makenotwork

18.7 KB · 545 lines History Blame Raw
1 use crate::harness::{HarnessOptions, TestHarness};
2 use axum::http::StatusCode;
3 use wiremock::matchers::{body_string_contains, header, method, path};
4 use wiremock::{Mock, MockServer, ResponseTemplate};
5
6 /// What `POST /oauth/token` must be sent as. RFC 6749 ยง4.1.3, and what the MNW
7 /// server enforces with `axum::Form`.
8 const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded";
9
10 #[tokio::test]
11 async fn unauthenticated_sees_login_link() {
12 let mut h = TestHarness::new().await;
13 let resp = h.client.get("/").await;
14
15 assert!(resp.status.is_success());
16 assert!(
17 resp.text.contains("Login"),
18 "Expected 'Login' link in unauthenticated page"
19 );
20 }
21
22 #[tokio::test]
23 async fn login_redirects_to_mnw() {
24 let mut h = TestHarness::new().await;
25 let resp = h.client.get("/auth/login").await;
26
27 // Should redirect to the MNW OAuth authorize endpoint
28 assert!(
29 resp.status.is_redirection(),
30 "Expected redirect, got {}",
31 resp.status
32 );
33 }
34
35 #[tokio::test]
36 async fn logout_clears_session() {
37 let mut h = TestHarness::new().await;
38 let user_id = h.login_as("logouttest").await;
39 let comm_id = h.create_community("Test", "test").await;
40 let _cat_id = h.create_category(comm_id, "General", "general").await;
41 h.add_membership(user_id, comm_id, "member").await;
42
43 // Verify logged in, page shows username
44 let resp = h.client.get("/").await;
45 assert!(resp.text.contains("logouttest"));
46
47 // Logout (POST)
48 h.client.post_form("/auth/logout", "").await;
49
50 // Should show Login link again
51 let resp = h.client.get("/").await;
52 assert!(
53 resp.text.contains("Login"),
54 "Expected 'Login' link after logout"
55 );
56 }
57
58 #[tokio::test]
59 async fn login_redirect_includes_pkce_and_state() {
60 let mut h = TestHarness::new().await;
61 let resp = h.client.get("/auth/login").await;
62
63 assert!(resp.status.is_redirection());
64 let location = resp
65 .headers
66 .get("location")
67 .and_then(|v| v.to_str().ok())
68 .expect("redirect should have location header");
69
70 assert!(
71 location.contains("client_id=test-client-id"),
72 "URL should contain client_id"
73 );
74 assert!(
75 location.contains("code_challenge="),
76 "URL should contain code_challenge"
77 );
78 assert!(
79 location.contains("code_challenge_method=S256"),
80 "URL should contain S256 method"
81 );
82 assert!(
83 location.contains("state="),
84 "URL should contain state parameter"
85 );
86 assert!(
87 location.contains("response_type=code"),
88 "URL should contain response_type=code"
89 );
90 assert!(
91 location.starts_with("http://127.0.0.1:9999/oauth/authorize"),
92 "Should redirect to MNW OAuth endpoint"
93 );
94 }
95
96 #[tokio::test]
97 async fn callback_without_prior_login_rejects_state() {
98 let mut h = TestHarness::new().await;
99 // Establish session without going through login
100 h.client.get("/").await;
101
102 // Call callback directly, session has no stored state
103 let resp = h
104 .client
105 .get("/auth/callback?code=fake&state=somestate")
106 .await;
107
108 assert!(resp.status.is_redirection());
109 let location = resp
110 .headers
111 .get("location")
112 .and_then(|v| v.to_str().ok())
113 .expect("should have location header");
114 assert!(
115 location.contains("error=state_mismatch"),
116 "Should redirect with state_mismatch error, got: {location}"
117 );
118 }
119
120 #[tokio::test]
121 async fn callback_with_wrong_state_rejects() {
122 let mut h = TestHarness::new().await;
123 // Login sets state + PKCE verifier in session
124 h.client.get("/auth/login").await;
125
126 // Call callback with wrong state
127 let resp = h
128 .client
129 .get("/auth/callback?code=fake&state=wrong_state_value")
130 .await;
131
132 assert!(resp.status.is_redirection());
133 let location = resp
134 .headers
135 .get("location")
136 .and_then(|v| v.to_str().ok())
137 .expect("should have location header");
138 assert!(
139 location.contains("error=state_mismatch"),
140 "Should redirect with state_mismatch error, got: {location}"
141 );
142 }
143
144 #[tokio::test]
145 async fn callback_with_correct_state_fails_at_token_exchange() {
146 let mut h = TestHarness::new().await;
147
148 // Login to set state in session
149 let login_resp = h.client.get("/auth/login").await;
150 let location = login_resp
151 .headers
152 .get("location")
153 .and_then(|v| v.to_str().ok())
154 .expect("login should redirect");
155
156 // Extract state from redirect URL
157 let state_start = location.find("state=").expect("state in URL") + 6;
158 let state_end = location[state_start..]
159 .find('&')
160 .map_or(location.len(), |i| state_start + i);
161 let state = &location[state_start..state_end];
162
163 // Call callback with correct state, will try HTTP to 127.0.0.1:9999 (no server)
164 let resp = h
165 .client
166 .get(&format!("/auth/callback?code=fake&state={state}"))
167 .await;
168
169 assert!(resp.status.is_redirection());
170 let cb_location = resp
171 .headers
172 .get("location")
173 .and_then(|v| v.to_str().ok())
174 .expect("should have location header");
175 assert!(
176 cb_location.contains("error=token_request_failed"),
177 "Should fail at token exchange, got: {cb_location}"
178 );
179 }
180
181 #[tokio::test]
182 async fn callback_exchanges_the_code_as_a_form_and_logs_in() {
183 // The success path of the code exchange, which had no test at all: every
184 // callback test above asserts a failure slug, so the request mt actually
185 // sends was never looked at. It sent JSON, the server takes `axum::Form`,
186 // and login had been answering `token_exchange_failed` on a 415 since the
187 // server switched extractors. The matchers below are the contract.
188 let (mut h, mock) = harness_with_mock_mnw().await;
189 let user_id = uuid::Uuid::new_v4();
190
191 Mock::given(method("POST"))
192 .and(path("/oauth/token"))
193 .and(header("content-type", FORM_CONTENT_TYPE))
194 .and(body_string_contains("grant_type=authorization_code"))
195 .and(body_string_contains("code=the-code"))
196 .and(body_string_contains("code_verifier="))
197 // Required by the server on this grant, absent from its published
198 // contract, and a 422 when it is missing.
199 .and(body_string_contains("key="))
200 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
201 "access_token": "acc", "token_type": "Bearer", "expires_in": 300,
202 "refresh_token": "rt", "scope": "profile:read perks:read offline_access",
203 })))
204 .expect(1)
205 .mount(&mock)
206 .await;
207 Mock::given(method("GET"))
208 .and(path("/oauth/userinfo"))
209 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
210 "user_id": user_id, "username": "callbackuser", "display_name": null,
211 "avatar_url": null,
212 "perks": { "fan_plus": false, "is_creator": false, "creator_tier": null },
213 })))
214 .mount(&mock)
215 .await;
216
217 // /auth/login mints the state and PKCE verifier into the session; the
218 // callback only works with the state that round trip produced.
219 let login = h.client.get("/auth/login").await;
220 let location = login
221 .headers
222 .get("location")
223 .and_then(|v| v.to_str().ok())
224 .expect("login should redirect");
225 let state_start = location.find("state=").expect("state in URL") + 6;
226 let state_end = location[state_start..]
227 .find('&')
228 .map_or(location.len(), |i| state_start + i);
229 let state = location[state_start..state_end].to_string();
230
231 let resp = h
232 .client
233 .get(&format!("/auth/callback?code=the-code&state={state}"))
234 .await;
235 let cb_location = resp
236 .headers
237 .get("location")
238 .and_then(|v| v.to_str().ok())
239 .unwrap_or_default()
240 .to_string();
241 assert_eq!(
242 cb_location, "/",
243 "callback should land logged in, got: {cb_location}"
244 );
245
246 let home = h.client.get("/").await;
247 assert!(
248 home.text.contains("callbackuser"),
249 "the session should carry the logged-in username"
250 );
251 }
252
253 // --- perks refresh (`POST /auth/refresh`)
254 //
255 // Refresh re-hits MNW's `/oauth/userinfo` using the cached access token and
256 // overwrites the session's `perks`. These tests use wiremock to stand in for
257 // MNW.
258
259 /// Spin up a TestHarness pointed at a wiremock MNW. Returns both so individual
260 /// tests can register response expectations on the mock.
261 async fn harness_with_mock_mnw() -> (TestHarness, MockServer) {
262 let mock = MockServer::start().await;
263 let h = TestHarness::with_options(HarnessOptions {
264 mnw_base_url: Some(mock.uri()),
265 ..Default::default()
266 })
267 .await;
268 (h, mock)
269 }
270
271 /// Log in via the test harness and seed a refresh token + perks into the
272 /// session. Perk refresh now trades this refresh token for a short-lived access
273 /// token at `/oauth/token`, then fetches userinfo.
274 async fn login_with_token(
275 h: &mut TestHarness,
276 username: &str,
277 refresh_token: &str,
278 perks: serde_json::Value,
279 ) -> uuid::Uuid {
280 let user_id = uuid::Uuid::new_v4();
281 sqlx::query(
282 "INSERT INTO users (mnw_account_id, username, display_name) \
283 VALUES ($1, $2, $2) ON CONFLICT (mnw_account_id) DO NOTHING",
284 )
285 .bind(user_id)
286 .bind(username)
287 .execute(&h.db)
288 .await
289 .expect("insert test user");
290 h.client.get("/").await;
291 let body = serde_json::json!({
292 "user_id": user_id.to_string(),
293 "username": username,
294 "refresh_token": refresh_token,
295 "perks": perks,
296 });
297 h.client.post_json("/_test/login", &body.to_string()).await;
298 user_id
299 }
300
301 /// Mount a `POST /oauth/token` refresh-grant responder returning the given
302 /// access + (rotated) refresh token.
303 ///
304 /// Matches on the form content type as well as the path, because that is the
305 /// half of the contract a mock is otherwise free to ignore: the real endpoint
306 /// takes `axum::Form` and answers 415 to anything else, and mt sent JSON here
307 /// for months while every one of these tests passed.
308 async fn mock_refresh_grant(mock: &MockServer, access_token: &str, new_refresh_token: &str) {
309 Mock::given(method("POST"))
310 .and(path("/oauth/token"))
311 .and(header("content-type", FORM_CONTENT_TYPE))
312 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
313 "access_token": access_token,
314 "token_type": "Bearer",
315 "expires_in": 300,
316 "refresh_token": new_refresh_token,
317 "scope": "perks:read profile:read offline_access",
318 })))
319 .mount(mock)
320 .await;
321 }
322
323 #[tokio::test]
324 async fn refresh_updates_perks_from_mnw() {
325 let (mut h, mock) = harness_with_mock_mnw().await;
326 let user_id = login_with_token(
327 &mut h,
328 "refreshuser",
329 "rt-original",
330 serde_json::json!({ "fan_plus": false, "is_creator": false }),
331 )
332 .await;
333
334 mock_refresh_grant(&mock, "fresh-access", "rt-rotated").await;
335 Mock::given(method("GET"))
336 .and(path("/oauth/userinfo"))
337 .and(header("authorization", "Bearer fresh-access"))
338 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
339 "user_id": user_id,
340 "username": "refreshuser",
341 "display_name": "Refresh User",
342 "avatar_url": null,
343 "perks": {
344 "fan_plus": true,
345 "is_creator": false,
346 "creator_tier": null,
347 },
348 })))
349 .expect(1)
350 .mount(&mock)
351 .await;
352
353 let resp = h.client.post_form("/auth/refresh", "").await;
354 assert_eq!(resp.status, StatusCode::OK, "body: {}", resp.text);
355 let body: serde_json::Value = serde_json::from_str(&resp.text).expect("json body");
356 assert_eq!(body["perks"]["fan_plus"], true);
357 assert_eq!(body["perks"]["is_creator"], false);
358 }
359
360 #[tokio::test]
361 async fn refresh_rotates_stored_token() {
362 // The second refresh must present the ROTATED token, proving the rotation
363 // was persisted to the session.
364 let (mut h, mock) = harness_with_mock_mnw().await;
365 let user_id = login_with_token(&mut h, "rotuser", "rt-1", serde_json::json!({})).await;
366
367 let userinfo_body = serde_json::json!({
368 "user_id": user_id, "username": "rotuser", "display_name": null, "avatar_url": null,
369 "perks": { "fan_plus": false, "is_creator": false, "creator_tier": null },
370 });
371 Mock::given(method("GET"))
372 .and(path("/oauth/userinfo"))
373 .respond_with(ResponseTemplate::new(200).set_body_json(userinfo_body))
374 .mount(&mock)
375 .await;
376
377 // First refresh: presents rt-1, gets rt-2.
378 Mock::given(method("POST"))
379 .and(path("/oauth/token"))
380 .and(header("content-type", FORM_CONTENT_TYPE))
381 .and(body_string_contains("refresh_token=rt-1"))
382 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
383 "access_token": "acc-1", "token_type": "Bearer", "expires_in": 300, "refresh_token": "rt-2",
384 })))
385 .expect(1)
386 .mount(&mock)
387 .await;
388 let resp = h.client.post_form("/auth/refresh", "").await;
389 assert_eq!(resp.status, StatusCode::OK);
390
391 // Second refresh must present rt-2 (the rotated token), not rt-1.
392 Mock::given(method("POST"))
393 .and(path("/oauth/token"))
394 .and(header("content-type", FORM_CONTENT_TYPE))
395 .and(body_string_contains("refresh_token=rt-2"))
396 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
397 "access_token": "acc-2", "token_type": "Bearer", "expires_in": 300, "refresh_token": "rt-3",
398 })))
399 .expect(1)
400 .mount(&mock)
401 .await;
402 let resp = h.client.post_form("/auth/refresh", "").await;
403 assert_eq!(
404 resp.status,
405 StatusCode::OK,
406 "second refresh must use rotated token: {}",
407 resp.text
408 );
409 }
410
411 #[tokio::test]
412 async fn refresh_returns_creator_tier_features() {
413 let (mut h, mock) = harness_with_mock_mnw().await;
414 let user_id =
415 login_with_token(&mut h, "creatoruser", "creator-rt", serde_json::json!({})).await;
416
417 mock_refresh_grant(&mock, "creator-access", "creator-rt-2").await;
418 Mock::given(method("GET"))
419 .and(path("/oauth/userinfo"))
420 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
421 "user_id": user_id,
422 "username": "creatoruser",
423 "display_name": null,
424 "avatar_url": null,
425 "perks": {
426 "fan_plus": false,
427 "is_creator": true,
428 "creator_tier": { "tier": "big_files", "features": ["file_uploads", "large_files"] },
429 },
430 })))
431 .mount(&mock)
432 .await;
433
434 let resp = h.client.post_form("/auth/refresh", "").await;
435 assert_eq!(resp.status, StatusCode::OK);
436 let body: serde_json::Value = serde_json::from_str(&resp.text).unwrap();
437 assert_eq!(body["perks"]["is_creator"], true);
438 assert_eq!(body["perks"]["creator_tier"]["tier"], "big_files");
439 let features = body["perks"]["creator_tier"]["features"]
440 .as_array()
441 .expect("features array");
442 assert!(features.iter().any(|f| f == "file_uploads"));
443 assert!(features.iter().any(|f| f == "large_files"));
444 }
445
446 #[tokio::test]
447 async fn refresh_invalid_grant_keeps_session() {
448 // A dead refresh token must NOT log the user out, the MT session is the
449 // login-longevity mechanism. (Inverts the old flush-on-unauthorized test.)
450 let (mut h, mock) = harness_with_mock_mnw().await;
451 login_with_token(&mut h, "expireduser", "dead-rt", serde_json::json!({})).await;
452
453 Mock::given(method("POST"))
454 .and(path("/oauth/token"))
455 .respond_with(
456 ResponseTemplate::new(400).set_body_json(serde_json::json!({"error": "invalid_grant"})),
457 )
458 .mount(&mock)
459 .await;
460
461 let resp = h.client.post_form("/auth/refresh", "").await;
462 assert_eq!(resp.status, StatusCode::UNAUTHORIZED);
463
464 // Session is intact, the user stays logged in with last-known perks.
465 let resp = h.client.get("/").await;
466 assert!(
467 resp.text.contains("expireduser"),
468 "user must stay logged in after a dead refresh token"
469 );
470 }
471
472 #[tokio::test]
473 async fn refresh_without_session_returns_401() {
474 let mut h = TestHarness::new().await;
475 // Establish an anonymous session + CSRF token (refresh is now CSRF-protected);
476 // there is still no *logged-in* session, so the handler returns 401.
477 h.client.get("/").await;
478 let resp = h.client.post_form("/auth/refresh", "").await;
479 assert_eq!(resp.status, StatusCode::UNAUTHORIZED);
480 }
481
482 #[tokio::test]
483 async fn refresh_on_mnw_5xx_returns_bad_gateway() {
484 let (mut h, mock) = harness_with_mock_mnw().await;
485 login_with_token(&mut h, "transientuser", "any-rt", serde_json::json!({})).await;
486
487 Mock::given(method("POST"))
488 .and(path("/oauth/token"))
489 .respond_with(ResponseTemplate::new(503))
490 .mount(&mock)
491 .await;
492
493 let resp = h.client.post_form("/auth/refresh", "").await;
494 assert_eq!(resp.status, StatusCode::BAD_GATEWAY);
495
496 // Session should still be valid, 5xx is transient.
497 let resp = h.client.get("/").await;
498 assert!(
499 resp.text.contains("transientuser"),
500 "session should survive transient MNW error"
501 );
502 }
503
504 #[tokio::test]
505 async fn suspended_user_sees_error_page() {
506 let mut h = TestHarness::new().await;
507 let user_id = h.login_as("suspendeduser").await;
508 let comm_id = h.create_community("Test", "test").await;
509 let _cat_id = h.create_category(comm_id, "General", "general").await;
510 h.add_membership(user_id, comm_id, "member").await;
511
512 // Verify can access community page while not suspended
513 let resp = h.client.get("/p/test").await;
514 assert_eq!(resp.status, StatusCode::OK);
515
516 // Suspend the user
517 sqlx::query("UPDATE users SET suspended_at = now(), suspension_reason = 'test' WHERE mnw_account_id = $1")
518 .bind(user_id)
519 .execute(&h.db)
520 .await
521 .unwrap();
522
523 // Suspension gates writes, not reads: a suspended user with a live session can
524 // still browse (reads go through check_community_access, which checks community
525 // suspension + ban, not user suspension).
526 let resp = h.client.get("/p/test").await;
527 assert_eq!(
528 resp.status,
529 StatusCode::OK,
530 "suspended user should still be able to read with an existing session"
531 );
532
533 // But a write must be refused: check_write_access rejects a suspended account.
534 let resp = h
535 .client
536 .post_form("/p/test/general/new", "title=Nope&body=I+am+suspended")
537 .await;
538 assert_eq!(
539 resp.status,
540 StatusCode::FORBIDDEN,
541 "suspended user must not be able to create a thread, got: {}",
542 resp.status
543 );
544 }
545