Skip to main content

max / makenotwork

12.4 KB · 380 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!("{}/reply", url), "body=Hello+still")
61 .await;
62 assert!(resp.status.is_redirection() || resp.status.is_success(), "status: {}", resp.status);
63 }
64
65 #[tokio::test]
66 async fn restricted_mod_can_create_thread() {
67 let mut h = TestHarness::new().await;
68 let user_id = h.login_as("rmod").await;
69 let (comm_id, _cat) = setup_community(&mut h, "restricted").await;
70 h.add_membership(user_id, comm_id, "moderator").await;
71
72 h.client.get("/p/test/general/new").await;
73 let resp = h
74 .client
75 .post_form("/p/test/general/new", "title=Mod+Thread&body=Allowed")
76 .await;
77 assert!(resp.status.is_redirection(), "mod should bypass, got {}", resp.status);
78 }
79
80 // ── Frozen: block all member writes ──
81
82 #[tokio::test]
83 async fn frozen_blocks_member_reply() {
84 let mut h = TestHarness::new().await;
85 let user_id = h.login_as("fmem").await;
86 let (comm_id, cat_id) = setup_community(&mut h, "active").await;
87 h.add_membership(user_id, comm_id, "member").await;
88 let thread_id = h
89 .create_thread_with_post(cat_id, user_id, "Thread", "OP")
90 .await;
91 // Freeze after thread exists
92 sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
93 .bind(comm_id)
94 .execute(&h.db)
95 .await
96 .unwrap();
97
98 let url = format!("/p/test/general/{}", thread_id);
99 h.client.get(&url).await;
100 let resp = h
101 .client
102 .post_form(&format!("{}/reply", url), "body=Reply")
103 .await;
104 assert_eq!(resp.status, StatusCode::FORBIDDEN);
105 assert!(resp.text.contains("frozen"));
106 }
107
108 #[tokio::test]
109 async fn frozen_blocks_member_endorsement() {
110 let mut h = TestHarness::new().await;
111 let author_id = h.login_as("fauth").await;
112 let (comm_id, cat_id) = setup_community(&mut h, "active").await;
113 h.add_membership(author_id, comm_id, "member").await;
114 let thread_id = h
115 .create_thread_with_post(cat_id, author_id, "T", "OP")
116 .await;
117 let post_id: Uuid =
118 sqlx::query_scalar("SELECT id FROM posts WHERE thread_id = $1 LIMIT 1")
119 .bind(thread_id)
120 .fetch_one(&h.db)
121 .await
122 .unwrap();
123
124 // Switch to a different user and freeze the community
125 h.client.post_form("/auth/logout", "").await;
126 let other_id = h.login_as("fother").await;
127 h.add_membership(other_id, comm_id, "member").await;
128 sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
129 .bind(comm_id)
130 .execute(&h.db)
131 .await
132 .unwrap();
133
134 let url = format!("/p/test/general/{}/posts/{}/endorse", thread_id, post_id);
135 h.client.get(&format!("/p/test/general/{}", thread_id)).await;
136 let resp = h.client.post_form(&url, "").await;
137 assert_eq!(resp.status, StatusCode::FORBIDDEN);
138 }
139
140 #[tokio::test]
141 async fn frozen_mod_can_reply() {
142 let mut h = TestHarness::new().await;
143 let user_id = h.login_as("fmod").await;
144 let (comm_id, cat_id) = setup_community(&mut h, "active").await;
145 h.add_membership(user_id, comm_id, "moderator").await;
146 let thread_id = h
147 .create_thread_with_post(cat_id, user_id, "T", "OP")
148 .await;
149 sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
150 .bind(comm_id)
151 .execute(&h.db)
152 .await
153 .unwrap();
154
155 let url = format!("/p/test/general/{}", thread_id);
156 h.client.get(&url).await;
157 let resp = h
158 .client
159 .post_form(&format!("{}/reply", url), "body=Mod+reply")
160 .await;
161 assert!(resp.status.is_redirection(), "mod reply blocked: {}", resp.status);
162 }
163
164 // ── Superadmin override ──
165
166 #[tokio::test]
167 async fn superadmin_can_reply_in_frozen_without_role() {
168 // Superadmin is a platform-level user with no community role here.
169 let admin_id = Uuid::new_v4();
170 let mut h = TestHarness::new_with_admin(admin_id).await;
171
172 // Seed a community + author with role, then a thread.
173 let author_id = h.login_as("sauth").await;
174 let (comm_id, cat_id) = setup_community(&mut h, "active").await;
175 h.add_membership(author_id, comm_id, "member").await;
176 let thread_id = h
177 .create_thread_with_post(cat_id, author_id, "T", "OP")
178 .await;
179 sqlx::query("UPDATE communities SET state = 'frozen' WHERE id = $1")
180 .bind(comm_id)
181 .execute(&h.db)
182 .await
183 .unwrap();
184
185 // Become the superadmin — explicitly no community role.
186 h.client.post_form("/auth/logout", "").await;
187 sqlx::query(
188 "INSERT INTO users (mnw_account_id, username, display_name) \
189 VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING",
190 )
191 .bind(admin_id)
192 .execute(&h.db)
193 .await
194 .unwrap();
195 h.client.get("/").await;
196 h.client
197 .post_json(
198 "/_test/login",
199 &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" })
200 .to_string(),
201 )
202 .await;
203
204 let url = format!("/p/test/general/{}", thread_id);
205 h.client.get(&url).await;
206 let resp = h
207 .client
208 .post_form(&format!("{}/reply", url), "body=Super+reply")
209 .await;
210 assert!(
211 resp.status.is_redirection(),
212 "superadmin reply blocked: status={} body={}",
213 resp.status,
214 resp.text
215 );
216 }
217
218 // ── Archived: same as frozen + hidden from default listing ──
219
220 #[tokio::test]
221 async fn archived_blocks_member_reply() {
222 let mut h = TestHarness::new().await;
223 let user_id = h.login_as("amem").await;
224 let (comm_id, cat_id) = setup_community(&mut h, "active").await;
225 h.add_membership(user_id, comm_id, "member").await;
226 let thread_id = h
227 .create_thread_with_post(cat_id, user_id, "T", "OP")
228 .await;
229 sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1")
230 .bind(comm_id)
231 .execute(&h.db)
232 .await
233 .unwrap();
234
235 let url = format!("/p/test/general/{}", thread_id);
236 h.client.get(&url).await;
237 let resp = h
238 .client
239 .post_form(&format!("{}/reply", url), "body=No")
240 .await;
241 assert_eq!(resp.status, StatusCode::FORBIDDEN);
242 assert!(resp.text.contains("archived"));
243 }
244
245 #[tokio::test]
246 async fn archived_excluded_from_default_listing() {
247 let mut h = TestHarness::new().await;
248 h.create_community("Active", "active-comm").await;
249 let arch_id = h.create_community("Archived", "arch-comm").await;
250 sqlx::query("UPDATE communities SET state = 'archived' WHERE id = $1")
251 .bind(arch_id)
252 .execute(&h.db)
253 .await
254 .unwrap();
255
256 let resp = h.client.get("/").await;
257 assert!(resp.text.contains("Active"));
258 assert!(
259 !resp.text.contains("/p/arch-comm"),
260 "archived community should not appear in default listing"
261 );
262
263 let resp = h.client.get("/?filter=archived").await;
264 assert!(resp.text.contains("/p/arch-comm"), "archived view should show it");
265 assert!(
266 !resp.text.contains("/p/active-comm"),
267 "archived view should not include active communities"
268 );
269 }
270
271 // ── State-change route ──
272
273 #[tokio::test]
274 async fn owner_can_change_state() {
275 let mut h = TestHarness::new().await;
276 let user_id = h.login_as("owner").await;
277 let (comm_id, _cat) = setup_community(&mut h, "active").await;
278 h.add_membership(user_id, comm_id, "owner").await;
279
280 // Prime CSRF + cookie
281 h.client.get("/p/test/settings").await;
282 let resp = h
283 .client
284 .post_form("/p/test/settings/state", "state=frozen")
285 .await;
286 assert!(resp.status.is_redirection(), "status: {}", resp.status);
287
288 let state: String =
289 sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
290 .bind(comm_id)
291 .fetch_one(&h.db)
292 .await
293 .unwrap();
294 assert_eq!(state, "frozen");
295 }
296
297 #[tokio::test]
298 async fn superadmin_can_change_state_without_role() {
299 let admin_id = Uuid::new_v4();
300 let mut h = TestHarness::new_with_admin(admin_id).await;
301 let (comm_id, _cat) = setup_community(&mut h, "active").await;
302 sqlx::query(
303 "INSERT INTO users (mnw_account_id, username, display_name) \
304 VALUES ($1, 'superadmin', 'superadmin') ON CONFLICT (mnw_account_id) DO NOTHING",
305 )
306 .bind(admin_id)
307 .execute(&h.db)
308 .await
309 .unwrap();
310 h.client.get("/").await;
311 h.client
312 .post_json(
313 "/_test/login",
314 &serde_json::json!({ "user_id": admin_id.to_string(), "username": "superadmin" })
315 .to_string(),
316 )
317 .await;
318
319 h.client.get("/p/test/settings").await;
320 let resp = h
321 .client
322 .post_form("/p/test/settings/state", "state=archived")
323 .await;
324 assert!(resp.status.is_redirection(), "status: {}", resp.status);
325
326 let state: String =
327 sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
328 .bind(comm_id)
329 .fetch_one(&h.db)
330 .await
331 .unwrap();
332 assert_eq!(state, "archived");
333 }
334
335 #[tokio::test]
336 async fn member_cannot_change_state() {
337 let mut h = TestHarness::new().await;
338 let user_id = h.login_as("nope").await;
339 let (comm_id, _cat) = setup_community(&mut h, "active").await;
340 h.add_membership(user_id, comm_id, "member").await;
341
342 h.client.get("/p/test/settings").await;
343 let resp = h
344 .client
345 .post_form("/p/test/settings/state", "state=frozen")
346 .await;
347 assert_eq!(resp.status, StatusCode::FORBIDDEN);
348
349 let state: String =
350 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, "active", "state should be unchanged");
356 }
357
358 #[tokio::test]
359 async fn state_change_rejects_unknown_value() {
360 let mut h = TestHarness::new().await;
361 let user_id = h.login_as("ownerbad").await;
362 let (comm_id, _cat) = setup_community(&mut h, "active").await;
363 h.add_membership(user_id, comm_id, "owner").await;
364
365 h.client.get("/p/test/settings").await;
366 let resp = h
367 .client
368 .post_form("/p/test/settings/state", "state=bogus")
369 .await;
370 assert_eq!(resp.status, StatusCode::UNPROCESSABLE_ENTITY);
371
372 let state: String =
373 sqlx::query_scalar("SELECT state FROM communities WHERE id = $1")
374 .bind(comm_id)
375 .fetch_one(&h.db)
376 .await
377 .unwrap();
378 assert_eq!(state, "active", "state should be unchanged on validation error");
379 }
380