| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | func modAdminGet(t *testing.T, url, bearer string) (int, map[string]any) { |
| 12 | t.Helper() |
| 13 | req, _ := http.NewRequest("GET", url, nil) |
| 14 | req.Header.Set("Authorization", "Bearer "+bearer) |
| 15 | resp, err := http.DefaultClient.Do(req) |
| 16 | if err != nil { |
| 17 | t.Fatalf("get %s: %v", url, err) |
| 18 | } |
| 19 | defer resp.Body.Close() |
| 20 | out := map[string]any{} |
| 21 | _ = json.NewDecoder(resp.Body).Decode(&out) |
| 22 | return resp.StatusCode, out |
| 23 | } |
| 24 | |
| 25 | func modAdminPost(t *testing.T, url, bearer, body string) (int, map[string]any) { |
| 26 | t.Helper() |
| 27 | req, _ := http.NewRequest("POST", url, strings.NewReader(body)) |
| 28 | req.Header.Set("Authorization", "Bearer "+bearer) |
| 29 | req.Header.Set("Content-Type", "application/json") |
| 30 | resp, err := http.DefaultClient.Do(req) |
| 31 | if err != nil { |
| 32 | t.Fatalf("post %s: %v", url, err) |
| 33 | } |
| 34 | defer resp.Body.Close() |
| 35 | out := map[string]any{} |
| 36 | _ = json.NewDecoder(resp.Body).Decode(&out) |
| 37 | return resp.StatusCode, out |
| 38 | } |
| 39 | |
| 40 | // A report's lifecycle: filed with per-admin sealed blobs, listed with the |
| 41 | // caller's own blob + repeat counts, resolved exactly once, audited. The |
| 42 | // server never holds report content — only the sealed blobs it can't open. |
| 43 | func TestReportLifecycle(t *testing.T) { |
| 44 | ts, srv := newTestServer(t) |
| 45 | |
| 46 | tokRep, uidRep, _ := joinedMember(t, srv, "Reporter") |
| 47 | _, uidBad, _ := joinedMember(t, srv, "Troublemaker") |
| 48 | tokAdm, uidAdm, _ := joinedMember(t, srv, "Root") |
| 49 | if err := srv.db.setUserRole(uidAdm, "admin"); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | |
| 53 | // Blobs must be addressed to real admins. |
| 54 | if code, _ := modPost(t, ts.URL+"/api/reports", `{"token":"`+tokRep+`","reported_id":"`+uidBad+`","blobs":[{"admin_id":"`+uidRep+`","blob":"sealed"}]}`); code != 400 { |
| 55 | t.Fatalf("blob to a non-admin should be 400, got %d", code) |
| 56 | } |
| 57 | // You can't report yourself. |
| 58 | if code, _ := modPost(t, ts.URL+"/api/reports", `{"token":"`+tokRep+`","reported_id":"`+uidRep+`","blobs":[{"admin_id":"`+uidAdm+`","blob":"sealed"}]}`); code != 400 { |
| 59 | t.Fatalf("self-report should be 400, got %d", code) |
| 60 | } |
| 61 | |
| 62 | code, out := modPost(t, ts.URL+"/api/reports", `{"token":"`+tokRep+`","reported_id":"`+uidBad+`","blobs":[{"admin_id":"`+uidAdm+`","blob":"sealed-to-root"}]}`) |
| 63 | if code != 200 { |
| 64 | t.Fatalf("file report: %d %v", code, out) |
| 65 | } |
| 66 | reportID := out["id"].(string) |
| 67 | |
| 68 | // The admin member sees the queue with THEIR blob and the repeat counts. |
| 69 | code, got := modAdminGet(t, ts.URL+"/api/admin/reports", tokAdm) |
| 70 | if code != 200 { |
| 71 | t.Fatalf("list reports: %d", code) |
| 72 | } |
| 73 | rows := got["reports"].([]any) |
| 74 | if len(rows) != 1 { |
| 75 | t.Fatalf("want 1 report, got %d", len(rows)) |
| 76 | } |
| 77 | row := rows[0].(map[string]any) |
| 78 | if row["blob"] != "sealed-to-root" || row["status"] != "open" || |
| 79 | row["reporter_id"] != uidRep || row["reported_id"] != uidBad || |
| 80 | row["reporter_count"].(float64) != 1 || row["reported_count"].(float64) != 1 { |
| 81 | t.Fatalf("queue row wrong: %v", row) |
| 82 | } |
| 83 | |
| 84 | // Members can't read the queue. |
| 85 | if code, _ := modAdminGet(t, ts.URL+"/api/admin/reports", tokRep); code != 401 { |
| 86 | t.Fatalf("member reading the queue should be 401, got %d", code) |
| 87 | } |
| 88 | |
| 89 | // Resolve once; a second resolve conflicts (one outcome per report). |
| 90 | if code, _ := modAdminPost(t, ts.URL+"/api/admin/reports/"+reportID+"/resolve", tokAdm, `{"action":"warned"}`); code != 200 { |
| 91 | t.Fatal("resolve should succeed") |
| 92 | } |
| 93 | if code, _ := modAdminPost(t, ts.URL+"/api/admin/reports/"+reportID+"/resolve", tokAdm, `{"action":"dismissed"}`); code != 409 { |
| 94 | t.Fatal("second resolve should 409") |
| 95 | } |
| 96 | |
| 97 | // Warn + resolution both audited; the log pages newest-first. |
| 98 | if code, _ := modAdminPost(t, ts.URL+"/api/admin/warn", tokAdm, `{"user_id":"`+uidBad+`","report_id":"`+reportID+`"}`); code != 200 { |
| 99 | t.Fatal("warn record should succeed") |
| 100 | } |
| 101 | code, audit := modAdminGet(t, ts.URL+"/api/admin/audit", tokAdm) |
| 102 | if code != 200 { |
| 103 | t.Fatalf("audit: %d", code) |
| 104 | } |
| 105 | entries := audit["entries"].([]any) |
| 106 | if len(entries) != 2 { |
| 107 | t.Fatalf("want 2 audit entries (resolve + warn), got %d", len(entries)) |
| 108 | } |
| 109 | first := entries[0].(map[string]any) |
| 110 | if first["action"] != "member.warned" || first["actor_id"] != uidAdm || first["subject_id"] != uidBad { |
| 111 | t.Fatalf("newest audit entry wrong: %v", first) |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // The content-blind report brake: the sixth filing within an hour bounces. |
| 116 | func TestReportRateCap(t *testing.T) { |
| 117 | ts, srv := newTestServer(t) |
| 118 | tokRep, _, _ := joinedMember(t, srv, "Busy") |
| 119 | _, uidBad, _ := joinedMember(t, srv, "Target") |
| 120 | _, uidAdm, _ := joinedMember(t, srv, "Root") |
| 121 | if err := srv.db.setUserRole(uidAdm, "admin"); err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | body := `{"token":"` + tokRep + `","reported_id":"` + uidBad + `","blobs":[{"admin_id":"` + uidAdm + `","blob":"s"}]}` |
| 125 | for i := 0; i < 5; i++ { |
| 126 | if code, _ := modPost(t, ts.URL+"/api/reports", body); code != 200 { |
| 127 | t.Fatalf("report %d should pass, got %d", i+1, code) |
| 128 | } |
| 129 | } |
| 130 | if code, _ := modPost(t, ts.URL+"/api/reports", body); code != 429 { |
| 131 | t.Fatalf("sixth report in the hour should be 429, got %d", code) |
| 132 | } |
| 133 | } |
| 134 | |
| 135 | // Admin removal and admin-performed group removals land in the audit log; |
| 136 | // an ordinary member's group removal does not (housekeeping, not moderation). |
| 137 | func TestModerationAuditHooks(t *testing.T) { |
| 138 | ts, srv := newTestServer(t) |
| 139 | tokAdm, uidAdm, _ := joinedMember(t, srv, "Root") |
| 140 | if err := srv.db.setUserRole(uidAdm, "admin"); err != nil { |
| 141 | t.Fatal(err) |
| 142 | } |
| 143 | tokM, uidM, _ := joinedMember(t, srv, "Member") |
| 144 | _, uidX, _ := joinedMember(t, srv, "Removee") |
| 145 | |
| 146 | gid, err := srv.db.createThread("group", "", []ThreadMemberKey{ |
| 147 | {UserID: uidAdm, Wrapped: "w"}, {UserID: uidM, Wrapped: "w"}, {UserID: uidX, Wrapped: "w"}, |
| 148 | }) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | // Ordinary member removes someone from the group — no audit entry. |
| 153 | if code, _ := modPost(t, ts.URL+"/api/threads/"+gid+"/remove", `{"token":"`+tokM+`","user_id":"`+uidX+`"}`); code != 200 { |
| 154 | t.Fatal("member group-remove should work") |
| 155 | } |
| 156 | // Admin removes the member from the whole space — audited. |
| 157 | req, _ := http.NewRequest("POST", ts.URL+"/api/admin/users/"+uidM+"/remove", nil) |
| 158 | req.Header.Set("Authorization", "Bearer "+tokAdm) |
| 159 | resp, err := http.DefaultClient.Do(req) |
| 160 | if err != nil { |
| 161 | t.Fatal(err) |
| 162 | } |
| 163 | resp.Body.Close() |
| 164 | if resp.StatusCode != 200 { |
| 165 | t.Fatalf("admin remove: %d", resp.StatusCode) |
| 166 | } |
| 167 | entries, err := srv.db.listAudit(0, 10) |
| 168 | if err != nil { |
| 169 | t.Fatal(err) |
| 170 | } |
| 171 | if len(entries) != 1 || entries[0].Action != "member.removed" || entries[0].SubjectID != uidM || entries[0].ActorID != uidAdm { |
| 172 | t.Fatalf("want exactly the admin removal audited, got %v", entries) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | // The report-notification throttle: the first report of a burst notifies, the |
| 177 | // rest inside the quiet window don't, and the window reopening notifies again. |
| 178 | func TestReportPushThrottle(t *testing.T) { |
| 179 | _, srv := newTestServer(t) |
| 180 | if !srv.pushReportNotice() { |
| 181 | t.Fatal("the first report of a burst should notify") |
| 182 | } |
| 183 | if srv.pushReportNotice() { |
| 184 | t.Fatal("a report inside the quiet window must stay silent") |
| 185 | } |
| 186 | srv.reportPushMu.Lock() |
| 187 | srv.lastReportPush = time.Now().Add(-reportPushCooldown - time.Minute) |
| 188 | srv.reportPushMu.Unlock() |
| 189 | if !srv.pushReportNotice() { |
| 190 | t.Fatal("after the window, the next report notifies again") |
| 191 | } |
| 192 | } |
| 193 | |