package main import ( "encoding/json" "net/http" "strings" "testing" "time" ) func modAdminGet(t *testing.T, url, bearer string) (int, map[string]any) { t.Helper() req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer "+bearer) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("get %s: %v", url, err) } defer resp.Body.Close() out := map[string]any{} _ = json.NewDecoder(resp.Body).Decode(&out) return resp.StatusCode, out } func modAdminPost(t *testing.T, url, bearer, body string) (int, map[string]any) { t.Helper() req, _ := http.NewRequest("POST", url, strings.NewReader(body)) req.Header.Set("Authorization", "Bearer "+bearer) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("post %s: %v", url, err) } defer resp.Body.Close() out := map[string]any{} _ = json.NewDecoder(resp.Body).Decode(&out) return resp.StatusCode, out } // A report's lifecycle: filed with per-admin sealed blobs, listed with the // caller's own blob + repeat counts, resolved exactly once, audited. The // server never holds report content — only the sealed blobs it can't open. func TestReportLifecycle(t *testing.T) { ts, srv := newTestServer(t) tokRep, uidRep, _ := joinedMember(t, srv, "Reporter") _, uidBad, _ := joinedMember(t, srv, "Troublemaker") tokAdm, uidAdm, _ := joinedMember(t, srv, "Root") if err := srv.db.setUserRole(uidAdm, "admin"); err != nil { t.Fatal(err) } // Blobs must be addressed to real admins. if code, _ := modPost(t, ts.URL+"/api/reports", `{"token":"`+tokRep+`","reported_id":"`+uidBad+`","blobs":[{"admin_id":"`+uidRep+`","blob":"sealed"}]}`); code != 400 { t.Fatalf("blob to a non-admin should be 400, got %d", code) } // You can't report yourself. if code, _ := modPost(t, ts.URL+"/api/reports", `{"token":"`+tokRep+`","reported_id":"`+uidRep+`","blobs":[{"admin_id":"`+uidAdm+`","blob":"sealed"}]}`); code != 400 { t.Fatalf("self-report should be 400, got %d", code) } code, out := modPost(t, ts.URL+"/api/reports", `{"token":"`+tokRep+`","reported_id":"`+uidBad+`","blobs":[{"admin_id":"`+uidAdm+`","blob":"sealed-to-root"}]}`) if code != 200 { t.Fatalf("file report: %d %v", code, out) } reportID := out["id"].(string) // The admin member sees the queue with THEIR blob and the repeat counts. code, got := modAdminGet(t, ts.URL+"/api/admin/reports", tokAdm) if code != 200 { t.Fatalf("list reports: %d", code) } rows := got["reports"].([]any) if len(rows) != 1 { t.Fatalf("want 1 report, got %d", len(rows)) } row := rows[0].(map[string]any) if row["blob"] != "sealed-to-root" || row["status"] != "open" || row["reporter_id"] != uidRep || row["reported_id"] != uidBad || row["reporter_count"].(float64) != 1 || row["reported_count"].(float64) != 1 { t.Fatalf("queue row wrong: %v", row) } // Members can't read the queue. if code, _ := modAdminGet(t, ts.URL+"/api/admin/reports", tokRep); code != 401 { t.Fatalf("member reading the queue should be 401, got %d", code) } // Resolve once; a second resolve conflicts (one outcome per report). if code, _ := modAdminPost(t, ts.URL+"/api/admin/reports/"+reportID+"/resolve", tokAdm, `{"action":"warned"}`); code != 200 { t.Fatal("resolve should succeed") } if code, _ := modAdminPost(t, ts.URL+"/api/admin/reports/"+reportID+"/resolve", tokAdm, `{"action":"dismissed"}`); code != 409 { t.Fatal("second resolve should 409") } // Warn + resolution both audited; the log pages newest-first. if code, _ := modAdminPost(t, ts.URL+"/api/admin/warn", tokAdm, `{"user_id":"`+uidBad+`","report_id":"`+reportID+`"}`); code != 200 { t.Fatal("warn record should succeed") } code, audit := modAdminGet(t, ts.URL+"/api/admin/audit", tokAdm) if code != 200 { t.Fatalf("audit: %d", code) } entries := audit["entries"].([]any) if len(entries) != 2 { t.Fatalf("want 2 audit entries (resolve + warn), got %d", len(entries)) } first := entries[0].(map[string]any) if first["action"] != "member.warned" || first["actor_id"] != uidAdm || first["subject_id"] != uidBad { t.Fatalf("newest audit entry wrong: %v", first) } } // The content-blind report brake: the sixth filing within an hour bounces. func TestReportRateCap(t *testing.T) { ts, srv := newTestServer(t) tokRep, _, _ := joinedMember(t, srv, "Busy") _, uidBad, _ := joinedMember(t, srv, "Target") _, uidAdm, _ := joinedMember(t, srv, "Root") if err := srv.db.setUserRole(uidAdm, "admin"); err != nil { t.Fatal(err) } body := `{"token":"` + tokRep + `","reported_id":"` + uidBad + `","blobs":[{"admin_id":"` + uidAdm + `","blob":"s"}]}` for i := 0; i < 5; i++ { if code, _ := modPost(t, ts.URL+"/api/reports", body); code != 200 { t.Fatalf("report %d should pass, got %d", i+1, code) } } if code, _ := modPost(t, ts.URL+"/api/reports", body); code != 429 { t.Fatalf("sixth report in the hour should be 429, got %d", code) } } // Admin removal and admin-performed group removals land in the audit log; // an ordinary member's group removal does not (housekeeping, not moderation). func TestModerationAuditHooks(t *testing.T) { ts, srv := newTestServer(t) tokAdm, uidAdm, _ := joinedMember(t, srv, "Root") if err := srv.db.setUserRole(uidAdm, "admin"); err != nil { t.Fatal(err) } tokM, uidM, _ := joinedMember(t, srv, "Member") _, uidX, _ := joinedMember(t, srv, "Removee") gid, err := srv.db.createThread("group", "", []ThreadMemberKey{ {UserID: uidAdm, Wrapped: "w"}, {UserID: uidM, Wrapped: "w"}, {UserID: uidX, Wrapped: "w"}, }) if err != nil { t.Fatal(err) } // Ordinary member removes someone from the group — no audit entry. if code, _ := modPost(t, ts.URL+"/api/threads/"+gid+"/remove", `{"token":"`+tokM+`","user_id":"`+uidX+`"}`); code != 200 { t.Fatal("member group-remove should work") } // Admin removes the member from the whole space — audited. req, _ := http.NewRequest("POST", ts.URL+"/api/admin/users/"+uidM+"/remove", nil) req.Header.Set("Authorization", "Bearer "+tokAdm) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("admin remove: %d", resp.StatusCode) } entries, err := srv.db.listAudit(0, 10) if err != nil { t.Fatal(err) } if len(entries) != 1 || entries[0].Action != "member.removed" || entries[0].SubjectID != uidM || entries[0].ActorID != uidAdm { t.Fatalf("want exactly the admin removal audited, got %v", entries) } } // The report-notification throttle: the first report of a burst notifies, the // rest inside the quiet window don't, and the window reopening notifies again. func TestReportPushThrottle(t *testing.T) { _, srv := newTestServer(t) if !srv.pushReportNotice() { t.Fatal("the first report of a burst should notify") } if srv.pushReportNotice() { t.Fatal("a report inside the quiet window must stay silent") } srv.reportPushMu.Lock() srv.lastReportPush = time.Now().Add(-reportPushCooldown - time.Minute) srv.reportPushMu.Unlock() if !srv.pushReportNotice() { t.Fatal("after the window, the next report notifies again") } }