eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "encoding/json"
5 "net/http"
6 "strings"
7 "testing"
8 "time"
9)
10
11func 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
25func 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.
43func 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.
116func 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).
137func 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.
178func 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