//! Report workflow tests: submit, self-report prevention, auth, admin resolve/dismiss, validation. use crate::harness::TestHarness; // ── Submit report (logged in) ── #[tokio::test] async fn submit_report_logged_in() { let mut h = TestHarness::new().await; // Creator creates a project+item let setup = h.create_creator_with_item("rptcreator", "text", 0).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; // Different user logs in and reports the project h.client.post_form("/logout", "").await; let _reporter = h .signup("rptreporter", "rptreporter@test.com", "password123") .await; h.login("rptreporter", "password123").await; let resp = h .client .post_form( "/api/reports", &format!( "target_type=project&target_id={}&report_type=spam&reason=looks+like+spam", setup.project_id ), ) .await; assert!( resp.status.is_success(), "Submit report failed: {} {}", resp.status, resp.text ); assert!( resp.text.contains("Report submitted"), "Should show success message, got: {}", resp.text ); } // ── Submit report for item ── #[tokio::test] async fn submit_report_for_item() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("rptitem", "text", 0).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; h.client.post_form("/logout", "").await; let _reporter = h .signup("rptitemrpt", "rptitemrpt@test.com", "password123") .await; h.login("rptitemrpt", "password123").await; let resp = h .client .post_form( "/api/reports", &format!( "target_type=item&target_id={}&report_type=abuse&reason=offensive+content", setup.item_id ), ) .await; assert!( resp.status.is_success(), "Submit item report failed: {} {}", resp.status, resp.text ); } // ── Submit report (not logged in → rejected) ── #[tokio::test] async fn submit_report_not_logged_in() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("rptnoauth", "text", 0).await; h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let resp = h .client .post_form( "/api/reports", &format!( "target_type=project&target_id={}&report_type=spam&reason=test", setup.project_id ), ) .await; assert!( resp.status.as_u16() == 401 || resp.status.as_u16() == 303, "Unauthenticated report should be rejected: {} {}", resp.status, resp.text ); } // ── Self-report prevention ── #[tokio::test] async fn cannot_report_own_project() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("rptself", "text", 0).await; let resp = h .client .post_form( "/api/reports", &format!( "target_type=project&target_id={}&report_type=spam&reason=testing", setup.project_id ), ) .await; assert!( resp.status.as_u16() == 422 || resp.text.contains("cannot report your own"), "Self-report should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn cannot_report_own_item() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("rptselfitm", "text", 0).await; let resp = h .client .post_form( "/api/reports", &format!( "target_type=item&target_id={}&report_type=spam&reason=testing", setup.item_id ), ) .await; assert!( resp.status.as_u16() == 422 || resp.text.contains("cannot report your own"), "Self-report should be rejected: {} {}", resp.status, resp.text ); } // ── Report type validation ── #[tokio::test] async fn invalid_report_type_rejected() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("rptbadtype", "text", 0).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; h.client.post_form("/logout", "").await; let _reporter = h .signup("rptbadrptr", "rptbadrptr@test.com", "password123") .await; h.login("rptbadrptr", "password123").await; let resp = h .client .post_form( "/api/reports", &format!( "target_type=project&target_id={}&report_type=invalid_type&reason=test", setup.project_id ), ) .await; assert!( resp.status.as_u16() == 422 || resp.text.contains("Invalid report type"), "Invalid report type should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn other_report_type_requires_reason() { let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("rptnoreas", "text", 0).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; h.client.post_form("/logout", "").await; let _reporter = h .signup("rptnorsrpt", "rptnorsrpt@test.com", "password123") .await; h.login("rptnorsrpt", "password123").await; let resp = h .client .post_form( "/api/reports", &format!( "target_type=project&target_id={}&report_type=other&reason=", setup.project_id ), ) .await; assert!( resp.status.as_u16() == 422 || resp.text.contains("provide details"), "Other report without reason should be rejected: {} {}", resp.status, resp.text ); } // ── Admin resolve/dismiss ── #[tokio::test] async fn admin_resolve_report() { let (mut h, _admin_id) = TestHarness::with_admin().await; // Creator makes content let setup = h.create_creator_with_item("rptadmres", "text", 0).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; // Different user reports it h.client.post_form("/logout", "").await; let _reporter = h .signup("rptadmrptr", "rptadmrptr@test.com", "password123") .await; h.login("rptadmrptr", "password123").await; h.client .post_form( "/api/reports", &format!( "target_type=project&target_id={}&report_type=spam&reason=looks+spammy", setup.project_id ), ) .await; // Get report ID from DB let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM reports ORDER BY created_at DESC LIMIT 1") .fetch_one(&h.db) .await .unwrap(); let report_id = row.0; // Admin resolves it h.client.post_form("/logout", "").await; h.login("admin", "password123").await; let resp = h .client .post_form( &format!("/api/admin/reports/{report_id}/resolve"), "decision=resolve&admin_notes=Investigated+and+confirmed+spam", ) .await; assert!( resp.status.is_success(), "Admin resolve failed: {} {}", resp.status, resp.text ); // Verify status in DB let (status,): (String,) = sqlx::query_as("SELECT status FROM reports WHERE id = $1") .bind(report_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "resolved"); } #[tokio::test] async fn admin_dismiss_report() { let (mut h, _admin_id) = TestHarness::with_admin().await; let setup = h.create_creator_with_item("rptadmdis", "text", 0).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; h.client.post_form("/logout", "").await; let _reporter = h .signup("rptdisrptr", "rptdisrptr@test.com", "password123") .await; h.login("rptdisrptr", "password123").await; h.client .post_form( "/api/reports", &format!( "target_type=item&target_id={}&report_type=abuse&reason=test+report", setup.item_id ), ) .await; let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM reports ORDER BY created_at DESC LIMIT 1") .fetch_one(&h.db) .await .unwrap(); let report_id = row.0; h.client.post_form("/logout", "").await; h.login("admin", "password123").await; let resp = h .client .post_form( &format!("/api/admin/reports/{report_id}/resolve"), "decision=dismiss&admin_notes=Not+a+real+issue", ) .await; assert!( resp.status.is_success(), "Admin dismiss failed: {} {}", resp.status, resp.text ); let (status,): (String,) = sqlx::query_as("SELECT status FROM reports WHERE id = $1") .bind(report_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(status, "dismissed"); } // ── Admin page access ── #[tokio::test] async fn admin_reports_page_loads() { let (mut h, _admin_id) = TestHarness::with_admin().await; h.client.post_form("/logout", "").await; h.login("admin", "password123").await; let resp = h.client.get("/admin/reports").await; assert!( resp.status.is_success(), "Admin reports page failed: {} {}", resp.status, resp.text ); assert!( resp.text.contains("Reports Queue"), "Page should have title" ); } #[tokio::test] async fn non_admin_cannot_access_reports_page() { let mut h = TestHarness::new().await; let _user_id = h.create_creator("rptnonadm").await; let resp = h.client.get("/admin/reports").await; assert!( resp.status.as_u16() == 404 || resp.status.as_u16() == 403 || resp.status.as_u16() == 401, "Non-admin should be rejected: {} {}", resp.status, resp.text ); } #[tokio::test] async fn non_admin_cannot_resolve_report() { let (mut h, _admin_id) = TestHarness::with_admin().await; // Create a report via admin flow let setup = h.create_creator_with_item("rptnoadmr", "text", 0).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; h.client.post_form("/logout", "").await; let _reporter = h .signup("rptnarptr", "rptnarptr@test.com", "password123") .await; h.login("rptnarptr", "password123").await; h.client .post_form( "/api/reports", &format!( "target_type=project&target_id={}&report_type=spam&reason=test", setup.project_id ), ) .await; let row: (uuid::Uuid,) = sqlx::query_as("SELECT id FROM reports ORDER BY created_at DESC LIMIT 1") .fetch_one(&h.db) .await .unwrap(); let report_id = row.0; // Non-admin tries to resolve let resp = h .client .post_form( &format!("/api/admin/reports/{report_id}/resolve"), "decision=resolve&admin_notes=hacked", ) .await; assert!( resp.status.as_u16() == 404 || resp.status.as_u16() == 403 || resp.status.as_u16() == 401, "Non-admin should be rejected: {} {}", resp.status, resp.text ); } // Report-spam rate limit (test-fuzz Phase 2.3) // // submit_report enforces max 10 reports per reporter per 24h via // count_recent_reports_by_user. The cap itself was untested, a regression // (e.g. an off-by-one or a dropped check) would let one user flood the // moderation queue. This pins the boundary: the 10th is accepted, the 11th is // rejected, and exactly 10 rows land. #[tokio::test] async fn report_daily_cap_enforced_across_distinct_targets() { let mut h = TestHarness::new().await; // A creator with one real, public item, the target for the over-cap report. let setup = h.create_creator_with_item("spamtarget", "text", 0).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; h.client.post_form("/logout", "").await; let reporter_id = h .signup("spamreporter", "spamreporter@test.com", "password123") .await; h.login("spamreporter", "password123").await; // Seed the reporter's day with 10 prior reports against distinct targets, // directly (the HTTP write-rate limiter, burst 10/IP, would otherwise trip // long before the per-day report cap). Distinct random target_ids so the // per-target dedup does not interfere with the over-cap HTTP report below. for _ in 0..10 { sqlx::query( "INSERT INTO reports (reporter_user_id, target_type, target_id, report_type, reason) \ VALUES ($1, 'item', $2, 'spam', 'seed')", ) .bind(reporter_id) .bind(uuid::Uuid::new_v4()) .execute(&h.db) .await .unwrap(); } // The 11th report (against the real item) sees a count of 10 and is rejected. let body = format!( "target_type=item&target_id={}&report_type=spam&reason=spam", setup.item_id ); let resp = h.client.post_form("/api/reports", &body).await; assert!( !resp.status.is_success() || resp.text.contains("limit reached"), "the over-cap report must be rate-limited, got: {} {}", resp.status, resp.text ); // Still exactly 10 rows, the rejected one wrote nothing. let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM reports WHERE reporter_user_id = $1") .bind(reporter_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( count, 10, "rate limit must cap stored reports at 10, found {count}" ); } #[tokio::test] async fn report_same_target_deduped_while_open() { // Run #22 LOW: a reporter must not be able to stack multiple still-open // reports against one target (queue-flooding). The first lands; a second // against the same target while the first is unresolved is rejected and // writes nothing. let mut h = TestHarness::new().await; let setup = h.create_creator_with_item("dedupcreator", "text", 0).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; h.client.post_form("/logout", "").await; let reporter_id = h .signup("dedupreporter", "dedupreporter@test.com", "password123") .await; h.login("dedupreporter", "password123").await; let body = format!( "target_type=item&target_id={}&report_type=spam&reason=spam", setup.item_id ); let first = h.client.post_form("/api/reports", &body).await; assert!( first.status.is_success(), "first report should land: {} {}", first.status, first.text ); let second = h.client.post_form("/api/reports", &body).await; assert!( !second.status.is_success() || second.text.contains("limit reached"), "a second open report on the same target must be rejected, got: {} {}", second.status, second.text ); let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM reports WHERE reporter_user_id = $1 AND target_id = $2", ) .bind(reporter_id) .bind(setup.item_id.parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert_eq!( count, 1, "duplicate open report must not be stored, found {count}" ); } // Dedup blocks open-stacking, not recurrence (test-fuzz Phase 2.3) // // report_same_target_deduped_while_open pins that a second OPEN report on a // target is rejected. The complement, once the first report is resolved, a // legitimate re-report of a recurrence must be allowed again, was untested. A // regression that keyed the dedup on (reporter, target) regardless of status // would silently swallow every follow-up report after the first resolution. #[tokio::test] async fn report_allowed_again_after_prior_resolved() { let (mut h, _admin_id) = TestHarness::with_admin().await; let setup = h.create_creator_with_item("reagcreator", "text", 0).await; h.publish_project_and_item(&setup.project_id, &setup.item_id) .await; h.client.post_form("/logout", "").await; let reporter_id = h .signup("reagreporter", "reagreporter@test.com", "password123") .await; h.login("reagreporter", "password123").await; let body = format!( "target_type=item&target_id={}&report_type=spam&reason=spam", setup.item_id ); let first = h.client.post_form("/api/reports", &body).await; assert!( first.status.is_success(), "first report should land: {} {}", first.status, first.text ); // Admin resolves the open report. let report_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM reports WHERE reporter_user_id = $1") .bind(reporter_id) .fetch_one(&h.db) .await .unwrap(); h.client.post_form("/logout", "").await; h.login("admin", "password123").await; let resolve = h .client .post_form( &format!("/api/admin/reports/{report_id}/resolve"), "decision=resolve&admin_notes=handled", ) .await; assert!( resolve.status.is_success(), "resolve failed: {} {}", resolve.status, resolve.text ); // The reporter can file again, nothing OPEN stands against the target, so // the dedup guard does not block a fresh recurrence report. h.client.post_form("/logout", "").await; h.login("reagreporter", "password123").await; let second = h.client.post_form("/api/reports", &body).await; assert!( second.status.is_success(), "re-report after resolution must be allowed: {} {}", second.status, second.text ); let count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM reports WHERE reporter_user_id = $1 AND target_id = $2", ) .bind(reporter_id) .bind(setup.item_id.parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert_eq!( count, 2, "a fresh report after resolution must be stored, found {count}" ); } // Resolving a stale/nonexistent report id 404s (test-fuzz Phase 2.3) // // resolve_report surfaces a zero-row UPDATE as NotFound rather than a false // "resolved", the guard against an admin silently acting on a report that was // already cleared away (or never existed). Only the happy path was tested. #[tokio::test] async fn admin_resolve_nonexistent_report_is_404() { let (mut h, _admin_id) = TestHarness::with_admin().await; h.client.post_form("/logout", "").await; h.login("admin", "password123").await; let bogus = uuid::Uuid::new_v4(); let resp = h .client .post_form( &format!("/api/admin/reports/{bogus}/resolve"), "decision=resolve&admin_notes=nope", ) .await; assert_eq!( resp.status.as_u16(), 404, "resolving a nonexistent report must 404, got: {} {}", resp.status, resp.text ); }