Skip to main content

max / makenotwork

14.4 KB · 441 lines History Blame Raw
1 //! Community moderation state machine, enforcement and state-change tests.
2 //!
3 //! Predicate semantics (which actions are allowed in each state) live in
4 //! `mt-core::types::CommunityState` unit tests. These tests exercise the
5 //! wire-up: that write handlers consult the state, mods/superadmin bypass,
6 //! and the state-change route is authorized correctly.
7
8 use crate::harness::TestHarness;
9 use axum::http::StatusCode;
10 use uuid::Uuid;
11
12 // ── Setup ──
13
14 /// Build a community in the requested state with a category, returning
15 /// (community_id, category_id).
16 async fn setup_community(h: &mut TestHarness, state: &str) -> (Uuid, Uuid) {
17 let comm_id = h.create_community("Test", "test").await;
18 let cat_id = h.create_category(comm_id, "General", "general").await;
19 sqlx::query("UPDATE communities SET state = $2 WHERE id = $1")
20 .bind(comm_id)
21 .bind(state)
22 .execute(&h.db)
23 .await
24 .expect("set state");
25 (comm_id, cat_id)
26 }
27
28 // ── Restricted: block new threads, allow replies ──
29
30 #[tokio::test]
31 async fn restricted_blocks_member_new_thread() {
32 let mut h = TestHarness::new().await;
33 let user_id = h.login_as("rmem").await;
34 let (comm_id, _cat) = setup_community(&mut h, "restricted").await;
35 h.add_membership(user_id, comm_id, "member").await;
36
37 h.client.get("/p/test/general/new").await;
38 let resp = h
39 .client
40 .post_form("/p/test/general/new", "title=Blocked&body=No")
41 .await;
42 assert_eq!(resp.status, StatusCode::FORBIDDEN);
43 assert!(resp.text.contains("restricted"), "body: {}", resp.text);
44 }
45
46 #[tokio::test]
47 async fn restricted_allows_member_reply() {
48 let mut h = TestHarness::new().await;
49 let user_id = h.login_as("rmem2").await;
50 let (comm_id, cat_id) = setup_community(&mut h, "restricted").await;
51 h.add_membership(user_id, comm_id, "member").await;
52 let thread_id = h
53 .create_thread_with_post(cat_id, user_id, "Existing", "OP")
54 .await;
55
56 let url = format!("/p/test/general/{thread_id}");
57 h.client.get(&url).await;
58 let resp = h
59 .client
60 .post_form(&format!("{url}/reply"), "body=Hello+still")
61 .await;
62 assert!(
63 resp.status.is_redirection() || resp.status.is_success(),
64 "status: {}",
65 resp.status
66 );
67 }
68
69 #[tokio::test]
70 async fn restricted_mod_can_create_thread() {
71 let mut h = TestHarness::new().await;
72 let user_id = h.login_as("rmod").await;
73 let (comm_id, _cat) = setup_community(&mut h, "restricted").await;
74 h.add_membership(user_id, comm_id, "moderator").await;
75
76 h.client.get("/p/test/general/new").await;
77 let resp = h
78 .client
79 .post_form("/p/test/general/new", "title=Mod+Thread&body=Allowed")
80 .await;
81 assert!(
82 resp.status.is_redirection(),
83 "mod should bypass, got {}",
84 resp.status
85 );
86 }
87
88 // ── Frozen: block all member writes ──
89
90 #[tokio::test]
91 async fn frozen_blocks_member_reply() {
92 let mut h = TestHarness::new().await;
93 let user_id = h.login_as("fmem").await;
94 let (comm_id, cat_id) = setup_community(&mut h, "active").await;
95 h.add_membership(user_id, comm_id, "member").await;
96 let thread_id = h
97 .create_thread_with_post(cat_id, user_id, "Thread", "OP")
98 .await;
99 // Freeze after thread exists
100 sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
101 .bind(comm_id)
102 .execute(&h.db)
103 .await
104 .unwrap();
105
106 let url = format!("/p/test/general/{thread_id}");
107 h.client.get(&url).await;
108 let resp = h
109 .client
110 .post_form(&format!("{url}/reply"), "body=Reply")
111 .await;
112 assert_eq!(resp.status, StatusCode::FORBIDDEN);
113 assert!(resp.text.contains("frozen"));
114 }
115
116 #[tokio::test]
117 async fn frozen_blocks_member_endorsement() {
118 let mut h = TestHarness::new().await;
119 let author_id = h.login_as("fauth").await;
120 let (comm_id, cat_id) = setup_community(&mut h, "active").await;
121 h.add_membership(author_id, comm_id, "member").await;
122 let thread_id = h
123 .create_thread_with_post(cat_id, author_id, "T", "OP")
124 .await;
125 let post_id: Uuid = sqlx::query_scalar("SELECT id FROM posts WHERE thread_id = $1 LIMIT 1")
126 .bind(thread_id)
127 .fetch_one(&h.db)
128 .await
129 .unwrap();
130
131 // Switch to a different user and freeze the community
132 h.client.post_form("/auth/logout", "").await;
133 let other_id = h.login_as("fother").await;
134 h.add_membership(other_id, comm_id, "member").await;
135 sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
136 .bind(comm_id)
137 .execute(&h.db)
138 .await
139 .unwrap();
140
141 let url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse");
142 h.client.get(&format!("/p/test/general/{thread_id}")).await;
143 let resp = h.client.post_form(&url, "").await;
144 assert_eq!(resp.status, StatusCode::FORBIDDEN);
145 }
146
147 #[tokio::test]
148 async fn frozen_mod_can_reply() {
149 let mut h = TestHarness::new().await;
150 let user_id = h.login_as("fmod").await;
151 let (comm_id, cat_id) = setup_community(&mut h, "active").await;
152 h.add_membership(user_id, comm_id, "moderator").await;
153 let thread_id = h.create_thread_with_post(cat_id, user_id, "T", "OP").await;
154 sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
155 .bind(comm_id)
156 .execute(&h.db)
157 .await
158 .unwrap();
159
160 let url = format!("/p/test/general/{thread_id}");
161 h.client.get(&url).await;
162 let resp = h
163 .client
164 .post_form(&format!("{url}/reply"), "body=Mod+reply")
165 .await;
166 assert!(
167 resp.status.is_redirection(),
168 "mod reply blocked: {}",
169 resp.status
170 );
171 }
172
173 // ── Superadmin override ──
174
175 #[tokio::test]
176 async fn superadmin_can_reply_in_frozen_without_role() {
177 // Superadmin is a platform-level user with no community role here.
178 let admin_id = Uuid::new_v4();
179 let mut h = TestHarness::new_with_admin(admin_id).await;
180
181 // Seed a community + author with role, then a thread.
182 let author_id = h.login_as("sauth").await;
183 let (comm_id, cat_id) = setup_community(&mut h, "active").await;
184 h.add_membership(author_id, comm_id, "member").await;
185 let thread_id = h
186 .create_thread_with_post(cat_id, author_id, "T", "OP")
187 .await;
188 sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
189 .bind(comm_id)
190 .execute(&h.db)
191 .await
192 .unwrap();
193
194 // Become the superadmin, explicitly no community role.
195 h.client.post_form("/auth/logout", "").await;
196 sqlx::query(
197 "INSERT INTO users (mnw_account_id, username, display_name) \
198 VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING",
199 )
200 .bind(admin_id)
201 .execute(&h.db)
202 .await
203 .unwrap();
204 h.client.get("/").await;
205 h.client
206 .post_json(
207 "/_test/login",
208 &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" })
209 .to_string(),
210 )
211 .await;
212
213 let url = format!("/p/test/general/{thread_id}");
214 h.client.get(&url).await;
215 let resp = h
216 .client
217 .post_form(&format!("{url}/reply"), "body=Super+reply")
218 .await;
219 assert!(
220 resp.status.is_redirection(),
221 "superadmin reply blocked: status={} body={}",
222 resp.status,
223 resp.text
224 );
225 }
226
227 // ── Archived: same as frozen + hidden from default listing ──
228
229 #[tokio::test]
230 async fn archived_blocks_member_reply() {
231 let mut h = TestHarness::new().await;
232 let user_id = h.login_as("amem").await;
233 let (comm_id, cat_id) = setup_community(&mut h, "active").await;
234 h.add_membership(user_id, comm_id, "member").await;
235 let thread_id = h.create_thread_with_post(cat_id, user_id, "T", "OP").await;
236 sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1")
237 .bind(comm_id)
238 .execute(&h.db)
239 .await
240 .unwrap();
241
242 let url = format!("/p/test/general/{thread_id}");
243 h.client.get(&url).await;
244 let resp = h.client.post_form(&format!("{url}/reply"), "body=No").await;
245 assert_eq!(resp.status, StatusCode::FORBIDDEN);
246 assert!(resp.text.contains("archived"));
247 }
248
249 #[tokio::test]
250 async fn archived_excluded_from_default_listing() {
251 let mut h = TestHarness::new().await;
252 h.create_community("Active", "active-comm").await;
253 let arch_id = h.create_community("Archived", "arch-comm").await;
254 sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1")
255 .bind(arch_id)
256 .execute(&h.db)
257 .await
258 .unwrap();
259
260 let resp = h.client.get("/").await;
261 assert!(resp.text.contains("Active"));
262 assert!(
263 !resp.text.contains("/p/arch-comm"),
264 "archived community should not appear in default listing"
265 );
266
267 let resp = h.client.get("/?filter=archived").await;
268 assert!(
269 resp.text.contains("/p/arch-comm"),
270 "archived view should show it"
271 );
272 assert!(
273 !resp.text.contains("/p/active-comm"),
274 "archived view should not include active communities"
275 );
276 }
277
278 #[tokio::test]
279 async fn archived_directory_pagination_preserves_filter() {
280 // With >1 page of archived communities, the next/prev links must keep
281 // ?filter=archived, else page 2 reverts to the default listing (audit U2).
282 let mut h = TestHarness::new().await;
283 // 26 archived communities → 2 pages at 25/page.
284 for i in 0..26 {
285 let id = h
286 .create_community(&format!("Arch {i}"), &format!("arch-{i}"))
287 .await;
288 sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1")
289 .bind(id)
290 .execute(&h.db)
291 .await
292 .unwrap();
293 }
294
295 let resp = h.client.get("/?filter=archived").await;
296 assert_eq!(resp.status.as_u16(), 200);
297 // The Next link must carry the filter forward. Askama HTML-escapes the `&`
298 // to the numeric entity `&`, which the browser decodes back to `&`.
299 assert!(
300 resp.text.contains("?page=2&filter=archived"),
301 "archived pagination Next link must preserve filter; got body:\n{}",
302 resp.text
303 );
304
305 // Page 2 of the archived view still shows archived communities (filter held).
306 let resp2 = h.client.get("/?page=2&filter=archived").await;
307 assert_eq!(resp2.status.as_u16(), 200);
308 assert!(
309 resp2.text.contains("?page=1&filter=archived"),
310 "Previous link on page 2 must preserve filter"
311 );
312 }
313
314 #[tokio::test]
315 async fn missing_community_page_renders_branded_404() {
316 // An in-handler 404 (missing community) must render the branded error page
317 // with site chrome, not bare "Not found" plaintext (audit U1).
318 let mut h = TestHarness::new().await;
319 let resp = h.client.get("/p/no-such-community").await;
320 assert_eq!(resp.status.as_u16(), 404);
321 assert!(
322 resp.text
323 .contains("The page you're looking for doesn't exist."),
324 "should render the branded 404 body"
325 );
326 assert!(
327 resp.text.contains("Back to Forums"),
328 "branded 404 includes the styled call-to-action"
329 );
330 assert_ne!(resp.text.trim(), "Not found", "must not be bare plaintext");
331 }
332
333 // ── State-change route ──
334
335 #[tokio::test]
336 async fn owner_can_change_state() {
337 let mut h = TestHarness::new().await;
338 let user_id = h.login_as("owner").await;
339 let (comm_id, _cat) = setup_community(&mut h, "active").await;
340 h.add_membership(user_id, comm_id, "owner").await;
341
342 // Prime CSRF + cookie
343 h.client.get("/p/test/settings").await;
344 let resp = h
345 .client
346 .post_form("/p/test/settings/state", "state=frozen")
347 .await;
348 assert!(resp.status.is_redirection(), "status: {}", resp.status);
349
350 let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
351 .bind(comm_id)
352 .fetch_one(&h.db)
353 .await
354 .unwrap();
355 assert_eq!(state, "frozen");
356 }
357
358 #[tokio::test]
359 async fn superadmin_can_change_state_without_role() {
360 let admin_id = Uuid::new_v4();
361 let mut h = TestHarness::new_with_admin(admin_id).await;
362 let (comm_id, _cat) = setup_community(&mut h, "active").await;
363 sqlx::query(
364 "INSERT INTO users (mnw_account_id, username, display_name) \
365 VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING",
366 )
367 .bind(admin_id)
368 .execute(&h.db)
369 .await
370 .unwrap();
371 h.client.get("/").await;
372 h.client
373 .post_json(
374 "/_test/login",
375 &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" })
376 .to_string(),
377 )
378 .await;
379
380 h.client.get("/p/test/settings").await;
381 let resp = h
382 .client
383 .post_form("/p/test/settings/state", "state=archived")
384 .await;
385 assert!(resp.status.is_redirection(), "status: {}", resp.status);
386
387 let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
388 .bind(comm_id)
389 .fetch_one(&h.db)
390 .await
391 .unwrap();
392 assert_eq!(state, "archived");
393 }
394
395 #[tokio::test]
396 async fn member_cannot_change_state() {
397 let mut h = TestHarness::new().await;
398 let user_id = h.login_as("nope").await;
399 let (comm_id, _cat) = setup_community(&mut h, "active").await;
400 h.add_membership(user_id, comm_id, "member").await;
401
402 h.client.get("/p/test/settings").await;
403 let resp = h
404 .client
405 .post_form("/p/test/settings/state", "state=frozen")
406 .await;
407 assert_eq!(resp.status, StatusCode::FORBIDDEN);
408
409 let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
410 .bind(comm_id)
411 .fetch_one(&h.db)
412 .await
413 .unwrap();
414 assert_eq!(state, "active", "state should be unchanged");
415 }
416
417 #[tokio::test]
418 async fn state_change_rejects_unknown_value() {
419 let mut h = TestHarness::new().await;
420 let user_id = h.login_as("ownerbad").await;
421 let (comm_id, _cat) = setup_community(&mut h, "active").await;
422 h.add_membership(user_id, comm_id, "owner").await;
423
424 h.client.get("/p/test/settings").await;
425 let resp = h
426 .client
427 .post_form("/p/test/settings/state", "state=bogus")
428 .await;
429 assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
430
431 let state: String = sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
432 .bind(comm_id)
433 .fetch_one(&h.db)
434 .await
435 .unwrap();
436 assert_eq!(
437 state, "active",
438 "state should be unchanged on validation error"
439 );
440 }
441