eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "io"
8 "net/http"
9 "net/http/httptest"
10 "net/url"
11 "os"
12 "strconv"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/coder/websocket"
18)
19
20func newTestServer(t *testing.T) (*httptest.Server, *Server) {
21 t.Helper()
22 db, err := openDB(t.TempDir() + "/test.db")
23 if err != nil {
24 t.Fatalf("openDB: %v", err)
25 }
26 db.ensureAdminToken("admintok")
27 pub, priv, _ := db.ensureVAPID()
28 pages, err := loadPages()
29 if err != nil {
30 t.Fatalf("loadPages: %v", err)
31 }
32 hub := newHub()
33 go hub.run()
34 s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), vapidPub: pub, vapidPriv: priv, homeOrigin: defaultHomeOrigin,
35 maxUpload: defaultMaxUploadBytes, blobQuota: defaultBlobQuotaBytes}
36 ts := httptest.NewServer(s.handler())
37 t.Cleanup(ts.Close)
38 return ts, s
39}
40
41// adminPost issues a POST with the admin bearer token.
42func adminPost(t *testing.T, url, body string) *http.Response {
43 t.Helper()
44 req, _ := http.NewRequest("POST", url, strings.NewReader(body))
45 req.Header.Set("Authorization", "Bearer admintok")
46 req.Header.Set("Content-Type", "application/json")
47 resp, err := http.DefaultClient.Do(req)
48 if err != nil {
49 t.Fatalf("request: %v", err)
50 }
51 return resp
52}
53
54func TestAdminAuthGating(t *testing.T) {
55 ts, _ := newTestServer(t)
56
57 resp, _ := http.Post(ts.URL+"/api/admin/key-invites", "application/json", strings.NewReader(`{"label":"x"}`))
58 if resp.StatusCode != http.StatusUnauthorized {
59 t.Fatalf("no auth: want 401, got %d", resp.StatusCode)
60 }
61
62 req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", strings.NewReader(`{}`))
63 req.Header.Set("Authorization", "Bearer wrong")
64 resp, _ = http.DefaultClient.Do(req)
65 if resp.StatusCode != http.StatusUnauthorized {
66 t.Fatalf("wrong auth: want 401, got %d", resp.StatusCode)
67 }
68}
69
70// Renaming a user (e.g. a bot after its default name changes) is admin-gated,
71// validates the label, and shows up in the roster; keys are untouched.
72func TestRenameUser(t *testing.T) {
73 ts, srv := newTestServer(t)
74 uid, _ := srv.db.createUser("Claude")
75 srv.db.setUserPublicKey(uid, "pub-bot", "")
76 srv.db.setUserAgent(uid) // only bots have an admin-settable plaintext name now
77
78 if r, _ := http.Post(ts.URL+"/api/admin/users/"+uid+"/label", "application/json", strings.NewReader(`{"label":"Eleven"}`)); r.StatusCode != http.StatusUnauthorized {
79 t.Fatalf("no auth: want 401, got %d", r.StatusCode)
80 }
81 if r := adminPost(t, ts.URL+"/api/admin/users/"+uid+"/label", `{"label":""}`); r.StatusCode != http.StatusBadRequest {
82 t.Fatalf("empty label: want 400, got %d", r.StatusCode)
83 }
84 if r := adminPost(t, ts.URL+"/api/admin/users/nosuch/label", `{"label":"Eleven"}`); r.StatusCode != http.StatusBadRequest {
85 t.Fatalf("unknown user: want 400, got %d", r.StatusCode)
86 }
87 if r := adminPost(t, ts.URL+"/api/admin/users/"+uid+"/label", `{"label":"Eleven"}`); r.StatusCode != http.StatusOK {
88 t.Fatalf("rename: want 200, got %d", r.StatusCode)
89 }
90 entries, _ := srv.db.roster()
91 if len(entries) != 1 || entries[0].Label != "Eleven" || entries[0].PublicKey != "pub-bot" {
92 t.Fatalf("roster after rename: %+v", entries)
93 }
94}
95
96func TestMessagesThreadScoping(t *testing.T) {
97 ts, srv := newTestServer(t)
98
99 // Messages are thread-scoped: a bad token is rejected; a real member reading
100 // their own thread is allowed; a member reading a thread they're not in is not.
101 resp, _ := http.Get(ts.URL + "/api/messages?token=bogus&thread=x")
102 if resp.StatusCode != http.StatusForbidden {
103 t.Fatalf("messages bad token: want 403, got %d", resp.StatusCode)
104 }
105 tok, _, tid := joinedMember(t, srv, "Member")
106 resp, _ = http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + tid)
107 if resp.StatusCode != http.StatusOK {
108 t.Fatalf("messages member's thread: want 200, got %d", resp.StatusCode)
109 }
110 resp, _ = http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=not-my-thread")
111 if resp.StatusCode != http.StatusForbidden {
112 t.Fatalf("messages foreign thread: want 403, got %d", resp.StatusCode)
113 }
114}
115
116// A delete's tombstone row (#581) rides the ordinary /api/messages read:
117// clients that were offline for the live broadcast see `tombstone_of` on a
118// fresh row and fold it as a delete. The wire shape is what old clients skip
119// (empty blob) and what the chat.json event vector pins.
120func TestMessagesTombstoneOnWire(t *testing.T) {
121 ts, srv := newTestServer(t)
122 tok, _, tid := joinedMember(t, srv, "Member")
123 m1, _ := srv.db.insertMessage(tid, "blob1", hashToken(tok))
124 m2, _ := srv.db.insertMessage(tid, "blob2", hashToken(tok))
125 if ok, err := srv.db.deleteMessage(m1.ID, hashToken(tok)); err != nil || !ok {
126 t.Fatalf("deleteMessage: ok=%v err=%v", ok, err)
127 }
128
129 // A client catching up from m2 (its watermark) receives just the tombstone.
130 r, _ := http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + tid + "&after=" + strconv.FormatInt(m2.ID, 10))
131 var got struct {
132 Messages []Message `json:"messages"`
133 }
134 if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
135 t.Fatalf("decode: %v", err)
136 }
137 if len(got.Messages) != 1 || got.Messages[0].TombstoneOf != m1.ID || got.Messages[0].Blob != "" {
138 t.Fatalf("catch-up page: %+v (want one tombstone of %d)", got.Messages, m1.ID)
139 }
140}
141
142func TestKeyInviteFlow(t *testing.T) {
143 ts, _ := newTestServer(t)
144
145 // Admin mints a key-invite. The browser would derive `id` and wrap the key;
146 // here we just store opaque strings (the server never interprets them).
147 resp := adminPost(t, ts.URL+"/api/admin/key-invites",
148 `{"label":"Paul","id":"derived-id-abc","wrapped":"wrapped-blob","claim_hash":"`+testClaimHash+`"}`)
149 if resp.StatusCode != http.StatusOK {
150 t.Fatalf("create key-invite: %d", resp.StatusCode)
151 }
152 // Without admin auth it's rejected.
153 r, _ := http.Post(ts.URL+"/api/admin/key-invites", "application/json",
154 strings.NewReader(`{"label":"x","id":"y","wrapped":"z"}`))
155 if r.StatusCode != http.StatusUnauthorized {
156 t.Fatalf("create without admin: want 401, got %d", r.StatusCode)
157 }
158
159 // Anyone with the id can fetch the (encrypted) wrapped blob.
160 r, _ = http.Get(ts.URL + "/api/key-invite/derived-id-abc")
161 var got struct {
162 Ok bool `json:"ok"`
163 Wrapped string `json:"wrapped"`
164 }
165 json.NewDecoder(r.Body).Decode(&got)
166 if !got.Ok || got.Wrapped != "wrapped-blob" {
167 t.Fatalf("get key-invite: %+v", got)
168 }
169
170 // A random device token is not yet valid.
171 r, _ = http.Get(ts.URL + "/api/messages?token=randomdevice")
172 if r.StatusCode != http.StatusForbidden {
173 t.Fatalf("unclaimed device: want 403, got %d", r.StatusCode)
174 }
175
176 // Claiming mints a device token...
177 r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json",
178 strings.NewReader(`{"label":"laptop","claim_secret":"test-claim-secret-vector"}`))
179 var claim struct {
180 Ok bool `json:"ok"`
181 Token string `json:"token"`
182 }
183 json.NewDecoder(r.Body).Decode(&claim)
184 if !claim.Ok || claim.Token == "" {
185 t.Fatalf("claim: %+v", claim)
186 }
187 // ...which then authorises the chat API (the thread list, here — messages are
188 // thread-scoped and this fresh member has none yet).
189 r, _ = http.Get(ts.URL + "/api/threads?token=" + claim.Token)
190 if r.StatusCode != http.StatusOK {
191 t.Fatalf("device-token threads: %d", r.StatusCode)
192 }
193
194 // Single-use: a second claim fails and the wrapped blob is no longer served.
195 r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json", strings.NewReader(`{"claim_secret":"test-claim-secret-vector"}`))
196 if r.StatusCode == http.StatusOK {
197 t.Fatal("second claim should fail")
198 }
199 r, _ = http.Get(ts.URL + "/api/key-invite/derived-id-abc")
200 json.NewDecoder(r.Body).Decode(&got)
201 if got.Ok {
202 t.Fatal("used invite should report ok:false")
203 }
204}
205
206func TestMemberAddsDevice(t *testing.T) {
207 ts, _ := newTestServer(t)
208 // Mint + claim a key-invite to get a member device token.
209 adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Paul","id":"inv-1","wrapped":"w1","claim_hash":"`+testClaimHash+`"}`)
210 r, _ := http.Post(ts.URL+"/api/key-invite/inv-1/claim", "application/json", strings.NewReader(`{"claim_secret":"test-claim-secret-vector"}`))
211 var claim struct {
212 Token string `json:"token"`
213 }
214 json.NewDecoder(r.Body).Decode(&claim)
215
216 before := countUsers(t, ts) // 1: Paul
217
218 // The member mints a device invite for themselves (no admin token).
219 r, _ = http.Post(ts.URL+"/api/device-invites", "application/json",
220 strings.NewReader(`{"token":"`+claim.Token+`","label":"Paul","id":"inv-2","wrapped":"w2","claim_hash":"`+testClaimHash+`"}`))
221 if r.StatusCode != http.StatusOK {
222 t.Fatalf("member add-device: %d", r.StatusCode)
223 }
224 // A stranger (no valid token) cannot.
225 r, _ = http.Post(ts.URL+"/api/device-invites", "application/json",
226 strings.NewReader(`{"token":"bogus","id":"x","wrapped":"y"}`))
227 if r.StatusCode != http.StatusForbidden {
228 t.Fatalf("stranger add-device: want 403, got %d", r.StatusCode)
229 }
230
231 // The new invite is claimable and tied to the SAME user (no new user created).
232 r, _ = http.Get(ts.URL + "/api/key-invite/inv-2")
233 var got struct {
234 Ok bool `json:"ok"`
235 }
236 json.NewDecoder(r.Body).Decode(&got)
237 if !got.Ok {
238 t.Fatal("new device invite should be claimable")
239 }
240 if after := countUsers(t, ts); after != before {
241 t.Fatalf("add-device should reuse the user: before=%d after=%d", before, after)
242 }
243}
244
245func countUsers(t *testing.T, ts *httptest.Server) int {
246 t.Helper()
247 r := adminGet(t, ts.URL+"/api/admin/users")
248 var out struct {
249 Users []struct{ ID string } `json:"users"`
250 }
251 json.NewDecoder(r.Body).Decode(&out)
252 return len(out.Users)
253}
254
255func adminGet(t *testing.T, url string) *http.Response {
256 t.Helper()
257 req, _ := http.NewRequest("GET", url, nil)
258 req.Header.Set("Authorization", "Bearer admintok")
259 resp, err := http.DefaultClient.Do(req)
260 if err != nil {
261 t.Fatalf("request: %v", err)
262 }
263 return resp
264}
265
266func TestActiveTokenTracking(t *testing.T) {
267 _, srv := newTestServer(t)
268
269 if srv.isActive("tok") {
270 t.Fatal("unknown token should be inactive")
271 }
272 srv.markActive("tok")
273 if !srv.isActive("tok") {
274 t.Fatal("just-marked token should be active")
275 }
276 // A stale timestamp (well past the window) reads as inactive.
277 srv.active.Store("old", time.Now().Add(-2*activeWindow).Unix())
278 if srv.isActive("old") {
279 t.Fatal("stale token should be inactive")
280 }
281}
282
283func TestMessagesPaginationAPI(t *testing.T) {
284 ts, srv := newTestServer(t)
285 tok, _, tid := joinedMember(t, srv, "x")
286
287 ids := []int64{}
288 for i := 0; i < 5; i++ {
289 m, _ := srv.db.insertMessage(tid, "b", "author-a")
290 ids = append(ids, m.ID)
291 }
292 get := func(q string) []int64 {
293 r, _ := http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + tid + q)
294 var out struct {
295 Messages []struct {
296 ID int64 `json:"id"`
297 } `json:"messages"`
298 }
299 json.NewDecoder(r.Body).Decode(&out)
300 got := []int64{}
301 for _, m := range out.Messages {
302 got = append(got, m.ID)
303 }
304 return got
305 }
306
307 // limit returns the most recent N, ascending.
308 if g := get("&limit=2"); len(g) != 2 || g[0] != ids[3] || g[1] != ids[4] {
309 t.Fatalf("limit=2: %v", g)
310 }
311 // before paginates older, ascending.
312 if g := get("&before=" + strconv.FormatInt(ids[3], 10) + "&limit=2"); len(g) != 2 || g[0] != ids[1] || g[1] != ids[2] {
313 t.Fatalf("before: %v", g)
314 }
315}
316
317func TestPostMessageAPI(t *testing.T) {
318 ts, srv := newTestServer(t)
319 tok, _, tid := joinedMember(t, srv, "bot")
320
321 post := func(token, body string) *http.Response {
322 r, err := http.Post(ts.URL+"/api/messages?token="+token, "application/json", strings.NewReader(body))
323 if err != nil {
324 t.Fatalf("post: %v", err)
325 }
326 return r
327 }
328
329 // A bogus token can't post.
330 if r := post("bogus", `{"thread_id":"`+tid+`","blob":"abc"}`); r.StatusCode != http.StatusForbidden {
331 t.Fatalf("bad token: want 403, got %d", r.StatusCode)
332 }
333 // Empty blob is rejected.
334 if r := post(tok, `{"thread_id":"`+tid+`","blob":""}`); r.StatusCode != http.StatusBadRequest {
335 t.Fatalf("empty blob: want 400, got %d", r.StatusCode)
336 }
337 // Oversized blob is rejected (mirrors the WebSocket cap).
338 if r := post(tok, `{"thread_id":"`+tid+`","blob":"`+strings.Repeat("a", maxBlobBytes+1)+`"}`); r.StatusCode != http.StatusBadRequest {
339 t.Fatalf("oversized blob: want 400, got %d", r.StatusCode)
340 }
341 // A member can't post to a thread they don't belong to (thread-scoped gate).
342 if r := post(tok, `{"thread_id":"not-my-thread","blob":"cipher"}`); r.StatusCode != http.StatusForbidden {
343 t.Fatalf("non-member thread: want 403, got %d", r.StatusCode)
344 }
345 // A valid post is stored and echoed back.
346 r := post(tok, `{"thread_id":"`+tid+`","blob":"cipher-blob-xyz"}`)
347 if r.StatusCode != http.StatusOK {
348 t.Fatalf("valid post: want 200, got %d", r.StatusCode)
349 }
350 var m struct {
351 ID int64 `json:"id"`
352 Blob string `json:"blob"`
353 }
354 json.NewDecoder(r.Body).Decode(&m)
355 if m.ID == 0 || m.Blob != "cipher-blob-xyz" {
356 t.Fatalf("unexpected response: %+v", m)
357 }
358 // It must come back through the thread-scoped read API as the same ciphertext.
359 gr, _ := http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + tid)
360 var out struct {
361 Messages []struct {
362 ID int64 `json:"id"`
363 Blob string `json:"blob"`
364 } `json:"messages"`
365 }
366 json.NewDecoder(gr.Body).Decode(&out)
367 if len(out.Messages) != 1 || out.Messages[0].Blob != "cipher-blob-xyz" {
368 t.Fatalf("message not stored: %+v", out.Messages)
369 }
370}
371
372func TestBlobUploadDownload(t *testing.T) {
373 ts, srv := newTestServer(t)
374 tok := mintDeviceToken(t, srv.db, "x")
375
376 payload := []byte("pretend-this-is-encrypted-bytes\x00\x01\x02")
377
378 // Upload requires a valid token.
379 resp, _ := http.Post(ts.URL+"/api/upload?token=bogus", "application/octet-stream", strings.NewReader("x"))
380 if resp.StatusCode != http.StatusForbidden {
381 t.Fatalf("upload bad token: want 403, got %d", resp.StatusCode)
382 }
383
384 resp, err := http.Post(ts.URL+"/api/upload?token="+tok, "application/octet-stream", strings.NewReader(string(payload)))
385 if err != nil || resp.StatusCode != http.StatusOK {
386 t.Fatalf("upload: err=%v status=%d", err, resp.StatusCode)
387 }
388 var up struct {
389 ID string `json:"id"`
390 }
391 json.NewDecoder(resp.Body).Decode(&up)
392 if up.ID == "" {
393 t.Fatal("no blob id returned")
394 }
395
396 // Download requires a valid token.
397 resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=bogus")
398 if resp.StatusCode != http.StatusForbidden {
399 t.Fatalf("download bad token: want 403, got %d", resp.StatusCode)
400 }
401
402 // Round-trips exactly.
403 resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + tok)
404 if resp.StatusCode != http.StatusOK {
405 t.Fatalf("download: %d", resp.StatusCode)
406 }
407 got, _ := io.ReadAll(resp.Body)
408 if string(got) != string(payload) {
409 t.Fatalf("blob mismatch: got %q", got)
410 }
411
412 // Unknown id is a 404.
413 resp, _ = http.Get(ts.URL + "/api/blob/nope?token=" + tok)
414 if resp.StatusCode != http.StatusNotFound {
415 t.Fatalf("missing blob: want 404, got %d", resp.StatusCode)
416 }
417
418 // The bytes live on disk (not bloating the SQLite file), and a malformed id
419 // (path traversal / dots) is rejected before it can reach the filesystem.
420 if _, err := os.Stat(srv.db.blobPath(up.ID)); err != nil {
421 t.Fatalf("blob not written to disk: %v", err)
422 }
423 resp, _ = http.Get(ts.URL + "/api/blob/has.dot?token=" + tok)
424 if resp.StatusCode != http.StatusNotFound {
425 t.Fatalf("malformed id: want 404, got %d", resp.StatusCode)
426 }
427}
428
429// A pre-disk blob (bytes in the DB column, no file) still downloads via the
430// legacy fallback — existing image/avatar blobs must keep working after the
431// switch to disk-backed storage.
432func TestBlobLegacyColumnFallback(t *testing.T) {
433 ts, srv := newTestServer(t)
434 tok := mintDeviceToken(t, srv.db, "x")
435
436 if _, err := srv.db.sql.Exec(
437 "INSERT INTO blobs(id, data, size, created_at) VALUES(?, ?, ?, ?)",
438 "legacyone", []byte("old-column-bytes"), 16, time.Now().Unix(),
439 ); err != nil {
440 t.Fatalf("seed legacy blob: %v", err)
441 }
442 resp, _ := http.Get(ts.URL + "/api/blob/legacyone?token=" + tok)
443 if resp.StatusCode != http.StatusOK {
444 t.Fatalf("legacy download: %d", resp.StatusCode)
445 }
446 got, _ := io.ReadAll(resp.Body)
447 if string(got) != "old-column-bytes" {
448 t.Fatalf("legacy blob mismatch: got %q", got)
449 }
450}
451
452// A blob tagged with its thread is reclaimable by thread-scoped GC, and a client
453// can only drop blobs from its own thread (the scope guard).
454func TestBlobThreadScopedGC(t *testing.T) {
455 ts, srv := newTestServer(t)
456 tok := mintDeviceToken(t, srv.db, "x")
457
458 resp, err := http.Post(ts.URL+"/api/upload?token="+tok+"&thread=T1", "application/octet-stream", strings.NewReader("clip-bytes"))
459 if err != nil || resp.StatusCode != http.StatusOK {
460 t.Fatalf("upload: err=%v status=%d", err, resp.StatusCode)
461 }
462 var up struct {
463 ID string `json:"id"`
464 }
465 json.NewDecoder(resp.Body).Decode(&up)
466
467 // Wrong thread: the scope guard means nothing is deleted.
468 if err := srv.db.deleteBlobs("OTHER", []string{up.ID}); err != nil {
469 t.Fatalf("deleteBlobs(other): %v", err)
470 }
471 if resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + tok); resp.StatusCode != http.StatusOK {
472 t.Fatalf("blob wrongly deleted by cross-thread GC: %d", resp.StatusCode)
473 }
474
475 // Correct thread: row + file both go.
476 if err := srv.db.deleteBlobs("T1", []string{up.ID}); err != nil {
477 t.Fatalf("deleteBlobs(T1): %v", err)
478 }
479 if resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + tok); resp.StatusCode != http.StatusNotFound {
480 t.Fatalf("blob not GC'd: %d", resp.StatusCode)
481 }
482 if _, err := os.Stat(srv.db.blobPath(up.ID)); !os.IsNotExist(err) {
483 t.Fatalf("blob file not unlinked: %v", err)
484 }
485}
486
487// Uploads past the per-instance storage quota are refused with 507.
488func TestBlobQuota(t *testing.T) {
489 ts, srv := newTestServer(t)
490 srv.blobQuota = 8 // bytes — tiny, for the test
491 tok := mintDeviceToken(t, srv.db, "x")
492
493 resp, _ := http.Post(ts.URL+"/api/upload?token="+tok, "application/octet-stream", strings.NewReader("way-too-many-bytes"))
494 if resp.StatusCode != http.StatusInsufficientStorage {
495 t.Fatalf("over-quota upload: want 507, got %d", resp.StatusCode)
496 }
497}
498
499func TestPushSubscribeGating(t *testing.T) {
500 ts, _ := newTestServer(t)
501 body := `{"token":"bad","subscription":{"endpoint":"https://e","keys":{"p256dh":"p","auth":"a"}}}`
502 resp, _ := http.Post(ts.URL+"/api/push/subscribe", "application/json", strings.NewReader(body))
503 if resp.StatusCode != http.StatusForbidden {
504 t.Fatalf("subscribe with bad invite: want 403, got %d", resp.StatusCode)
505 }
506}
507
508func TestGroupNameEndpoints(t *testing.T) {
509 ts, _ := newTestServer(t)
510
511 resp, _ := http.Get(ts.URL + "/api/group")
512 var g struct{ Name string }
513 json.NewDecoder(resp.Body).Decode(&g)
514 if g.Name != defaultGroupName {
515 t.Fatalf("default name = %q", g.Name)
516 }
517
518 // Setting requires admin.
519 resp, _ = http.Post(ts.URL+"/api/admin/group", "application/json", strings.NewReader(`{"name":"Fam"}`))
520 if resp.StatusCode != http.StatusUnauthorized {
521 t.Fatalf("set without auth: want 401, got %d", resp.StatusCode)
522 }
523 resp = adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam"}`)
524 if resp.StatusCode != http.StatusOK {
525 t.Fatalf("set name: %d", resp.StatusCode)
526 }
527
528 // Reflected in the page title and the manifest.
529 resp, _ = http.Get(ts.URL + "/")
530 body, _ := io.ReadAll(resp.Body)
531 if !strings.Contains(string(body), "<title>Fam</title>") {
532 t.Fatal("group name not injected into page title")
533 }
534 resp, _ = http.Get(ts.URL + "/manifest.webmanifest")
535 var m struct{ Name string }
536 json.NewDecoder(resp.Body).Decode(&m)
537 if m.Name != "Fam" {
538 t.Fatalf("manifest name = %q", m.Name)
539 }
540}
541
542func TestGroupNameEscapedInHTML(t *testing.T) {
543 ts, _ := newTestServer(t)
544 adminPost(t, ts.URL+"/api/admin/group", `{"name":"<script>x</script>"}`)
545 resp, _ := http.Get(ts.URL + "/")
546 body, _ := io.ReadAll(resp.Body)
547 if strings.Contains(string(body), "<script>x</script>") {
548 t.Fatal("group name not HTML-escaped (XSS risk)")
549 }
550 if !strings.Contains(string(body), "&lt;script&gt;") {
551 t.Fatal("expected escaped group name in page")
552 }
553}
554
555func TestAdminIdentity(t *testing.T) {
556 ts, _ := newTestServer(t)
557
558 // Set the space name + the optional administrator identity.
559 if resp := adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam","admin_name":"Paul Campbell","admin_email":"paul@example.com"}`); resp.StatusCode != http.StatusOK {
560 t.Fatalf("set: %d", resp.StatusCode)
561 }
562
563 // The admin console reads it back via the admin-authed settings endpoint.
564 var s struct {
565 AdminName string `json:"admin_name"`
566 AdminEmail string `json:"admin_email"`
567 }
568 json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&s)
569 if s.AdminName != "Paul Campbell" || s.AdminEmail != "paul@example.com" {
570 t.Fatalf("settings identity = %q / %q", s.AdminName, s.AdminEmail)
571 }
572
573 // Public /api/group (onboarding, no token) must NOT leak the admin identity.
574 var pub map[string]any
575 json.NewDecoder(mustGet(t, ts.URL+"/api/group").Body).Decode(&pub)
576 if _, ok := pub["admin_name"]; ok {
577 t.Fatal("public /api/group leaked admin_name")
578 }
579
580 // A valid member token gets it (used by Eleven's tour).
581 adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Paul","id":"inv-1","wrapped":"w1","claim_hash":"`+testClaimHash+`"}`)
582 r, _ := http.Post(ts.URL+"/api/key-invite/inv-1/claim", "application/json", strings.NewReader(`{"claim_secret":"test-claim-secret-vector"}`))
583 var claim struct {
584 Token string `json:"token"`
585 }
586 json.NewDecoder(r.Body).Decode(&claim)
587 var member struct {
588 AdminName string `json:"admin_name"`
589 AdminEmail string `json:"admin_email"`
590 }
591 json.NewDecoder(mustGet(t, ts.URL+"/api/group?token="+claim.Token).Body).Decode(&member)
592 if member.AdminName != "Paul Campbell" || member.AdminEmail != "paul@example.com" {
593 t.Fatalf("member identity = %q / %q", member.AdminName, member.AdminEmail)
594 }
595
596 // Both are optional: empty clears them.
597 adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam"}`)
598 json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&s)
599 if s.AdminName != "" || s.AdminEmail != "" {
600 t.Fatalf("expected cleared identity, got %q / %q", s.AdminName, s.AdminEmail)
601 }
602
603 // A malformed email is rejected (so does an over-long name).
604 if resp := adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam","admin_email":"not an email"}`); resp.StatusCode != http.StatusBadRequest {
605 t.Fatalf("bad email: want 400, got %d", resp.StatusCode)
606 }
607 if resp := adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam","admin_name":"`+strings.Repeat("x", 61)+`"}`); resp.StatusCode != http.StatusBadRequest {
608 t.Fatalf("long name: want 400, got %d", resp.StatusCode)
609 }
610}
611
612func mustGet(t *testing.T, url string) *http.Response {
613 t.Helper()
614 resp, err := http.Get(url)
615 if err != nil {
616 t.Fatalf("get %s: %v", url, err)
617 }
618 return resp
619}
620
621// adminReq issues a request with a raw body and the admin bearer token.
622func adminReq(t *testing.T, method, url, contentType string, body []byte) *http.Response {
623 t.Helper()
624 req, _ := http.NewRequest(method, url, bytes.NewReader(body))
625 req.Header.Set("Authorization", "Bearer admintok")
626 if contentType != "" {
627 req.Header.Set("Content-Type", contentType)
628 }
629 resp, err := http.DefaultClient.Do(req)
630 if err != nil {
631 t.Fatalf("request: %v", err)
632 }
633 return resp
634}
635
636// Alt text travels with the photo (plaintext branding, like the photo itself):
637// set via ?alt= on upload, served by /api/group, reset by a new photo or clear.
638func TestServerAvatarAltText(t *testing.T) {
639 ts, _ := newTestServer(t)
640 png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
641
642 groupAlt := func() (string, bool) {
643 resp, _ := http.Get(ts.URL + "/api/group")
644 var g map[string]any
645 json.NewDecoder(resp.Body).Decode(&g)
646 alt, present := g["avatar_alt"]
647 s, _ := alt.(string)
648 return s, present
649 }
650
651 if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar?alt="+url.QueryEscape("Two canaries on a branch"), "", png); resp.StatusCode != http.StatusOK {
652 t.Fatalf("set with alt: %d", resp.StatusCode)
653 }
654 if alt, _ := groupAlt(); alt != "Two canaries on a branch" {
655 t.Fatalf("alt round-trip: got %q", alt)
656 }
657 // A new photo without alt resets it (the text belongs to the image).
658 if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", png); resp.StatusCode != http.StatusOK {
659 t.Fatalf("re-set: %d", resp.StatusCode)
660 }
661 if alt, _ := groupAlt(); alt != "" {
662 t.Fatalf("alt should reset with a new photo, got %q", alt)
663 }
664 // Clearing the photo drops the field from /api/group entirely.
665 if resp := adminReq(t, "DELETE", ts.URL+"/api/admin/server-avatar", "", nil); resp.StatusCode != http.StatusOK {
666 t.Fatalf("clear: %d", resp.StatusCode)
667 }
668 if _, present := groupAlt(); present {
669 t.Fatal("avatar_alt should be absent with no photo")
670 }
671}
672
673// The server avatar is admin-set, public plaintext branding: set/clear are admin
674// only; read is unauthenticated and reports the stored bytes + type, and the
675// group endpoint flags its presence so clients know whether to fetch it.
676func TestServerAvatar(t *testing.T) {
677 ts, _ := newTestServer(t)
678
679 // A minimal payload that http.DetectContentType recognises as a PNG.
680 png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
681
682 groupAvatarFlag := func() bool {
683 resp, _ := http.Get(ts.URL + "/api/group")
684 var g struct{ Avatar bool }
685 json.NewDecoder(resp.Body).Decode(&g)
686 return g.Avatar
687 }
688
689 // Unset: 404 on read, and the group endpoint reports no avatar.
690 if resp, _ := http.Get(ts.URL + "/api/server-avatar"); resp.StatusCode != http.StatusNotFound {
691 t.Fatalf("unset read: want 404, got %d", resp.StatusCode)
692 }
693 if groupAvatarFlag() {
694 t.Fatal("group avatar flag true before any upload")
695 }
696
697 // Set requires admin.
698 if resp := adminReqNoAuth(t, "POST", ts.URL+"/api/admin/server-avatar", png); resp.StatusCode != http.StatusUnauthorized {
699 t.Fatalf("set without auth: want 401, got %d", resp.StatusCode)
700 }
701 // Non-image bytes are rejected (type sniffed, not trusted).
702 if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", []byte("just text, not an image")); resp.StatusCode != http.StatusUnsupportedMediaType {
703 t.Fatalf("non-image: want 415, got %d", resp.StatusCode)
704 }
705 // Oversize is rejected before storage.
706 if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", make([]byte, maxServerAvatarBytes+16)); resp.StatusCode != http.StatusRequestEntityTooLarge {
707 t.Fatalf("oversize: want 413, got %d", resp.StatusCode)
708 }
709
710 // A valid PNG stores.
711 if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", png); resp.StatusCode != http.StatusOK {
712 t.Fatalf("set avatar: %d", resp.StatusCode)
713 }
714
715 // Read serves the exact bytes, the sniffed type, and an ETag; group flags it.
716 resp, _ := http.Get(ts.URL + "/api/server-avatar")
717 if resp.StatusCode != http.StatusOK {
718 t.Fatalf("read: %d", resp.StatusCode)
719 }
720 if ct := resp.Header.Get("Content-Type"); ct != "image/png" {
721 t.Fatalf("content-type = %q", ct)
722 }
723 got, _ := io.ReadAll(resp.Body)
724 if !bytes.Equal(got, png) {
725 t.Fatal("served bytes differ from stored")
726 }
727 etag := resp.Header.Get("ETag")
728 if etag == "" {
729 t.Fatal("missing ETag")
730 }
731 if !groupAvatarFlag() {
732 t.Fatal("group avatar flag false after upload")
733 }
734
735 // A matching If-None-Match revalidates to 304.
736 req, _ := http.NewRequest("GET", ts.URL+"/api/server-avatar", nil)
737 req.Header.Set("If-None-Match", etag)
738 resp, _ = http.DefaultClient.Do(req)
739 if resp.StatusCode != http.StatusNotModified {
740 t.Fatalf("If-None-Match: want 304, got %d", resp.StatusCode)
741 }
742
743 // Clear requires admin, then read 404s again and the flag clears.
744 if resp := adminReqNoAuth(t, "DELETE", ts.URL+"/api/admin/server-avatar", nil); resp.StatusCode != http.StatusUnauthorized {
745 t.Fatalf("clear without auth: want 401, got %d", resp.StatusCode)
746 }
747 if resp := adminReq(t, "DELETE", ts.URL+"/api/admin/server-avatar", "", nil); resp.StatusCode != http.StatusOK {
748 t.Fatalf("clear: %d", resp.StatusCode)
749 }
750 if resp, _ := http.Get(ts.URL + "/api/server-avatar"); resp.StatusCode != http.StatusNotFound {
751 t.Fatalf("read after clear: want 404, got %d", resp.StatusCode)
752 }
753 if groupAvatarFlag() {
754 t.Fatal("group avatar flag true after clear")
755 }
756}
757
758// adminReqNoAuth issues a raw-body request WITHOUT the admin token (to prove the gate).
759func adminReqNoAuth(t *testing.T, method, url string, body []byte) *http.Response {
760 t.Helper()
761 req, _ := http.NewRequest(method, url, bytes.NewReader(body))
762 resp, err := http.DefaultClient.Do(req)
763 if err != nil {
764 t.Fatalf("request: %v", err)
765 }
766 return resp
767}
768
769// Starting a DM you already have must reopen the same thread, not spawn a
770// second. The client relies on the server returning the existing thread with
771// existing:true on a repeat POST (see createThread / existingDM in chat.js).
772func TestPostThreadDedupesDM(t *testing.T) {
773 ts, srv := newTestServer(t)
774
775 mkUser := func(label string) (tok, uid string) {
776 u, err := srv.db.createUser(label)
777 if err != nil {
778 t.Fatalf("createUser %s: %v", label, err)
779 }
780 if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
781 t.Fatalf("createKeyInvite %s: %v", label, err)
782 }
783 tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
784 if err != nil || !ok {
785 t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
786 }
787 if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil {
788 t.Fatalf("setUserPublicKey %s: %v", label, err)
789 }
790 return tk, u
791 }
792 tokA, uidA := mkUser("Alice")
793 _, uidB := mkUser("Bob")
794
795 body := `{"token":"` + tokA + `","kind":"dm","members":[` +
796 `{"user_id":"` + uidA + `","wrapped":"wa"},` +
797 `{"user_id":"` + uidB + `","wrapped":"wb"}]}`
798
799 post := func() (string, bool) {
800 resp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(body))
801 if err != nil {
802 t.Fatalf("post: %v", err)
803 }
804 defer resp.Body.Close()
805 if resp.StatusCode != http.StatusOK {
806 t.Fatalf("status %d", resp.StatusCode)
807 }
808 var out struct {
809 ThreadID string `json:"thread_id"`
810 Existing bool `json:"existing"`
811 }
812 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
813 t.Fatalf("decode: %v", err)
814 }
815 return out.ThreadID, out.Existing
816 }
817
818 id1, existing1 := post()
819 if id1 == "" || existing1 {
820 t.Fatalf("first create: id=%q existing=%v (want a fresh id, existing=false)", id1, existing1)
821 }
822 id2, existing2 := post()
823 if id2 != id1 || !existing2 {
824 t.Fatalf("repeat create should dedupe: id=%q existing=%v (want %q, true)", id2, existing2, id1)
825 }
826 if got, ok, _ := srv.db.dmThreadFor(uidA, uidB); !ok || got != id1 {
827 t.Fatalf("dmThreadFor: got %q ok=%v want %q", got, ok, id1)
828 }
829}
830
831// Closing a chat hides it for that user (synced via getThreads); new activity
832// reopens it; and a group member can be removed (but not from a DM).
833func TestThreadHiddenAndRemove(t *testing.T) {
834 ts, srv := newTestServer(t)
835
836 mkUser := func(label string) (tok, uid string) {
837 u, err := srv.db.createUser(label)
838 if err != nil {
839 t.Fatalf("createUser %s: %v", label, err)
840 }
841 if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
842 t.Fatalf("createKeyInvite %s: %v", label, err)
843 }
844 tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
845 if err != nil || !ok {
846 t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
847 }
848 if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil {
849 t.Fatalf("setUserPublicKey %s: %v", label, err)
850 }
851 return tk, u
852 }
853 tokA, uidA := mkUser("Alice")
854 _, uidB := mkUser("Bob")
855 gid, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}})
856 if err != nil {
857 t.Fatalf("createThread: %v", err)
858 }
859
860 post := func(path, body string) int {
861 resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
862 if err != nil {
863 t.Fatalf("post %s: %v", path, err)
864 }
865 resp.Body.Close()
866 return resp.StatusCode
867 }
868 hiddenForAlice := func() bool {
869 resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA)
870 if err != nil {
871 t.Fatalf("getThreads: %v", err)
872 }
873 defer resp.Body.Close()
874 var out struct {
875 Threads []Thread `json:"threads"`
876 }
877 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
878 t.Fatalf("decode: %v", err)
879 }
880 for _, th := range out.Threads {
881 if th.ID == gid {
882 return th.Hidden
883 }
884 }
885 t.Fatalf("group %q missing from Alice's threads", gid)
886 return false
887 }
888
889 if code := post("/api/threads/"+gid+"/hidden", `{"token":"`+tokA+`","hidden":true}`); code != 200 {
890 t.Fatalf("hide: got %d", code)
891 }
892 if !hiddenForAlice() {
893 t.Fatal("chat should be hidden for Alice after closing")
894 }
895 // New activity reopens it.
896 if _, err := srv.db.insertMessage(gid, "blob", "author"); err != nil {
897 t.Fatalf("insertMessage: %v", err)
898 }
899 if hiddenForAlice() {
900 t.Fatal("new activity should reopen the closed chat")
901 }
902
903 // Remove Bob from the group.
904 if code := post("/api/threads/"+gid+"/remove", `{"token":"`+tokA+`","user_id":"`+uidB+`"}`); code != 200 {
905 t.Fatalf("remove: got %d", code)
906 }
907 if srv.db.threadMember(gid, uidB) {
908 t.Fatal("Bob should no longer be a member")
909 }
910 if !srv.db.threadMember(gid, uidA) {
911 t.Fatal("Alice should still be a member")
912 }
913
914 // Removal is groups-only — a DM rejects it.
915 dm, err := srv.db.createThread("dm", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}})
916 if err != nil {
917 t.Fatalf("createThread dm: %v", err)
918 }
919 if code := post("/api/threads/"+dm+"/remove", `{"token":"`+tokA+`","user_id":"`+uidB+`"}`); code != 400 {
920 t.Fatalf("remove from DM should be 400, got %d", code)
921 }
922}
923
924// Pinning a chat floats it to the top of that user's list (synced via
925// getThreads) and survives new activity elsewhere; unpinning restores
926// recency order. Per-user: one member's pin never affects another's list.
927func TestThreadPinned(t *testing.T) {
928 ts, srv := newTestServer(t)
929
930 tokA, uidA, tid1 := joinedMember(t, srv, "Alice")
931 // A second thread with newer activity, so tid1 only sorts first when pinned.
932 tid2, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}})
933 if err != nil {
934 t.Fatalf("createThread: %v", err)
935 }
936 if _, err := srv.db.insertMessage(tid2, "blob", "author"); err != nil {
937 t.Fatalf("insertMessage: %v", err)
938 }
939
940 post := func(path, body string) int {
941 resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
942 if err != nil {
943 t.Fatalf("post %s: %v", path, err)
944 }
945 resp.Body.Close()
946 return resp.StatusCode
947 }
948 threadsForAlice := func() []Thread {
949 resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA)
950 if err != nil {
951 t.Fatalf("getThreads: %v", err)
952 }
953 defer resp.Body.Close()
954 var out struct {
955 Threads []Thread `json:"threads"`
956 }
957 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
958 t.Fatalf("decode: %v", err)
959 }
960 return out.Threads
961 }
962
963 if got := threadsForAlice(); got[0].ID != tid2 {
964 t.Fatalf("before pinning, newest-activity thread should sort first, got %q", got[0].ID)
965 }
966
967 if code := post("/api/threads/"+tid1+"/pinned", `{"token":"`+tokA+`","pinned":true}`); code != 200 {
968 t.Fatalf("pin: got %d", code)
969 }
970 got := threadsForAlice()
971 if got[0].ID != tid1 || !got[0].Pinned {
972 t.Fatalf("pinned thread should sort first with pinned=true, got id=%q pinned=%v", got[0].ID, got[0].Pinned)
973 }
974 // New activity in the other thread must not displace the pin.
975 if _, err := srv.db.insertMessage(tid2, "blob2", "author"); err != nil {
976 t.Fatalf("insertMessage: %v", err)
977 }
978 if got := threadsForAlice(); got[0].ID != tid1 {
979 t.Fatal("pinned thread should stay first over newer activity elsewhere")
980 }
981
982 if code := post("/api/threads/"+tid1+"/pinned", `{"token":"`+tokA+`","pinned":false}`); code != 200 {
983 t.Fatalf("unpin: got %d", code)
984 }
985 got = threadsForAlice()
986 if got[0].ID != tid2 || got[0].Pinned {
987 t.Fatalf("after unpinning, recency order should return, got id=%q pinned=%v", got[0].ID, got[0].Pinned)
988 }
989
990 // Non-members can't pin.
991 tokB, _, _ := joinedMember(t, srv, "Bob")
992 if code := post("/api/threads/"+tid1+"/pinned", `{"token":"`+tokB+`","pinned":true}`); code != 403 {
993 t.Fatalf("non-member pin should be 403, got %d", code)
994 }
995}
996
997// The read watermark is per-user and forward-only: POSTing it advances
998// last_read_at in getThreads, a stale (older) write never regresses it, and
999// the "read" WS frame fans out to the reader's OTHER devices but never to
1000// other members.
1001func TestThreadRead(t *testing.T) {
1002 ts, srv := newTestServer(t)
1003
1004 tokA, uidA, tid := joinedMember(t, srv, "Alice")
1005 m, err := srv.db.insertMessage(tid, "blob", "author")
1006 if err != nil {
1007 t.Fatalf("insertMessage: %v", err)
1008 }
1009
1010 post := func(path, body string) int {
1011 resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
1012 if err != nil {
1013 t.Fatalf("post %s: %v", path, err)
1014 }
1015 resp.Body.Close()
1016 return resp.StatusCode
1017 }
1018 lastReadForAlice := func() int64 {
1019 resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA)
1020 if err != nil {
1021 t.Fatalf("getThreads: %v", err)
1022 }
1023 defer resp.Body.Close()
1024 var out struct {
1025 Threads []Thread `json:"threads"`
1026 }
1027 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
1028 t.Fatalf("decode: %v", err)
1029 }
1030 for _, th := range out.Threads {
1031 if th.ID == tid {
1032 return th.LastReadAt
1033 }
1034 }
1035 t.Fatalf("thread %s missing from list", tid)
1036 return 0
1037 }
1038
1039 if got := lastReadForAlice(); got != 0 {
1040 t.Fatalf("before any read, last_read_at should be 0, got %d", got)
1041 }
1042
1043 // A second device for Alice (same user, own token) and a second member, both
1044 // on WebSocket — the read fan-out must reach the former and never the latter.
1045 if err := srv.db.createKeyInvite("inv-Alice-2", uidA, "wrap", testClaimHash, 0); err != nil {
1046 t.Fatalf("createKeyInvite: %v", err)
1047 }
1048 tokA2, ok, err := srv.db.claimKeyInvite("inv-Alice-2", "Alice", "", testClaimSecret)
1049 if err != nil || !ok {
1050 t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err)
1051 }
1052 tokB, uidB, _ := joinedMember(t, srv, "Bob")
1053 if err := srv.db.addThreadMember(tid, uidB, "wrap-b"); err != nil {
1054 t.Fatalf("addThreadMember: %v", err)
1055 }
1056
1057 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1058 defer cancel()
1059 dial := func(tok string) *websocket.Conn {
1060 conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(ts.URL, "http")+"/ws?token="+tok, nil)
1061 if err != nil {
1062 t.Fatalf("ws dial: %v", err)
1063 }
1064 return conn
1065 }
1066 readUntil := func(conn *websocket.Conn, stage string, want func(map[string]any) bool) map[string]any {
1067 for {
1068 _, data, err := conn.Read(ctx)
1069 if err != nil {
1070 t.Fatalf("%s read: %v", stage, err)
1071 }
1072 var msg map[string]any
1073 if json.Unmarshal(data, &msg) != nil {
1074 continue
1075 }
1076 if want(msg) {
1077 return msg
1078 }
1079 }
1080 }
1081 connA2 := dial(tokA2)
1082 defer connA2.CloseNow()
1083 connB := dial(tokB)
1084 defer connB.CloseNow()
1085 // Prove both registrations landed before the POST below (Dial returns before
1086 // the server's register does): a stored-message round-trip through each.
1087 if err := connA2.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup-a2"}`)); err != nil {
1088 t.Fatalf("warmup write: %v", err)
1089 }
1090 readUntil(connA2, "warmup-a2", func(m map[string]any) bool { return m["blob"] == "warmup-a2" })
1091 if err := connB.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup-b"}`)); err != nil {
1092 t.Fatalf("warmup write: %v", err)
1093 }
1094 readUntil(connB, "warmup-b", func(m map[string]any) bool { return m["blob"] == "warmup-b" })
1095 readUntil(connA2, "warmup-b on A2", func(m map[string]any) bool { return m["blob"] == "warmup-b" })
1096
1097 at := m.CreatedAt + 10 // past the warmup messages' time
1098 if code := post("/api/threads/"+tid+"/read", `{"token":"`+tokA+`","last_read_at":`+strconv.FormatInt(at, 10)+`}`); code != 200 {
1099 t.Fatalf("read: got %d", code)
1100 }
1101 if got := lastReadForAlice(); got != at {
1102 t.Fatalf("last_read_at should be %d, got %d", at, got)
1103 }
1104
1105 // Alice's other device hears about it…
1106 frame := readUntil(connA2, "read frame", func(m map[string]any) bool { return m["type"] == "read" })
1107 if frame["thread_id"] != tid || int64(frame["last_read_at"].(float64)) != at {
1108 t.Fatalf("read frame: got %v", frame)
1109 }
1110 // …Bob never does: the next thing on his socket is a later message, with no
1111 // read frame before it (the hub is FIFO, so the read fan-out would have come
1112 // first if it had been addressed to him).
1113 if err := connA2.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"after"}`)); err != nil {
1114 t.Fatalf("after write: %v", err)
1115 }
1116 _, data, err := connB.Read(ctx)
1117 if err != nil {
1118 t.Fatalf("bob read: %v", err)
1119 }
1120 var bobMsg map[string]any
1121 json.Unmarshal(data, &bobMsg)
1122 if bobMsg["type"] != "msg" || bobMsg["blob"] != "after" {
1123 t.Fatalf("bob should only see the message, got %s", data)
1124 }
1125
1126 // Forward-only: an older mark from a lagging device changes nothing.
1127 if code := post("/api/threads/"+tid+"/read", `{"token":"`+tokA+`","last_read_at":`+strconv.FormatInt(at-5, 10)+`}`); code != 200 {
1128 t.Fatalf("stale read: got %d", code)
1129 }
1130 if got := lastReadForAlice(); got != at {
1131 t.Fatalf("stale write must not regress the mark: want %d, got %d", at, got)
1132 }
1133
1134 // Bob has his own mark; Alice's is untouched by it.
1135 if code := post("/api/threads/"+tid+"/read", `{"token":"`+tokB+`","last_read_at":`+strconv.FormatInt(at+5, 10)+`}`); code != 200 {
1136 t.Fatalf("bob read: got %d", code)
1137 }
1138 if got := lastReadForAlice(); got != at {
1139 t.Fatalf("another member's read must not move Alice's mark: want %d, got %d", at, got)
1140 }
1141
1142 // Guardrails: a missing/zero timestamp is a 400; non-members get 403.
1143 if code := post("/api/threads/"+tid+"/read", `{"token":"`+tokA+`"}`); code != 400 {
1144 t.Fatalf("zero timestamp should be 400, got %d", code)
1145 }
1146 tokC, _, _ := joinedMember(t, srv, "Carol")
1147 if code := post("/api/threads/"+tid+"/read", `{"token":"`+tokC+`","last_read_at":1}`); code != 403 {
1148 t.Fatalf("non-member read should be 403, got %d", code)
1149 }
1150}
1151
1152// joinedMember sets up a real "joined" member — a user with a device token and a
1153// registered public key — plus a thread they belong to, for exercising the
1154// thread-scoped APIs. Returns the device token, the user id, and the thread id.
1155func joinedMember(t *testing.T, srv *Server, label string) (token, userID, threadID string) {
1156 t.Helper()
1157 uid, err := srv.db.createUser(label)
1158 if err != nil {
1159 t.Fatalf("createUser: %v", err)
1160 }
1161 if err := srv.db.createKeyInvite("inv-"+label, uid, "wrap", testClaimHash, 0); err != nil {
1162 t.Fatalf("createKeyInvite: %v", err)
1163 }
1164 tok, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
1165 if err != nil || !ok {
1166 t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err)
1167 }
1168 if err := srv.db.setUserPublicKey(uid, "pub-"+label, ""); err != nil {
1169 t.Fatalf("setUserPublicKey: %v", err)
1170 }
1171 tid, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uid, Wrapped: "wrap"}})
1172 if err != nil {
1173 t.Fatalf("createThread: %v", err)
1174 }
1175 return tok, uid, tid
1176}
1177
1178// APNs registration is token-gated and idempotent; unsubscribe removes the row.
1179func TestAPNsSubscribe(t *testing.T) {
1180 ts, s := newTestServer(t)
1181
1182 // Mint a member the same way TestKeyInviteFlow does: admin key-invite + claim.
1183 adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Pat","id":"apns-invite","wrapped":"","claim_hash":"`+testClaimHash+`"}`)
1184 r, _ := http.Post(ts.URL+"/api/key-invite/apns-invite/claim", "application/json",
1185 strings.NewReader(`{"label":"Pat","claim_secret":"test-claim-secret-vector"}`))
1186 var claim struct {
1187 Ok bool `json:"ok"`
1188 Token string `json:"token"`
1189 }
1190 json.NewDecoder(r.Body).Decode(&claim)
1191 if !claim.Ok || claim.Token == "" {
1192 t.Fatalf("claim: %+v", claim)
1193 }
1194 post := func(path, body string) *http.Response {
1195 resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
1196 if err != nil {
1197 t.Fatal(err)
1198 }
1199 return resp
1200 }
1201
1202 // Bad token refused.
1203 if resp := post("/api/apns/subscribe", `{"token":"nope","apns_token":"abc"}`); resp.StatusCode != http.StatusForbidden {
1204 t.Fatalf("bad token: want 403, got %d", resp.StatusCode)
1205 }
1206
1207 // Valid registration lands; enabled=false without LCHAT_APNS_* configured.
1208 resp := post("/api/apns/subscribe", `{"token":"`+claim.Token+`","apns_token":"abc","sandbox":true}`)
1209 var out struct {
1210 Ok bool `json:"ok"`
1211 Enabled bool `json:"enabled"`
1212 }
1213 json.NewDecoder(resp.Body).Decode(&out)
1214 if !out.Ok || out.Enabled {
1215 t.Fatalf("subscribe: %+v", out)
1216 }
1217 rows, err := s.db.listAPNsTokens()
1218 // The session token is stored hashed, never in clear (it's a live credential).
1219 if err != nil || len(rows) != 1 || rows[0].APNsToken != "abc" || !rows[0].Sandbox ||
1220 rows[0].Token != "" || rows[0].TokenHash != hashToken(claim.Token) {
1221 t.Fatalf("stored: %v %v", rows, err)
1222 }
1223
1224 // Re-registering the same APNs token updates in place, never duplicates.
1225 post("/api/apns/subscribe", `{"token":"`+claim.Token+`","apns_token":"abc"}`)
1226 if rows, _ = s.db.listAPNsTokens(); len(rows) != 1 || rows[0].Sandbox {
1227 t.Fatalf("upsert: %v", rows)
1228 }
1229
1230 if resp := post("/api/apns/unsubscribe", `{"token":"`+claim.Token+`","apns_token":"abc"}`); resp.StatusCode != http.StatusOK {
1231 t.Fatalf("unsubscribe: %d", resp.StatusCode)
1232 }
1233 if rows, _ = s.db.listAPNsTokens(); len(rows) != 0 {
1234 t.Fatalf("not removed: %v", rows)
1235 }
1236}
1237
1238// The AASA file must be public (Apple's CDN fetches it anonymously) and name the
1239// app over the /join/* pattern, so invite links open in the installed iOS app.
1240func TestAppleAppSiteAssociation(t *testing.T) {
1241 ts, _ := newTestServer(t)
1242 resp, err := http.Get(ts.URL + "/.well-known/apple-app-site-association")
1243 if err != nil {
1244 t.Fatalf("get aasa: %v", err)
1245 }
1246 defer resp.Body.Close()
1247 if resp.StatusCode != 200 {
1248 t.Fatalf("aasa status = %d, want 200", resp.StatusCode)
1249 }
1250 if ct := resp.Header.Get("Content-Type"); ct != "application/json" {
1251 t.Fatalf("aasa content-type = %q", ct)
1252 }
1253 b, _ := io.ReadAll(resp.Body)
1254 var doc struct {
1255 Applinks struct {
1256 Details []struct {
1257 AppIDs []string `json:"appIDs"`
1258 Components []map[string]string `json:"components"`
1259 } `json:"details"`
1260 } `json:"applinks"`
1261 }
1262 if err := json.Unmarshal(b, &doc); err != nil {
1263 t.Fatalf("aasa not valid JSON: %v\n%s", err, b)
1264 }
1265 if len(doc.Applinks.Details) != 1 || len(doc.Applinks.Details[0].AppIDs) != 2 {
1266 t.Fatalf("aasa shape wrong: %s", b)
1267 }
1268 // iOS first, then the Mac app (universal links' server half — #1376).
1269 if got := doc.Applinks.Details[0].AppIDs[0]; got != appleAppID {
1270 t.Fatalf("aasa appID = %q, want %q", got, appleAppID)
1271 }
1272 if got := doc.Applinks.Details[0].AppIDs[1]; got != appleMacAppID {
1273 t.Fatalf("aasa mac appID = %q, want %q", got, appleMacAppID)
1274 }
1275 if got := doc.Applinks.Details[0].Components[0]["/"]; got != "/join/*" {
1276 t.Fatalf("aasa join pattern = %q", got)
1277 }
1278}
1279
1280// The welcome page's "Get the app" URL is injected per instance; unset config
1281// must substitute to empty (the page hides the button) — and the placeholder
1282// must never leak to the client.
1283func TestJoinPageAppStoreURL(t *testing.T) {
1284 ts, s := newTestServer(t)
1285 get := func() string {
1286 resp, err := http.Get(ts.URL + "/join/join")
1287 if err != nil {
1288 t.Fatalf("get join: %v", err)
1289 }
1290 defer resp.Body.Close()
1291 b, _ := io.ReadAll(resp.Body)
1292 return string(b)
1293 }
1294 if body := get(); strings.Contains(body, "__APP_STORE_URL__") {
1295 t.Fatal("join page leaks the __APP_STORE_URL__ placeholder")
1296 }
1297 s.appStoreURL = "https://testflight.apple.com/join/xyz"
1298 if body := get(); !strings.Contains(body, `data-store-url="https://testflight.apple.com/join/xyz"`) {
1299 t.Fatal("join page missing configured app store URL")
1300 }
1301}
1302
1303// The join page carries an OpenGraph card so invite links preview nicely. The
1304// title is the (plaintext) group name — never the inviter's screen name, which is
1305// E2E — and the image is the server avatar when set, else the app logo.
1306func TestJoinPageOpenGraph(t *testing.T) {
1307 ts, s := newTestServer(t)
1308 if err := s.db.setGroupName("Paul's Eleven Server"); err != nil {
1309 t.Fatalf("setGroupName: %v", err)
1310 }
1311 get := func() string {
1312 resp, err := http.Get(ts.URL + "/join/join")
1313 if err != nil {
1314 t.Fatalf("get join: %v", err)
1315 }
1316 defer resp.Body.Close()
1317 b, _ := io.ReadAll(resp.Body)
1318 return string(b)
1319 }
1320
1321 body := get()
1322 if strings.Contains(body, "__OG_IMAGE__") || strings.Contains(body, "__OG_URL__") {
1323 t.Fatal("join page leaks an __OG_ placeholder")
1324 }
1325 if !strings.Contains(body, `<meta property="og:title" content="You're invited to Paul&#39;s Eleven Server" />`) {
1326 t.Fatalf("join page missing og:title with the group name:\n%s", body)
1327 }
1328 // No avatar set → the app logo is the preview image.
1329 if !strings.Contains(body, `<meta property="og:image" content="`+ts.URL+`/static/icon-512.png" />`) {
1330 t.Fatalf("join page og:image should fall back to the app logo:\n%s", body)
1331 }
1332
1333 // With a branding avatar set, the preview image is the server avatar.
1334 if err := s.db.setInstanceAvatar([]byte("\x89PNG\r\n\x1a\n"), "image/png", 1); err != nil {
1335 t.Fatalf("setInstanceAvatar: %v", err)
1336 }
1337 if body := get(); !strings.Contains(body, `<meta property="og:image" content="`+ts.URL+`/api/server-avatar" />`) {
1338 t.Fatalf("join page og:image should be the server avatar when set:\n%s", body)
1339 }
1340}
1341
1342// The chat page exposes the passkey-backup home origin to JS (window.__homeOrigin).
1343// The default must be substituted and the placeholder must never leak to the client.
1344func TestChatPageHomeOrigin(t *testing.T) {
1345 ts, _ := newTestServer(t)
1346 resp, err := http.Get(ts.URL + "/")
1347 if err != nil {
1348 t.Fatalf("get chat page: %v", err)
1349 }
1350 defer resp.Body.Close()
1351 b, _ := io.ReadAll(resp.Body)
1352 body := string(b)
1353 if strings.Contains(body, "__HOME_ORIGIN__") {
1354 t.Fatal("chat page leaks the __HOME_ORIGIN__ placeholder")
1355 }
1356 if !strings.Contains(body, `window.__homeOrigin = "`+defaultHomeOrigin+`"`) {
1357 t.Fatal("chat page missing the default home origin")
1358 }
1359}
1360
1361// ---- custom apps (customapps.go) -----------------------------------------
1362
1363func TestCustomAppsManifestEmptyWhenUnset(t *testing.T) {
1364 ts, _ := newTestServer(t)
1365
1366 resp, err := http.Get(ts.URL + "/api/apps")
1367 if err != nil {
1368 t.Fatalf("get manifest: %v", err)
1369 }
1370 if resp.StatusCode != http.StatusOK {
1371 t.Fatalf("manifest: want 200, got %d", resp.StatusCode)
1372 }
1373 var body struct {
1374 Apps []map[string]string `json:"apps"`
1375 }
1376 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
1377 t.Fatalf("decode: %v", err)
1378 }
1379 if body.Apps == nil || len(body.Apps) != 0 {
1380 t.Fatalf("want empty apps list, got %v", body.Apps)
1381 }
1382 // The module route 404s too — the feature is fully off without the env var.
1383 resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js")
1384 if resp.StatusCode != http.StatusNotFound {
1385 t.Fatalf("module with no dir: want 404, got %d", resp.StatusCode)
1386 }
1387}
1388
1389func TestCustomAppsSideload(t *testing.T) {
1390 ts, s := newTestServer(t)
1391 src := `import { defineApp } from "../apps.js";` + "\n" + `defineApp({ name: "kanban" });` + "\n"
1392 dir := t.TempDir()
1393 if err := os.WriteFile(dir+"/kanban.js", []byte(src), 0o644); err != nil {
1394 t.Fatalf("write app: %v", err)
1395 }
1396 // Non-matching names never appear in the manifest, valid or not on disk.
1397 os.WriteFile(dir+"/README.md", []byte("not an app"), 0o644)
1398 os.WriteFile(dir+"/Bad.js", []byte("uppercase"), 0o644)
1399 s.appsDir = dir
1400
1401 resp, _ := http.Get(ts.URL + "/api/apps")
1402 var body struct {
1403 Apps []map[string]string `json:"apps"`
1404 }
1405 if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
1406 t.Fatalf("decode: %v", err)
1407 }
1408 if len(body.Apps) != 1 || body.Apps[0]["name"] != "kanban" || body.Apps[0]["url"] != "/static/apps-custom/kanban.js" {
1409 t.Fatalf("manifest: got %v", body.Apps)
1410 }
1411
1412 resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js")
1413 if resp.StatusCode != http.StatusOK {
1414 t.Fatalf("module: want 200, got %d", resp.StatusCode)
1415 }
1416 if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
1417 t.Fatalf("module content type: got %q", ct)
1418 }
1419 if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
1420 t.Fatalf("module cache-control: got %q", cc)
1421 }
1422 got, _ := io.ReadAll(resp.Body)
1423 if string(got) != src {
1424 t.Fatalf("module body: got %q", got)
1425 }
1426}
1427
1428func TestCustomAppsRejectsInvalidNames(t *testing.T) {
1429 ts, s := newTestServer(t)
1430 dir := t.TempDir()
1431 os.WriteFile(dir+"/kanban.js", []byte("ok"), 0o644)
1432 // A secret next door that a traversal would love to read.
1433 os.WriteFile(dir+"/../secret.js", []byte("nope"), 0o644)
1434 s.appsDir = dir
1435
1436 for _, path := range []string{
1437 "/static/apps-custom/Kanban.js", // uppercase
1438 "/static/apps-custom/kanban.txt", // not .js
1439 "/static/apps-custom/kanban", // no extension
1440 "/static/apps-custom/no-such.js", // valid name, missing file
1441 "/static/apps-custom/..%2Fsecret.js", // encoded traversal
1442 "/static/apps-custom/%2e%2e%2fsecret.js", // fully encoded traversal
1443 "/static/apps-custom/a.b.js", // extra dot
1444 } {
1445 resp, err := http.Get(ts.URL + path)
1446 if err != nil {
1447 t.Fatalf("get %s: %v", path, err)
1448 }
1449 if resp.StatusCode != http.StatusNotFound {
1450 t.Fatalf("%s: want 404, got %d", path, resp.StatusCode)
1451 }
1452 }
1453}
1454
1455// The mini-app authoring guide is embedded in the binary and served as markdown
1456// at /api/app-guide — deliberately un-gated (static docs, not instance data),
1457// so an external LLM can be pointed straight at the URL.
1458func TestAppGuide(t *testing.T) {
1459 ts, _ := newTestServer(t)
1460 resp, err := http.Get(ts.URL + "/api/app-guide")
1461 if err != nil {
1462 t.Fatalf("get app guide: %v", err)
1463 }
1464 defer resp.Body.Close()
1465 if resp.StatusCode != http.StatusOK {
1466 t.Fatalf("want 200, got %d", resp.StatusCode)
1467 }
1468 if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
1469 t.Fatalf("want text/markdown content type, got %q", ct)
1470 }
1471 b, _ := io.ReadAll(resp.Body)
1472 body := string(b)
1473 if !strings.Contains(body, "defineApp") {
1474 t.Fatal("guide body missing defineApp — wrong document served?")
1475 }
1476 if !strings.Contains(body, "🤖") {
1477 t.Fatal("guide missing its 🤖 authorship marker")
1478 }
1479}
1480
1481// The static app checker the guide points at is served un-gated too, so an
1482// author following /api/app-guide can run it with no repo checkout.
1483func TestAppChecker(t *testing.T) {
1484 ts, _ := newTestServer(t)
1485 resp, err := http.Get(ts.URL + "/api/check-app.mjs")
1486 if err != nil {
1487 t.Fatalf("get app checker: %v", err)
1488 }
1489 defer resp.Body.Close()
1490 if resp.StatusCode != http.StatusOK {
1491 t.Fatalf("want 200, got %d", resp.StatusCode)
1492 }
1493 if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
1494 t.Fatalf("want text/javascript content type, got %q", ct)
1495 }
1496 b, _ := io.ReadAll(resp.Body)
1497 if !strings.Contains(string(b), "defineApp") {
1498 t.Fatal("checker body missing defineApp — wrong file served?")
1499 }
1500}
1501
1502// adminJSON issues a method+JSON-body request with the admin bearer token
1503// (adminReq above takes raw bytes; this wraps it for the string-literal cases).
1504func adminJSON(t *testing.T, method, url, body string) *http.Response {
1505 t.Helper()
1506 ct := ""
1507 if body != "" {
1508 ct = "application/json"
1509 }
1510 return adminReq(t, method, url, ct, []byte(body))
1511}
1512
1513func TestCustomAppsAdminAuthGating(t *testing.T) {
1514 ts, _ := newTestServer(t)
1515
1516 for _, c := range []struct{ method, path string }{
1517 {"GET", "/api/admin/apps"},
1518 {"PUT", "/api/admin/apps/kanban"},
1519 {"DELETE", "/api/admin/apps/kanban"},
1520 } {
1521 // No token at all.
1522 req, _ := http.NewRequest(c.method, ts.URL+c.path, strings.NewReader(`{"source":"x"}`))
1523 resp, _ := http.DefaultClient.Do(req)
1524 if resp.StatusCode != http.StatusUnauthorized {
1525 t.Fatalf("%s %s no auth: want 401, got %d", c.method, c.path, resp.StatusCode)
1526 }
1527 // Wrong token.
1528 req, _ = http.NewRequest(c.method, ts.URL+c.path, strings.NewReader(`{"source":"x"}`))
1529 req.Header.Set("Authorization", "Bearer wrong")
1530 resp, _ = http.DefaultClient.Do(req)
1531 if resp.StatusCode != http.StatusUnauthorized {
1532 t.Fatalf("%s %s bad auth: want 401, got %d", c.method, c.path, resp.StatusCode)
1533 }
1534 }
1535}
1536
1537func TestCustomAppsAdminRoundTrip(t *testing.T) {
1538 ts, _ := newTestServer(t)
1539 src := `import { defineApp } from "../apps.js"; defineApp({ name: "kanban" });`
1540 b, _ := json.Marshal(map[string]string{"source": src})
1541
1542 // Install.
1543 resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", string(b))
1544 if resp.StatusCode != http.StatusOK {
1545 t.Fatalf("install: want 200, got %d", resp.StatusCode)
1546 }
1547
1548 // The public manifest lists it.
1549 resp, _ = http.Get(ts.URL + "/api/apps")
1550 var manifest struct {
1551 Apps []map[string]string `json:"apps"`
1552 }
1553 json.NewDecoder(resp.Body).Decode(&manifest)
1554 if len(manifest.Apps) != 1 || manifest.Apps[0]["url"] != "/static/apps-custom/kanban.js" {
1555 t.Fatalf("manifest after install: got %v", manifest.Apps)
1556 }
1557
1558 // The module serves the stored source.
1559 resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js")
1560 if resp.StatusCode != http.StatusOK {
1561 t.Fatalf("module: want 200, got %d", resp.StatusCode)
1562 }
1563 got, _ := io.ReadAll(resp.Body)
1564 if string(got) != src {
1565 t.Fatalf("module body: got %q", got)
1566 }
1567
1568 // Updating bumps the version; the admin list reports name/version/size.
1569 resp = adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", string(b))
1570 if resp.StatusCode != http.StatusOK {
1571 t.Fatalf("update: want 200, got %d", resp.StatusCode)
1572 }
1573 resp = adminJSON(t, "GET", ts.URL+"/api/admin/apps", "")
1574 var listed struct {
1575 Apps []CustomApp `json:"apps"`
1576 }
1577 json.NewDecoder(resp.Body).Decode(&listed)
1578 if len(listed.Apps) != 1 || listed.Apps[0].Name != "kanban" || listed.Apps[0].Version != 2 || listed.Apps[0].Size != len(src) {
1579 t.Fatalf("admin list: got %+v", listed.Apps)
1580 }
1581
1582 // Delete → gone from the manifest and the module route.
1583 resp = adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "")
1584 if resp.StatusCode != http.StatusOK {
1585 t.Fatalf("delete: want 200, got %d", resp.StatusCode)
1586 }
1587 resp, _ = http.Get(ts.URL + "/api/apps")
1588 manifest.Apps = nil
1589 json.NewDecoder(resp.Body).Decode(&manifest)
1590 if len(manifest.Apps) != 0 {
1591 t.Fatalf("manifest after delete: got %v", manifest.Apps)
1592 }
1593 resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js")
1594 if resp.StatusCode != http.StatusNotFound {
1595 t.Fatalf("module after delete: want 404, got %d", resp.StatusCode)
1596 }
1597 // Deleting again 404s.
1598 resp = adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "")
1599 if resp.StatusCode != http.StatusNotFound {
1600 t.Fatalf("re-delete: want 404, got %d", resp.StatusCode)
1601 }
1602}
1603
1604func TestCustomAppsAdminRejectsInvalidNames(t *testing.T) {
1605 ts, _ := newTestServer(t)
1606
1607 for _, name := range []string{"Kanban", "kanban.js", "a_b", "a%20b", "-"} {
1608 resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/"+name, `{"source":"x"}`)
1609 // "-" is actually valid per the pattern; skip it (documents the boundary).
1610 if name == "-" {
1611 if resp.StatusCode != http.StatusOK {
1612 t.Fatalf("install %q: want 200, got %d", name, resp.StatusCode)
1613 }
1614 continue
1615 }
1616 if resp.StatusCode != http.StatusBadRequest {
1617 t.Fatalf("install %q: want 400, got %d", name, resp.StatusCode)
1618 }
1619 }
1620 // Empty source is rejected too.
1621 resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", `{"source":" "}`)
1622 if resp.StatusCode != http.StatusBadRequest {
1623 t.Fatalf("empty source: want 400, got %d", resp.StatusCode)
1624 }
1625}
1626
1627func TestCustomAppsDBWinsOverDir(t *testing.T) {
1628 ts, s := newTestServer(t)
1629 dir := t.TempDir()
1630 os.WriteFile(dir+"/kanban.js", []byte("// disk copy"), 0o644)
1631 os.WriteFile(dir+"/other.js", []byte("// disk only"), 0o644)
1632 s.appsDir = dir
1633
1634 // Install a DB copy under the same name: it shadows the disk file.
1635 resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", `{"source":"// db copy"}`)
1636 if resp.StatusCode != http.StatusOK {
1637 t.Fatalf("install: want 200, got %d", resp.StatusCode)
1638 }
1639 resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js")
1640 got, _ := io.ReadAll(resp.Body)
1641 if string(got) != "// db copy" {
1642 t.Fatalf("db should win over dir: got %q", got)
1643 }
1644
1645 // The manifest merges both sources, deduped.
1646 resp, _ = http.Get(ts.URL + "/api/apps")
1647 var manifest struct {
1648 Apps []map[string]string `json:"apps"`
1649 }
1650 json.NewDecoder(resp.Body).Decode(&manifest)
1651 if len(manifest.Apps) != 2 || manifest.Apps[0]["name"] != "kanban" || manifest.Apps[1]["name"] != "other" {
1652 t.Fatalf("merged manifest: got %v", manifest.Apps)
1653 }
1654
1655 // The admin list shows both: the DB copy manageable, the disk-only file
1656 // marked on_disk — and the shadowed disk kanban.js doesn't double-list.
1657 resp = adminJSON(t, "GET", ts.URL+"/api/admin/apps", "")
1658 var listed struct {
1659 Apps []CustomApp `json:"apps"`
1660 }
1661 json.NewDecoder(resp.Body).Decode(&listed)
1662 if len(listed.Apps) != 2 || listed.Apps[0].Name != "kanban" || listed.Apps[0].OnDisk ||
1663 listed.Apps[1].Name != "other" || !listed.Apps[1].OnDisk {
1664 t.Fatalf("admin list with dir: got %+v", listed.Apps)
1665 }
1666
1667 // Deleting the DB copy unshadows the disk file.
1668 adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "")
1669 resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js")
1670 got, _ = io.ReadAll(resp.Body)
1671 if string(got) != "// disk copy" {
1672 t.Fatalf("after delete the disk copy should serve: got %q", got)
1673 }
1674}
1675
1676// Installing or deleting an app broadcasts apps_changed to every connected
1677// client, so open tabs reload and pick the change up right away.
1678func TestCustomAppsBroadcastsAppsChanged(t *testing.T) {
1679 ts, srv := newTestServer(t)
1680 tok, _, tid := joinedMember(t, srv, "Alice")
1681
1682 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1683 defer cancel()
1684 wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + tok
1685 conn, _, err := websocket.Dial(ctx, wsURL, nil)
1686 if err != nil {
1687 t.Fatalf("ws dial: %v", err)
1688 }
1689 defer conn.CloseNow()
1690 // Registration races the admin call (the hub processes register/broadcast on
1691 // one loop, but Dial returns before the server's register lands). A stored
1692 // message round-trip proves our registration landed first.
1693 if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup"}`)); err != nil {
1694 t.Fatalf("warmup write: %v", err)
1695 }
1696 if _, _, err := conn.Read(ctx); err != nil {
1697 t.Fatalf("warmup read: %v", err)
1698 }
1699
1700 // Skip unrelated traffic (our own presence broadcast and the warmup echo,
1701 // whose order isn't fixed) — the assertion is that apps_changed arrives.
1702 expectAppsChanged := func(stage string) {
1703 for {
1704 var msg struct {
1705 Type string `json:"type"`
1706 }
1707 _, data, err := conn.Read(ctx)
1708 if err != nil {
1709 t.Fatalf("%s read: %v (no apps_changed)", stage, err)
1710 }
1711 if json.Unmarshal(data, &msg) == nil && msg.Type == "apps_changed" {
1712 return
1713 }
1714 }
1715 }
1716
1717 resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", `{"source":"x"}`)
1718 if resp.StatusCode != http.StatusOK {
1719 t.Fatalf("install: want 200, got %d", resp.StatusCode)
1720 }
1721 expectAppsChanged("install")
1722
1723 resp = adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "")
1724 if resp.StatusCode != http.StatusOK {
1725 t.Fatalf("delete: want 200, got %d", resp.StatusCode)
1726 }
1727 expectAppsChanged("delete")
1728}
1729
1730// Flipping "users can invite people" broadcasts the new value live, so a
1731// connected member's invite UI updates without a reload (issue #346).
1732func TestUserInvitesBroadcastsLive(t *testing.T) {
1733 ts, srv := newTestServer(t)
1734 tok, _, tid := joinedMember(t, srv, "Alice")
1735
1736 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1737 defer cancel()
1738 wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + tok
1739 conn, _, err := websocket.Dial(ctx, wsURL, nil)
1740 if err != nil {
1741 t.Fatalf("ws dial: %v", err)
1742 }
1743 defer conn.CloseNow()
1744 // See TestCustomAppsBroadcastsAppsChanged: a stored round-trip proves our
1745 // registration landed before the admin call below.
1746 if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup"}`)); err != nil {
1747 t.Fatalf("warmup write: %v", err)
1748 }
1749 if _, _, err := conn.Read(ctx); err != nil {
1750 t.Fatalf("warmup read: %v", err)
1751 }
1752
1753 expectInvitesChanged := func(stage string, want bool) {
1754 for {
1755 var msg struct {
1756 Type string `json:"type"`
1757 Enabled bool `json:"enabled"`
1758 }
1759 _, data, err := conn.Read(ctx)
1760 if err != nil {
1761 t.Fatalf("%s read: %v (no user_invites_changed)", stage, err)
1762 }
1763 if json.Unmarshal(data, &msg) == nil && msg.Type == "user_invites_changed" {
1764 if msg.Enabled != want {
1765 t.Fatalf("%s: enabled = %v, want %v", stage, msg.Enabled, want)
1766 }
1767 return
1768 }
1769 }
1770 }
1771
1772 if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":true}`); resp.StatusCode != http.StatusOK {
1773 t.Fatalf("enable: want 200, got %d", resp.StatusCode)
1774 }
1775 expectInvitesChanged("enable", true)
1776
1777 if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":false}`); resp.StatusCode != http.StatusOK {
1778 t.Fatalf("disable: want 200, got %d", resp.StatusCode)
1779 }
1780 expectInvitesChanged("disable", false)
1781
1782 // Re-posting the SAME value is a no-op — no audit entry, no broadcast (only
1783 // a genuine flip is a moderation-relevant event). Confirm no broadcast
1784 // arrives by racing a distinguishable follow-up message.
1785 adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":false}`)
1786 if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"canary"}`)); err != nil {
1787 t.Fatalf("canary write: %v", err)
1788 }
1789 for {
1790 var msg struct {
1791 Type string `json:"type"`
1792 Blob string `json:"blob"`
1793 }
1794 _, data, err := conn.Read(ctx)
1795 if err != nil {
1796 t.Fatalf("canary read: %v", err)
1797 }
1798 if json.Unmarshal(data, &msg) == nil && msg.Type == "user_invites_changed" {
1799 t.Fatal("re-posting the unchanged value should not broadcast")
1800 }
1801 if json.Unmarshal(data, &msg) == nil && msg.Type == "msg" && msg.Blob == "canary" {
1802 break // canary arrived first — no stray broadcast was queued ahead of it
1803 }
1804 }
1805}
1806
1807func TestNicknamesRoundTrip(t *testing.T) {
1808 ts, srv := newTestServer(t)
1809 tok, _, _ := joinedMember(t, srv, "viewer")
1810 other, _, _ := joinedMember(t, srv, "other")
1811
1812 get := func(token string) (*http.Response, string) {
1813 r, err := http.Get(ts.URL + "/api/nicknames?token=" + token)
1814 if err != nil {
1815 t.Fatalf("get: %v", err)
1816 }
1817 var body struct {
1818 Blob string `json:"nicknames_blob"`
1819 }
1820 json.NewDecoder(r.Body).Decode(&body)
1821 return r, body.Blob
1822 }
1823 post := func(token, blob string) *http.Response {
1824 r, err := http.Post(ts.URL+"/api/nicknames", "application/json",
1825 strings.NewReader(`{"token":"`+token+`","nicknames_blob":"`+blob+`"}`))
1826 if err != nil {
1827 t.Fatalf("post: %v", err)
1828 }
1829 return r
1830 }
1831
1832 // Token-gated on both verbs.
1833 if r, _ := get("bogus"); r.StatusCode != http.StatusForbidden {
1834 t.Fatalf("bad-token get: want 403, got %d", r.StatusCode)
1835 }
1836 if r := post("bogus", "sealed"); r.StatusCode != http.StatusForbidden {
1837 t.Fatalf("bad-token post: want 403, got %d", r.StatusCode)
1838 }
1839
1840 // Never set → empty blob, not an error.
1841 if r, blob := get(tok); r.StatusCode != http.StatusOK || blob != "" {
1842 t.Fatalf("unset: want 200/empty, got %d/%q", r.StatusCode, blob)
1843 }
1844
1845 // Round-trip; the blob is opaque ciphertext to the server.
1846 if r := post(tok, "sealed-overrides-v1"); r.StatusCode != http.StatusOK {
1847 t.Fatalf("post: want 200, got %d", r.StatusCode)
1848 }
1849 if _, blob := get(tok); blob != "sealed-overrides-v1" {
1850 t.Fatalf("round-trip: got %q", blob)
1851 }
1852
1853 // Viewer-private: another member reads their OWN (empty) blob, never yours.
1854 if _, blob := get(other); blob != "" {
1855 t.Fatalf("other member saw a foreign blob: %q", blob)
1856 }
1857
1858 // Oversized blob is rejected; "" clears.
1859 if r := post(tok, strings.Repeat("a", 64*1024+1)); r.StatusCode != http.StatusBadRequest {
1860 t.Fatalf("oversized: want 400, got %d", r.StatusCode)
1861 }
1862 if r := post(tok, ""); r.StatusCode != http.StatusOK {
1863 t.Fatalf("clear: want 200, got %d", r.StatusCode)
1864 }
1865 if _, blob := get(tok); blob != "" {
1866 t.Fatalf("clear: got %q", blob)
1867 }
1868}
1869
1870// TestNicknamesVersioning covers the compare-and-swap on the nicknames blob:
1871// a stale writer 409s (told the current version so it can re-read and retry),
1872// a fresh writer advances the version, and a version-less write (a
1873// pre-versioning client) stays unconditional.
1874func TestNicknamesVersioning(t *testing.T) {
1875 ts, srv := newTestServer(t)
1876 tok, _, _ := joinedMember(t, srv, "viewer")
1877
1878 get := func() (string, int64) {
1879 r, err := http.Get(ts.URL + "/api/nicknames?token=" + tok)
1880 if err != nil {
1881 t.Fatalf("get: %v", err)
1882 }
1883 var body struct {
1884 Blob string `json:"nicknames_blob"`
1885 Version int64 `json:"version"`
1886 }
1887 json.NewDecoder(r.Body).Decode(&body)
1888 return body.Blob, body.Version
1889 }
1890 post := func(payload string) (*http.Response, int64) {
1891 r, err := http.Post(ts.URL+"/api/nicknames", "application/json", strings.NewReader(payload))
1892 if err != nil {
1893 t.Fatalf("post: %v", err)
1894 }
1895 var body struct {
1896 Version int64 `json:"version"`
1897 }
1898 json.NewDecoder(r.Body).Decode(&body)
1899 return r, body.Version
1900 }
1901
1902 // Fresh row reads version 0; a CAS write from that base lands at 1.
1903 if _, v := get(); v != 0 {
1904 t.Fatalf("fresh version: want 0, got %d", v)
1905 }
1906 if r, v := post(`{"token":"` + tok + `","nicknames_blob":"one","version":0}`); r.StatusCode != http.StatusOK || v != 1 {
1907 t.Fatalf("first cas write: want 200/v1, got %d/v%d", r.StatusCode, v)
1908 }
1909
1910 // A second writer still holding base 0 (device B's stale read) conflicts —
1911 // and is told where the row is now.
1912 if r, v := post(`{"token":"` + tok + `","nicknames_blob":"stale","version":0}`); r.StatusCode != http.StatusConflict || v != 1 {
1913 t.Fatalf("stale cas write: want 409/v1, got %d/v%d", r.StatusCode, v)
1914 }
1915 if blob, _ := get(); blob != "one" {
1916 t.Fatalf("stale write clobbered the blob: %q", blob)
1917 }
1918
1919 // Retrying from the fresh read succeeds.
1920 if r, v := post(`{"token":"` + tok + `","nicknames_blob":"two","version":1}`); r.StatusCode != http.StatusOK || v != 2 {
1921 t.Fatalf("retry cas write: want 200/v2, got %d/v%d", r.StatusCode, v)
1922 }
1923
1924 // A pre-versioning client (no version field) writes unconditionally and
1925 // still advances the counter, so CAS writers see its write as fresh state.
1926 if r, v := post(`{"token":"` + tok + `","nicknames_blob":"legacy"}`); r.StatusCode != http.StatusOK || v != 3 {
1927 t.Fatalf("legacy write: want 200/v3, got %d/v%d", r.StatusCode, v)
1928 }
1929 if blob, v := get(); blob != "legacy" || v != 3 {
1930 t.Fatalf("after legacy write: got %q/v%d", blob, v)
1931 }
1932}
1933
1934// TestOpenGroups covers the community directory: creating an open group,
1935// discovering it, self-serve joining, and flipping visibility — including the
1936// open_key overwrite guard.
1937func TestOpenGroups(t *testing.T) {
1938 ts, srv := newTestServer(t)
1939
1940 mkUser := func(label string) (tok, uid string) {
1941 u, err := srv.db.createUser(label)
1942 if err != nil {
1943 t.Fatalf("createUser %s: %v", label, err)
1944 }
1945 if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
1946 t.Fatalf("createKeyInvite %s: %v", label, err)
1947 }
1948 tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
1949 if err != nil || !ok {
1950 t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
1951 }
1952 if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil {
1953 t.Fatalf("setUserPublicKey %s: %v", label, err)
1954 }
1955 return tk, u
1956 }
1957 tokA, uidA := mkUser("Alice")
1958 tokB, uidB := mkUser("Bob")
1959
1960 post := func(path, body string) (int, map[string]any) {
1961 resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
1962 if err != nil {
1963 t.Fatalf("post %s: %v", path, err)
1964 }
1965 defer resp.Body.Close()
1966 var out map[string]any
1967 _ = json.NewDecoder(resp.Body).Decode(&out)
1968 return resp.StatusCode, out
1969 }
1970
1971 // Open must be group-only, and open requires open_key (and only open may carry one).
1972 if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"self","visibility":"open","open_key":"ok1","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`); code != 400 {
1973 t.Fatalf("open self thread: code=%d, want 400", code)
1974 }
1975 if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"group","visibility":"open","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`); code != 400 {
1976 t.Fatalf("open group without open_key: code=%d, want 400", code)
1977 }
1978 if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"group","open_key":"ok1","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`); code != 400 {
1979 t.Fatalf("invite group with open_key: code=%d, want 400", code)
1980 }
1981 // An invite-only group still requires per-member wraps.
1982 if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"group","members":[{"user_id":"`+uidA+`","wrapped":""}]}`); code != 400 {
1983 t.Fatalf("invite group with empty wrap: code=%d, want 400", code)
1984 }
1985
1986 // Create an open group; the creator may omit their wrap (open_key covers them).
1987 code, out := post("/api/threads", `{"token":"`+tokA+`","kind":"group","name_blob":"nb","visibility":"open","open_key":"ok1","members":[{"user_id":"`+uidA+`","wrapped":""}]}`)
1988 if code != 200 {
1989 t.Fatalf("create open group: code=%d", code)
1990 }
1991 gid, _ := out["thread_id"].(string)
1992 if gid == "" {
1993 t.Fatalf("create open group: no thread_id in %v", out)
1994 }
1995 // And an ordinary invite-only group to check the directory filters it out.
1996 code, out = post("/api/threads", `{"token":"`+tokA+`","kind":"group","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`)
1997 if code != 200 {
1998 t.Fatalf("create invite group: code=%d", code)
1999 }
2000 inviteGid, _ := out["thread_id"].(string)
2001
2002 directory := func(tok string) []OpenThread {
2003 resp, err := http.Get(ts.URL + "/api/threads/open?token=" + tok)
2004 if err != nil {
2005 t.Fatalf("directory: %v", err)
2006 }
2007 defer resp.Body.Close()
2008 if resp.StatusCode != http.StatusOK {
2009 t.Fatalf("directory: code=%d", resp.StatusCode)
2010 }
2011 var out struct {
2012 Threads []OpenThread `json:"threads"`
2013 }
2014 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
2015 t.Fatalf("directory decode: %v", err)
2016 }
2017 return out.Threads
2018 }
2019
2020 // The directory is token-gated.
2021 if resp, _ := http.Get(ts.URL + "/api/threads/open?token=bogus"); resp.StatusCode != 403 {
2022 t.Fatalf("directory with bad token: code=%d, want 403", resp.StatusCode)
2023 }
2024 // Bob (not a member of the group) sees exactly the open group, with its key blob.
2025 dir := directory(tokB)
2026 if len(dir) != 1 || dir[0].ID != gid {
2027 t.Fatalf("directory: %+v, want just %s", dir, gid)
2028 }
2029 if dir[0].OpenKey != "ok1" || dir[0].Joined || dir[0].MemberCount != 1 || dir[0].NameBlob != "nb" {
2030 t.Fatalf("directory row: %+v", dir[0])
2031 }
2032
2033 // Self-serve join; joining twice is idempotent; invite-only groups refuse.
2034 if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokB+`"}`); code != 200 {
2035 t.Fatalf("join: code=%d", code)
2036 }
2037 if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokB+`"}`); code != 200 {
2038 t.Fatalf("re-join: code=%d", code)
2039 }
2040 if code, _ := post("/api/threads/"+inviteGid+"/join", `{"token":"`+tokB+`"}`); code != 403 {
2041 t.Fatalf("join invite-only: code=%d, want 403", code)
2042 }
2043 if code, _ := post("/api/threads/nope/join", `{"token":"`+tokB+`"}`); code != 403 {
2044 t.Fatalf("join unknown: code=%d, want 403", code)
2045 }
2046 if !srv.db.threadMember(gid, uidB) {
2047 t.Fatalf("Bob should be a member after joining")
2048 }
2049 dir = directory(tokB)
2050 if !dir[0].Joined || dir[0].MemberCount != 2 {
2051 t.Fatalf("directory after join: %+v", dir[0])
2052 }
2053
2054 // Bob's thread list carries the open group with open_key and no per-member wrap.
2055 resp, err := http.Get(ts.URL + "/api/threads?token=" + tokB)
2056 if err != nil {
2057 t.Fatalf("threads: %v", err)
2058 }
2059 defer resp.Body.Close()
2060 var tl struct {
2061 Threads []Thread `json:"threads"`
2062 }
2063 if err := json.NewDecoder(resp.Body).Decode(&tl); err != nil {
2064 t.Fatalf("threads decode: %v", err)
2065 }
2066 if len(tl.Threads) != 1 {
2067 t.Fatalf("Bob's threads: %+v", tl.Threads)
2068 }
2069 if got := tl.Threads[0]; got.ID != gid || got.Visibility != "open" || got.OpenKey != "ok1" || got.Wrapped != "" {
2070 t.Fatalf("Bob's open-group row: %+v", got)
2071 }
2072
2073 // Closing hides it from discovery but keeps open_key (Bob's key source).
2074 if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokA+`","visibility":"invite"}`); code != 200 {
2075 t.Fatalf("close: code=%d", code)
2076 }
2077 if dir = directory(tokB); len(dir) != 0 {
2078 t.Fatalf("directory after close: %+v", dir)
2079 }
2080 tokC, _ := mkUser("Carol")
2081 if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokC+`"}`); code != 403 {
2082 t.Fatalf("join closed group: code=%d, want 403", code)
2083 }
2084
2085 // Reopening never overwrites the stored open_key (anti-brick rule), and
2086 // non-members / non-groups can't flip visibility.
2087 if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokC+`","visibility":"open","open_key":"junk"}`); code != 403 {
2088 t.Fatalf("non-member flip: code=%d, want 403", code)
2089 }
2090 if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokA+`","visibility":"open","open_key":"junk"}`); code != 200 {
2091 t.Fatalf("reopen: code=%d", code)
2092 }
2093 if dir = directory(tokC); len(dir) != 1 || dir[0].OpenKey != "ok1" {
2094 t.Fatalf("open_key must survive a reopen: %+v", dir)
2095 }
2096
2097 // A self-serve joiner has an EMPTY thread_keys slot; another member must
2098 // not be able to fill it with an arbitrary blob (add-member is a no-op for
2099 // existing members — the junk-wrap lockout the DO NOTHING rule exists for).
2100 if code, _ := post("/api/threads/"+gid+"/members", `{"token":"`+tokA+`","user_id":"`+uidB+`","wrapped":"junk-wrap"}`); code != 200 {
2101 t.Fatalf("re-add existing member: code=%d", code)
2102 }
2103 if w, ok, _ := srv.db.wrappedKeyFor(gid, uidB); ok || w != "" {
2104 t.Fatalf("joiner's empty key slot was filled: %q", w)
2105 }
2106}
2107
2108func TestAdminCanAlwaysInvite(t *testing.T) {
2109 ts, srv := newTestServer(t)
2110 adminTok, adminUID, _ := joinedMember(t, srv, "boss")
2111 if err := srv.db.setUserRole(adminUID, "admin"); err != nil {
2112 t.Fatalf("setUserRole: %v", err)
2113 }
2114 memberTok, _, _ := joinedMember(t, srv, "pal")
2115 if err := srv.db.setUsersCanInvite(false); err != nil {
2116 t.Fatalf("setUsersCanInvite: %v", err)
2117 }
2118
2119 post := func(token, id string) *http.Response {
2120 body := `{"token":"` + token + `","id":"` + id + `","wrapped":"w","claim_hash":"h","label_blob":"lb","self_wrapped":"sw"}`
2121 r, err := http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body))
2122 if err != nil {
2123 t.Fatalf("post: %v", err)
2124 }
2125 return r
2126 }
2127
2128 // The toggle governs ordinary members…
2129 if r := post(memberTok, "inv-member-try"); r.StatusCode != http.StatusForbidden {
2130 t.Fatalf("member with invites off: want 403, got %d", r.StatusCode)
2131 }
2132 // …but never the admin.
2133 if r := post(adminTok, "inv-admin-try"); r.StatusCode != http.StatusOK {
2134 t.Fatalf("admin with invites off: want 200, got %d", r.StatusCode)
2135 }
2136 // Flipping the toggle on admits members too (the pre-existing behaviour).
2137 if err := srv.db.setUsersCanInvite(true); err != nil {
2138 t.Fatalf("setUsersCanInvite: %v", err)
2139 }
2140 if r := post(memberTok, "inv-member-ok"); r.StatusCode != http.StatusOK {
2141 t.Fatalf("member with invites on: want 200, got %d", r.StatusCode)
2142 }
2143}
2144
2145// TestDefaultGroups: admins flag open groups as joined-by-default; brand-new
2146// members land in them on their first device claim, add-device claims and
2147// closed groups don't auto-join.
2148func TestDefaultGroups(t *testing.T) {
2149 ts, srv := newTestServer(t)
2150
2151 mkUser := func(label string) (tok, uid string) {
2152 u, err := srv.db.createUser(label)
2153 if err != nil {
2154 t.Fatalf("createUser %s: %v", label, err)
2155 }
2156 if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
2157 t.Fatalf("createKeyInvite %s: %v", label, err)
2158 }
2159 tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
2160 if err != nil || !ok {
2161 t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
2162 }
2163 if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil {
2164 t.Fatalf("setUserPublicKey %s: %v", label, err)
2165 }
2166 return tk, u
2167 }
2168 post := func(path, body string) int {
2169 resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
2170 if err != nil {
2171 t.Fatalf("post %s: %v", path, err)
2172 }
2173 resp.Body.Close()
2174 return resp.StatusCode
2175 }
2176
2177 tokAdmin, uidAdmin := mkUser("Root")
2178 if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
2179 t.Fatalf("setUserRole: %v", err)
2180 }
2181 tokPeer, uidPeer := mkUser("Peer")
2182
2183 // One open group (will be default), one open non-default, one closed+flagged.
2184 mkGroup := func(vis, openKey string) string {
2185 body := `{"token":"` + tokAdmin + `","kind":"group","members":[{"user_id":"` + uidAdmin + `","wrapped":"w"}]}`
2186 if vis == "open" {
2187 body = `{"token":"` + tokAdmin + `","kind":"group","visibility":"open","open_key":"` + openKey + `","members":[{"user_id":"` + uidAdmin + `","wrapped":""}]}`
2188 }
2189 resp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(body))
2190 if err != nil {
2191 t.Fatalf("create group: %v", err)
2192 }
2193 defer resp.Body.Close()
2194 var out struct {
2195 ThreadID string `json:"thread_id"`
2196 }
2197 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.ThreadID == "" {
2198 t.Fatalf("create group: id=%q err=%v", out.ThreadID, err)
2199 }
2200 return out.ThreadID
2201 }
2202 defGid := mkGroup("open", "ok-def")
2203 plainGid := mkGroup("open", "ok-plain")
2204 closedGid := mkGroup("invite", "")
2205
2206 // Gating: members can't set it; a closed group can't be default.
2207 if code := post("/api/threads/"+defGid+"/default", `{"token":"`+tokPeer+`","default":true}`); code != 403 {
2208 t.Fatalf("non-admin default: code=%d, want 403", code)
2209 }
2210 if code := post("/api/threads/"+closedGid+"/default", `{"token":"`+tokAdmin+`","default":true}`); code != 400 {
2211 t.Fatalf("default on closed group: code=%d, want 400", code)
2212 }
2213 if code := post("/api/threads/"+defGid+"/default", `{"token":"`+tokAdmin+`","default":true}`); code != 200 {
2214 t.Fatalf("set default: code=%d", code)
2215 }
2216 // Flag the closed group directly in the DB: even if flagged, a closed
2217 // group must never auto-join (the sweep filters on visibility).
2218 if err := srv.db.setThreadDefault(closedGid, true); err != nil {
2219 t.Fatalf("setThreadDefault: %v", err)
2220 }
2221
2222 // Existing members are never force-joined.
2223 if srv.db.threadMember(defGid, uidPeer) {
2224 t.Fatalf("existing member force-joined")
2225 }
2226
2227 // A brand-new member lands in the default group only.
2228 _, uidNew := mkUser("Newbie")
2229 if !srv.db.threadMember(defGid, uidNew) {
2230 t.Fatalf("new member not auto-joined to default group")
2231 }
2232 if srv.db.threadMember(plainGid, uidNew) || srv.db.threadMember(closedGid, uidNew) {
2233 t.Fatalf("new member auto-joined to a non-default or closed group")
2234 }
2235 if w, ok, _ := srv.db.wrappedKeyFor(defGid, uidNew); ok || w != "" {
2236 t.Fatalf("auto-join must not mint a thread_keys row: %q", w)
2237 }
2238
2239 // Leaving sticks: an add-device claim later must not re-join.
2240 if code := post("/api/threads/"+defGid+"/leave", `{"token":"`+tokPeer+`"}`); code != 200 {
2241 // tokPeer isn't a member (never joined) — use the newbie's leave instead.
2242 t.Logf("peer leave (not a member): code=%d", code)
2243 }
2244 newTok := func(uid, label string) {
2245 if err := srv.db.createKeyInvite("dev-"+label, uid, "wrap", testClaimHash, 0); err != nil {
2246 t.Fatalf("device invite: %v", err)
2247 }
2248 if _, ok, err := srv.db.claimKeyInvite("dev-"+label, label, "", testClaimSecret); !ok || err != nil {
2249 t.Fatalf("device claim: ok=%v err=%v", ok, err)
2250 }
2251 }
2252 if err := srv.db.removeThreadMember(defGid, uidNew); err != nil {
2253 t.Fatalf("leave: %v", err)
2254 }
2255 newTok(uidNew, "second-device")
2256 if srv.db.threadMember(defGid, uidNew) {
2257 t.Fatalf("add-device claim re-joined a left default group")
2258 }
2259}
2260
2261// TestTiers covers the tier server core: admin-gated management, the
2262// mint-vs-holder gate on key grants, the never-overwrite rule, targeted
2263// membership checks, and revocation dropping the key row.
2264func TestTiers(t *testing.T) {
2265 ts, srv := newTestServer(t)
2266
2267 mkUser := func(label string) (tok, uid string) {
2268 u, err := srv.db.createUser(label)
2269 if err != nil {
2270 t.Fatalf("createUser %s: %v", label, err)
2271 }
2272 if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
2273 t.Fatalf("createKeyInvite %s: %v", label, err)
2274 }
2275 tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
2276 if err != nil || !ok {
2277 t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
2278 }
2279 if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil {
2280 t.Fatalf("setUserPublicKey %s: %v", label, err)
2281 }
2282 return tk, u
2283 }
2284 do := func(method, path, body string) int {
2285 req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
2286 req.Header.Set("Content-Type", "application/json")
2287 resp, err := http.DefaultClient.Do(req)
2288 if err != nil {
2289 t.Fatalf("%s %s: %v", method, path, err)
2290 }
2291 resp.Body.Close()
2292 return resp.StatusCode
2293 }
2294 tiersFor := func(tok string) []Tier {
2295 resp, err := http.Get(ts.URL + "/api/tiers?token=" + tok)
2296 if err != nil || resp.StatusCode != 200 {
2297 t.Fatalf("get tiers: %v code=%d", err, resp.StatusCode)
2298 }
2299 defer resp.Body.Close()
2300 var out struct {
2301 Tiers []Tier `json:"tiers"`
2302 }
2303 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
2304 t.Fatalf("decode tiers: %v", err)
2305 }
2306 return out.Tiers
2307 }
2308
2309 tokAdmin, uidAdmin := mkUser("Root")
2310 if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
2311 t.Fatalf("setUserRole: %v", err)
2312 }
2313 tokB, uidB := mkUser("Bea")
2314 tokC, uidC := mkUser("Cal")
2315 _, uidD := mkUser("Dot")
2316
2317 // Create: members can't, admins can, names are validated.
2318 if code := do("POST", "/api/tiers", `{"token":"`+tokB+`","name":"Coaches"}`); code != 403 {
2319 t.Fatalf("member create tier: code=%d, want 403", code)
2320 }
2321 if code := do("POST", "/api/tiers", `{"token":"`+tokAdmin+`","name":" "}`); code != 400 {
2322 t.Fatalf("blank tier name: code=%d, want 400", code)
2323 }
2324 resp, err := http.Post(ts.URL+"/api/tiers", "application/json",
2325 strings.NewReader(`{"token":"`+tokAdmin+`","name":"Coaches"}`))
2326 if err != nil || resp.StatusCode != 200 {
2327 t.Fatalf("create tier: %v code=%d", err, resp.StatusCode)
2328 }
2329 var created struct {
2330 ID string `json:"id"`
2331 }
2332 if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ID == "" {
2333 t.Fatalf("create tier: id=%q err=%v", created.ID, err)
2334 }
2335 resp.Body.Close()
2336 tid := created.ID
2337
2338 // Assign: admin-gated; unknown tier/member rejected.
2339 if code := do("POST", "/api/tiers/"+tid+"/members", `{"token":"`+tokB+`","user_id":"`+uidC+`"}`); code != 403 {
2340 t.Fatalf("member assign: code=%d, want 403", code)
2341 }
2342 if code := do("POST", "/api/tiers/nope/members", `{"token":"`+tokAdmin+`","user_id":"`+uidB+`"}`); code != 400 {
2343 t.Fatalf("assign to unknown tier: code=%d, want 400", code)
2344 }
2345 for _, u := range []string{uidB, uidC} {
2346 if code := do("POST", "/api/tiers/"+tid+"/members", `{"token":"`+tokAdmin+`","user_id":"`+u+`"}`); code != 200 {
2347 t.Fatalf("assign %s: code=%d", u, code)
2348 }
2349 }
2350 got := tiersFor(tokB)
2351 if len(got) != 1 || got[0].Name != "Coaches" || !got[0].Mine || len(got[0].Members) != 2 {
2352 t.Fatalf("Bea's view: %+v", got)
2353 }
2354 if admin := tiersFor(tokAdmin); admin[0].Mine {
2355 t.Fatalf("assigning admin must not be a member themselves")
2356 }
2357
2358 // Key grants: minting (no copies yet) is admin-only; grants land only on
2359 // tier members; a holder can grant; nothing ever overwrites.
2360 if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokB+`","grants":[{"user_id":"`+uidB+`","wrapped":"wb"}]}`); code != 403 {
2361 t.Fatalf("non-admin mint: code=%d, want 403", code)
2362 }
2363 if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokAdmin+`","grants":[{"user_id":"`+uidB+`","wrapped":"wb"},{"user_id":"`+uidD+`","wrapped":"junk"}]}`); code != 200 {
2364 t.Fatalf("mint: code=%d", code)
2365 }
2366 got = tiersFor(tokB)
2367 if got[0].Wrapped != "wb" || len(got[0].Have) != 1 || got[0].Have[0] != uidB {
2368 t.Fatalf("after mint: %+v", got[0])
2369 }
2370 if d := tiersFor(tokAdmin); len(d[0].Have) != 1 {
2371 t.Fatalf("non-member Dot must not receive a key: %+v", d[0])
2372 }
2373 // Keyless member can't grant; holder can; overwrite is a no-op.
2374 if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokC+`","grants":[{"user_id":"`+uidC+`","wrapped":"wc"}]}`); code != 403 {
2375 t.Fatalf("keyless grant: code=%d, want 403", code)
2376 }
2377 if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokB+`","grants":[{"user_id":"`+uidC+`","wrapped":"wc"},{"user_id":"`+uidB+`","wrapped":"evil"}]}`); code != 200 {
2378 t.Fatalf("holder grant: code=%d", code)
2379 }
2380 got = tiersFor(tokB)
2381 if got[0].Wrapped != "wb" {
2382 t.Fatalf("overwrite must be a no-op: %+v", got[0])
2383 }
2384 if c := tiersFor(tokC); c[0].Wrapped != "wc" {
2385 t.Fatalf("Cal's grant missing: %+v", c[0])
2386 }
2387
2388 // Nudge: members only.
2389 if code := do("POST", "/api/tiers/"+tid+"/nudge", `{"token":"`+tokAdmin+`"}`); code != 403 {
2390 t.Fatalf("non-member nudge: code=%d, want 403", code)
2391 }
2392 if code := do("POST", "/api/tiers/"+tid+"/nudge", `{"token":"`+tokC+`"}`); code != 200 {
2393 t.Fatalf("member nudge: code=%d", code)
2394 }
2395
2396 // Revocation: unassign drops membership AND the key row.
2397 if code := do("POST", "/api/tiers/"+tid+"/remove", `{"token":"`+tokAdmin+`","user_id":"`+uidC+`"}`); code != 200 {
2398 t.Fatalf("remove: code=%d", code)
2399 }
2400 got = tiersFor(tokC)
2401 // Cal is now an outsider: on this default 'shown' level they still see the
2402 // member list (Bea), but the holder list is insiders/admins only.
2403 if got[0].Mine || got[0].Wrapped != "" || len(got[0].Members) != 1 || len(got[0].Have) != 0 {
2404 t.Fatalf("after remove: %+v", got[0])
2405 }
2406
2407 // Delete: admin-gated, cascades.
2408 if code := do("DELETE", "/api/tiers/"+tid, `{"token":"`+tokB+`"}`); code != 403 {
2409 t.Fatalf("member delete: code=%d, want 403", code)
2410 }
2411 if code := do("DELETE", "/api/tiers/"+tid, `{"token":"`+tokAdmin+`"}`); code != 200 {
2412 t.Fatalf("delete: code=%d", code)
2413 }
2414 if got = tiersFor(tokB); len(got) != 0 {
2415 t.Fatalf("after delete: %+v", got)
2416 }
2417 if srv.db.tierKeyCount(tid) != 0 {
2418 t.Fatalf("delete must cascade key rows")
2419 }
2420}
2421
2422// TestLevelGatedGroups: an open group gated to an access level — create gate,
2423// directory honesty (eligible flag + plaintext level name), join gate, the
2424// default-join sweep skipping gated groups, and gate immutability.
2425func TestLevelGatedGroups(t *testing.T) {
2426 ts, srv := newTestServer(t)
2427
2428 mkUser := func(label string) (tok, uid string) {
2429 u, err := srv.db.createUser(label)
2430 if err != nil {
2431 t.Fatalf("createUser %s: %v", label, err)
2432 }
2433 if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
2434 t.Fatalf("createKeyInvite %s: %v", label, err)
2435 }
2436 tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
2437 if err != nil || !ok {
2438 t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
2439 }
2440 if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil {
2441 t.Fatalf("setUserPublicKey %s: %v", label, err)
2442 }
2443 return tk, u
2444 }
2445 post := func(path, body string) (int, map[string]any) {
2446 resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body))
2447 if err != nil {
2448 t.Fatalf("post %s: %v", path, err)
2449 }
2450 defer resp.Body.Close()
2451 var out map[string]any
2452 _ = json.NewDecoder(resp.Body).Decode(&out)
2453 return resp.StatusCode, out
2454 }
2455
2456 tokB, uidB := mkUser("Bea")
2457 tokC, _ := mkUser("Cal")
2458
2459 tid, err := srv.db.createTier("Coaches")
2460 if err != nil {
2461 t.Fatalf("createTier: %v", err)
2462 }
2463 if err := srv.db.assignTier(tid, uidB, "admin"); err != nil {
2464 t.Fatalf("assignTier: %v", err)
2465 }
2466 if err := srv.db.grantTierKey(tid, uidB, "wb"); err != nil {
2467 t.Fatalf("grantTierKey: %v", err)
2468 }
2469
2470 // Create: only a member of the level can gate a group on it.
2471 if code, _ := post("/api/threads", `{"token":"`+tokC+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"`+tid+`","members":[{"user_id":"`+mustUID(t, srv, tokC)+`","wrapped":""}]}`); code != 400 {
2472 t.Fatalf("non-member gates a group: code=%d, want 400", code)
2473 }
2474 if code, _ := post("/api/threads", `{"token":"`+tokB+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"nope","members":[{"user_id":"`+uidB+`","wrapped":""}]}`); code != 400 {
2475 t.Fatalf("unknown level: code=%d, want 400", code)
2476 }
2477 code, out := post("/api/threads", `{"token":"`+tokB+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"`+tid+`","members":[{"user_id":"`+uidB+`","wrapped":""}]}`)
2478 if code != 200 {
2479 t.Fatalf("create gated group: code=%d", code)
2480 }
2481 gid, _ := out["thread_id"].(string)
2482
2483 // Directory: Cal sees the row with an honest lock (name + eligible=false).
2484 resp, err := http.Get(ts.URL + "/api/threads/open?token=" + tokC)
2485 if err != nil {
2486 t.Fatalf("directory: %v", err)
2487 }
2488 defer resp.Body.Close()
2489 var dir struct {
2490 Threads []OpenThread `json:"threads"`
2491 }
2492 if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil {
2493 t.Fatalf("decode: %v", err)
2494 }
2495 if len(dir.Threads) != 1 || dir.Threads[0].Eligible || dir.Threads[0].TierName != "Coaches" || dir.Threads[0].OpenTier != tid {
2496 t.Fatalf("Cal's directory row: %+v", dir.Threads)
2497 }
2498
2499 // Join: gated to the level.
2500 if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokC+`"}`); code != 403 {
2501 t.Fatalf("non-member join: code=%d, want 403", code)
2502 }
2503 if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokB+`"}`); code != 200 {
2504 t.Fatalf("member join: code=%d", code)
2505 }
2506
2507 // The default-join sweep never lands newcomers in a gated group.
2508 if err := srv.db.setThreadDefault(gid, true); err != nil {
2509 t.Fatalf("setThreadDefault: %v", err)
2510 }
2511 _, uidNew := mkUser("Newbie")
2512 if srv.db.threadMember(gid, uidNew) {
2513 t.Fatalf("newcomer auto-joined a level-gated group")
2514 }
2515
2516 // The gate is fixed once opened: closing keeps it, reopening with a
2517 // different (or no) gate is refused, the same gate is accepted.
2518 if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"invite"}`); code != 200 {
2519 t.Fatalf("close: code=%d", code)
2520 }
2521 if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"open","open_key":"gk2"}`); code != 400 {
2522 t.Fatalf("reopen without the gate: code=%d, want 400", code)
2523 }
2524 if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"open","open_key":"gk2","open_tier":"`+tid+`"}`); code != 200 {
2525 t.Fatalf("reopen with the same gate: code=%d", code)
2526 }
2527 // Reopening needs NO key blob at all — the stored open_key survives, so
2528 // any member can reopen (symmetric with closing).
2529 if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"invite"}`); code != 200 {
2530 t.Fatalf("re-close: code=%d", code)
2531 }
2532 if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"open","open_tier":"`+tid+`"}`); code != 200 {
2533 t.Fatalf("keyless reopen: code=%d", code)
2534 }
2535 resp2, err := http.Get(ts.URL + "/api/threads/open?token=" + tokB)
2536 if err != nil {
2537 t.Fatalf("open directory: %v", err)
2538 }
2539 defer resp2.Body.Close()
2540 var dir2 struct {
2541 Threads []OpenThread `json:"threads"`
2542 }
2543 _ = json.NewDecoder(resp2.Body).Decode(&dir2)
2544 if len(dir2.Threads) != 1 || dir2.Threads[0].OpenKey != "gk" || !dir2.Threads[0].Eligible {
2545 t.Fatalf("after reopen: %+v", dir2.Threads)
2546 }
2547
2548 // default_join is refused on gated groups (the sweep skips them — a saved
2549 // flag would be a silent lie).
2550 if err := srv.db.setUserRole(uidB, "admin"); err != nil {
2551 t.Fatalf("setUserRole: %v", err)
2552 }
2553 if code, _ := post("/api/threads/"+gid+"/default", `{"token":"`+tokB+`","default":true}`); code != 400 {
2554 t.Fatalf("default on gated group: code=%d, want 400", code)
2555 }
2556}
2557
2558// mustUID resolves a token's user id or fails the test.
2559func mustUID(t *testing.T, srv *Server, token string) string {
2560 t.Helper()
2561 uid, ok := srv.db.deviceTokenUser(token)
2562 if !ok {
2563 t.Fatalf("no uid for token")
2564 }
2565 return uid
2566}
2567
2568// TestTierKeyLifecycle pins the one-way mint and the honest dead ends: a
2569// minted level whose holders are all gone is LOST (410, never re-minted), a
2570// level still gating groups can't be deleted, and removing a user cascades
2571// their tier rows.
2572func TestTierKeyLifecycle(t *testing.T) {
2573 ts, srv := newTestServer(t)
2574
2575 mkUser := func(label string) (tok, uid string) {
2576 u, err := srv.db.createUser(label)
2577 if err != nil {
2578 t.Fatalf("createUser %s: %v", label, err)
2579 }
2580 if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
2581 t.Fatalf("createKeyInvite %s: %v", label, err)
2582 }
2583 tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
2584 if err != nil || !ok {
2585 t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err)
2586 }
2587 if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil {
2588 t.Fatalf("setUserPublicKey %s: %v", label, err)
2589 }
2590 return tk, u
2591 }
2592 do := func(method, path, body string) int {
2593 req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
2594 req.Header.Set("Content-Type", "application/json")
2595 resp, err := http.DefaultClient.Do(req)
2596 if err != nil {
2597 t.Fatalf("%s %s: %v", method, path, err)
2598 }
2599 resp.Body.Close()
2600 return resp.StatusCode
2601 }
2602
2603 tokAdmin, uidAdmin := mkUser("Root")
2604 if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
2605 t.Fatalf("setUserRole: %v", err)
2606 }
2607 tokB, uidB := mkUser("Bea")
2608
2609 tid, err := srv.db.createTier("Coaches")
2610 if err != nil {
2611 t.Fatalf("createTier: %v", err)
2612 }
2613 if code := do("POST", "/api/tiers/"+tid+"/members", `{"token":"`+tokAdmin+`","user_id":"`+uidB+`"}`); code != 200 {
2614 t.Fatalf("assign: code=%d", code)
2615 }
2616 if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokAdmin+`","grants":[{"user_id":"`+uidB+`","wrapped":"wb"}]}`); code != 200 {
2617 t.Fatalf("mint: code=%d", code)
2618 }
2619
2620 // Mint is one-way: a second mint can never happen (db-level serialization).
2621 if err := srv.db.mintTierKeys(tid, nil); err != errAlreadyMinted {
2622 t.Fatalf("second mint: err=%v, want errAlreadyMinted", err)
2623 }
2624
2625 // Bea gates a group on the level, then loses her level membership: the
2626 // level is minted with ZERO holders → the key is lost, loudly.
2627 code := 0
2628 {
2629 resp, err := http.Post(ts.URL+"/api/threads", "application/json",
2630 strings.NewReader(`{"token":"`+tokB+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"`+tid+`","members":[{"user_id":"`+uidB+`","wrapped":""}]}`))
2631 if err != nil {
2632 t.Fatalf("create gated: %v", err)
2633 }
2634 code = resp.StatusCode
2635 resp.Body.Close()
2636 }
2637 if code != 200 {
2638 t.Fatalf("create gated group: code=%d", code)
2639 }
2640 if code := do("POST", "/api/tiers/"+tid+"/remove", `{"token":"`+tokAdmin+`","user_id":"`+uidB+`"}`); code != 200 {
2641 t.Fatalf("unassign holder: code=%d", code)
2642 }
2643 if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokAdmin+`","grants":[{"user_id":"`+uidB+`","wrapped":"w2"}]}`); code != 410 {
2644 t.Fatalf("grant on lost key: code=%d, want 410 (never a silent re-mint)", code)
2645 }
2646 tiers, err := srv.db.listTiers(uidAdmin, true)
2647 if err != nil || len(tiers) != 1 || !tiers[0].KeyLost {
2648 t.Fatalf("key_lost not reported: %+v err=%v", tiers, err)
2649 }
2650
2651 // The level still gates a group → deletion refused with the reason.
2652 if code := do("DELETE", "/api/tiers/"+tid, `{"token":"`+tokAdmin+`"}`); code != 409 {
2653 t.Fatalf("delete while gating: code=%d, want 409", code)
2654 }
2655
2656 // removeUser cascades tier rows: re-assign Bea, grant via a fresh level,
2657 // then remove her — no ghost membership or holdership may remain.
2658 tid2, err := srv.db.createTier("Founders")
2659 if err != nil {
2660 t.Fatalf("createTier 2: %v", err)
2661 }
2662 if err := srv.db.assignTier(tid2, uidB, "admin"); err != nil {
2663 t.Fatalf("assignTier: %v", err)
2664 }
2665 if err := srv.db.mintTierKeys(tid2, []ThreadMemberKey{{UserID: uidB, Wrapped: "wb2"}}); err != nil {
2666 t.Fatalf("mint 2: %v", err)
2667 }
2668 if err := srv.db.removeUser(uidB); err != nil {
2669 t.Fatalf("removeUser: %v", err)
2670 }
2671 if ms, _ := srv.db.tierMemberIDs(tid2); len(ms) != 0 {
2672 t.Fatalf("ghost tier membership after removeUser: %v", ms)
2673 }
2674 if hs, _ := srv.db.tierHolderIDs(tid2); len(hs) != 0 {
2675 t.Fatalf("ghost key holder after removeUser: %v", hs)
2676 }
2677}
2678
2679// TestCohortInvites: an admin-minted invite carries an access level; its
2680// claimer lands in the level on FIRST-device claim (source 'invite'), the
2681// welcome card learns the level's name, and stale/invalid gates fail safely.
2682func TestCohortInvites(t *testing.T) {
2683 ts, srv := newTestServer(t)
2684
2685 tid, err := srv.db.createTier("January 2027")
2686 if err != nil {
2687 t.Fatalf("createTier: %v", err)
2688 }
2689
2690 mint := func(inviteID, tierID string) int {
2691 body := `{"id":"` + inviteID + `","wrapped":"","claim_hash":"` + testClaimHash + `","tier_id":"` + tierID + `"}`
2692 req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", strings.NewReader(body))
2693 req.Header.Set("Authorization", "Bearer admintok")
2694 req.Header.Set("Content-Type", "application/json")
2695 resp, err := http.DefaultClient.Do(req)
2696 if err != nil {
2697 t.Fatalf("mint: %v", err)
2698 }
2699 resp.Body.Close()
2700 return resp.StatusCode
2701 }
2702
2703 if code := mint("coh-bad", "nope"); code != 400 {
2704 t.Fatalf("mint with unknown level: code=%d, want 400", code)
2705 }
2706 if code := mint("coh-1", tid); code != 200 {
2707 t.Fatalf("mint cohort invite: code=%d", code)
2708 }
2709
2710 // The invite lookup names the level (link-holders only, like description).
2711 resp, err := http.Get(ts.URL + "/api/key-invite/coh-1")
2712 if err != nil {
2713 t.Fatalf("lookup: %v", err)
2714 }
2715 var info struct {
2716 OK bool `json:"ok"`
2717 TierName string `json:"tier_name"`
2718 }
2719 if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
2720 t.Fatalf("decode: %v", err)
2721 }
2722 resp.Body.Close()
2723 if !info.OK || info.TierName != "January 2027" {
2724 t.Fatalf("lookup: %+v", info)
2725 }
2726
2727 // Claim → the joiner is in the level, attributed to the invite.
2728 ki, _, _ := srv.db.getKeyInvite("coh-1")
2729 if _, ok, err := srv.db.claimKeyInvite("coh-1", "phone", "", testClaimSecret); !ok || err != nil {
2730 t.Fatalf("claim: ok=%v err=%v", ok, err)
2731 }
2732 if !srv.db.tierMember(tid, ki.UserID) {
2733 t.Fatalf("claimer not in the cohort level")
2734 }
2735 var src string
2736 srv.db.sql.QueryRow("SELECT source FROM user_tiers WHERE tier_id = ? AND user_id = ?", tid, ki.UserID).Scan(&src)
2737 if src != "invite" {
2738 t.Fatalf("assignment source = %q, want invite", src)
2739 }
2740
2741 // An add-device claim later never re-assigns: leave the level, add a
2742 // device, still out.
2743 if err := srv.db.unassignTier(tid, ki.UserID); err != nil {
2744 t.Fatalf("unassign: %v", err)
2745 }
2746 if err := srv.db.createKeyInvite("coh-dev", ki.UserID, "wrap", testClaimHash, 0); err != nil {
2747 t.Fatalf("device invite: %v", err)
2748 }
2749 if err := srv.db.setInviteTier("coh-dev", tid); err != nil {
2750 t.Fatalf("setInviteTier: %v", err)
2751 }
2752 if _, ok, err := srv.db.claimKeyInvite("coh-dev", "laptop", "", testClaimSecret); !ok || err != nil {
2753 t.Fatalf("device claim: ok=%v err=%v", ok, err)
2754 }
2755 if srv.db.tierMember(tid, ki.UserID) {
2756 t.Fatalf("add-device claim re-assigned a left level")
2757 }
2758
2759 // A level deleted between mint and claim fails safe: claim succeeds, no
2760 // assignment (tierExists guard in claimKeyInvite).
2761 if code := mint("coh-2", tid); code != 200 {
2762 t.Fatalf("mint 2: code=%d", code)
2763 }
2764 if err := srv.db.deleteTier(tid); err != nil {
2765 t.Fatalf("deleteTier: %v", err)
2766 }
2767 ki2, _, _ := srv.db.getKeyInvite("coh-2")
2768 if _, ok, err := srv.db.claimKeyInvite("coh-2", "", "", testClaimSecret); !ok || err != nil {
2769 t.Fatalf("claim after delete: ok=%v err=%v", ok, err)
2770 }
2771 if srv.db.tierMember(tid, ki2.UserID) {
2772 t.Fatalf("assignment to a deleted level")
2773 }
2774}
2775
2776// TestCohortInviteFromChat: the in-chat invite path accepts a level from
2777// role-admins only — a member invite can't smuggle anyone into a level.
2778func TestCohortInviteFromChat(t *testing.T) {
2779 ts, srv := newTestServer(t)
2780
2781 mkUser := func(label string) (tok, uid string) {
2782 u, err := srv.db.createUser(label)
2783 if err != nil {
2784 t.Fatalf("createUser: %v", err)
2785 }
2786 if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
2787 t.Fatalf("createKeyInvite: %v", err)
2788 }
2789 tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
2790 if err != nil || !ok {
2791 t.Fatalf("claim: ok=%v err=%v", ok, err)
2792 }
2793 if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil {
2794 t.Fatalf("setUserPublicKey: %v", err)
2795 }
2796 return tk, u
2797 }
2798 tokAdmin, uidAdmin := mkUser("Root")
2799 if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
2800 t.Fatalf("setUserRole: %v", err)
2801 }
2802 tokB, _ := mkUser("Bea")
2803 if err := srv.db.setUsersCanInvite(true); err != nil {
2804 t.Fatalf("setUsersCanInvite: %v", err)
2805 }
2806 tid, err := srv.db.createTier("VIP")
2807 if err != nil {
2808 t.Fatalf("createTier: %v", err)
2809 }
2810
2811 mint := func(tok, inviteID, tierID string) int {
2812 body := `{"token":"` + tok + `","label_blob":"lb","id":"` + inviteID + `","wrapped":"w","self_wrapped":"sw","claim_hash":"` + testClaimHash + `","tier_id":"` + tierID + `"}`
2813 resp, err := http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body))
2814 if err != nil {
2815 t.Fatalf("post: %v", err)
2816 }
2817 resp.Body.Close()
2818 return resp.StatusCode
2819 }
2820
2821 // A member NOT in the level can't mint access to it (they don't hold the
2822 // key — they couldn't wrap it anyway).
2823 if code := mint(tokB, "chat-coh-1", tid); code != 403 {
2824 t.Fatalf("outsider cohort invite: code=%d, want 403", code)
2825 }
2826 if code := mint(tokAdmin, "chat-coh-2", "nope"); code != 400 {
2827 t.Fatalf("unknown level: code=%d, want 400", code)
2828 }
2829 // Admins mint for any level; a member IN the level can too (the flat-group
2830 // rule — membership is the power to extend membership).
2831 if code := mint(tokAdmin, "chat-coh-3", tid); code != 200 {
2832 t.Fatalf("admin cohort invite: code=%d", code)
2833 }
2834 if err := srv.db.assignTier(tid, mustUID(t, srv, tokB), "admin"); err != nil {
2835 t.Fatalf("assignTier: %v", err)
2836 }
2837 if code := mint(tokB, "chat-coh-4", tid); code != 200 {
2838 t.Fatalf("in-level member cohort invite: code=%d, want 200", code)
2839 }
2840 ki, found, _ := srv.db.getKeyInvite("chat-coh-3")
2841 if !found || ki.TierID != tid {
2842 t.Fatalf("tier not stored on chat invite: %+v", ki)
2843 }
2844 if _, ok, err := srv.db.claimKeyInvite("chat-coh-3", "", "sw2", testClaimSecret); !ok || err != nil {
2845 t.Fatalf("claim: ok=%v err=%v", ok, err)
2846 }
2847 if !srv.db.tierMember(tid, ki.UserID) {
2848 t.Fatalf("chat-invite claimer not in the level")
2849 }
2850}
2851
2852// TestTierVisibility: admin policy (shown/hidden/user) and the per-member
2853// choice govern what an OUTSIDER sees in a level's member list; insiders and
2854// admins always see the full roster (they hold the shared key).
2855func TestTierVisibility(t *testing.T) {
2856 ts, srv := newTestServer(t)
2857
2858 mkUser := func(label string) (tok, uid string) {
2859 u, err := srv.db.createUser(label)
2860 if err != nil {
2861 t.Fatalf("createUser: %v", err)
2862 }
2863 if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil {
2864 t.Fatalf("createKeyInvite: %v", err)
2865 }
2866 tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret)
2867 if err != nil || !ok {
2868 t.Fatalf("claim: ok=%v err=%v", ok, err)
2869 }
2870 if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil {
2871 t.Fatalf("setUserPublicKey: %v", err)
2872 }
2873 return tk, u
2874 }
2875 do := func(method, path, body string) int {
2876 req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body))
2877 req.Header.Set("Content-Type", "application/json")
2878 resp, err := http.DefaultClient.Do(req)
2879 if err != nil {
2880 t.Fatalf("%s %s: %v", method, path, err)
2881 }
2882 resp.Body.Close()
2883 return resp.StatusCode
2884 }
2885 tierRow := func(tok, id string) Tier {
2886 resp, err := http.Get(ts.URL + "/api/tiers?token=" + tok)
2887 if err != nil {
2888 t.Fatalf("get tiers: %v", err)
2889 }
2890 defer resp.Body.Close()
2891 var out struct {
2892 Tiers []Tier `json:"tiers"`
2893 }
2894 json.NewDecoder(resp.Body).Decode(&out)
2895 for _, tr := range out.Tiers {
2896 if tr.ID == id {
2897 return tr
2898 }
2899 }
2900 t.Fatalf("tier %s not in %s's view", id, tok)
2901 return Tier{}
2902 }
2903
2904 tokAdmin, uidAdmin := mkUser("Root")
2905 if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil {
2906 t.Fatalf("setUserRole: %v", err)
2907 }
2908 _, uidB := mkUser("Bea")
2909 tokOut, _ := mkUser("Outsider")
2910
2911 tid, _ := srv.db.createTier("VIP")
2912 if err := srv.db.assignTier(tid, uidB, "admin"); err != nil {
2913 t.Fatalf("assign: %v", err)
2914 }
2915
2916 // Default 'shown': the outsider sees Bea.
2917 if m := tierRow(tokOut, tid).Members; len(m) != 1 || m[0] != uidB {
2918 t.Fatalf("shown → outsider sees members: %v", m)
2919 }
2920
2921 // Only admins set policy.
2922 if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokOut+`","policy":"hidden"}`); code != 403 {
2923 t.Fatalf("non-admin policy: code=%d, want 403", code)
2924 }
2925 if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"nope"}`); code != 400 {
2926 t.Fatalf("bad policy value: code=%d, want 400", code)
2927 }
2928
2929 // 'hidden': the outsider sees an empty roster; the admin still sees Bea.
2930 if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"hidden"}`); code != 200 {
2931 t.Fatalf("set hidden: code=%d", code)
2932 }
2933 if m := tierRow(tokOut, tid).Members; len(m) != 0 {
2934 t.Fatalf("hidden → outsider sees nobody: %v", m)
2935 }
2936 if m := tierRow(tokAdmin, tid).Members; len(m) != 1 {
2937 t.Fatalf("hidden → admin still sees members: %v", m)
2938 }
2939
2940 // 'user': Bea shows by default; when she hides, the outsider loses her.
2941 if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"user"}`); code != 200 {
2942 t.Fatalf("set user: code=%d", code)
2943 }
2944 if m := tierRow(tokOut, tid).Members; len(m) != 1 {
2945 t.Fatalf("user default → outsider sees Bea: %v", m)
2946 }
2947 // Only a member sets their own show/hide — an outsider can't touch it.
2948 if code := do("POST", "/api/tiers/"+tid+"/show", `{"token":"`+tokOut+`","show":false}`); code != 403 {
2949 t.Fatalf("outsider sets show: code=%d, want 403", code)
2950 }
2951 // Bea hides herself → the outsider loses her, the admin still sees her.
2952 if err := srv.db.setUserTierVisible(tid, uidB, false); err != nil {
2953 t.Fatalf("hide: %v", err)
2954 }
2955 if m := tierRow(tokOut, tid).Members; len(m) != 0 {
2956 t.Fatalf("user + Bea hidden → outsider sees nobody: %v", m)
2957 }
2958 if m := tierRow(tokAdmin, tid).Members; len(m) != 1 {
2959 t.Fatalf("user + Bea hidden → admin still sees Bea: %v", m)
2960 }
2961}
2962
2963// The orphaned-instance-key story (#785), full circle: a keyless member sees
2964// holder liveness on the key read (honest copy needs it); the admin reset
2965// clears the holders, the dead-key-sealed user columns, and demotes
2966// instance-key open groups; afterwards a fresh bootstrap works — the space
2967// heals instead of staying wedged forever.
2968func TestInstanceKeyOrphanAndReset(t *testing.T) {
2969 ts, srv := newTestServer(t)
2970
2971 // Holder Anna bootstrapped the key once; Bea joined later and never got it.
2972 annaTok, annaUID, _ := joinedMember(t, srv, "Anna")
2973 beaTok, beaUID, _ := joinedMember(t, srv, "Bea")
2974 grant := `{"token":"` + annaTok + `","grants":[{"user_id":"` + annaUID + `","wrapped":"wrapped-to-anna"}]}`
2975 if r, _ := http.Post(ts.URL+"/api/instance-key", "application/json", strings.NewReader(grant)); r.StatusCode != http.StatusOK {
2976 t.Fatalf("bootstrap grant: %d", r.StatusCode)
2977 }
2978 if _, err := srv.db.setThreadRead(annaUID, "t-any", 1751000000); err != nil {
2979 t.Fatalf("setThreadRead: %v", err)
2980 }
2981 srv.db.setUserLabelBlob(beaUID, "sealed-name")
2982
2983 // Bea's key read carries holder liveness: Anna holds it, isn't connected,
2984 // and was last around at her read mark.
2985 r, _ := http.Get(ts.URL + "/api/instance-key?token=" + beaTok)
2986 var got struct {
2987 Wrapped string `json:"wrapped"`
2988 Have []string `json:"have"`
2989 HoldersOnline *bool `json:"holders_online"`
2990 HoldersLastActive int64 `json:"holders_last_active"`
2991 }
2992 json.NewDecoder(r.Body).Decode(&got)
2993 if got.Wrapped != "" || len(got.Have) != 1 {
2994 t.Fatalf("keyless read: %+v", got)
2995 }
2996 if got.HoldersOnline == nil || *got.HoldersOnline {
2997 t.Fatalf("holders_online: %+v (want present and false)", got.HoldersOnline)
2998 }
2999 if got.HoldersLastActive != 1751000000 {
3000 t.Fatalf("holders_last_active: %d", got.HoldersLastActive)
3001 }
3002
3003 // A holder's OWN read carries no liveness noise.
3004 r, _ = http.Get(ts.URL + "/api/instance-key?token=" + annaTok)
3005 var holder map[string]any
3006 json.NewDecoder(r.Body).Decode(&holder)
3007 if _, present := holder["holders_online"]; present {
3008 t.Fatal("holder's read must not carry liveness fields")
3009 }
3010
3011 // An open group gated under the instance key, plus one under a tier —
3012 // reset demotes only the former.
3013 openTid, err := srv.db.createThreadVis("group", "n", []ThreadMemberKey{{UserID: annaUID, Wrapped: "w"}}, "open", "open-key-blob", "")
3014 if err != nil {
3015 t.Fatalf("createThreadVis: %v", err)
3016 }
3017 tierTid, err := srv.db.createThreadVis("group", "n2", []ThreadMemberKey{{UserID: annaUID, Wrapped: "w2"}}, "open", "tier-open-key", "tier-1")
3018 if err != nil {
3019 t.Fatalf("createThreadVis tier: %v", err)
3020 }
3021
3022 // The reset is admin-gated.
3023 if r, _ := http.Post(ts.URL+"/api/admin/instance-key/reset", "application/json", nil); r.StatusCode != http.StatusUnauthorized {
3024 t.Fatalf("unauthenticated reset: want 401, got %d", r.StatusCode)
3025 }
3026 if r := adminPost(t, ts.URL+"/api/admin/instance-key/reset", `{}`); r.StatusCode != http.StatusOK {
3027 t.Fatalf("admin reset: %d", r.StatusCode)
3028 }
3029
3030 // Holders gone; Bea's next read shows a bootstrappable space (no liveness
3031 // fields — there are no holders to wait for).
3032 r, _ = http.Get(ts.URL + "/api/instance-key?token=" + beaTok)
3033 var after map[string]any
3034 json.NewDecoder(r.Body).Decode(&after)
3035 if have := after["have"].([]any); len(have) != 0 {
3036 t.Fatalf("holders after reset: %v", have)
3037 }
3038 if _, present := after["holders_online"]; present {
3039 t.Fatal("no-holder read must not carry liveness fields")
3040 }
3041
3042 // Dead-key-sealed columns cleared; instance-key open group demoted with
3043 // its open_key dropped; tier-gated one untouched.
3044 entries, _ := srv.db.roster()
3045 for _, e := range entries {
3046 if e.LabelBlob != "" {
3047 t.Fatalf("sealed label survived reset: %+v", e)
3048 }
3049 }
3050 threads, _ := srv.db.threadsForUser(annaUID)
3051 for _, th := range threads {
3052 switch th.ID {
3053 case openTid:
3054 if th.Visibility == "open" || th.OpenKey != "" {
3055 t.Fatalf("instance-key open group not demoted: %+v", th)
3056 }
3057 case tierTid:
3058 if th.Visibility != "open" || th.OpenKey != "tier-open-key" {
3059 t.Fatalf("tier-gated group wrongly touched: %+v", th)
3060 }
3061 }
3062 }
3063
3064 // Full circle: Bea bootstraps a fresh key.
3065 grant = `{"token":"` + beaTok + `","grants":[{"user_id":"` + beaUID + `","wrapped":"wrapped-to-bea"}]}`
3066 if r, _ := http.Post(ts.URL+"/api/instance-key", "application/json", strings.NewReader(grant)); r.StatusCode != http.StatusOK {
3067 t.Fatalf("re-bootstrap: %d", r.StatusCode)
3068 }
3069 if w, ok, _ := srv.db.instanceKeyFor(beaUID); !ok || w != "wrapped-to-bea" {
3070 t.Fatalf("fresh key not held after reset: %q ok=%v", w, ok)
3071 }
3072}
3073