//! 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/{}/resolve", report_id), "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/{}/resolve", report_id), "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/{}/resolve", report_id), "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_spam_rate_limited_at_ten_per_day() { let mut h = TestHarness::new().await; // A creator with a public item to report. (create_report has no per-target // dedup, so repeatedly reporting the same item is what a spammer would do.) 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; let body = format!( "target_type=item&target_id={}&report_type=spam&reason=spam", setup.item_id ); // First 10 are accepted (the cap is `>= 10` checked BEFORE insert, so the // 10th sees a count of 9 and goes through). for n in 1..=10 { let resp = h.client.post_form("/api/reports", &body).await; assert!(resp.status.is_success(), "report #{} should be accepted: {} {}", n, resp.status, resp.text); } // The 11th sees a count of 10 and is rejected. let resp = h.client.post_form("/api/reports", &body).await; assert!(!resp.status.is_success() || resp.text.contains("limit reached"), "the 11th report must be rate-limited, got: {} {}", resp.status, resp.text); // Exactly 10 rows persisted — 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}"); }