Skip to main content

max / makenotwork

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