Skip to main content

max / makenotwork

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