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)
}
}