Skip to main content

max / makenotwork

13.0 KB · 386 lines History Blame Raw
1 use crate::harness::TestHarness;
2
3 /// Regression (ultra-fuzz NOTE-1): footnoting your own post by routing the
4 /// request through a *different* community's slug must 404, matching the
5 /// flag/endorse/remove paths. Closes the lone remaining ban-evasion-via-slug gap.
6 #[tokio::test]
7 async fn footnote_via_mismatched_community_slug_is_404() {
8 let mut h = TestHarness::new().await;
9 let author_id = h.login_as("author").await;
10 let comm_a = h.create_community("Alpha", "alpha").await;
11 let cat_a = h.create_category(comm_a, "General", "general").await;
12 h.add_membership(author_id, comm_a, "member").await;
13 let comm_b = h.create_community("Beta", "beta").await;
14 h.create_category(comm_b, "General", "general").await;
15 h.add_membership(author_id, comm_b, "member").await;
16
17 let thread_id = h
18 .create_thread_with_post(cat_a, author_id, "Post in A", "Content")
19 .await;
20 let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
21 .await
22 .unwrap()[0]
23 .id;
24
25 h.client.get(&format!("/p/alpha/general/{thread_id}")).await; // CSRF
26
27 // Author footnotes their own post but via community B's slug.
28 let url = format!("/p/beta/general/{thread_id}/posts/{post_id}/footnote");
29 let resp = h.client.post_form(&url, "body=sneaky+footnote").await;
30 assert_eq!(
31 resp.status.as_u16(),
32 404,
33 "mismatched-slug footnote must 404"
34 );
35
36 let count = mt_db::queries::count_footnotes_for_post(&h.db, post_id)
37 .await
38 .unwrap();
39 assert_eq!(count, 0, "no footnote should be recorded");
40 }
41
42 #[tokio::test]
43 async fn endorse_post_happy_path() {
44 let mut h = TestHarness::new().await;
45 let author_id = h.login_as("author").await;
46 let comm_id = h.create_community("Test", "test").await;
47 let cat_id = h.create_category(comm_id, "General", "general").await;
48 h.add_membership(author_id, comm_id, "member").await;
49
50 let thread_id = h
51 .create_thread_with_post(cat_id, author_id, "Good Post", "Content")
52 .await;
53
54 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
55 .await
56 .unwrap();
57 let post_id = posts[0].id;
58
59 // Login as a different user
60 let endorser_id = h.login_as("endorser").await;
61 h.add_membership(endorser_id, comm_id, "member").await;
62
63 // GET thread page for CSRF
64 let thread_url = format!("/p/test/general/{thread_id}");
65 h.client.get(&thread_url).await;
66
67 let endorse_url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse");
68 let resp = h.client.post_form(&endorse_url, "").await;
69
70 assert!(
71 resp.status.is_redirection(),
72 "Expected redirect after endorse, got {}",
73 resp.status
74 );
75
76 // Verify DB row exists
77 let endorsements = mt_db::queries::list_endorsements_for_posts(&h.db, &[post_id])
78 .await
79 .unwrap();
80 assert_eq!(endorsements.len(), 1);
81 assert_eq!(endorsements[0].endorser_id, endorser_id);
82 }
83
84 #[tokio::test]
85 async fn endorse_via_mismatched_community_slug_is_404() {
86 // A post lives in community A; routing the endorse through community B's
87 // slug must 404 (post.community_id != slug-derived community), so a user
88 // banned in A can't evade the ban check via another slug (audit S-2).
89 let mut h = TestHarness::new().await;
90 let author_id = h.login_as("author").await;
91 let comm_a = h.create_community("Alpha", "alpha").await;
92 let cat_a = h.create_category(comm_a, "General", "general").await;
93 h.add_membership(author_id, comm_a, "member").await;
94 // A second, unrelated community the attacker is a member of.
95 let comm_b = h.create_community("Beta", "beta").await;
96 h.create_category(comm_b, "General", "general").await;
97
98 let thread_id = h
99 .create_thread_with_post(cat_a, author_id, "Post in A", "Content")
100 .await;
101 let post_id = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
102 .await
103 .unwrap()[0]
104 .id;
105
106 let endorser_id = h.login_as("endorser").await;
107 h.add_membership(endorser_id, comm_b, "member").await;
108 h.client.get(&format!("/p/alpha/general/{thread_id}")).await; // CSRF
109
110 // Use community B's slug with A's thread/post ids.
111 let url = format!("/p/beta/general/{thread_id}/posts/{post_id}/endorse");
112 let resp = h.client.post_form(&url, "").await;
113 assert_eq!(
114 resp.status.as_u16(),
115 404,
116 "mismatched-slug endorse must 404"
117 );
118
119 // And no endorsement was recorded.
120 let endorsements = mt_db::queries::list_endorsements_for_posts(&h.db, &[post_id])
121 .await
122 .unwrap();
123 assert!(endorsements.is_empty(), "no endorsement should be recorded");
124 }
125
126 #[tokio::test]
127 async fn toggle_endorsement_removes() {
128 let mut h = TestHarness::new().await;
129 let author_id = h.login_as("author2").await;
130 let comm_id = h.create_community("Test", "test").await;
131 let cat_id = h.create_category(comm_id, "General", "general").await;
132 h.add_membership(author_id, comm_id, "member").await;
133
134 let thread_id = h
135 .create_thread_with_post(cat_id, author_id, "Toggle Post", "Content")
136 .await;
137
138 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
139 .await
140 .unwrap();
141 let post_id = posts[0].id;
142
143 let toggler_id = h.login_as("toggler").await;
144 h.add_membership(toggler_id, comm_id, "member").await;
145
146 let thread_url = format!("/p/test/general/{thread_id}");
147 h.client.get(&thread_url).await;
148
149 let endorse_url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse");
150
151 // First endorse
152 h.client.post_form(&endorse_url, "").await;
153 let endorsements = mt_db::queries::list_endorsements_for_posts(&h.db, &[post_id])
154 .await
155 .unwrap();
156 assert_eq!(
157 endorsements.len(),
158 1,
159 "Should have 1 endorsement after first toggle"
160 );
161
162 // GET again for fresh CSRF
163 h.client.get(&thread_url).await;
164
165 // Second endorse (un-endorse)
166 h.client.post_form(&endorse_url, "").await;
167 let endorsements = mt_db::queries::list_endorsements_for_posts(&h.db, &[post_id])
168 .await
169 .unwrap();
170 assert_eq!(
171 endorsements.len(),
172 0,
173 "Should have 0 endorsements after second toggle"
174 );
175 }
176
177 #[tokio::test]
178 async fn self_endorse_rejected() {
179 let mut h = TestHarness::new().await;
180 let author_id = h.login_as("selfendorser").await;
181 let comm_id = h.create_community("Test", "test").await;
182 let cat_id = h.create_category(comm_id, "General", "general").await;
183 h.add_membership(author_id, comm_id, "member").await;
184
185 let thread_id = h
186 .create_thread_with_post(cat_id, author_id, "My Post", "Content")
187 .await;
188
189 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
190 .await
191 .unwrap();
192 let post_id = posts[0].id;
193
194 // GET thread page for CSRF
195 let thread_url = format!("/p/test/general/{thread_id}");
196 h.client.get(&thread_url).await;
197
198 let endorse_url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse");
199 let resp = h.client.post_form(&endorse_url, "").await;
200
201 assert_eq!(resp.status.as_u16(), 403, "Self-endorsement should be 403");
202 }
203
204 #[tokio::test]
205 async fn endorse_requires_login() {
206 let mut h = TestHarness::new().await;
207 let author_id = h.login_as("loginauthor").await;
208 let comm_id = h.create_community("Test", "test").await;
209 let cat_id = h.create_category(comm_id, "General", "general").await;
210 h.add_membership(author_id, comm_id, "member").await;
211
212 let thread_id = h
213 .create_thread_with_post(cat_id, author_id, "Login Test", "Content")
214 .await;
215
216 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
217 .await
218 .unwrap();
219 let post_id = posts[0].id;
220
221 // Don't login; create a fresh harness without login
222 let mut h2 = TestHarness::new().await;
223 // GET some page for CSRF
224 h2.client.get("/").await;
225
226 let endorse_url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse");
227 let resp = h2.client.post_form(&endorse_url, "").await;
228
229 assert!(
230 resp.status.is_redirection(),
231 "Expected redirect to login, got {}",
232 resp.status
233 );
234 }
235
236 #[tokio::test]
237 async fn endorse_removed_post_rejected() {
238 let mut h = TestHarness::new().await;
239 let author_id = h.login_as("removedauthor").await;
240 let comm_id = h.create_community("Test", "test").await;
241 let cat_id = h.create_category(comm_id, "General", "general").await;
242 h.add_membership(author_id, comm_id, "member").await;
243
244 let thread_id = h
245 .create_thread_with_post(cat_id, author_id, "Removed Post", "Content")
246 .await;
247
248 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
249 .await
250 .unwrap();
251 let post_id = posts[0].id;
252
253 // Mod-remove the post
254 mt_db::mutations::mod_remove_post(&h.db, post_id, author_id)
255 .await
256 .unwrap();
257
258 let endorser_id = h.login_as("removedendorser").await;
259 h.add_membership(endorser_id, comm_id, "member").await;
260
261 let thread_url = format!("/p/test/general/{thread_id}");
262 h.client.get(&thread_url).await;
263
264 let endorse_url = format!("/p/test/general/{thread_id}/posts/{post_id}/endorse");
265 let resp = h.client.post_form(&endorse_url, "").await;
266
267 assert_eq!(
268 resp.status.as_u16(),
269 403,
270 "Endorsing removed post should be 403"
271 );
272 }
273
274 #[tokio::test]
275 async fn endorsement_count_visible_to_author() {
276 let mut h = TestHarness::new().await;
277 let author_id = h.login_as("countauthor").await;
278 let comm_id = h.create_community("Test", "test").await;
279 let cat_id = h.create_category(comm_id, "General", "general").await;
280 h.add_membership(author_id, comm_id, "member").await;
281
282 let thread_id = h
283 .create_thread_with_post(cat_id, author_id, "Count Test", "Content")
284 .await;
285
286 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
287 .await
288 .unwrap();
289 let post_id = posts[0].id;
290
291 // Insert endorsement directly via DB (avoids needing to switch users)
292 let endorser_id = uuid::Uuid::new_v4();
293 sqlx::query("INSERT INTO users (mnw_account_id, username, display_name) VALUES ($1, $2, $2)")
294 .bind(endorser_id)
295 .bind("countendorser")
296 .execute(&h.db)
297 .await
298 .unwrap();
299 mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser_id)
300 .await
301 .unwrap();
302
303 // Author is still logged in, view thread
304 let thread_url = format!("/p/test/general/{thread_id}");
305 let resp = h.client.get(&thread_url).await;
306
307 assert!(
308 resp.text.contains("endorsement-count"),
309 "Author should see endorsement count"
310 );
311 }
312
313 #[tokio::test]
314 async fn endorsement_count_visible_to_mod() {
315 let mut h = TestHarness::new().await;
316 let author_id = h.login_as("modcountauthor").await;
317 let comm_id = h.create_community("Test", "test").await;
318 let cat_id = h.create_category(comm_id, "General", "general").await;
319 h.add_membership(author_id, comm_id, "member").await;
320
321 let thread_id = h
322 .create_thread_with_post(cat_id, author_id, "Mod Count Test", "Content")
323 .await;
324
325 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
326 .await
327 .unwrap();
328 let post_id = posts[0].id;
329
330 // Have someone endorse via DB
331 let endorser_id = h.login_as("modcountendorser").await;
332 h.add_membership(endorser_id, comm_id, "member").await;
333 mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser_id)
334 .await
335 .unwrap();
336
337 // Login as moderator and view thread
338 let mod_id = h.login_as("modviewer").await;
339 h.add_membership(mod_id, comm_id, "moderator").await;
340
341 let thread_url = format!("/p/test/general/{thread_id}");
342 let resp = h.client.get(&thread_url).await;
343
344 assert!(
345 resp.text.contains("endorsement-count"),
346 "Mod should see endorsement count"
347 );
348 }
349
350 #[tokio::test]
351 async fn endorsement_count_hidden_from_others() {
352 let mut h = TestHarness::new().await;
353 let author_id = h.login_as("hiddenauthor").await;
354 let comm_id = h.create_community("Test", "test").await;
355 let cat_id = h.create_category(comm_id, "General", "general").await;
356 h.add_membership(author_id, comm_id, "member").await;
357
358 let thread_id = h
359 .create_thread_with_post(cat_id, author_id, "Hidden Count", "Content")
360 .await;
361
362 let posts = mt_db::queries::list_posts_in_thread(&h.db, thread_id)
363 .await
364 .unwrap();
365 let post_id = posts[0].id;
366
367 // Have someone endorse via DB
368 let endorser_id = h.login_as("hiddenendorser").await;
369 h.add_membership(endorser_id, comm_id, "member").await;
370 mt_db::mutations::toggle_endorsement(&h.db, post_id, endorser_id)
371 .await
372 .unwrap();
373
374 // Login as a third user (not author, not endorser, not mod)
375 let bystander_id = h.login_as("bystander").await;
376 h.add_membership(bystander_id, comm_id, "member").await;
377
378 let thread_url = format!("/p/test/general/{thread_id}");
379 let resp = h.client.get(&thread_url).await;
380
381 assert!(
382 !resp.text.contains("endorsement-count"),
383 "Bystander should NOT see endorsement count"
384 );
385 }
386