package main import ( "bytes" "context" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "os" "strconv" "strings" "testing" "time" "github.com/coder/websocket" ) func newTestServer(t *testing.T) (*httptest.Server, *Server) { t.Helper() db, err := openDB(t.TempDir() + "/test.db") if err != nil { t.Fatalf("openDB: %v", err) } db.ensureAdminToken("admintok") pub, priv, _ := db.ensureVAPID() pages, err := loadPages() if err != nil { t.Fatalf("loadPages: %v", err) } hub := newHub() go hub.run() s := &Server{db: db, hub: hub, pages: pages, changelog: loadChangelog(), vapidPub: pub, vapidPriv: priv, homeOrigin: defaultHomeOrigin, maxUpload: defaultMaxUploadBytes, blobQuota: defaultBlobQuotaBytes} ts := httptest.NewServer(s.handler()) t.Cleanup(ts.Close) return ts, s } // adminPost issues a POST with the admin bearer token. func adminPost(t *testing.T, url, body string) *http.Response { t.Helper() req, _ := http.NewRequest("POST", url, strings.NewReader(body)) req.Header.Set("Authorization", "Bearer admintok") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request: %v", err) } return resp } func TestAdminAuthGating(t *testing.T) { ts, _ := newTestServer(t) resp, _ := http.Post(ts.URL+"/api/admin/key-invites", "application/json", strings.NewReader(`{"label":"x"}`)) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("no auth: want 401, got %d", resp.StatusCode) } req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", strings.NewReader(`{}`)) req.Header.Set("Authorization", "Bearer wrong") resp, _ = http.DefaultClient.Do(req) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("wrong auth: want 401, got %d", resp.StatusCode) } } // Renaming a user (e.g. a bot after its default name changes) is admin-gated, // validates the label, and shows up in the roster; keys are untouched. func TestRenameUser(t *testing.T) { ts, srv := newTestServer(t) uid, _ := srv.db.createUser("Claude") srv.db.setUserPublicKey(uid, "pub-bot", "") srv.db.setUserAgent(uid) // only bots have an admin-settable plaintext name now if r, _ := http.Post(ts.URL+"/api/admin/users/"+uid+"/label", "application/json", strings.NewReader(`{"label":"Eleven"}`)); r.StatusCode != http.StatusUnauthorized { t.Fatalf("no auth: want 401, got %d", r.StatusCode) } if r := adminPost(t, ts.URL+"/api/admin/users/"+uid+"/label", `{"label":""}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("empty label: want 400, got %d", r.StatusCode) } if r := adminPost(t, ts.URL+"/api/admin/users/nosuch/label", `{"label":"Eleven"}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("unknown user: want 400, got %d", r.StatusCode) } if r := adminPost(t, ts.URL+"/api/admin/users/"+uid+"/label", `{"label":"Eleven"}`); r.StatusCode != http.StatusOK { t.Fatalf("rename: want 200, got %d", r.StatusCode) } entries, _ := srv.db.roster() if len(entries) != 1 || entries[0].Label != "Eleven" || entries[0].PublicKey != "pub-bot" { t.Fatalf("roster after rename: %+v", entries) } } func TestMessagesThreadScoping(t *testing.T) { ts, srv := newTestServer(t) // Messages are thread-scoped: a bad token is rejected; a real member reading // their own thread is allowed; a member reading a thread they're not in is not. resp, _ := http.Get(ts.URL + "/api/messages?token=bogus&thread=x") if resp.StatusCode != http.StatusForbidden { t.Fatalf("messages bad token: want 403, got %d", resp.StatusCode) } tok, _, tid := joinedMember(t, srv, "Member") resp, _ = http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + tid) if resp.StatusCode != http.StatusOK { t.Fatalf("messages member's thread: want 200, got %d", resp.StatusCode) } resp, _ = http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=not-my-thread") if resp.StatusCode != http.StatusForbidden { t.Fatalf("messages foreign thread: want 403, got %d", resp.StatusCode) } } // A delete's tombstone row (#581) rides the ordinary /api/messages read: // clients that were offline for the live broadcast see `tombstone_of` on a // fresh row and fold it as a delete. The wire shape is what old clients skip // (empty blob) and what the chat.json event vector pins. func TestMessagesTombstoneOnWire(t *testing.T) { ts, srv := newTestServer(t) tok, _, tid := joinedMember(t, srv, "Member") m1, _ := srv.db.insertMessage(tid, "blob1", hashToken(tok)) m2, _ := srv.db.insertMessage(tid, "blob2", hashToken(tok)) if ok, err := srv.db.deleteMessage(m1.ID, hashToken(tok)); err != nil || !ok { t.Fatalf("deleteMessage: ok=%v err=%v", ok, err) } // A client catching up from m2 (its watermark) receives just the tombstone. r, _ := http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + tid + "&after=" + strconv.FormatInt(m2.ID, 10)) var got struct { Messages []Message `json:"messages"` } if err := json.NewDecoder(r.Body).Decode(&got); err != nil { t.Fatalf("decode: %v", err) } if len(got.Messages) != 1 || got.Messages[0].TombstoneOf != m1.ID || got.Messages[0].Blob != "" { t.Fatalf("catch-up page: %+v (want one tombstone of %d)", got.Messages, m1.ID) } } func TestKeyInviteFlow(t *testing.T) { ts, _ := newTestServer(t) // Admin mints a key-invite. The browser would derive `id` and wrap the key; // here we just store opaque strings (the server never interprets them). resp := adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Paul","id":"derived-id-abc","wrapped":"wrapped-blob","claim_hash":"`+testClaimHash+`"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("create key-invite: %d", resp.StatusCode) } // Without admin auth it's rejected. r, _ := http.Post(ts.URL+"/api/admin/key-invites", "application/json", strings.NewReader(`{"label":"x","id":"y","wrapped":"z"}`)) if r.StatusCode != http.StatusUnauthorized { t.Fatalf("create without admin: want 401, got %d", r.StatusCode) } // Anyone with the id can fetch the (encrypted) wrapped blob. r, _ = http.Get(ts.URL + "/api/key-invite/derived-id-abc") var got struct { Ok bool `json:"ok"` Wrapped string `json:"wrapped"` } json.NewDecoder(r.Body).Decode(&got) if !got.Ok || got.Wrapped != "wrapped-blob" { t.Fatalf("get key-invite: %+v", got) } // A random device token is not yet valid. r, _ = http.Get(ts.URL + "/api/messages?token=randomdevice") if r.StatusCode != http.StatusForbidden { t.Fatalf("unclaimed device: want 403, got %d", r.StatusCode) } // Claiming mints a device token... r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json", strings.NewReader(`{"label":"laptop","claim_secret":"test-claim-secret-vector"}`)) var claim struct { Ok bool `json:"ok"` Token string `json:"token"` } json.NewDecoder(r.Body).Decode(&claim) if !claim.Ok || claim.Token == "" { t.Fatalf("claim: %+v", claim) } // ...which then authorises the chat API (the thread list, here — messages are // thread-scoped and this fresh member has none yet). r, _ = http.Get(ts.URL + "/api/threads?token=" + claim.Token) if r.StatusCode != http.StatusOK { t.Fatalf("device-token threads: %d", r.StatusCode) } // Single-use: a second claim fails and the wrapped blob is no longer served. r, _ = http.Post(ts.URL+"/api/key-invite/derived-id-abc/claim", "application/json", strings.NewReader(`{"claim_secret":"test-claim-secret-vector"}`)) if r.StatusCode == http.StatusOK { t.Fatal("second claim should fail") } r, _ = http.Get(ts.URL + "/api/key-invite/derived-id-abc") json.NewDecoder(r.Body).Decode(&got) if got.Ok { t.Fatal("used invite should report ok:false") } } func TestMemberAddsDevice(t *testing.T) { ts, _ := newTestServer(t) // Mint + claim a key-invite to get a member device token. adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Paul","id":"inv-1","wrapped":"w1","claim_hash":"`+testClaimHash+`"}`) r, _ := http.Post(ts.URL+"/api/key-invite/inv-1/claim", "application/json", strings.NewReader(`{"claim_secret":"test-claim-secret-vector"}`)) var claim struct { Token string `json:"token"` } json.NewDecoder(r.Body).Decode(&claim) before := countUsers(t, ts) // 1: Paul // The member mints a device invite for themselves (no admin token). r, _ = http.Post(ts.URL+"/api/device-invites", "application/json", strings.NewReader(`{"token":"`+claim.Token+`","label":"Paul","id":"inv-2","wrapped":"w2","claim_hash":"`+testClaimHash+`"}`)) if r.StatusCode != http.StatusOK { t.Fatalf("member add-device: %d", r.StatusCode) } // A stranger (no valid token) cannot. r, _ = http.Post(ts.URL+"/api/device-invites", "application/json", strings.NewReader(`{"token":"bogus","id":"x","wrapped":"y"}`)) if r.StatusCode != http.StatusForbidden { t.Fatalf("stranger add-device: want 403, got %d", r.StatusCode) } // The new invite is claimable and tied to the SAME user (no new user created). r, _ = http.Get(ts.URL + "/api/key-invite/inv-2") var got struct { Ok bool `json:"ok"` } json.NewDecoder(r.Body).Decode(&got) if !got.Ok { t.Fatal("new device invite should be claimable") } if after := countUsers(t, ts); after != before { t.Fatalf("add-device should reuse the user: before=%d after=%d", before, after) } } func countUsers(t *testing.T, ts *httptest.Server) int { t.Helper() r := adminGet(t, ts.URL+"/api/admin/users") var out struct { Users []struct{ ID string } `json:"users"` } json.NewDecoder(r.Body).Decode(&out) return len(out.Users) } func adminGet(t *testing.T, url string) *http.Response { t.Helper() req, _ := http.NewRequest("GET", url, nil) req.Header.Set("Authorization", "Bearer admintok") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request: %v", err) } return resp } func TestActiveTokenTracking(t *testing.T) { _, srv := newTestServer(t) if srv.isActive("tok") { t.Fatal("unknown token should be inactive") } srv.markActive("tok") if !srv.isActive("tok") { t.Fatal("just-marked token should be active") } // A stale timestamp (well past the window) reads as inactive. srv.active.Store("old", time.Now().Add(-2*activeWindow).Unix()) if srv.isActive("old") { t.Fatal("stale token should be inactive") } } func TestMessagesPaginationAPI(t *testing.T) { ts, srv := newTestServer(t) tok, _, tid := joinedMember(t, srv, "x") ids := []int64{} for i := 0; i < 5; i++ { m, _ := srv.db.insertMessage(tid, "b", "author-a") ids = append(ids, m.ID) } get := func(q string) []int64 { r, _ := http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + tid + q) var out struct { Messages []struct { ID int64 `json:"id"` } `json:"messages"` } json.NewDecoder(r.Body).Decode(&out) got := []int64{} for _, m := range out.Messages { got = append(got, m.ID) } return got } // limit returns the most recent N, ascending. if g := get("&limit=2"); len(g) != 2 || g[0] != ids[3] || g[1] != ids[4] { t.Fatalf("limit=2: %v", g) } // before paginates older, ascending. if g := get("&before=" + strconv.FormatInt(ids[3], 10) + "&limit=2"); len(g) != 2 || g[0] != ids[1] || g[1] != ids[2] { t.Fatalf("before: %v", g) } } func TestPostMessageAPI(t *testing.T) { ts, srv := newTestServer(t) tok, _, tid := joinedMember(t, srv, "bot") post := func(token, body string) *http.Response { r, err := http.Post(ts.URL+"/api/messages?token="+token, "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post: %v", err) } return r } // A bogus token can't post. if r := post("bogus", `{"thread_id":"`+tid+`","blob":"abc"}`); r.StatusCode != http.StatusForbidden { t.Fatalf("bad token: want 403, got %d", r.StatusCode) } // Empty blob is rejected. if r := post(tok, `{"thread_id":"`+tid+`","blob":""}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("empty blob: want 400, got %d", r.StatusCode) } // Oversized blob is rejected (mirrors the WebSocket cap). if r := post(tok, `{"thread_id":"`+tid+`","blob":"`+strings.Repeat("a", maxBlobBytes+1)+`"}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("oversized blob: want 400, got %d", r.StatusCode) } // A member can't post to a thread they don't belong to (thread-scoped gate). if r := post(tok, `{"thread_id":"not-my-thread","blob":"cipher"}`); r.StatusCode != http.StatusForbidden { t.Fatalf("non-member thread: want 403, got %d", r.StatusCode) } // A valid post is stored and echoed back. r := post(tok, `{"thread_id":"`+tid+`","blob":"cipher-blob-xyz"}`) if r.StatusCode != http.StatusOK { t.Fatalf("valid post: want 200, got %d", r.StatusCode) } var m struct { ID int64 `json:"id"` Blob string `json:"blob"` } json.NewDecoder(r.Body).Decode(&m) if m.ID == 0 || m.Blob != "cipher-blob-xyz" { t.Fatalf("unexpected response: %+v", m) } // It must come back through the thread-scoped read API as the same ciphertext. gr, _ := http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + tid) var out struct { Messages []struct { ID int64 `json:"id"` Blob string `json:"blob"` } `json:"messages"` } json.NewDecoder(gr.Body).Decode(&out) if len(out.Messages) != 1 || out.Messages[0].Blob != "cipher-blob-xyz" { t.Fatalf("message not stored: %+v", out.Messages) } } func TestBlobUploadDownload(t *testing.T) { ts, srv := newTestServer(t) tok := mintDeviceToken(t, srv.db, "x") payload := []byte("pretend-this-is-encrypted-bytes\x00\x01\x02") // Upload requires a valid token. resp, _ := http.Post(ts.URL+"/api/upload?token=bogus", "application/octet-stream", strings.NewReader("x")) if resp.StatusCode != http.StatusForbidden { t.Fatalf("upload bad token: want 403, got %d", resp.StatusCode) } resp, err := http.Post(ts.URL+"/api/upload?token="+tok, "application/octet-stream", strings.NewReader(string(payload))) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("upload: err=%v status=%d", err, resp.StatusCode) } var up struct { ID string `json:"id"` } json.NewDecoder(resp.Body).Decode(&up) if up.ID == "" { t.Fatal("no blob id returned") } // Download requires a valid token. resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=bogus") if resp.StatusCode != http.StatusForbidden { t.Fatalf("download bad token: want 403, got %d", resp.StatusCode) } // Round-trips exactly. resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + tok) if resp.StatusCode != http.StatusOK { t.Fatalf("download: %d", resp.StatusCode) } got, _ := io.ReadAll(resp.Body) if string(got) != string(payload) { t.Fatalf("blob mismatch: got %q", got) } // Unknown id is a 404. resp, _ = http.Get(ts.URL + "/api/blob/nope?token=" + tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("missing blob: want 404, got %d", resp.StatusCode) } // The bytes live on disk (not bloating the SQLite file), and a malformed id // (path traversal / dots) is rejected before it can reach the filesystem. if _, err := os.Stat(srv.db.blobPath(up.ID)); err != nil { t.Fatalf("blob not written to disk: %v", err) } resp, _ = http.Get(ts.URL + "/api/blob/has.dot?token=" + tok) if resp.StatusCode != http.StatusNotFound { t.Fatalf("malformed id: want 404, got %d", resp.StatusCode) } } // A pre-disk blob (bytes in the DB column, no file) still downloads via the // legacy fallback — existing image/avatar blobs must keep working after the // switch to disk-backed storage. func TestBlobLegacyColumnFallback(t *testing.T) { ts, srv := newTestServer(t) tok := mintDeviceToken(t, srv.db, "x") if _, err := srv.db.sql.Exec( "INSERT INTO blobs(id, data, size, created_at) VALUES(?, ?, ?, ?)", "legacyone", []byte("old-column-bytes"), 16, time.Now().Unix(), ); err != nil { t.Fatalf("seed legacy blob: %v", err) } resp, _ := http.Get(ts.URL + "/api/blob/legacyone?token=" + tok) if resp.StatusCode != http.StatusOK { t.Fatalf("legacy download: %d", resp.StatusCode) } got, _ := io.ReadAll(resp.Body) if string(got) != "old-column-bytes" { t.Fatalf("legacy blob mismatch: got %q", got) } } // A blob tagged with its thread is reclaimable by thread-scoped GC, and a client // can only drop blobs from its own thread (the scope guard). func TestBlobThreadScopedGC(t *testing.T) { ts, srv := newTestServer(t) tok := mintDeviceToken(t, srv.db, "x") resp, err := http.Post(ts.URL+"/api/upload?token="+tok+"&thread=T1", "application/octet-stream", strings.NewReader("clip-bytes")) if err != nil || resp.StatusCode != http.StatusOK { t.Fatalf("upload: err=%v status=%d", err, resp.StatusCode) } var up struct { ID string `json:"id"` } json.NewDecoder(resp.Body).Decode(&up) // Wrong thread: the scope guard means nothing is deleted. if err := srv.db.deleteBlobs("OTHER", []string{up.ID}); err != nil { t.Fatalf("deleteBlobs(other): %v", err) } if resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + tok); resp.StatusCode != http.StatusOK { t.Fatalf("blob wrongly deleted by cross-thread GC: %d", resp.StatusCode) } // Correct thread: row + file both go. if err := srv.db.deleteBlobs("T1", []string{up.ID}); err != nil { t.Fatalf("deleteBlobs(T1): %v", err) } if resp, _ = http.Get(ts.URL + "/api/blob/" + up.ID + "?token=" + tok); resp.StatusCode != http.StatusNotFound { t.Fatalf("blob not GC'd: %d", resp.StatusCode) } if _, err := os.Stat(srv.db.blobPath(up.ID)); !os.IsNotExist(err) { t.Fatalf("blob file not unlinked: %v", err) } } // Uploads past the per-instance storage quota are refused with 507. func TestBlobQuota(t *testing.T) { ts, srv := newTestServer(t) srv.blobQuota = 8 // bytes — tiny, for the test tok := mintDeviceToken(t, srv.db, "x") resp, _ := http.Post(ts.URL+"/api/upload?token="+tok, "application/octet-stream", strings.NewReader("way-too-many-bytes")) if resp.StatusCode != http.StatusInsufficientStorage { t.Fatalf("over-quota upload: want 507, got %d", resp.StatusCode) } } func TestPushSubscribeGating(t *testing.T) { ts, _ := newTestServer(t) body := `{"token":"bad","subscription":{"endpoint":"https://e","keys":{"p256dh":"p","auth":"a"}}}` resp, _ := http.Post(ts.URL+"/api/push/subscribe", "application/json", strings.NewReader(body)) if resp.StatusCode != http.StatusForbidden { t.Fatalf("subscribe with bad invite: want 403, got %d", resp.StatusCode) } } func TestGroupNameEndpoints(t *testing.T) { ts, _ := newTestServer(t) resp, _ := http.Get(ts.URL + "/api/group") var g struct{ Name string } json.NewDecoder(resp.Body).Decode(&g) if g.Name != defaultGroupName { t.Fatalf("default name = %q", g.Name) } // Setting requires admin. resp, _ = http.Post(ts.URL+"/api/admin/group", "application/json", strings.NewReader(`{"name":"Fam"}`)) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("set without auth: want 401, got %d", resp.StatusCode) } resp = adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("set name: %d", resp.StatusCode) } // Reflected in the page title and the manifest. resp, _ = http.Get(ts.URL + "/") body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "Fam") { t.Fatal("group name not injected into page title") } resp, _ = http.Get(ts.URL + "/manifest.webmanifest") var m struct{ Name string } json.NewDecoder(resp.Body).Decode(&m) if m.Name != "Fam" { t.Fatalf("manifest name = %q", m.Name) } } func TestGroupNameEscapedInHTML(t *testing.T) { ts, _ := newTestServer(t) adminPost(t, ts.URL+"/api/admin/group", `{"name":""}`) resp, _ := http.Get(ts.URL + "/") body, _ := io.ReadAll(resp.Body) if strings.Contains(string(body), "") { t.Fatal("group name not HTML-escaped (XSS risk)") } if !strings.Contains(string(body), "<script>") { t.Fatal("expected escaped group name in page") } } func TestAdminIdentity(t *testing.T) { ts, _ := newTestServer(t) // Set the space name + the optional administrator identity. if resp := adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam","admin_name":"Paul Campbell","admin_email":"paul@example.com"}`); resp.StatusCode != http.StatusOK { t.Fatalf("set: %d", resp.StatusCode) } // The admin console reads it back via the admin-authed settings endpoint. var s struct { AdminName string `json:"admin_name"` AdminEmail string `json:"admin_email"` } json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&s) if s.AdminName != "Paul Campbell" || s.AdminEmail != "paul@example.com" { t.Fatalf("settings identity = %q / %q", s.AdminName, s.AdminEmail) } // Public /api/group (onboarding, no token) must NOT leak the admin identity. var pub map[string]any json.NewDecoder(mustGet(t, ts.URL+"/api/group").Body).Decode(&pub) if _, ok := pub["admin_name"]; ok { t.Fatal("public /api/group leaked admin_name") } // A valid member token gets it (used by Eleven's tour). adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Paul","id":"inv-1","wrapped":"w1","claim_hash":"`+testClaimHash+`"}`) r, _ := http.Post(ts.URL+"/api/key-invite/inv-1/claim", "application/json", strings.NewReader(`{"claim_secret":"test-claim-secret-vector"}`)) var claim struct { Token string `json:"token"` } json.NewDecoder(r.Body).Decode(&claim) var member struct { AdminName string `json:"admin_name"` AdminEmail string `json:"admin_email"` } json.NewDecoder(mustGet(t, ts.URL+"/api/group?token="+claim.Token).Body).Decode(&member) if member.AdminName != "Paul Campbell" || member.AdminEmail != "paul@example.com" { t.Fatalf("member identity = %q / %q", member.AdminName, member.AdminEmail) } // Both are optional: empty clears them. adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam"}`) json.NewDecoder(adminGet(t, ts.URL+"/api/admin/settings").Body).Decode(&s) if s.AdminName != "" || s.AdminEmail != "" { t.Fatalf("expected cleared identity, got %q / %q", s.AdminName, s.AdminEmail) } // A malformed email is rejected (so does an over-long name). if resp := adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam","admin_email":"not an email"}`); resp.StatusCode != http.StatusBadRequest { t.Fatalf("bad email: want 400, got %d", resp.StatusCode) } if resp := adminPost(t, ts.URL+"/api/admin/group", `{"name":"Fam","admin_name":"`+strings.Repeat("x", 61)+`"}`); resp.StatusCode != http.StatusBadRequest { t.Fatalf("long name: want 400, got %d", resp.StatusCode) } } func mustGet(t *testing.T, url string) *http.Response { t.Helper() resp, err := http.Get(url) if err != nil { t.Fatalf("get %s: %v", url, err) } return resp } // adminReq issues a request with a raw body and the admin bearer token. func adminReq(t *testing.T, method, url, contentType string, body []byte) *http.Response { t.Helper() req, _ := http.NewRequest(method, url, bytes.NewReader(body)) req.Header.Set("Authorization", "Bearer admintok") if contentType != "" { req.Header.Set("Content-Type", contentType) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request: %v", err) } return resp } // Alt text travels with the photo (plaintext branding, like the photo itself): // set via ?alt= on upload, served by /api/group, reset by a new photo or clear. func TestServerAvatarAltText(t *testing.T) { ts, _ := newTestServer(t) png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...) groupAlt := func() (string, bool) { resp, _ := http.Get(ts.URL + "/api/group") var g map[string]any json.NewDecoder(resp.Body).Decode(&g) alt, present := g["avatar_alt"] s, _ := alt.(string) return s, present } if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar?alt="+url.QueryEscape("Two canaries on a branch"), "", png); resp.StatusCode != http.StatusOK { t.Fatalf("set with alt: %d", resp.StatusCode) } if alt, _ := groupAlt(); alt != "Two canaries on a branch" { t.Fatalf("alt round-trip: got %q", alt) } // A new photo without alt resets it (the text belongs to the image). if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", png); resp.StatusCode != http.StatusOK { t.Fatalf("re-set: %d", resp.StatusCode) } if alt, _ := groupAlt(); alt != "" { t.Fatalf("alt should reset with a new photo, got %q", alt) } // Clearing the photo drops the field from /api/group entirely. if resp := adminReq(t, "DELETE", ts.URL+"/api/admin/server-avatar", "", nil); resp.StatusCode != http.StatusOK { t.Fatalf("clear: %d", resp.StatusCode) } if _, present := groupAlt(); present { t.Fatal("avatar_alt should be absent with no photo") } } // The server avatar is admin-set, public plaintext branding: set/clear are admin // only; read is unauthenticated and reports the stored bytes + type, and the // group endpoint flags its presence so clients know whether to fetch it. func TestServerAvatar(t *testing.T) { ts, _ := newTestServer(t) // A minimal payload that http.DetectContentType recognises as a PNG. png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...) groupAvatarFlag := func() bool { resp, _ := http.Get(ts.URL + "/api/group") var g struct{ Avatar bool } json.NewDecoder(resp.Body).Decode(&g) return g.Avatar } // Unset: 404 on read, and the group endpoint reports no avatar. if resp, _ := http.Get(ts.URL + "/api/server-avatar"); resp.StatusCode != http.StatusNotFound { t.Fatalf("unset read: want 404, got %d", resp.StatusCode) } if groupAvatarFlag() { t.Fatal("group avatar flag true before any upload") } // Set requires admin. if resp := adminReqNoAuth(t, "POST", ts.URL+"/api/admin/server-avatar", png); resp.StatusCode != http.StatusUnauthorized { t.Fatalf("set without auth: want 401, got %d", resp.StatusCode) } // Non-image bytes are rejected (type sniffed, not trusted). if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", []byte("just text, not an image")); resp.StatusCode != http.StatusUnsupportedMediaType { t.Fatalf("non-image: want 415, got %d", resp.StatusCode) } // Oversize is rejected before storage. if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", make([]byte, maxServerAvatarBytes+16)); resp.StatusCode != http.StatusRequestEntityTooLarge { t.Fatalf("oversize: want 413, got %d", resp.StatusCode) } // A valid PNG stores. if resp := adminReq(t, "POST", ts.URL+"/api/admin/server-avatar", "", png); resp.StatusCode != http.StatusOK { t.Fatalf("set avatar: %d", resp.StatusCode) } // Read serves the exact bytes, the sniffed type, and an ETag; group flags it. resp, _ := http.Get(ts.URL + "/api/server-avatar") if resp.StatusCode != http.StatusOK { t.Fatalf("read: %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); ct != "image/png" { t.Fatalf("content-type = %q", ct) } got, _ := io.ReadAll(resp.Body) if !bytes.Equal(got, png) { t.Fatal("served bytes differ from stored") } etag := resp.Header.Get("ETag") if etag == "" { t.Fatal("missing ETag") } if !groupAvatarFlag() { t.Fatal("group avatar flag false after upload") } // A matching If-None-Match revalidates to 304. req, _ := http.NewRequest("GET", ts.URL+"/api/server-avatar", nil) req.Header.Set("If-None-Match", etag) resp, _ = http.DefaultClient.Do(req) if resp.StatusCode != http.StatusNotModified { t.Fatalf("If-None-Match: want 304, got %d", resp.StatusCode) } // Clear requires admin, then read 404s again and the flag clears. if resp := adminReqNoAuth(t, "DELETE", ts.URL+"/api/admin/server-avatar", nil); resp.StatusCode != http.StatusUnauthorized { t.Fatalf("clear without auth: want 401, got %d", resp.StatusCode) } if resp := adminReq(t, "DELETE", ts.URL+"/api/admin/server-avatar", "", nil); resp.StatusCode != http.StatusOK { t.Fatalf("clear: %d", resp.StatusCode) } if resp, _ := http.Get(ts.URL + "/api/server-avatar"); resp.StatusCode != http.StatusNotFound { t.Fatalf("read after clear: want 404, got %d", resp.StatusCode) } if groupAvatarFlag() { t.Fatal("group avatar flag true after clear") } } // adminReqNoAuth issues a raw-body request WITHOUT the admin token (to prove the gate). func adminReqNoAuth(t *testing.T, method, url string, body []byte) *http.Response { t.Helper() req, _ := http.NewRequest(method, url, bytes.NewReader(body)) resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("request: %v", err) } return resp } // Starting a DM you already have must reopen the same thread, not spawn a // second. The client relies on the server returning the existing thread with // existing:true on a repeat POST (see createThread / existingDM in chat.js). func TestPostThreadDedupesDM(t *testing.T) { ts, srv := newTestServer(t) mkUser := func(label string) (tok, uid string) { u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser %s: %v", label, err) } if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite %s: %v", label, err) } tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey %s: %v", label, err) } return tk, u } tokA, uidA := mkUser("Alice") _, uidB := mkUser("Bob") body := `{"token":"` + tokA + `","kind":"dm","members":[` + `{"user_id":"` + uidA + `","wrapped":"wa"},` + `{"user_id":"` + uidB + `","wrapped":"wb"}]}` post := func() (string, bool) { resp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("status %d", resp.StatusCode) } var out struct { ThreadID string `json:"thread_id"` Existing bool `json:"existing"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } return out.ThreadID, out.Existing } id1, existing1 := post() if id1 == "" || existing1 { t.Fatalf("first create: id=%q existing=%v (want a fresh id, existing=false)", id1, existing1) } id2, existing2 := post() if id2 != id1 || !existing2 { t.Fatalf("repeat create should dedupe: id=%q existing=%v (want %q, true)", id2, existing2, id1) } if got, ok, _ := srv.db.dmThreadFor(uidA, uidB); !ok || got != id1 { t.Fatalf("dmThreadFor: got %q ok=%v want %q", got, ok, id1) } } // Closing a chat hides it for that user (synced via getThreads); new activity // reopens it; and a group member can be removed (but not from a DM). func TestThreadHiddenAndRemove(t *testing.T) { ts, srv := newTestServer(t) mkUser := func(label string) (tok, uid string) { u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser %s: %v", label, err) } if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite %s: %v", label, err) } tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey %s: %v", label, err) } return tk, u } tokA, uidA := mkUser("Alice") _, uidB := mkUser("Bob") gid, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}}) if err != nil { t.Fatalf("createThread: %v", err) } post := func(path, body string) int { resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post %s: %v", path, err) } resp.Body.Close() return resp.StatusCode } hiddenForAlice := func() bool { resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA) if err != nil { t.Fatalf("getThreads: %v", err) } defer resp.Body.Close() var out struct { Threads []Thread `json:"threads"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } for _, th := range out.Threads { if th.ID == gid { return th.Hidden } } t.Fatalf("group %q missing from Alice's threads", gid) return false } if code := post("/api/threads/"+gid+"/hidden", `{"token":"`+tokA+`","hidden":true}`); code != 200 { t.Fatalf("hide: got %d", code) } if !hiddenForAlice() { t.Fatal("chat should be hidden for Alice after closing") } // New activity reopens it. if _, err := srv.db.insertMessage(gid, "blob", "author"); err != nil { t.Fatalf("insertMessage: %v", err) } if hiddenForAlice() { t.Fatal("new activity should reopen the closed chat") } // Remove Bob from the group. if code := post("/api/threads/"+gid+"/remove", `{"token":"`+tokA+`","user_id":"`+uidB+`"}`); code != 200 { t.Fatalf("remove: got %d", code) } if srv.db.threadMember(gid, uidB) { t.Fatal("Bob should no longer be a member") } if !srv.db.threadMember(gid, uidA) { t.Fatal("Alice should still be a member") } // Removal is groups-only — a DM rejects it. dm, err := srv.db.createThread("dm", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}, {UserID: uidB, Wrapped: "wb"}}) if err != nil { t.Fatalf("createThread dm: %v", err) } if code := post("/api/threads/"+dm+"/remove", `{"token":"`+tokA+`","user_id":"`+uidB+`"}`); code != 400 { t.Fatalf("remove from DM should be 400, got %d", code) } } // Pinning a chat floats it to the top of that user's list (synced via // getThreads) and survives new activity elsewhere; unpinning restores // recency order. Per-user: one member's pin never affects another's list. func TestThreadPinned(t *testing.T) { ts, srv := newTestServer(t) tokA, uidA, tid1 := joinedMember(t, srv, "Alice") // A second thread with newer activity, so tid1 only sorts first when pinned. tid2, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uidA, Wrapped: "wa"}}) if err != nil { t.Fatalf("createThread: %v", err) } if _, err := srv.db.insertMessage(tid2, "blob", "author"); err != nil { t.Fatalf("insertMessage: %v", err) } post := func(path, body string) int { resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post %s: %v", path, err) } resp.Body.Close() return resp.StatusCode } threadsForAlice := func() []Thread { resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA) if err != nil { t.Fatalf("getThreads: %v", err) } defer resp.Body.Close() var out struct { Threads []Thread `json:"threads"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } return out.Threads } if got := threadsForAlice(); got[0].ID != tid2 { t.Fatalf("before pinning, newest-activity thread should sort first, got %q", got[0].ID) } if code := post("/api/threads/"+tid1+"/pinned", `{"token":"`+tokA+`","pinned":true}`); code != 200 { t.Fatalf("pin: got %d", code) } got := threadsForAlice() if got[0].ID != tid1 || !got[0].Pinned { t.Fatalf("pinned thread should sort first with pinned=true, got id=%q pinned=%v", got[0].ID, got[0].Pinned) } // New activity in the other thread must not displace the pin. if _, err := srv.db.insertMessage(tid2, "blob2", "author"); err != nil { t.Fatalf("insertMessage: %v", err) } if got := threadsForAlice(); got[0].ID != tid1 { t.Fatal("pinned thread should stay first over newer activity elsewhere") } if code := post("/api/threads/"+tid1+"/pinned", `{"token":"`+tokA+`","pinned":false}`); code != 200 { t.Fatalf("unpin: got %d", code) } got = threadsForAlice() if got[0].ID != tid2 || got[0].Pinned { t.Fatalf("after unpinning, recency order should return, got id=%q pinned=%v", got[0].ID, got[0].Pinned) } // Non-members can't pin. tokB, _, _ := joinedMember(t, srv, "Bob") if code := post("/api/threads/"+tid1+"/pinned", `{"token":"`+tokB+`","pinned":true}`); code != 403 { t.Fatalf("non-member pin should be 403, got %d", code) } } // The read watermark is per-user and forward-only: POSTing it advances // last_read_at in getThreads, a stale (older) write never regresses it, and // the "read" WS frame fans out to the reader's OTHER devices but never to // other members. func TestThreadRead(t *testing.T) { ts, srv := newTestServer(t) tokA, uidA, tid := joinedMember(t, srv, "Alice") m, err := srv.db.insertMessage(tid, "blob", "author") if err != nil { t.Fatalf("insertMessage: %v", err) } post := func(path, body string) int { resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post %s: %v", path, err) } resp.Body.Close() return resp.StatusCode } lastReadForAlice := func() int64 { resp, err := http.Get(ts.URL + "/api/threads?token=" + tokA) if err != nil { t.Fatalf("getThreads: %v", err) } defer resp.Body.Close() var out struct { Threads []Thread `json:"threads"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode: %v", err) } for _, th := range out.Threads { if th.ID == tid { return th.LastReadAt } } t.Fatalf("thread %s missing from list", tid) return 0 } if got := lastReadForAlice(); got != 0 { t.Fatalf("before any read, last_read_at should be 0, got %d", got) } // A second device for Alice (same user, own token) and a second member, both // on WebSocket — the read fan-out must reach the former and never the latter. if err := srv.db.createKeyInvite("inv-Alice-2", uidA, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite: %v", err) } tokA2, ok, err := srv.db.claimKeyInvite("inv-Alice-2", "Alice", "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err) } tokB, uidB, _ := joinedMember(t, srv, "Bob") if err := srv.db.addThreadMember(tid, uidB, "wrap-b"); err != nil { t.Fatalf("addThreadMember: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() dial := func(tok string) *websocket.Conn { conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(ts.URL, "http")+"/ws?token="+tok, nil) if err != nil { t.Fatalf("ws dial: %v", err) } return conn } readUntil := func(conn *websocket.Conn, stage string, want func(map[string]any) bool) map[string]any { for { _, data, err := conn.Read(ctx) if err != nil { t.Fatalf("%s read: %v", stage, err) } var msg map[string]any if json.Unmarshal(data, &msg) != nil { continue } if want(msg) { return msg } } } connA2 := dial(tokA2) defer connA2.CloseNow() connB := dial(tokB) defer connB.CloseNow() // Prove both registrations landed before the POST below (Dial returns before // the server's register does): a stored-message round-trip through each. if err := connA2.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup-a2"}`)); err != nil { t.Fatalf("warmup write: %v", err) } readUntil(connA2, "warmup-a2", func(m map[string]any) bool { return m["blob"] == "warmup-a2" }) if err := connB.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup-b"}`)); err != nil { t.Fatalf("warmup write: %v", err) } readUntil(connB, "warmup-b", func(m map[string]any) bool { return m["blob"] == "warmup-b" }) readUntil(connA2, "warmup-b on A2", func(m map[string]any) bool { return m["blob"] == "warmup-b" }) at := m.CreatedAt + 10 // past the warmup messages' time if code := post("/api/threads/"+tid+"/read", `{"token":"`+tokA+`","last_read_at":`+strconv.FormatInt(at, 10)+`}`); code != 200 { t.Fatalf("read: got %d", code) } if got := lastReadForAlice(); got != at { t.Fatalf("last_read_at should be %d, got %d", at, got) } // Alice's other device hears about it… frame := readUntil(connA2, "read frame", func(m map[string]any) bool { return m["type"] == "read" }) if frame["thread_id"] != tid || int64(frame["last_read_at"].(float64)) != at { t.Fatalf("read frame: got %v", frame) } // …Bob never does: the next thing on his socket is a later message, with no // read frame before it (the hub is FIFO, so the read fan-out would have come // first if it had been addressed to him). if err := connA2.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"after"}`)); err != nil { t.Fatalf("after write: %v", err) } _, data, err := connB.Read(ctx) if err != nil { t.Fatalf("bob read: %v", err) } var bobMsg map[string]any json.Unmarshal(data, &bobMsg) if bobMsg["type"] != "msg" || bobMsg["blob"] != "after" { t.Fatalf("bob should only see the message, got %s", data) } // Forward-only: an older mark from a lagging device changes nothing. if code := post("/api/threads/"+tid+"/read", `{"token":"`+tokA+`","last_read_at":`+strconv.FormatInt(at-5, 10)+`}`); code != 200 { t.Fatalf("stale read: got %d", code) } if got := lastReadForAlice(); got != at { t.Fatalf("stale write must not regress the mark: want %d, got %d", at, got) } // Bob has his own mark; Alice's is untouched by it. if code := post("/api/threads/"+tid+"/read", `{"token":"`+tokB+`","last_read_at":`+strconv.FormatInt(at+5, 10)+`}`); code != 200 { t.Fatalf("bob read: got %d", code) } if got := lastReadForAlice(); got != at { t.Fatalf("another member's read must not move Alice's mark: want %d, got %d", at, got) } // Guardrails: a missing/zero timestamp is a 400; non-members get 403. if code := post("/api/threads/"+tid+"/read", `{"token":"`+tokA+`"}`); code != 400 { t.Fatalf("zero timestamp should be 400, got %d", code) } tokC, _, _ := joinedMember(t, srv, "Carol") if code := post("/api/threads/"+tid+"/read", `{"token":"`+tokC+`","last_read_at":1}`); code != 403 { t.Fatalf("non-member read should be 403, got %d", code) } } // joinedMember sets up a real "joined" member — a user with a device token and a // registered public key — plus a thread they belong to, for exercising the // thread-scoped APIs. Returns the device token, the user id, and the thread id. func joinedMember(t *testing.T, srv *Server, label string) (token, userID, threadID string) { t.Helper() uid, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser: %v", err) } if err := srv.db.createKeyInvite("inv-"+label, uid, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite: %v", err) } tok, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite: ok=%v err=%v", ok, err) } if err := srv.db.setUserPublicKey(uid, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey: %v", err) } tid, err := srv.db.createThread("group", "", []ThreadMemberKey{{UserID: uid, Wrapped: "wrap"}}) if err != nil { t.Fatalf("createThread: %v", err) } return tok, uid, tid } // APNs registration is token-gated and idempotent; unsubscribe removes the row. func TestAPNsSubscribe(t *testing.T) { ts, s := newTestServer(t) // Mint a member the same way TestKeyInviteFlow does: admin key-invite + claim. adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Pat","id":"apns-invite","wrapped":"","claim_hash":"`+testClaimHash+`"}`) r, _ := http.Post(ts.URL+"/api/key-invite/apns-invite/claim", "application/json", strings.NewReader(`{"label":"Pat","claim_secret":"test-claim-secret-vector"}`)) var claim struct { Ok bool `json:"ok"` Token string `json:"token"` } json.NewDecoder(r.Body).Decode(&claim) if !claim.Ok || claim.Token == "" { t.Fatalf("claim: %+v", claim) } post := func(path, body string) *http.Response { resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) if err != nil { t.Fatal(err) } return resp } // Bad token refused. if resp := post("/api/apns/subscribe", `{"token":"nope","apns_token":"abc"}`); resp.StatusCode != http.StatusForbidden { t.Fatalf("bad token: want 403, got %d", resp.StatusCode) } // Valid registration lands; enabled=false without LCHAT_APNS_* configured. resp := post("/api/apns/subscribe", `{"token":"`+claim.Token+`","apns_token":"abc","sandbox":true}`) var out struct { Ok bool `json:"ok"` Enabled bool `json:"enabled"` } json.NewDecoder(resp.Body).Decode(&out) if !out.Ok || out.Enabled { t.Fatalf("subscribe: %+v", out) } rows, err := s.db.listAPNsTokens() // The session token is stored hashed, never in clear (it's a live credential). if err != nil || len(rows) != 1 || rows[0].APNsToken != "abc" || !rows[0].Sandbox || rows[0].Token != "" || rows[0].TokenHash != hashToken(claim.Token) { t.Fatalf("stored: %v %v", rows, err) } // Re-registering the same APNs token updates in place, never duplicates. post("/api/apns/subscribe", `{"token":"`+claim.Token+`","apns_token":"abc"}`) if rows, _ = s.db.listAPNsTokens(); len(rows) != 1 || rows[0].Sandbox { t.Fatalf("upsert: %v", rows) } if resp := post("/api/apns/unsubscribe", `{"token":"`+claim.Token+`","apns_token":"abc"}`); resp.StatusCode != http.StatusOK { t.Fatalf("unsubscribe: %d", resp.StatusCode) } if rows, _ = s.db.listAPNsTokens(); len(rows) != 0 { t.Fatalf("not removed: %v", rows) } } // The AASA file must be public (Apple's CDN fetches it anonymously) and name the // app over the /join/* pattern, so invite links open in the installed iOS app. func TestAppleAppSiteAssociation(t *testing.T) { ts, _ := newTestServer(t) resp, err := http.Get(ts.URL + "/.well-known/apple-app-site-association") if err != nil { t.Fatalf("get aasa: %v", err) } defer resp.Body.Close() if resp.StatusCode != 200 { t.Fatalf("aasa status = %d, want 200", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); ct != "application/json" { t.Fatalf("aasa content-type = %q", ct) } b, _ := io.ReadAll(resp.Body) var doc struct { Applinks struct { Details []struct { AppIDs []string `json:"appIDs"` Components []map[string]string `json:"components"` } `json:"details"` } `json:"applinks"` } if err := json.Unmarshal(b, &doc); err != nil { t.Fatalf("aasa not valid JSON: %v\n%s", err, b) } if len(doc.Applinks.Details) != 1 || len(doc.Applinks.Details[0].AppIDs) != 2 { t.Fatalf("aasa shape wrong: %s", b) } // iOS first, then the Mac app (universal links' server half — #1376). if got := doc.Applinks.Details[0].AppIDs[0]; got != appleAppID { t.Fatalf("aasa appID = %q, want %q", got, appleAppID) } if got := doc.Applinks.Details[0].AppIDs[1]; got != appleMacAppID { t.Fatalf("aasa mac appID = %q, want %q", got, appleMacAppID) } if got := doc.Applinks.Details[0].Components[0]["/"]; got != "/join/*" { t.Fatalf("aasa join pattern = %q", got) } } // The welcome page's "Get the app" URL is injected per instance; unset config // must substitute to empty (the page hides the button) — and the placeholder // must never leak to the client. func TestJoinPageAppStoreURL(t *testing.T) { ts, s := newTestServer(t) get := func() string { resp, err := http.Get(ts.URL + "/join/join") if err != nil { t.Fatalf("get join: %v", err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } if body := get(); strings.Contains(body, "__APP_STORE_URL__") { t.Fatal("join page leaks the __APP_STORE_URL__ placeholder") } s.appStoreURL = "https://testflight.apple.com/join/xyz" if body := get(); !strings.Contains(body, `data-store-url="https://testflight.apple.com/join/xyz"`) { t.Fatal("join page missing configured app store URL") } } // The join page carries an OpenGraph card so invite links preview nicely. The // title is the (plaintext) group name — never the inviter's screen name, which is // E2E — and the image is the server avatar when set, else the app logo. func TestJoinPageOpenGraph(t *testing.T) { ts, s := newTestServer(t) if err := s.db.setGroupName("Paul's Eleven Server"); err != nil { t.Fatalf("setGroupName: %v", err) } get := func() string { resp, err := http.Get(ts.URL + "/join/join") if err != nil { t.Fatalf("get join: %v", err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return string(b) } body := get() if strings.Contains(body, "__OG_IMAGE__") || strings.Contains(body, "__OG_URL__") { t.Fatal("join page leaks an __OG_ placeholder") } if !strings.Contains(body, ``) { t.Fatalf("join page missing og:title with the group name:\n%s", body) } // No avatar set → the app logo is the preview image. if !strings.Contains(body, ``) { t.Fatalf("join page og:image should fall back to the app logo:\n%s", body) } // With a branding avatar set, the preview image is the server avatar. if err := s.db.setInstanceAvatar([]byte("\x89PNG\r\n\x1a\n"), "image/png", 1); err != nil { t.Fatalf("setInstanceAvatar: %v", err) } if body := get(); !strings.Contains(body, ``) { t.Fatalf("join page og:image should be the server avatar when set:\n%s", body) } } // The chat page exposes the passkey-backup home origin to JS (window.__homeOrigin). // The default must be substituted and the placeholder must never leak to the client. func TestChatPageHomeOrigin(t *testing.T) { ts, _ := newTestServer(t) resp, err := http.Get(ts.URL + "/") if err != nil { t.Fatalf("get chat page: %v", err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) body := string(b) if strings.Contains(body, "__HOME_ORIGIN__") { t.Fatal("chat page leaks the __HOME_ORIGIN__ placeholder") } if !strings.Contains(body, `window.__homeOrigin = "`+defaultHomeOrigin+`"`) { t.Fatal("chat page missing the default home origin") } } // ---- custom apps (customapps.go) ----------------------------------------- func TestCustomAppsManifestEmptyWhenUnset(t *testing.T) { ts, _ := newTestServer(t) resp, err := http.Get(ts.URL + "/api/apps") if err != nil { t.Fatalf("get manifest: %v", err) } if resp.StatusCode != http.StatusOK { t.Fatalf("manifest: want 200, got %d", resp.StatusCode) } var body struct { Apps []map[string]string `json:"apps"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if body.Apps == nil || len(body.Apps) != 0 { t.Fatalf("want empty apps list, got %v", body.Apps) } // The module route 404s too — the feature is fully off without the env var. resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js") if resp.StatusCode != http.StatusNotFound { t.Fatalf("module with no dir: want 404, got %d", resp.StatusCode) } } func TestCustomAppsSideload(t *testing.T) { ts, s := newTestServer(t) src := `import { defineApp } from "../apps.js";` + "\n" + `defineApp({ name: "kanban" });` + "\n" dir := t.TempDir() if err := os.WriteFile(dir+"/kanban.js", []byte(src), 0o644); err != nil { t.Fatalf("write app: %v", err) } // Non-matching names never appear in the manifest, valid or not on disk. os.WriteFile(dir+"/README.md", []byte("not an app"), 0o644) os.WriteFile(dir+"/Bad.js", []byte("uppercase"), 0o644) s.appsDir = dir resp, _ := http.Get(ts.URL + "/api/apps") var body struct { Apps []map[string]string `json:"apps"` } if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { t.Fatalf("decode: %v", err) } if len(body.Apps) != 1 || body.Apps[0]["name"] != "kanban" || body.Apps[0]["url"] != "/static/apps-custom/kanban.js" { t.Fatalf("manifest: got %v", body.Apps) } resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js") if resp.StatusCode != http.StatusOK { t.Fatalf("module: want 200, got %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { t.Fatalf("module content type: got %q", ct) } if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" { t.Fatalf("module cache-control: got %q", cc) } got, _ := io.ReadAll(resp.Body) if string(got) != src { t.Fatalf("module body: got %q", got) } } func TestCustomAppsRejectsInvalidNames(t *testing.T) { ts, s := newTestServer(t) dir := t.TempDir() os.WriteFile(dir+"/kanban.js", []byte("ok"), 0o644) // A secret next door that a traversal would love to read. os.WriteFile(dir+"/../secret.js", []byte("nope"), 0o644) s.appsDir = dir for _, path := range []string{ "/static/apps-custom/Kanban.js", // uppercase "/static/apps-custom/kanban.txt", // not .js "/static/apps-custom/kanban", // no extension "/static/apps-custom/no-such.js", // valid name, missing file "/static/apps-custom/..%2Fsecret.js", // encoded traversal "/static/apps-custom/%2e%2e%2fsecret.js", // fully encoded traversal "/static/apps-custom/a.b.js", // extra dot } { resp, err := http.Get(ts.URL + path) if err != nil { t.Fatalf("get %s: %v", path, err) } if resp.StatusCode != http.StatusNotFound { t.Fatalf("%s: want 404, got %d", path, resp.StatusCode) } } } // The mini-app authoring guide is embedded in the binary and served as markdown // at /api/app-guide — deliberately un-gated (static docs, not instance data), // so an external LLM can be pointed straight at the URL. func TestAppGuide(t *testing.T) { ts, _ := newTestServer(t) resp, err := http.Get(ts.URL + "/api/app-guide") if err != nil { t.Fatalf("get app guide: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("want 200, got %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") { t.Fatalf("want text/markdown content type, got %q", ct) } b, _ := io.ReadAll(resp.Body) body := string(b) if !strings.Contains(body, "defineApp") { t.Fatal("guide body missing defineApp — wrong document served?") } if !strings.Contains(body, "🤖") { t.Fatal("guide missing its 🤖 authorship marker") } } // The static app checker the guide points at is served un-gated too, so an // author following /api/app-guide can run it with no repo checkout. func TestAppChecker(t *testing.T) { ts, _ := newTestServer(t) resp, err := http.Get(ts.URL + "/api/check-app.mjs") if err != nil { t.Fatalf("get app checker: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("want 200, got %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") { t.Fatalf("want text/javascript content type, got %q", ct) } b, _ := io.ReadAll(resp.Body) if !strings.Contains(string(b), "defineApp") { t.Fatal("checker body missing defineApp — wrong file served?") } } // adminJSON issues a method+JSON-body request with the admin bearer token // (adminReq above takes raw bytes; this wraps it for the string-literal cases). func adminJSON(t *testing.T, method, url, body string) *http.Response { t.Helper() ct := "" if body != "" { ct = "application/json" } return adminReq(t, method, url, ct, []byte(body)) } func TestCustomAppsAdminAuthGating(t *testing.T) { ts, _ := newTestServer(t) for _, c := range []struct{ method, path string }{ {"GET", "/api/admin/apps"}, {"PUT", "/api/admin/apps/kanban"}, {"DELETE", "/api/admin/apps/kanban"}, } { // No token at all. req, _ := http.NewRequest(c.method, ts.URL+c.path, strings.NewReader(`{"source":"x"}`)) resp, _ := http.DefaultClient.Do(req) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("%s %s no auth: want 401, got %d", c.method, c.path, resp.StatusCode) } // Wrong token. req, _ = http.NewRequest(c.method, ts.URL+c.path, strings.NewReader(`{"source":"x"}`)) req.Header.Set("Authorization", "Bearer wrong") resp, _ = http.DefaultClient.Do(req) if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("%s %s bad auth: want 401, got %d", c.method, c.path, resp.StatusCode) } } } func TestCustomAppsAdminRoundTrip(t *testing.T) { ts, _ := newTestServer(t) src := `import { defineApp } from "../apps.js"; defineApp({ name: "kanban" });` b, _ := json.Marshal(map[string]string{"source": src}) // Install. resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", string(b)) if resp.StatusCode != http.StatusOK { t.Fatalf("install: want 200, got %d", resp.StatusCode) } // The public manifest lists it. resp, _ = http.Get(ts.URL + "/api/apps") var manifest struct { Apps []map[string]string `json:"apps"` } json.NewDecoder(resp.Body).Decode(&manifest) if len(manifest.Apps) != 1 || manifest.Apps[0]["url"] != "/static/apps-custom/kanban.js" { t.Fatalf("manifest after install: got %v", manifest.Apps) } // The module serves the stored source. resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js") if resp.StatusCode != http.StatusOK { t.Fatalf("module: want 200, got %d", resp.StatusCode) } got, _ := io.ReadAll(resp.Body) if string(got) != src { t.Fatalf("module body: got %q", got) } // Updating bumps the version; the admin list reports name/version/size. resp = adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", string(b)) if resp.StatusCode != http.StatusOK { t.Fatalf("update: want 200, got %d", resp.StatusCode) } resp = adminJSON(t, "GET", ts.URL+"/api/admin/apps", "") var listed struct { Apps []CustomApp `json:"apps"` } json.NewDecoder(resp.Body).Decode(&listed) if len(listed.Apps) != 1 || listed.Apps[0].Name != "kanban" || listed.Apps[0].Version != 2 || listed.Apps[0].Size != len(src) { t.Fatalf("admin list: got %+v", listed.Apps) } // Delete → gone from the manifest and the module route. resp = adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "") if resp.StatusCode != http.StatusOK { t.Fatalf("delete: want 200, got %d", resp.StatusCode) } resp, _ = http.Get(ts.URL + "/api/apps") manifest.Apps = nil json.NewDecoder(resp.Body).Decode(&manifest) if len(manifest.Apps) != 0 { t.Fatalf("manifest after delete: got %v", manifest.Apps) } resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js") if resp.StatusCode != http.StatusNotFound { t.Fatalf("module after delete: want 404, got %d", resp.StatusCode) } // Deleting again 404s. resp = adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "") if resp.StatusCode != http.StatusNotFound { t.Fatalf("re-delete: want 404, got %d", resp.StatusCode) } } func TestCustomAppsAdminRejectsInvalidNames(t *testing.T) { ts, _ := newTestServer(t) for _, name := range []string{"Kanban", "kanban.js", "a_b", "a%20b", "-"} { resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/"+name, `{"source":"x"}`) // "-" is actually valid per the pattern; skip it (documents the boundary). if name == "-" { if resp.StatusCode != http.StatusOK { t.Fatalf("install %q: want 200, got %d", name, resp.StatusCode) } continue } if resp.StatusCode != http.StatusBadRequest { t.Fatalf("install %q: want 400, got %d", name, resp.StatusCode) } } // Empty source is rejected too. resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", `{"source":" "}`) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("empty source: want 400, got %d", resp.StatusCode) } } func TestCustomAppsDBWinsOverDir(t *testing.T) { ts, s := newTestServer(t) dir := t.TempDir() os.WriteFile(dir+"/kanban.js", []byte("// disk copy"), 0o644) os.WriteFile(dir+"/other.js", []byte("// disk only"), 0o644) s.appsDir = dir // Install a DB copy under the same name: it shadows the disk file. resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", `{"source":"// db copy"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("install: want 200, got %d", resp.StatusCode) } resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js") got, _ := io.ReadAll(resp.Body) if string(got) != "// db copy" { t.Fatalf("db should win over dir: got %q", got) } // The manifest merges both sources, deduped. resp, _ = http.Get(ts.URL + "/api/apps") var manifest struct { Apps []map[string]string `json:"apps"` } json.NewDecoder(resp.Body).Decode(&manifest) if len(manifest.Apps) != 2 || manifest.Apps[0]["name"] != "kanban" || manifest.Apps[1]["name"] != "other" { t.Fatalf("merged manifest: got %v", manifest.Apps) } // The admin list shows both: the DB copy manageable, the disk-only file // marked on_disk — and the shadowed disk kanban.js doesn't double-list. resp = adminJSON(t, "GET", ts.URL+"/api/admin/apps", "") var listed struct { Apps []CustomApp `json:"apps"` } json.NewDecoder(resp.Body).Decode(&listed) if len(listed.Apps) != 2 || listed.Apps[0].Name != "kanban" || listed.Apps[0].OnDisk || listed.Apps[1].Name != "other" || !listed.Apps[1].OnDisk { t.Fatalf("admin list with dir: got %+v", listed.Apps) } // Deleting the DB copy unshadows the disk file. adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "") resp, _ = http.Get(ts.URL + "/static/apps-custom/kanban.js") got, _ = io.ReadAll(resp.Body) if string(got) != "// disk copy" { t.Fatalf("after delete the disk copy should serve: got %q", got) } } // Installing or deleting an app broadcasts apps_changed to every connected // client, so open tabs reload and pick the change up right away. func TestCustomAppsBroadcastsAppsChanged(t *testing.T) { ts, srv := newTestServer(t) tok, _, tid := joinedMember(t, srv, "Alice") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + tok conn, _, err := websocket.Dial(ctx, wsURL, nil) if err != nil { t.Fatalf("ws dial: %v", err) } defer conn.CloseNow() // Registration races the admin call (the hub processes register/broadcast on // one loop, but Dial returns before the server's register lands). A stored // message round-trip proves our registration landed first. if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup"}`)); err != nil { t.Fatalf("warmup write: %v", err) } if _, _, err := conn.Read(ctx); err != nil { t.Fatalf("warmup read: %v", err) } // Skip unrelated traffic (our own presence broadcast and the warmup echo, // whose order isn't fixed) — the assertion is that apps_changed arrives. expectAppsChanged := func(stage string) { for { var msg struct { Type string `json:"type"` } _, data, err := conn.Read(ctx) if err != nil { t.Fatalf("%s read: %v (no apps_changed)", stage, err) } if json.Unmarshal(data, &msg) == nil && msg.Type == "apps_changed" { return } } } resp := adminJSON(t, "PUT", ts.URL+"/api/admin/apps/kanban", `{"source":"x"}`) if resp.StatusCode != http.StatusOK { t.Fatalf("install: want 200, got %d", resp.StatusCode) } expectAppsChanged("install") resp = adminJSON(t, "DELETE", ts.URL+"/api/admin/apps/kanban", "") if resp.StatusCode != http.StatusOK { t.Fatalf("delete: want 200, got %d", resp.StatusCode) } expectAppsChanged("delete") } // Flipping "users can invite people" broadcasts the new value live, so a // connected member's invite UI updates without a reload (issue #346). func TestUserInvitesBroadcastsLive(t *testing.T) { ts, srv := newTestServer(t) tok, _, tid := joinedMember(t, srv, "Alice") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/ws?token=" + tok conn, _, err := websocket.Dial(ctx, wsURL, nil) if err != nil { t.Fatalf("ws dial: %v", err) } defer conn.CloseNow() // See TestCustomAppsBroadcastsAppsChanged: a stored round-trip proves our // registration landed before the admin call below. if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"warmup"}`)); err != nil { t.Fatalf("warmup write: %v", err) } if _, _, err := conn.Read(ctx); err != nil { t.Fatalf("warmup read: %v", err) } expectInvitesChanged := func(stage string, want bool) { for { var msg struct { Type string `json:"type"` Enabled bool `json:"enabled"` } _, data, err := conn.Read(ctx) if err != nil { t.Fatalf("%s read: %v (no user_invites_changed)", stage, err) } if json.Unmarshal(data, &msg) == nil && msg.Type == "user_invites_changed" { if msg.Enabled != want { t.Fatalf("%s: enabled = %v, want %v", stage, msg.Enabled, want) } return } } } if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":true}`); resp.StatusCode != http.StatusOK { t.Fatalf("enable: want 200, got %d", resp.StatusCode) } expectInvitesChanged("enable", true) if resp := adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":false}`); resp.StatusCode != http.StatusOK { t.Fatalf("disable: want 200, got %d", resp.StatusCode) } expectInvitesChanged("disable", false) // Re-posting the SAME value is a no-op — no audit entry, no broadcast (only // a genuine flip is a moderation-relevant event). Confirm no broadcast // arrives by racing a distinguishable follow-up message. adminPost(t, ts.URL+"/api/admin/settings", `{"user_invites":false}`) if err := conn.Write(ctx, websocket.MessageText, []byte(`{"type":"msg","thread_id":"`+tid+`","blob":"canary"}`)); err != nil { t.Fatalf("canary write: %v", err) } for { var msg struct { Type string `json:"type"` Blob string `json:"blob"` } _, data, err := conn.Read(ctx) if err != nil { t.Fatalf("canary read: %v", err) } if json.Unmarshal(data, &msg) == nil && msg.Type == "user_invites_changed" { t.Fatal("re-posting the unchanged value should not broadcast") } if json.Unmarshal(data, &msg) == nil && msg.Type == "msg" && msg.Blob == "canary" { break // canary arrived first — no stray broadcast was queued ahead of it } } } func TestNicknamesRoundTrip(t *testing.T) { ts, srv := newTestServer(t) tok, _, _ := joinedMember(t, srv, "viewer") other, _, _ := joinedMember(t, srv, "other") get := func(token string) (*http.Response, string) { r, err := http.Get(ts.URL + "/api/nicknames?token=" + token) if err != nil { t.Fatalf("get: %v", err) } var body struct { Blob string `json:"nicknames_blob"` } json.NewDecoder(r.Body).Decode(&body) return r, body.Blob } post := func(token, blob string) *http.Response { r, err := http.Post(ts.URL+"/api/nicknames", "application/json", strings.NewReader(`{"token":"`+token+`","nicknames_blob":"`+blob+`"}`)) if err != nil { t.Fatalf("post: %v", err) } return r } // Token-gated on both verbs. if r, _ := get("bogus"); r.StatusCode != http.StatusForbidden { t.Fatalf("bad-token get: want 403, got %d", r.StatusCode) } if r := post("bogus", "sealed"); r.StatusCode != http.StatusForbidden { t.Fatalf("bad-token post: want 403, got %d", r.StatusCode) } // Never set → empty blob, not an error. if r, blob := get(tok); r.StatusCode != http.StatusOK || blob != "" { t.Fatalf("unset: want 200/empty, got %d/%q", r.StatusCode, blob) } // Round-trip; the blob is opaque ciphertext to the server. if r := post(tok, "sealed-overrides-v1"); r.StatusCode != http.StatusOK { t.Fatalf("post: want 200, got %d", r.StatusCode) } if _, blob := get(tok); blob != "sealed-overrides-v1" { t.Fatalf("round-trip: got %q", blob) } // Viewer-private: another member reads their OWN (empty) blob, never yours. if _, blob := get(other); blob != "" { t.Fatalf("other member saw a foreign blob: %q", blob) } // Oversized blob is rejected; "" clears. if r := post(tok, strings.Repeat("a", 64*1024+1)); r.StatusCode != http.StatusBadRequest { t.Fatalf("oversized: want 400, got %d", r.StatusCode) } if r := post(tok, ""); r.StatusCode != http.StatusOK { t.Fatalf("clear: want 200, got %d", r.StatusCode) } if _, blob := get(tok); blob != "" { t.Fatalf("clear: got %q", blob) } } // TestNicknamesVersioning covers the compare-and-swap on the nicknames blob: // a stale writer 409s (told the current version so it can re-read and retry), // a fresh writer advances the version, and a version-less write (a // pre-versioning client) stays unconditional. func TestNicknamesVersioning(t *testing.T) { ts, srv := newTestServer(t) tok, _, _ := joinedMember(t, srv, "viewer") get := func() (string, int64) { r, err := http.Get(ts.URL + "/api/nicknames?token=" + tok) if err != nil { t.Fatalf("get: %v", err) } var body struct { Blob string `json:"nicknames_blob"` Version int64 `json:"version"` } json.NewDecoder(r.Body).Decode(&body) return body.Blob, body.Version } post := func(payload string) (*http.Response, int64) { r, err := http.Post(ts.URL+"/api/nicknames", "application/json", strings.NewReader(payload)) if err != nil { t.Fatalf("post: %v", err) } var body struct { Version int64 `json:"version"` } json.NewDecoder(r.Body).Decode(&body) return r, body.Version } // Fresh row reads version 0; a CAS write from that base lands at 1. if _, v := get(); v != 0 { t.Fatalf("fresh version: want 0, got %d", v) } if r, v := post(`{"token":"` + tok + `","nicknames_blob":"one","version":0}`); r.StatusCode != http.StatusOK || v != 1 { t.Fatalf("first cas write: want 200/v1, got %d/v%d", r.StatusCode, v) } // A second writer still holding base 0 (device B's stale read) conflicts — // and is told where the row is now. if r, v := post(`{"token":"` + tok + `","nicknames_blob":"stale","version":0}`); r.StatusCode != http.StatusConflict || v != 1 { t.Fatalf("stale cas write: want 409/v1, got %d/v%d", r.StatusCode, v) } if blob, _ := get(); blob != "one" { t.Fatalf("stale write clobbered the blob: %q", blob) } // Retrying from the fresh read succeeds. if r, v := post(`{"token":"` + tok + `","nicknames_blob":"two","version":1}`); r.StatusCode != http.StatusOK || v != 2 { t.Fatalf("retry cas write: want 200/v2, got %d/v%d", r.StatusCode, v) } // A pre-versioning client (no version field) writes unconditionally and // still advances the counter, so CAS writers see its write as fresh state. if r, v := post(`{"token":"` + tok + `","nicknames_blob":"legacy"}`); r.StatusCode != http.StatusOK || v != 3 { t.Fatalf("legacy write: want 200/v3, got %d/v%d", r.StatusCode, v) } if blob, v := get(); blob != "legacy" || v != 3 { t.Fatalf("after legacy write: got %q/v%d", blob, v) } } // TestOpenGroups covers the community directory: creating an open group, // discovering it, self-serve joining, and flipping visibility — including the // open_key overwrite guard. func TestOpenGroups(t *testing.T) { ts, srv := newTestServer(t) mkUser := func(label string) (tok, uid string) { u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser %s: %v", label, err) } if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite %s: %v", label, err) } tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey %s: %v", label, err) } return tk, u } tokA, uidA := mkUser("Alice") tokB, uidB := mkUser("Bob") post := func(path, body string) (int, map[string]any) { resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post %s: %v", path, err) } defer resp.Body.Close() var out map[string]any _ = json.NewDecoder(resp.Body).Decode(&out) return resp.StatusCode, out } // Open must be group-only, and open requires open_key (and only open may carry one). if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"self","visibility":"open","open_key":"ok1","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`); code != 400 { t.Fatalf("open self thread: code=%d, want 400", code) } if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"group","visibility":"open","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`); code != 400 { t.Fatalf("open group without open_key: code=%d, want 400", code) } if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"group","open_key":"ok1","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`); code != 400 { t.Fatalf("invite group with open_key: code=%d, want 400", code) } // An invite-only group still requires per-member wraps. if code, _ := post("/api/threads", `{"token":"`+tokA+`","kind":"group","members":[{"user_id":"`+uidA+`","wrapped":""}]}`); code != 400 { t.Fatalf("invite group with empty wrap: code=%d, want 400", code) } // Create an open group; the creator may omit their wrap (open_key covers them). code, out := post("/api/threads", `{"token":"`+tokA+`","kind":"group","name_blob":"nb","visibility":"open","open_key":"ok1","members":[{"user_id":"`+uidA+`","wrapped":""}]}`) if code != 200 { t.Fatalf("create open group: code=%d", code) } gid, _ := out["thread_id"].(string) if gid == "" { t.Fatalf("create open group: no thread_id in %v", out) } // And an ordinary invite-only group to check the directory filters it out. code, out = post("/api/threads", `{"token":"`+tokA+`","kind":"group","members":[{"user_id":"`+uidA+`","wrapped":"w"}]}`) if code != 200 { t.Fatalf("create invite group: code=%d", code) } inviteGid, _ := out["thread_id"].(string) directory := func(tok string) []OpenThread { resp, err := http.Get(ts.URL + "/api/threads/open?token=" + tok) if err != nil { t.Fatalf("directory: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("directory: code=%d", resp.StatusCode) } var out struct { Threads []OpenThread `json:"threads"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("directory decode: %v", err) } return out.Threads } // The directory is token-gated. if resp, _ := http.Get(ts.URL + "/api/threads/open?token=bogus"); resp.StatusCode != 403 { t.Fatalf("directory with bad token: code=%d, want 403", resp.StatusCode) } // Bob (not a member of the group) sees exactly the open group, with its key blob. dir := directory(tokB) if len(dir) != 1 || dir[0].ID != gid { t.Fatalf("directory: %+v, want just %s", dir, gid) } if dir[0].OpenKey != "ok1" || dir[0].Joined || dir[0].MemberCount != 1 || dir[0].NameBlob != "nb" { t.Fatalf("directory row: %+v", dir[0]) } // Self-serve join; joining twice is idempotent; invite-only groups refuse. if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokB+`"}`); code != 200 { t.Fatalf("join: code=%d", code) } if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokB+`"}`); code != 200 { t.Fatalf("re-join: code=%d", code) } if code, _ := post("/api/threads/"+inviteGid+"/join", `{"token":"`+tokB+`"}`); code != 403 { t.Fatalf("join invite-only: code=%d, want 403", code) } if code, _ := post("/api/threads/nope/join", `{"token":"`+tokB+`"}`); code != 403 { t.Fatalf("join unknown: code=%d, want 403", code) } if !srv.db.threadMember(gid, uidB) { t.Fatalf("Bob should be a member after joining") } dir = directory(tokB) if !dir[0].Joined || dir[0].MemberCount != 2 { t.Fatalf("directory after join: %+v", dir[0]) } // Bob's thread list carries the open group with open_key and no per-member wrap. resp, err := http.Get(ts.URL + "/api/threads?token=" + tokB) if err != nil { t.Fatalf("threads: %v", err) } defer resp.Body.Close() var tl struct { Threads []Thread `json:"threads"` } if err := json.NewDecoder(resp.Body).Decode(&tl); err != nil { t.Fatalf("threads decode: %v", err) } if len(tl.Threads) != 1 { t.Fatalf("Bob's threads: %+v", tl.Threads) } if got := tl.Threads[0]; got.ID != gid || got.Visibility != "open" || got.OpenKey != "ok1" || got.Wrapped != "" { t.Fatalf("Bob's open-group row: %+v", got) } // Closing hides it from discovery but keeps open_key (Bob's key source). if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokA+`","visibility":"invite"}`); code != 200 { t.Fatalf("close: code=%d", code) } if dir = directory(tokB); len(dir) != 0 { t.Fatalf("directory after close: %+v", dir) } tokC, _ := mkUser("Carol") if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokC+`"}`); code != 403 { t.Fatalf("join closed group: code=%d, want 403", code) } // Reopening never overwrites the stored open_key (anti-brick rule), and // non-members / non-groups can't flip visibility. if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokC+`","visibility":"open","open_key":"junk"}`); code != 403 { t.Fatalf("non-member flip: code=%d, want 403", code) } if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokA+`","visibility":"open","open_key":"junk"}`); code != 200 { t.Fatalf("reopen: code=%d", code) } if dir = directory(tokC); len(dir) != 1 || dir[0].OpenKey != "ok1" { t.Fatalf("open_key must survive a reopen: %+v", dir) } // A self-serve joiner has an EMPTY thread_keys slot; another member must // not be able to fill it with an arbitrary blob (add-member is a no-op for // existing members — the junk-wrap lockout the DO NOTHING rule exists for). if code, _ := post("/api/threads/"+gid+"/members", `{"token":"`+tokA+`","user_id":"`+uidB+`","wrapped":"junk-wrap"}`); code != 200 { t.Fatalf("re-add existing member: code=%d", code) } if w, ok, _ := srv.db.wrappedKeyFor(gid, uidB); ok || w != "" { t.Fatalf("joiner's empty key slot was filled: %q", w) } } func TestAdminCanAlwaysInvite(t *testing.T) { ts, srv := newTestServer(t) adminTok, adminUID, _ := joinedMember(t, srv, "boss") if err := srv.db.setUserRole(adminUID, "admin"); err != nil { t.Fatalf("setUserRole: %v", err) } memberTok, _, _ := joinedMember(t, srv, "pal") if err := srv.db.setUsersCanInvite(false); err != nil { t.Fatalf("setUsersCanInvite: %v", err) } post := func(token, id string) *http.Response { body := `{"token":"` + token + `","id":"` + id + `","wrapped":"w","claim_hash":"h","label_blob":"lb","self_wrapped":"sw"}` r, err := http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post: %v", err) } return r } // The toggle governs ordinary members… if r := post(memberTok, "inv-member-try"); r.StatusCode != http.StatusForbidden { t.Fatalf("member with invites off: want 403, got %d", r.StatusCode) } // …but never the admin. if r := post(adminTok, "inv-admin-try"); r.StatusCode != http.StatusOK { t.Fatalf("admin with invites off: want 200, got %d", r.StatusCode) } // Flipping the toggle on admits members too (the pre-existing behaviour). if err := srv.db.setUsersCanInvite(true); err != nil { t.Fatalf("setUsersCanInvite: %v", err) } if r := post(memberTok, "inv-member-ok"); r.StatusCode != http.StatusOK { t.Fatalf("member with invites on: want 200, got %d", r.StatusCode) } } // TestDefaultGroups: admins flag open groups as joined-by-default; brand-new // members land in them on their first device claim, add-device claims and // closed groups don't auto-join. func TestDefaultGroups(t *testing.T) { ts, srv := newTestServer(t) mkUser := func(label string) (tok, uid string) { u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser %s: %v", label, err) } if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite %s: %v", label, err) } tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey %s: %v", label, err) } return tk, u } post := func(path, body string) int { resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post %s: %v", path, err) } resp.Body.Close() return resp.StatusCode } tokAdmin, uidAdmin := mkUser("Root") if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { t.Fatalf("setUserRole: %v", err) } tokPeer, uidPeer := mkUser("Peer") // One open group (will be default), one open non-default, one closed+flagged. mkGroup := func(vis, openKey string) string { body := `{"token":"` + tokAdmin + `","kind":"group","members":[{"user_id":"` + uidAdmin + `","wrapped":"w"}]}` if vis == "open" { body = `{"token":"` + tokAdmin + `","kind":"group","visibility":"open","open_key":"` + openKey + `","members":[{"user_id":"` + uidAdmin + `","wrapped":""}]}` } resp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("create group: %v", err) } defer resp.Body.Close() var out struct { ThreadID string `json:"thread_id"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.ThreadID == "" { t.Fatalf("create group: id=%q err=%v", out.ThreadID, err) } return out.ThreadID } defGid := mkGroup("open", "ok-def") plainGid := mkGroup("open", "ok-plain") closedGid := mkGroup("invite", "") // Gating: members can't set it; a closed group can't be default. if code := post("/api/threads/"+defGid+"/default", `{"token":"`+tokPeer+`","default":true}`); code != 403 { t.Fatalf("non-admin default: code=%d, want 403", code) } if code := post("/api/threads/"+closedGid+"/default", `{"token":"`+tokAdmin+`","default":true}`); code != 400 { t.Fatalf("default on closed group: code=%d, want 400", code) } if code := post("/api/threads/"+defGid+"/default", `{"token":"`+tokAdmin+`","default":true}`); code != 200 { t.Fatalf("set default: code=%d", code) } // Flag the closed group directly in the DB: even if flagged, a closed // group must never auto-join (the sweep filters on visibility). if err := srv.db.setThreadDefault(closedGid, true); err != nil { t.Fatalf("setThreadDefault: %v", err) } // Existing members are never force-joined. if srv.db.threadMember(defGid, uidPeer) { t.Fatalf("existing member force-joined") } // A brand-new member lands in the default group only. _, uidNew := mkUser("Newbie") if !srv.db.threadMember(defGid, uidNew) { t.Fatalf("new member not auto-joined to default group") } if srv.db.threadMember(plainGid, uidNew) || srv.db.threadMember(closedGid, uidNew) { t.Fatalf("new member auto-joined to a non-default or closed group") } if w, ok, _ := srv.db.wrappedKeyFor(defGid, uidNew); ok || w != "" { t.Fatalf("auto-join must not mint a thread_keys row: %q", w) } // Leaving sticks: an add-device claim later must not re-join. if code := post("/api/threads/"+defGid+"/leave", `{"token":"`+tokPeer+`"}`); code != 200 { // tokPeer isn't a member (never joined) — use the newbie's leave instead. t.Logf("peer leave (not a member): code=%d", code) } newTok := func(uid, label string) { if err := srv.db.createKeyInvite("dev-"+label, uid, "wrap", testClaimHash, 0); err != nil { t.Fatalf("device invite: %v", err) } if _, ok, err := srv.db.claimKeyInvite("dev-"+label, label, "", testClaimSecret); !ok || err != nil { t.Fatalf("device claim: ok=%v err=%v", ok, err) } } if err := srv.db.removeThreadMember(defGid, uidNew); err != nil { t.Fatalf("leave: %v", err) } newTok(uidNew, "second-device") if srv.db.threadMember(defGid, uidNew) { t.Fatalf("add-device claim re-joined a left default group") } } // TestTiers covers the tier server core: admin-gated management, the // mint-vs-holder gate on key grants, the never-overwrite rule, targeted // membership checks, and revocation dropping the key row. func TestTiers(t *testing.T) { ts, srv := newTestServer(t) mkUser := func(label string) (tok, uid string) { u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser %s: %v", label, err) } if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite %s: %v", label, err) } tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey %s: %v", label, err) } return tk, u } do := func(method, path, body string) int { req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } resp.Body.Close() return resp.StatusCode } tiersFor := func(tok string) []Tier { resp, err := http.Get(ts.URL + "/api/tiers?token=" + tok) if err != nil || resp.StatusCode != 200 { t.Fatalf("get tiers: %v code=%d", err, resp.StatusCode) } defer resp.Body.Close() var out struct { Tiers []Tier `json:"tiers"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { t.Fatalf("decode tiers: %v", err) } return out.Tiers } tokAdmin, uidAdmin := mkUser("Root") if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { t.Fatalf("setUserRole: %v", err) } tokB, uidB := mkUser("Bea") tokC, uidC := mkUser("Cal") _, uidD := mkUser("Dot") // Create: members can't, admins can, names are validated. if code := do("POST", "/api/tiers", `{"token":"`+tokB+`","name":"Coaches"}`); code != 403 { t.Fatalf("member create tier: code=%d, want 403", code) } if code := do("POST", "/api/tiers", `{"token":"`+tokAdmin+`","name":" "}`); code != 400 { t.Fatalf("blank tier name: code=%d, want 400", code) } resp, err := http.Post(ts.URL+"/api/tiers", "application/json", strings.NewReader(`{"token":"`+tokAdmin+`","name":"Coaches"}`)) if err != nil || resp.StatusCode != 200 { t.Fatalf("create tier: %v code=%d", err, resp.StatusCode) } var created struct { ID string `json:"id"` } if err := json.NewDecoder(resp.Body).Decode(&created); err != nil || created.ID == "" { t.Fatalf("create tier: id=%q err=%v", created.ID, err) } resp.Body.Close() tid := created.ID // Assign: admin-gated; unknown tier/member rejected. if code := do("POST", "/api/tiers/"+tid+"/members", `{"token":"`+tokB+`","user_id":"`+uidC+`"}`); code != 403 { t.Fatalf("member assign: code=%d, want 403", code) } if code := do("POST", "/api/tiers/nope/members", `{"token":"`+tokAdmin+`","user_id":"`+uidB+`"}`); code != 400 { t.Fatalf("assign to unknown tier: code=%d, want 400", code) } for _, u := range []string{uidB, uidC} { if code := do("POST", "/api/tiers/"+tid+"/members", `{"token":"`+tokAdmin+`","user_id":"`+u+`"}`); code != 200 { t.Fatalf("assign %s: code=%d", u, code) } } got := tiersFor(tokB) if len(got) != 1 || got[0].Name != "Coaches" || !got[0].Mine || len(got[0].Members) != 2 { t.Fatalf("Bea's view: %+v", got) } if admin := tiersFor(tokAdmin); admin[0].Mine { t.Fatalf("assigning admin must not be a member themselves") } // Key grants: minting (no copies yet) is admin-only; grants land only on // tier members; a holder can grant; nothing ever overwrites. if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokB+`","grants":[{"user_id":"`+uidB+`","wrapped":"wb"}]}`); code != 403 { t.Fatalf("non-admin mint: code=%d, want 403", code) } if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokAdmin+`","grants":[{"user_id":"`+uidB+`","wrapped":"wb"},{"user_id":"`+uidD+`","wrapped":"junk"}]}`); code != 200 { t.Fatalf("mint: code=%d", code) } got = tiersFor(tokB) if got[0].Wrapped != "wb" || len(got[0].Have) != 1 || got[0].Have[0] != uidB { t.Fatalf("after mint: %+v", got[0]) } if d := tiersFor(tokAdmin); len(d[0].Have) != 1 { t.Fatalf("non-member Dot must not receive a key: %+v", d[0]) } // Keyless member can't grant; holder can; overwrite is a no-op. if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokC+`","grants":[{"user_id":"`+uidC+`","wrapped":"wc"}]}`); code != 403 { t.Fatalf("keyless grant: code=%d, want 403", code) } if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokB+`","grants":[{"user_id":"`+uidC+`","wrapped":"wc"},{"user_id":"`+uidB+`","wrapped":"evil"}]}`); code != 200 { t.Fatalf("holder grant: code=%d", code) } got = tiersFor(tokB) if got[0].Wrapped != "wb" { t.Fatalf("overwrite must be a no-op: %+v", got[0]) } if c := tiersFor(tokC); c[0].Wrapped != "wc" { t.Fatalf("Cal's grant missing: %+v", c[0]) } // Nudge: members only. if code := do("POST", "/api/tiers/"+tid+"/nudge", `{"token":"`+tokAdmin+`"}`); code != 403 { t.Fatalf("non-member nudge: code=%d, want 403", code) } if code := do("POST", "/api/tiers/"+tid+"/nudge", `{"token":"`+tokC+`"}`); code != 200 { t.Fatalf("member nudge: code=%d", code) } // Revocation: unassign drops membership AND the key row. if code := do("POST", "/api/tiers/"+tid+"/remove", `{"token":"`+tokAdmin+`","user_id":"`+uidC+`"}`); code != 200 { t.Fatalf("remove: code=%d", code) } got = tiersFor(tokC) // Cal is now an outsider: on this default 'shown' level they still see the // member list (Bea), but the holder list is insiders/admins only. if got[0].Mine || got[0].Wrapped != "" || len(got[0].Members) != 1 || len(got[0].Have) != 0 { t.Fatalf("after remove: %+v", got[0]) } // Delete: admin-gated, cascades. if code := do("DELETE", "/api/tiers/"+tid, `{"token":"`+tokB+`"}`); code != 403 { t.Fatalf("member delete: code=%d, want 403", code) } if code := do("DELETE", "/api/tiers/"+tid, `{"token":"`+tokAdmin+`"}`); code != 200 { t.Fatalf("delete: code=%d", code) } if got = tiersFor(tokB); len(got) != 0 { t.Fatalf("after delete: %+v", got) } if srv.db.tierKeyCount(tid) != 0 { t.Fatalf("delete must cascade key rows") } } // TestLevelGatedGroups: an open group gated to an access level — create gate, // directory honesty (eligible flag + plaintext level name), join gate, the // default-join sweep skipping gated groups, and gate immutability. func TestLevelGatedGroups(t *testing.T) { ts, srv := newTestServer(t) mkUser := func(label string) (tok, uid string) { u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser %s: %v", label, err) } if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite %s: %v", label, err) } tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey %s: %v", label, err) } return tk, u } post := func(path, body string) (int, map[string]any) { resp, err := http.Post(ts.URL+path, "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post %s: %v", path, err) } defer resp.Body.Close() var out map[string]any _ = json.NewDecoder(resp.Body).Decode(&out) return resp.StatusCode, out } tokB, uidB := mkUser("Bea") tokC, _ := mkUser("Cal") tid, err := srv.db.createTier("Coaches") if err != nil { t.Fatalf("createTier: %v", err) } if err := srv.db.assignTier(tid, uidB, "admin"); err != nil { t.Fatalf("assignTier: %v", err) } if err := srv.db.grantTierKey(tid, uidB, "wb"); err != nil { t.Fatalf("grantTierKey: %v", err) } // Create: only a member of the level can gate a group on it. 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 { t.Fatalf("non-member gates a group: code=%d, want 400", code) } if code, _ := post("/api/threads", `{"token":"`+tokB+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"nope","members":[{"user_id":"`+uidB+`","wrapped":""}]}`); code != 400 { t.Fatalf("unknown level: code=%d, want 400", code) } code, out := post("/api/threads", `{"token":"`+tokB+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"`+tid+`","members":[{"user_id":"`+uidB+`","wrapped":""}]}`) if code != 200 { t.Fatalf("create gated group: code=%d", code) } gid, _ := out["thread_id"].(string) // Directory: Cal sees the row with an honest lock (name + eligible=false). resp, err := http.Get(ts.URL + "/api/threads/open?token=" + tokC) if err != nil { t.Fatalf("directory: %v", err) } defer resp.Body.Close() var dir struct { Threads []OpenThread `json:"threads"` } if err := json.NewDecoder(resp.Body).Decode(&dir); err != nil { t.Fatalf("decode: %v", err) } if len(dir.Threads) != 1 || dir.Threads[0].Eligible || dir.Threads[0].TierName != "Coaches" || dir.Threads[0].OpenTier != tid { t.Fatalf("Cal's directory row: %+v", dir.Threads) } // Join: gated to the level. if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokC+`"}`); code != 403 { t.Fatalf("non-member join: code=%d, want 403", code) } if code, _ := post("/api/threads/"+gid+"/join", `{"token":"`+tokB+`"}`); code != 200 { t.Fatalf("member join: code=%d", code) } // The default-join sweep never lands newcomers in a gated group. if err := srv.db.setThreadDefault(gid, true); err != nil { t.Fatalf("setThreadDefault: %v", err) } _, uidNew := mkUser("Newbie") if srv.db.threadMember(gid, uidNew) { t.Fatalf("newcomer auto-joined a level-gated group") } // The gate is fixed once opened: closing keeps it, reopening with a // different (or no) gate is refused, the same gate is accepted. if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"invite"}`); code != 200 { t.Fatalf("close: code=%d", code) } if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"open","open_key":"gk2"}`); code != 400 { t.Fatalf("reopen without the gate: code=%d, want 400", code) } if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"open","open_key":"gk2","open_tier":"`+tid+`"}`); code != 200 { t.Fatalf("reopen with the same gate: code=%d", code) } // Reopening needs NO key blob at all — the stored open_key survives, so // any member can reopen (symmetric with closing). if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"invite"}`); code != 200 { t.Fatalf("re-close: code=%d", code) } if code, _ := post("/api/threads/"+gid+"/visibility", `{"token":"`+tokB+`","visibility":"open","open_tier":"`+tid+`"}`); code != 200 { t.Fatalf("keyless reopen: code=%d", code) } resp2, err := http.Get(ts.URL + "/api/threads/open?token=" + tokB) if err != nil { t.Fatalf("open directory: %v", err) } defer resp2.Body.Close() var dir2 struct { Threads []OpenThread `json:"threads"` } _ = json.NewDecoder(resp2.Body).Decode(&dir2) if len(dir2.Threads) != 1 || dir2.Threads[0].OpenKey != "gk" || !dir2.Threads[0].Eligible { t.Fatalf("after reopen: %+v", dir2.Threads) } // default_join is refused on gated groups (the sweep skips them — a saved // flag would be a silent lie). if err := srv.db.setUserRole(uidB, "admin"); err != nil { t.Fatalf("setUserRole: %v", err) } if code, _ := post("/api/threads/"+gid+"/default", `{"token":"`+tokB+`","default":true}`); code != 400 { t.Fatalf("default on gated group: code=%d, want 400", code) } } // mustUID resolves a token's user id or fails the test. func mustUID(t *testing.T, srv *Server, token string) string { t.Helper() uid, ok := srv.db.deviceTokenUser(token) if !ok { t.Fatalf("no uid for token") } return uid } // TestTierKeyLifecycle pins the one-way mint and the honest dead ends: a // minted level whose holders are all gone is LOST (410, never re-minted), a // level still gating groups can't be deleted, and removing a user cascades // their tier rows. func TestTierKeyLifecycle(t *testing.T) { ts, srv := newTestServer(t) mkUser := func(label string) (tok, uid string) { u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser %s: %v", label, err) } if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite %s: %v", label, err) } tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claimKeyInvite %s: ok=%v err=%v", label, ok, err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey %s: %v", label, err) } return tk, u } do := func(method, path, body string) int { req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } resp.Body.Close() return resp.StatusCode } tokAdmin, uidAdmin := mkUser("Root") if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { t.Fatalf("setUserRole: %v", err) } tokB, uidB := mkUser("Bea") tid, err := srv.db.createTier("Coaches") if err != nil { t.Fatalf("createTier: %v", err) } if code := do("POST", "/api/tiers/"+tid+"/members", `{"token":"`+tokAdmin+`","user_id":"`+uidB+`"}`); code != 200 { t.Fatalf("assign: code=%d", code) } if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokAdmin+`","grants":[{"user_id":"`+uidB+`","wrapped":"wb"}]}`); code != 200 { t.Fatalf("mint: code=%d", code) } // Mint is one-way: a second mint can never happen (db-level serialization). if err := srv.db.mintTierKeys(tid, nil); err != errAlreadyMinted { t.Fatalf("second mint: err=%v, want errAlreadyMinted", err) } // Bea gates a group on the level, then loses her level membership: the // level is minted with ZERO holders → the key is lost, loudly. code := 0 { resp, err := http.Post(ts.URL+"/api/threads", "application/json", strings.NewReader(`{"token":"`+tokB+`","kind":"group","visibility":"open","open_key":"gk","open_tier":"`+tid+`","members":[{"user_id":"`+uidB+`","wrapped":""}]}`)) if err != nil { t.Fatalf("create gated: %v", err) } code = resp.StatusCode resp.Body.Close() } if code != 200 { t.Fatalf("create gated group: code=%d", code) } if code := do("POST", "/api/tiers/"+tid+"/remove", `{"token":"`+tokAdmin+`","user_id":"`+uidB+`"}`); code != 200 { t.Fatalf("unassign holder: code=%d", code) } if code := do("POST", "/api/tiers/"+tid+"/keys", `{"token":"`+tokAdmin+`","grants":[{"user_id":"`+uidB+`","wrapped":"w2"}]}`); code != 410 { t.Fatalf("grant on lost key: code=%d, want 410 (never a silent re-mint)", code) } tiers, err := srv.db.listTiers(uidAdmin, true) if err != nil || len(tiers) != 1 || !tiers[0].KeyLost { t.Fatalf("key_lost not reported: %+v err=%v", tiers, err) } // The level still gates a group → deletion refused with the reason. if code := do("DELETE", "/api/tiers/"+tid, `{"token":"`+tokAdmin+`"}`); code != 409 { t.Fatalf("delete while gating: code=%d, want 409", code) } // removeUser cascades tier rows: re-assign Bea, grant via a fresh level, // then remove her — no ghost membership or holdership may remain. tid2, err := srv.db.createTier("Founders") if err != nil { t.Fatalf("createTier 2: %v", err) } if err := srv.db.assignTier(tid2, uidB, "admin"); err != nil { t.Fatalf("assignTier: %v", err) } if err := srv.db.mintTierKeys(tid2, []ThreadMemberKey{{UserID: uidB, Wrapped: "wb2"}}); err != nil { t.Fatalf("mint 2: %v", err) } if err := srv.db.removeUser(uidB); err != nil { t.Fatalf("removeUser: %v", err) } if ms, _ := srv.db.tierMemberIDs(tid2); len(ms) != 0 { t.Fatalf("ghost tier membership after removeUser: %v", ms) } if hs, _ := srv.db.tierHolderIDs(tid2); len(hs) != 0 { t.Fatalf("ghost key holder after removeUser: %v", hs) } } // TestCohortInvites: an admin-minted invite carries an access level; its // claimer lands in the level on FIRST-device claim (source 'invite'), the // welcome card learns the level's name, and stale/invalid gates fail safely. func TestCohortInvites(t *testing.T) { ts, srv := newTestServer(t) tid, err := srv.db.createTier("January 2027") if err != nil { t.Fatalf("createTier: %v", err) } mint := func(inviteID, tierID string) int { body := `{"id":"` + inviteID + `","wrapped":"","claim_hash":"` + testClaimHash + `","tier_id":"` + tierID + `"}` req, _ := http.NewRequest("POST", ts.URL+"/api/admin/key-invites", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer admintok") req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("mint: %v", err) } resp.Body.Close() return resp.StatusCode } if code := mint("coh-bad", "nope"); code != 400 { t.Fatalf("mint with unknown level: code=%d, want 400", code) } if code := mint("coh-1", tid); code != 200 { t.Fatalf("mint cohort invite: code=%d", code) } // The invite lookup names the level (link-holders only, like description). resp, err := http.Get(ts.URL + "/api/key-invite/coh-1") if err != nil { t.Fatalf("lookup: %v", err) } var info struct { OK bool `json:"ok"` TierName string `json:"tier_name"` } if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { t.Fatalf("decode: %v", err) } resp.Body.Close() if !info.OK || info.TierName != "January 2027" { t.Fatalf("lookup: %+v", info) } // Claim → the joiner is in the level, attributed to the invite. ki, _, _ := srv.db.getKeyInvite("coh-1") if _, ok, err := srv.db.claimKeyInvite("coh-1", "phone", "", testClaimSecret); !ok || err != nil { t.Fatalf("claim: ok=%v err=%v", ok, err) } if !srv.db.tierMember(tid, ki.UserID) { t.Fatalf("claimer not in the cohort level") } var src string srv.db.sql.QueryRow("SELECT source FROM user_tiers WHERE tier_id = ? AND user_id = ?", tid, ki.UserID).Scan(&src) if src != "invite" { t.Fatalf("assignment source = %q, want invite", src) } // An add-device claim later never re-assigns: leave the level, add a // device, still out. if err := srv.db.unassignTier(tid, ki.UserID); err != nil { t.Fatalf("unassign: %v", err) } if err := srv.db.createKeyInvite("coh-dev", ki.UserID, "wrap", testClaimHash, 0); err != nil { t.Fatalf("device invite: %v", err) } if err := srv.db.setInviteTier("coh-dev", tid); err != nil { t.Fatalf("setInviteTier: %v", err) } if _, ok, err := srv.db.claimKeyInvite("coh-dev", "laptop", "", testClaimSecret); !ok || err != nil { t.Fatalf("device claim: ok=%v err=%v", ok, err) } if srv.db.tierMember(tid, ki.UserID) { t.Fatalf("add-device claim re-assigned a left level") } // A level deleted between mint and claim fails safe: claim succeeds, no // assignment (tierExists guard in claimKeyInvite). if code := mint("coh-2", tid); code != 200 { t.Fatalf("mint 2: code=%d", code) } if err := srv.db.deleteTier(tid); err != nil { t.Fatalf("deleteTier: %v", err) } ki2, _, _ := srv.db.getKeyInvite("coh-2") if _, ok, err := srv.db.claimKeyInvite("coh-2", "", "", testClaimSecret); !ok || err != nil { t.Fatalf("claim after delete: ok=%v err=%v", ok, err) } if srv.db.tierMember(tid, ki2.UserID) { t.Fatalf("assignment to a deleted level") } } // TestCohortInviteFromChat: the in-chat invite path accepts a level from // role-admins only — a member invite can't smuggle anyone into a level. func TestCohortInviteFromChat(t *testing.T) { ts, srv := newTestServer(t) mkUser := func(label string) (tok, uid string) { u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser: %v", err) } if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite: %v", err) } tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claim: ok=%v err=%v", ok, err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey: %v", err) } return tk, u } tokAdmin, uidAdmin := mkUser("Root") if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { t.Fatalf("setUserRole: %v", err) } tokB, _ := mkUser("Bea") if err := srv.db.setUsersCanInvite(true); err != nil { t.Fatalf("setUsersCanInvite: %v", err) } tid, err := srv.db.createTier("VIP") if err != nil { t.Fatalf("createTier: %v", err) } mint := func(tok, inviteID, tierID string) int { body := `{"token":"` + tok + `","label_blob":"lb","id":"` + inviteID + `","wrapped":"w","self_wrapped":"sw","claim_hash":"` + testClaimHash + `","tier_id":"` + tierID + `"}` resp, err := http.Post(ts.URL+"/api/invites", "application/json", strings.NewReader(body)) if err != nil { t.Fatalf("post: %v", err) } resp.Body.Close() return resp.StatusCode } // A member NOT in the level can't mint access to it (they don't hold the // key — they couldn't wrap it anyway). if code := mint(tokB, "chat-coh-1", tid); code != 403 { t.Fatalf("outsider cohort invite: code=%d, want 403", code) } if code := mint(tokAdmin, "chat-coh-2", "nope"); code != 400 { t.Fatalf("unknown level: code=%d, want 400", code) } // Admins mint for any level; a member IN the level can too (the flat-group // rule — membership is the power to extend membership). if code := mint(tokAdmin, "chat-coh-3", tid); code != 200 { t.Fatalf("admin cohort invite: code=%d", code) } if err := srv.db.assignTier(tid, mustUID(t, srv, tokB), "admin"); err != nil { t.Fatalf("assignTier: %v", err) } if code := mint(tokB, "chat-coh-4", tid); code != 200 { t.Fatalf("in-level member cohort invite: code=%d, want 200", code) } ki, found, _ := srv.db.getKeyInvite("chat-coh-3") if !found || ki.TierID != tid { t.Fatalf("tier not stored on chat invite: %+v", ki) } if _, ok, err := srv.db.claimKeyInvite("chat-coh-3", "", "sw2", testClaimSecret); !ok || err != nil { t.Fatalf("claim: ok=%v err=%v", ok, err) } if !srv.db.tierMember(tid, ki.UserID) { t.Fatalf("chat-invite claimer not in the level") } } // TestTierVisibility: admin policy (shown/hidden/user) and the per-member // choice govern what an OUTSIDER sees in a level's member list; insiders and // admins always see the full roster (they hold the shared key). func TestTierVisibility(t *testing.T) { ts, srv := newTestServer(t) mkUser := func(label string) (tok, uid string) { u, err := srv.db.createUser(label) if err != nil { t.Fatalf("createUser: %v", err) } if err := srv.db.createKeyInvite("inv-"+label, u, "wrap", testClaimHash, 0); err != nil { t.Fatalf("createKeyInvite: %v", err) } tk, ok, err := srv.db.claimKeyInvite("inv-"+label, label, "", testClaimSecret) if err != nil || !ok { t.Fatalf("claim: ok=%v err=%v", ok, err) } if err := srv.db.setUserPublicKey(u, "pub-"+label, ""); err != nil { t.Fatalf("setUserPublicKey: %v", err) } return tk, u } do := func(method, path, body string) int { req, _ := http.NewRequest(method, ts.URL+path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } resp.Body.Close() return resp.StatusCode } tierRow := func(tok, id string) Tier { resp, err := http.Get(ts.URL + "/api/tiers?token=" + tok) if err != nil { t.Fatalf("get tiers: %v", err) } defer resp.Body.Close() var out struct { Tiers []Tier `json:"tiers"` } json.NewDecoder(resp.Body).Decode(&out) for _, tr := range out.Tiers { if tr.ID == id { return tr } } t.Fatalf("tier %s not in %s's view", id, tok) return Tier{} } tokAdmin, uidAdmin := mkUser("Root") if err := srv.db.setUserRole(uidAdmin, "admin"); err != nil { t.Fatalf("setUserRole: %v", err) } _, uidB := mkUser("Bea") tokOut, _ := mkUser("Outsider") tid, _ := srv.db.createTier("VIP") if err := srv.db.assignTier(tid, uidB, "admin"); err != nil { t.Fatalf("assign: %v", err) } // Default 'shown': the outsider sees Bea. if m := tierRow(tokOut, tid).Members; len(m) != 1 || m[0] != uidB { t.Fatalf("shown → outsider sees members: %v", m) } // Only admins set policy. if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokOut+`","policy":"hidden"}`); code != 403 { t.Fatalf("non-admin policy: code=%d, want 403", code) } if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"nope"}`); code != 400 { t.Fatalf("bad policy value: code=%d, want 400", code) } // 'hidden': the outsider sees an empty roster; the admin still sees Bea. if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"hidden"}`); code != 200 { t.Fatalf("set hidden: code=%d", code) } if m := tierRow(tokOut, tid).Members; len(m) != 0 { t.Fatalf("hidden → outsider sees nobody: %v", m) } if m := tierRow(tokAdmin, tid).Members; len(m) != 1 { t.Fatalf("hidden → admin still sees members: %v", m) } // 'user': Bea shows by default; when she hides, the outsider loses her. if code := do("POST", "/api/tiers/"+tid+"/policy", `{"token":"`+tokAdmin+`","policy":"user"}`); code != 200 { t.Fatalf("set user: code=%d", code) } if m := tierRow(tokOut, tid).Members; len(m) != 1 { t.Fatalf("user default → outsider sees Bea: %v", m) } // Only a member sets their own show/hide — an outsider can't touch it. if code := do("POST", "/api/tiers/"+tid+"/show", `{"token":"`+tokOut+`","show":false}`); code != 403 { t.Fatalf("outsider sets show: code=%d, want 403", code) } // Bea hides herself → the outsider loses her, the admin still sees her. if err := srv.db.setUserTierVisible(tid, uidB, false); err != nil { t.Fatalf("hide: %v", err) } if m := tierRow(tokOut, tid).Members; len(m) != 0 { t.Fatalf("user + Bea hidden → outsider sees nobody: %v", m) } if m := tierRow(tokAdmin, tid).Members; len(m) != 1 { t.Fatalf("user + Bea hidden → admin still sees Bea: %v", m) } } // The orphaned-instance-key story (#785), full circle: a keyless member sees // holder liveness on the key read (honest copy needs it); the admin reset // clears the holders, the dead-key-sealed user columns, and demotes // instance-key open groups; afterwards a fresh bootstrap works — the space // heals instead of staying wedged forever. func TestInstanceKeyOrphanAndReset(t *testing.T) { ts, srv := newTestServer(t) // Holder Anna bootstrapped the key once; Bea joined later and never got it. annaTok, annaUID, _ := joinedMember(t, srv, "Anna") beaTok, beaUID, _ := joinedMember(t, srv, "Bea") grant := `{"token":"` + annaTok + `","grants":[{"user_id":"` + annaUID + `","wrapped":"wrapped-to-anna"}]}` if r, _ := http.Post(ts.URL+"/api/instance-key", "application/json", strings.NewReader(grant)); r.StatusCode != http.StatusOK { t.Fatalf("bootstrap grant: %d", r.StatusCode) } if _, err := srv.db.setThreadRead(annaUID, "t-any", 1751000000); err != nil { t.Fatalf("setThreadRead: %v", err) } srv.db.setUserLabelBlob(beaUID, "sealed-name") // Bea's key read carries holder liveness: Anna holds it, isn't connected, // and was last around at her read mark. r, _ := http.Get(ts.URL + "/api/instance-key?token=" + beaTok) var got struct { Wrapped string `json:"wrapped"` Have []string `json:"have"` HoldersOnline *bool `json:"holders_online"` HoldersLastActive int64 `json:"holders_last_active"` } json.NewDecoder(r.Body).Decode(&got) if got.Wrapped != "" || len(got.Have) != 1 { t.Fatalf("keyless read: %+v", got) } if got.HoldersOnline == nil || *got.HoldersOnline { t.Fatalf("holders_online: %+v (want present and false)", got.HoldersOnline) } if got.HoldersLastActive != 1751000000 { t.Fatalf("holders_last_active: %d", got.HoldersLastActive) } // A holder's OWN read carries no liveness noise. r, _ = http.Get(ts.URL + "/api/instance-key?token=" + annaTok) var holder map[string]any json.NewDecoder(r.Body).Decode(&holder) if _, present := holder["holders_online"]; present { t.Fatal("holder's read must not carry liveness fields") } // An open group gated under the instance key, plus one under a tier — // reset demotes only the former. openTid, err := srv.db.createThreadVis("group", "n", []ThreadMemberKey{{UserID: annaUID, Wrapped: "w"}}, "open", "open-key-blob", "") if err != nil { t.Fatalf("createThreadVis: %v", err) } tierTid, err := srv.db.createThreadVis("group", "n2", []ThreadMemberKey{{UserID: annaUID, Wrapped: "w2"}}, "open", "tier-open-key", "tier-1") if err != nil { t.Fatalf("createThreadVis tier: %v", err) } // The reset is admin-gated. if r, _ := http.Post(ts.URL+"/api/admin/instance-key/reset", "application/json", nil); r.StatusCode != http.StatusUnauthorized { t.Fatalf("unauthenticated reset: want 401, got %d", r.StatusCode) } if r := adminPost(t, ts.URL+"/api/admin/instance-key/reset", `{}`); r.StatusCode != http.StatusOK { t.Fatalf("admin reset: %d", r.StatusCode) } // Holders gone; Bea's next read shows a bootstrappable space (no liveness // fields — there are no holders to wait for). r, _ = http.Get(ts.URL + "/api/instance-key?token=" + beaTok) var after map[string]any json.NewDecoder(r.Body).Decode(&after) if have := after["have"].([]any); len(have) != 0 { t.Fatalf("holders after reset: %v", have) } if _, present := after["holders_online"]; present { t.Fatal("no-holder read must not carry liveness fields") } // Dead-key-sealed columns cleared; instance-key open group demoted with // its open_key dropped; tier-gated one untouched. entries, _ := srv.db.roster() for _, e := range entries { if e.LabelBlob != "" { t.Fatalf("sealed label survived reset: %+v", e) } } threads, _ := srv.db.threadsForUser(annaUID) for _, th := range threads { switch th.ID { case openTid: if th.Visibility == "open" || th.OpenKey != "" { t.Fatalf("instance-key open group not demoted: %+v", th) } case tierTid: if th.Visibility != "open" || th.OpenKey != "tier-open-key" { t.Fatalf("tier-gated group wrongly touched: %+v", th) } } } // Full circle: Bea bootstraps a fresh key. grant = `{"token":"` + beaTok + `","grants":[{"user_id":"` + beaUID + `","wrapped":"wrapped-to-bea"}]}` if r, _ := http.Post(ts.URL+"/api/instance-key", "application/json", strings.NewReader(grant)); r.StatusCode != http.StatusOK { t.Fatalf("re-bootstrap: %d", r.StatusCode) } if w, ok, _ := srv.db.instanceKeyFor(beaUID); !ok || w != "wrapped-to-bea" { t.Fatalf("fresh key not held after reset: %q ok=%v", w, ok) } }