Skip to main content

max / makenotwork

11.8 KB · 375 lines History Blame Raw
1 use crate::harness::{HarnessOptions, TestHarness};
2 use axum::http::StatusCode;
3 use wiremock::matchers::{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: {}",
113 location
114 );
115 }
116
117 #[tokio::test]
118 async fn callback_with_wrong_state_rejects() {
119 let mut h = TestHarness::new().await;
120 // Login sets state + PKCE verifier in session
121 h.client.get("/auth/login").await;
122
123 // Call callback with wrong state
124 let resp = h
125 .client
126 .get("/auth/callback?code=fake&state=wrong_state_value")
127 .await;
128
129 assert!(resp.status.is_redirection());
130 let location = resp
131 .headers
132 .get("location")
133 .and_then(|v| v.to_str().ok())
134 .expect("should have location header");
135 assert!(
136 location.contains("error=state_mismatch"),
137 "Should redirect with state_mismatch error, got: {}",
138 location
139 );
140 }
141
142 #[tokio::test]
143 async fn callback_with_correct_state_fails_at_token_exchange() {
144 let mut h = TestHarness::new().await;
145
146 // Login to set state in session
147 let login_resp = h.client.get("/auth/login").await;
148 let location = login_resp
149 .headers
150 .get("location")
151 .and_then(|v| v.to_str().ok())
152 .expect("login should redirect");
153
154 // Extract state from redirect URL
155 let state_start = location.find("state=").expect("state in URL") + 6;
156 let state_end = location[state_start..]
157 .find('&')
158 .map(|i| state_start + i)
159 .unwrap_or(location.len());
160 let state = &location[state_start..state_end];
161
162 // Call callback with correct state — will try HTTP to 127.0.0.1:9999 (no server)
163 let resp = h
164 .client
165 .get(&format!("/auth/callback?code=fake&state={}", state))
166 .await;
167
168 assert!(resp.status.is_redirection());
169 let cb_location = resp
170 .headers
171 .get("location")
172 .and_then(|v| v.to_str().ok())
173 .expect("should have location header");
174 assert!(
175 cb_location.contains("error=token_request_failed"),
176 "Should fail at token exchange, got: {}",
177 cb_location
178 );
179 }
180
181 // ── Perks refresh (`POST /auth/refresh`) ──
182 //
183 // Refresh re-hits MNW's `/oauth/userinfo` using the cached access token and
184 // overwrites the session's `perks`. These tests use wiremock to stand in for
185 // MNW.
186
187 /// Spin up a TestHarness pointed at a wiremock MNW. Returns both so individual
188 /// tests can register response expectations on the mock.
189 async fn harness_with_mock_mnw() -> (TestHarness, MockServer) {
190 let mock = MockServer::start().await;
191 let h = TestHarness::with_options(HarnessOptions {
192 mnw_base_url: Some(mock.uri()),
193 ..Default::default()
194 })
195 .await;
196 (h, mock)
197 }
198
199 /// Log in via the test harness and seed access token + perks into the session.
200 async fn login_with_token(h: &mut TestHarness, username: &str, token: &str, perks: serde_json::Value) -> uuid::Uuid {
201 let user_id = uuid::Uuid::new_v4();
202 sqlx::query(
203 "INSERT INTO users (mnw_account_id, username, display_name) \
204 VALUES ($1, $2, $2) ON CONFLICT (mnw_account_id) DO NOTHING",
205 )
206 .bind(user_id)
207 .bind(username)
208 .execute(&h.db)
209 .await
210 .expect("insert test user");
211 h.client.get("/").await;
212 let body = serde_json::json!({
213 "user_id": user_id.to_string(),
214 "username": username,
215 "access_token": token,
216 "perks": perks,
217 });
218 h.client.post_json("/_test/login", &body.to_string()).await;
219 user_id
220 }
221
222 #[tokio::test]
223 async fn refresh_updates_perks_from_mnw() {
224 let (mut h, mock) = harness_with_mock_mnw().await;
225 let user_id = login_with_token(
226 &mut h,
227 "refreshuser",
228 "fake-token",
229 serde_json::json!({ "fan_plus": false, "is_creator": false }),
230 )
231 .await;
232
233 Mock::given(method("GET"))
234 .and(path("/oauth/userinfo"))
235 .and(header("authorization", "Bearer fake-token"))
236 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
237 "user_id": user_id,
238 "username": "refreshuser",
239 "display_name": "Refresh User",
240 "avatar_url": null,
241 "perks": {
242 "fan_plus": true,
243 "is_creator": false,
244 "creator_tier": null,
245 },
246 })))
247 .expect(1)
248 .mount(&mock)
249 .await;
250
251 let resp = h.client.post_form("/auth/refresh", "").await;
252 assert_eq!(resp.status, StatusCode::OK, "body: {}", resp.text);
253 let body: serde_json::Value = serde_json::from_str(&resp.text).expect("json body");
254 assert_eq!(body["perks"]["fan_plus"], true);
255 assert_eq!(body["perks"]["is_creator"], false);
256 }
257
258 #[tokio::test]
259 async fn refresh_returns_creator_tier_features() {
260 let (mut h, mock) = harness_with_mock_mnw().await;
261 let user_id = login_with_token(
262 &mut h,
263 "creatoruser",
264 "creator-token",
265 serde_json::json!({}),
266 )
267 .await;
268
269 Mock::given(method("GET"))
270 .and(path("/oauth/userinfo"))
271 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
272 "user_id": user_id,
273 "username": "creatoruser",
274 "display_name": null,
275 "avatar_url": null,
276 "perks": {
277 "fan_plus": false,
278 "is_creator": true,
279 "creator_tier": { "tier": "big_files", "features": ["file_uploads", "large_files"] },
280 },
281 })))
282 .mount(&mock)
283 .await;
284
285 let resp = h.client.post_form("/auth/refresh", "").await;
286 assert_eq!(resp.status, StatusCode::OK);
287 let body: serde_json::Value = serde_json::from_str(&resp.text).unwrap();
288 assert_eq!(body["perks"]["is_creator"], true);
289 assert_eq!(body["perks"]["creator_tier"]["tier"], "big_files");
290 let features = body["perks"]["creator_tier"]["features"].as_array().expect("features array");
291 assert!(features.iter().any(|f| f == "file_uploads"));
292 assert!(features.iter().any(|f| f == "large_files"));
293 }
294
295 #[tokio::test]
296 async fn refresh_unauthorized_flushes_session() {
297 let (mut h, mock) = harness_with_mock_mnw().await;
298 login_with_token(&mut h, "expireduser", "stale-token", serde_json::json!({})).await;
299
300 Mock::given(method("GET"))
301 .and(path("/oauth/userinfo"))
302 .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({"error": "invalid_token"})))
303 .mount(&mock)
304 .await;
305
306 let resp = h.client.post_form("/auth/refresh", "").await;
307 assert_eq!(resp.status, StatusCode::UNAUTHORIZED);
308
309 // Session should be flushed — the home page now shows the login link, not the username.
310 let resp = h.client.get("/").await;
311 assert!(resp.text.contains("Login"), "expected login link after session flush");
312 assert!(
313 !resp.text.contains("expireduser"),
314 "username should not appear after flush"
315 );
316 }
317
318 #[tokio::test]
319 async fn refresh_without_session_returns_401() {
320 let mut h = TestHarness::new().await;
321 let resp = h.client.post_form("/auth/refresh", "").await;
322 assert_eq!(resp.status, StatusCode::UNAUTHORIZED);
323 }
324
325 #[tokio::test]
326 async fn refresh_on_mnw_5xx_returns_bad_gateway() {
327 let (mut h, mock) = harness_with_mock_mnw().await;
328 login_with_token(&mut h, "transientuser", "any-token", serde_json::json!({})).await;
329
330 Mock::given(method("GET"))
331 .and(path("/oauth/userinfo"))
332 .respond_with(ResponseTemplate::new(503))
333 .mount(&mock)
334 .await;
335
336 let resp = h.client.post_form("/auth/refresh", "").await;
337 assert_eq!(resp.status, StatusCode::BAD_GATEWAY);
338
339 // Session should still be valid — 5xx is transient.
340 let resp = h.client.get("/").await;
341 assert!(
342 resp.text.contains("transientuser"),
343 "session should survive transient MNW error"
344 );
345 }
346
347 #[tokio::test]
348 async fn suspended_user_sees_error_page() {
349 let mut h = TestHarness::new().await;
350 let user_id = h.login_as("suspendeduser").await;
351 let comm_id = h.create_community("Test", "test").await;
352 let _cat_id = h.create_category(comm_id, "General", "general").await;
353 h.add_membership(user_id, comm_id, "member").await;
354
355 // Verify can access community page while not suspended
356 let resp = h.client.get("/p/test").await;
357 assert_eq!(resp.status, StatusCode::OK);
358
359 // Suspend the user
360 sqlx::query("UPDATE users SET suspended_at = now(), suspension_reason = 'test' WHERE mnw_account_id = $1")
361 .bind(user_id)
362 .execute(&h.db)
363 .await
364 .unwrap();
365
366 // Suspended user can still browse with existing session (suspension blocks new logins)
367 // but verify the page still loads (no crash)
368 let resp = h.client.get("/p/test").await;
369 assert!(
370 resp.status == StatusCode::OK || resp.status == StatusCode::FORBIDDEN,
371 "Should either allow (existing session) or block, got: {}",
372 resp.status
373 );
374 }
375