Skip to main content

max / makenotwork

19.0 KB · 636 lines History Blame Raw
1 //! Report workflow tests: submit, self-report prevention, auth, admin resolve/dismiss, validation.
2
3 use crate::harness::TestHarness;
4
5 // ── Submit report (logged in) ──
6
7 #[tokio::test]
8 async fn submit_report_logged_in() {
9 let mut h = TestHarness::new().await;
10
11 // Creator creates a project+item
12 let setup = h.create_creator_with_item("rptcreator", "text", 0).await;
13 h.publish_project_and_item(&setup.project_id, &setup.item_id)
14 .await;
15
16 // Different user logs in and reports the project
17 h.client.post_form("/logout", "").await;
18 let _reporter = h
19 .signup("rptreporter", "rptreporter@test.com", "password123")
20 .await;
21 h.login("rptreporter", "password123").await;
22
23 let resp = h
24 .client
25 .post_form(
26 "/api/reports",
27 &format!(
28 "target_type=project&target_id={}&report_type=spam&reason=looks+like+spam",
29 setup.project_id
30 ),
31 )
32 .await;
33 assert_eq!(
34 resp.status, 200,
35 "Submit report failed: {} {}",
36 resp.status, resp.text
37 );
38 assert!(
39 resp.text.contains("Report submitted"),
40 "Should show success message, got: {}",
41 resp.text
42 );
43 }
44
45 // ── Submit report for item ──
46
47 #[tokio::test]
48 async fn submit_report_for_item() {
49 let mut h = TestHarness::new().await;
50 let setup = h.create_creator_with_item("rptitem", "text", 0).await;
51 h.publish_project_and_item(&setup.project_id, &setup.item_id)
52 .await;
53
54 h.client.post_form("/logout", "").await;
55 let _reporter = h
56 .signup("rptitemrpt", "rptitemrpt@test.com", "password123")
57 .await;
58 h.login("rptitemrpt", "password123").await;
59
60 let resp = h
61 .client
62 .post_form(
63 "/api/reports",
64 &format!(
65 "target_type=item&target_id={}&report_type=abuse&reason=offensive+content",
66 setup.item_id
67 ),
68 )
69 .await;
70 assert_eq!(
71 resp.status, 200,
72 "Submit item report failed: {} {}",
73 resp.status, resp.text
74 );
75 }
76
77 // ── Submit report (not logged in → rejected) ──
78
79 #[tokio::test]
80 async fn submit_report_not_logged_in() {
81 let mut h = TestHarness::new().await;
82 let setup = h.create_creator_with_item("rptnoauth", "text", 0).await;
83
84 h.client.post_form("/logout", "").await;
85 h.client.fetch_csrf_token().await;
86
87 let resp = h
88 .client
89 .post_form(
90 "/api/reports",
91 &format!(
92 "target_type=project&target_id={}&report_type=spam&reason=test",
93 setup.project_id
94 ),
95 )
96 .await;
97 assert!(
98 resp.status.as_u16() == 401 || resp.status.as_u16() == 303,
99 "Unauthenticated report should be rejected: {} {}",
100 resp.status,
101 resp.text
102 );
103 }
104
105 // ── Self-report prevention ──
106
107 #[tokio::test]
108 async fn cannot_report_own_project() {
109 let mut h = TestHarness::new().await;
110 let setup = h.create_creator_with_item("rptself", "text", 0).await;
111
112 let resp = h
113 .client
114 .post_form(
115 "/api/reports",
116 &format!(
117 "target_type=project&target_id={}&report_type=spam&reason=testing",
118 setup.project_id
119 ),
120 )
121 .await;
122 assert!(
123 resp.status.as_u16() == 422 || resp.text.contains("cannot report your own"),
124 "Self-report should be rejected: {} {}",
125 resp.status,
126 resp.text
127 );
128 }
129
130 #[tokio::test]
131 async fn cannot_report_own_item() {
132 let mut h = TestHarness::new().await;
133 let setup = h.create_creator_with_item("rptselfitm", "text", 0).await;
134
135 let resp = h
136 .client
137 .post_form(
138 "/api/reports",
139 &format!(
140 "target_type=item&target_id={}&report_type=spam&reason=testing",
141 setup.item_id
142 ),
143 )
144 .await;
145 assert!(
146 resp.status.as_u16() == 422 || resp.text.contains("cannot report your own"),
147 "Self-report should be rejected: {} {}",
148 resp.status,
149 resp.text
150 );
151 }
152
153 // ── Report type validation ──
154
155 #[tokio::test]
156 async fn invalid_report_type_rejected() {
157 let mut h = TestHarness::new().await;
158 let setup = h.create_creator_with_item("rptbadtype", "text", 0).await;
159 h.publish_project_and_item(&setup.project_id, &setup.item_id)
160 .await;
161
162 h.client.post_form("/logout", "").await;
163 let _reporter = h
164 .signup("rptbadrptr", "rptbadrptr@test.com", "password123")
165 .await;
166 h.login("rptbadrptr", "password123").await;
167
168 let resp = h
169 .client
170 .post_form(
171 "/api/reports",
172 &format!(
173 "target_type=project&target_id={}&report_type=invalid_type&reason=test",
174 setup.project_id
175 ),
176 )
177 .await;
178 assert!(
179 resp.status.as_u16() == 422 || resp.text.contains("Invalid report type"),
180 "Invalid report type should be rejected: {} {}",
181 resp.status,
182 resp.text
183 );
184 }
185
186 #[tokio::test]
187 async fn other_report_type_requires_reason() {
188 let mut h = TestHarness::new().await;
189 let setup = h.create_creator_with_item("rptnoreas", "text", 0).await;
190 h.publish_project_and_item(&setup.project_id, &setup.item_id)
191 .await;
192
193 h.client.post_form("/logout", "").await;
194 let _reporter = h
195 .signup("rptnorsrpt", "rptnorsrpt@test.com", "password123")
196 .await;
197 h.login("rptnorsrpt", "password123").await;
198
199 let resp = h
200 .client
201 .post_form(
202 "/api/reports",
203 &format!(
204 "target_type=project&target_id={}&report_type=other&reason=",
205 setup.project_id
206 ),
207 )
208 .await;
209 assert!(
210 resp.status.as_u16() == 422 || resp.text.contains("provide details"),
211 "Other report without reason should be rejected: {} {}",
212 resp.status,
213 resp.text
214 );
215 }
216
217 // ── Admin resolve/dismiss ──
218
219 #[tokio::test]
220 async fn admin_resolve_report() {
221 let (mut h, _admin_id) = TestHarness::with_admin().await;
222
223 // Creator makes content
224 let setup = h.create_creator_with_item("rptadmres", "text", 0).await;
225 h.publish_project_and_item(&setup.project_id, &setup.item_id)
226 .await;
227
228 // Different user reports it
229 h.client.post_form("/logout", "").await;
230 let _reporter = h
231 .signup("rptadmrptr", "rptadmrptr@test.com", "password123")
232 .await;
233 h.login("rptadmrptr", "password123").await;
234
235 h.client
236 .post_form(
237 "/api/reports",
238 &format!(
239 "target_type=project&target_id={}&report_type=spam&reason=looks+spammy",
240 setup.project_id
241 ),
242 )
243 .await;
244
245 // Get report ID from DB
246 let row: (uuid::Uuid,) =
247 sqlx::query_as("SELECT id FROM reports ORDER BY created_at DESC LIMIT 1")
248 .fetch_one(&h.db)
249 .await
250 .unwrap();
251 let report_id = row.0;
252
253 // Admin resolves it
254 h.client.post_form("/logout", "").await;
255 h.login("admin", "password123").await;
256
257 let resp = h
258 .client
259 .post_form(
260 &format!("/api/admin/reports/{report_id}/resolve"),
261 "decision=resolve&admin_notes=Investigated+and+confirmed+spam",
262 )
263 .await;
264 assert_eq!(
265 resp.status, 200,
266 "Admin resolve failed: {} {}",
267 resp.status, resp.text
268 );
269
270 // Verify status in DB
271 let (status,): (String,) = sqlx::query_as("SELECT status FROM reports WHERE id = $1")
272 .bind(report_id)
273 .fetch_one(&h.db)
274 .await
275 .unwrap();
276 assert_eq!(status, "resolved");
277 }
278
279 #[tokio::test]
280 async fn admin_dismiss_report() {
281 let (mut h, _admin_id) = TestHarness::with_admin().await;
282
283 let setup = h.create_creator_with_item("rptadmdis", "text", 0).await;
284 h.publish_project_and_item(&setup.project_id, &setup.item_id)
285 .await;
286
287 h.client.post_form("/logout", "").await;
288 let _reporter = h
289 .signup("rptdisrptr", "rptdisrptr@test.com", "password123")
290 .await;
291 h.login("rptdisrptr", "password123").await;
292
293 h.client
294 .post_form(
295 "/api/reports",
296 &format!(
297 "target_type=item&target_id={}&report_type=abuse&reason=test+report",
298 setup.item_id
299 ),
300 )
301 .await;
302
303 let row: (uuid::Uuid,) =
304 sqlx::query_as("SELECT id FROM reports ORDER BY created_at DESC LIMIT 1")
305 .fetch_one(&h.db)
306 .await
307 .unwrap();
308 let report_id = row.0;
309
310 h.client.post_form("/logout", "").await;
311 h.login("admin", "password123").await;
312
313 let resp = h
314 .client
315 .post_form(
316 &format!("/api/admin/reports/{report_id}/resolve"),
317 "decision=dismiss&admin_notes=Not+a+real+issue",
318 )
319 .await;
320 assert_eq!(
321 resp.status, 200,
322 "Admin dismiss failed: {} {}",
323 resp.status, resp.text
324 );
325
326 let (status,): (String,) = sqlx::query_as("SELECT status FROM reports WHERE id = $1")
327 .bind(report_id)
328 .fetch_one(&h.db)
329 .await
330 .unwrap();
331 assert_eq!(status, "dismissed");
332 }
333
334 // ── Admin page access ──
335
336 #[tokio::test]
337 async fn admin_reports_page_loads() {
338 let (mut h, _admin_id) = TestHarness::with_admin().await;
339 h.client.post_form("/logout", "").await;
340 h.login("admin", "password123").await;
341
342 let resp = h.client.get("/admin/reports").await;
343 assert_eq!(
344 resp.status, 200,
345 "Admin reports page failed: {} {}",
346 resp.status, resp.text
347 );
348 assert!(
349 resp.text.contains("Reports Queue"),
350 "Page should have title"
351 );
352 }
353
354 #[tokio::test]
355 async fn non_admin_cannot_access_reports_page() {
356 let mut h = TestHarness::new().await;
357 let _user_id = h.create_creator("rptnonadm").await;
358
359 let resp = h.client.get("/admin/reports").await;
360 assert!(
361 resp.status.as_u16() == 404 || resp.status.as_u16() == 403 || resp.status.as_u16() == 401,
362 "Non-admin should be rejected: {} {}",
363 resp.status,
364 resp.text
365 );
366 }
367
368 #[tokio::test]
369 async fn non_admin_cannot_resolve_report() {
370 let (mut h, _admin_id) = TestHarness::with_admin().await;
371
372 // Create a report via admin flow
373 let setup = h.create_creator_with_item("rptnoadmr", "text", 0).await;
374 h.publish_project_and_item(&setup.project_id, &setup.item_id)
375 .await;
376
377 h.client.post_form("/logout", "").await;
378 let _reporter = h
379 .signup("rptnarptr", "rptnarptr@test.com", "password123")
380 .await;
381 h.login("rptnarptr", "password123").await;
382
383 h.client
384 .post_form(
385 "/api/reports",
386 &format!(
387 "target_type=project&target_id={}&report_type=spam&reason=test",
388 setup.project_id
389 ),
390 )
391 .await;
392
393 let row: (uuid::Uuid,) =
394 sqlx::query_as("SELECT id FROM reports ORDER BY created_at DESC LIMIT 1")
395 .fetch_one(&h.db)
396 .await
397 .unwrap();
398 let report_id = row.0;
399
400 // Non-admin tries to resolve
401 let resp = h
402 .client
403 .post_form(
404 &format!("/api/admin/reports/{report_id}/resolve"),
405 "decision=resolve&admin_notes=hacked",
406 )
407 .await;
408 assert!(
409 resp.status.as_u16() == 404 || resp.status.as_u16() == 403 || resp.status.as_u16() == 401,
410 "Non-admin should be rejected: {} {}",
411 resp.status,
412 resp.text
413 );
414 }
415
416 // Report-spam rate limit (test-fuzz Phase 2.3)
417 //
418 // submit_report enforces max 10 reports per reporter per 24h via
419 // count_recent_reports_by_user. The cap itself was untested, a regression
420 // (e.g. an off-by-one or a dropped check) would let one user flood the
421 // moderation queue. This pins the boundary: the 10th is accepted, the 11th is
422 // rejected, and exactly 10 rows land.
423
424 #[tokio::test]
425 async fn report_daily_cap_enforced_across_distinct_targets() {
426 let mut h = TestHarness::new().await;
427
428 // A creator with one real, public item, the target for the over-cap report.
429 let setup = h.create_creator_with_item("spamtarget", "text", 0).await;
430 h.publish_project_and_item(&setup.project_id, &setup.item_id)
431 .await;
432
433 h.client.post_form("/logout", "").await;
434 let reporter_id = h
435 .signup("spamreporter", "spamreporter@test.com", "password123")
436 .await;
437 h.login("spamreporter", "password123").await;
438
439 // Seed the reporter's day with 10 prior reports against distinct targets,
440 // directly (the HTTP write-rate limiter, burst 10/IP, would otherwise trip
441 // long before the per-day report cap). Distinct random target_ids so the
442 // per-target dedup does not interfere with the over-cap HTTP report below.
443 for _ in 0..10 {
444 sqlx::query(
445 "INSERT INTO reports (reporter_user_id, target_type, target_id, report_type, reason) \
446 VALUES ($1, 'item', $2, 'spam', 'seed')",
447 )
448 .bind(reporter_id)
449 .bind(uuid::Uuid::new_v4())
450 .execute(&h.db)
451 .await
452 .unwrap();
453 }
454
455 // The 11th report (against the real item) sees a count of 10 and is rejected.
456 let body = format!(
457 "target_type=item&target_id={}&report_type=spam&reason=spam",
458 setup.item_id
459 );
460 let resp = h.client.post_form("/api/reports", &body).await;
461 assert_eq!(
462 resp.status, 422,
463 "the over-cap report must be rate-limited, got: {} {}",
464 resp.status, resp.text
465 );
466
467 // Still exactly 10 rows, the rejected one wrote nothing.
468 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reports WHERE reporter_user_id = $1")
469 .bind(reporter_id)
470 .fetch_one(&h.db)
471 .await
472 .unwrap();
473 assert_eq!(
474 count, 10,
475 "rate limit must cap stored reports at 10, found {count}"
476 );
477 }
478
479 #[tokio::test]
480 async fn report_same_target_deduped_while_open() {
481 // Run #22 LOW: a reporter must not be able to stack multiple still-open
482 // reports against one target (queue-flooding). The first lands; a second
483 // against the same target while the first is unresolved is rejected and
484 // writes nothing.
485 let mut h = TestHarness::new().await;
486 let setup = h.create_creator_with_item("dedupcreator", "text", 0).await;
487 h.publish_project_and_item(&setup.project_id, &setup.item_id)
488 .await;
489
490 h.client.post_form("/logout", "").await;
491 let reporter_id = h
492 .signup("dedupreporter", "dedupreporter@test.com", "password123")
493 .await;
494 h.login("dedupreporter", "password123").await;
495
496 let body = format!(
497 "target_type=item&target_id={}&report_type=spam&reason=spam",
498 setup.item_id
499 );
500
501 let first = h.client.post_form("/api/reports", &body).await;
502 assert_eq!(
503 first.status, 200,
504 "first report should land: {} {}",
505 first.status, first.text
506 );
507
508 let second = h.client.post_form("/api/reports", &body).await;
509 assert_eq!(
510 second.status, 422,
511 "a second open report on the same target must be rejected, got: {} {}",
512 second.status, second.text
513 );
514
515 let count: i64 = sqlx::query_scalar(
516 "SELECT COUNT(*) FROM reports WHERE reporter_user_id = $1 AND target_id = $2",
517 )
518 .bind(reporter_id)
519 .bind(setup.item_id.parse::<uuid::Uuid>().unwrap())
520 .fetch_one(&h.db)
521 .await
522 .unwrap();
523 assert_eq!(
524 count, 1,
525 "duplicate open report must not be stored, found {count}"
526 );
527 }
528
529 // Dedup blocks open-stacking, not recurrence (test-fuzz Phase 2.3)
530 //
531 // report_same_target_deduped_while_open pins that a second OPEN report on a
532 // target is rejected. The complement, once the first report is resolved, a
533 // legitimate re-report of a recurrence must be allowed again, was untested. A
534 // regression that keyed the dedup on (reporter, target) regardless of status
535 // would silently swallow every follow-up report after the first resolution.
536
537 #[tokio::test]
538 async fn report_allowed_again_after_prior_resolved() {
539 let (mut h, _admin_id) = TestHarness::with_admin().await;
540 let setup = h.create_creator_with_item("reagcreator", "text", 0).await;
541 h.publish_project_and_item(&setup.project_id, &setup.item_id)
542 .await;
543
544 h.client.post_form("/logout", "").await;
545 let reporter_id = h
546 .signup("reagreporter", "reagreporter@test.com", "password123")
547 .await;
548 h.login("reagreporter", "password123").await;
549
550 let body = format!(
551 "target_type=item&target_id={}&report_type=spam&reason=spam",
552 setup.item_id
553 );
554 let first = h.client.post_form("/api/reports", &body).await;
555 assert_eq!(
556 first.status, 200,
557 "first report should land: {} {}",
558 first.status, first.text
559 );
560
561 // Admin resolves the open report.
562 let report_id: uuid::Uuid =
563 sqlx::query_scalar("SELECT id FROM reports WHERE reporter_user_id = $1")
564 .bind(reporter_id)
565 .fetch_one(&h.db)
566 .await
567 .unwrap();
568 h.client.post_form("/logout", "").await;
569 h.login("admin", "password123").await;
570 let resolve = h
571 .client
572 .post_form(
573 &format!("/api/admin/reports/{report_id}/resolve"),
574 "decision=resolve&admin_notes=handled",
575 )
576 .await;
577 assert_eq!(
578 resolve.status, 200,
579 "resolve failed: {} {}",
580 resolve.status, resolve.text
581 );
582
583 // The reporter can file again, nothing OPEN stands against the target, so
584 // the dedup guard does not block a fresh recurrence report.
585 h.client.post_form("/logout", "").await;
586 h.login("reagreporter", "password123").await;
587 let second = h.client.post_form("/api/reports", &body).await;
588 assert_eq!(
589 second.status, 200,
590 "re-report after resolution must be allowed: {} {}",
591 second.status, second.text
592 );
593
594 let count: i64 = sqlx::query_scalar(
595 "SELECT COUNT(*) FROM reports WHERE reporter_user_id = $1 AND target_id = $2",
596 )
597 .bind(reporter_id)
598 .bind(setup.item_id.parse::<uuid::Uuid>().unwrap())
599 .fetch_one(&h.db)
600 .await
601 .unwrap();
602 assert_eq!(
603 count, 2,
604 "a fresh report after resolution must be stored, found {count}"
605 );
606 }
607
608 // Resolving a stale/nonexistent report id 404s (test-fuzz Phase 2.3)
609 //
610 // resolve_report surfaces a zero-row UPDATE as NotFound rather than a false
611 // "resolved", the guard against an admin silently acting on a report that was
612 // already cleared away (or never existed). Only the happy path was tested.
613
614 #[tokio::test]
615 async fn admin_resolve_nonexistent_report_is_404() {
616 let (mut h, _admin_id) = TestHarness::with_admin().await;
617 h.client.post_form("/logout", "").await;
618 h.login("admin", "password123").await;
619
620 let bogus = uuid::Uuid::new_v4();
621 let resp = h
622 .client
623 .post_form(
624 &format!("/api/admin/reports/{bogus}/resolve"),
625 "decision=resolve&admin_notes=nope",
626 )
627 .await;
628 assert_eq!(
629 resp.status.as_u16(),
630 404,
631 "resolving a nonexistent report must 404, got: {} {}",
632 resp.status,
633 resp.text
634 );
635 }
636