package main // πŸ€– Tests for the space thread (spacethread.go): the hidden all-members // thread backing space-wide app installs. What must hold: creation is // member-gated, single, and idempotent under races (the stored open_key never // changes); membership is IMPLICIT for every non-agent member (messages, // state, scheduling all work with no rows) while agents stay out unless // explicitly added; the thread never appears in /api/threads; kind "space" // can't be minted through the generic thread endpoint; the instance-key // break-glass reset deletes it (its open_key was the only path to its key). import ( "encoding/json" "fmt" "io" "net/http" "strings" "testing" "time" ) func TestSpaceThreadCreateAndGet(t *testing.T) { ts, srv := newTestServer(t) tok, _, _ := joinedMember(t, srv, "Ana") // Member-gated on both verbs. if r, _ := http.Get(ts.URL + "/api/space-thread?token=bogus"); r.StatusCode != http.StatusForbidden { t.Fatalf("get bad token: want 403, got %d", r.StatusCode) } if r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"bogus","open_key":"k"}`); r.StatusCode != http.StatusForbidden { t.Fatalf("post bad token: want 403, got %d", r.StatusCode) } // Before anyone creates it: an empty object, not an error. r, _ := http.Get(ts.URL + "/api/space-thread?token=" + tok) var got struct { ID string `json:"id"` OpenKey string `json:"open_key"` } json.NewDecoder(r.Body).Decode(&got) if r.StatusCode != http.StatusOK || got.ID != "" { t.Fatalf("get before create: want 200 + empty, got %d %+v", r.StatusCode, got) } // A missing / oversize open_key is refused β€” the key blob is the point. if r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`"}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("post no key: want 400, got %d", r.StatusCode) } if r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"`+strings.Repeat("x", 5000)+`"}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("post oversize key: want 400, got %d", r.StatusCode) } // First create mints THE space thread. r = postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"wrapped-space-key"}`) var created struct { ID string `json:"id"` OpenKey string `json:"open_key"` Created bool `json:"created"` } json.NewDecoder(r.Body).Decode(&created) if r.StatusCode != http.StatusOK || created.ID == "" || !created.Created || created.OpenKey != "wrapped-space-key" { t.Fatalf("create: got %d %+v", r.StatusCode, created) } // A second create (a racing member with its own candidate key) gets the // winner's row back β€” same id, the ORIGINAL open_key, created:false. r = postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"a-losing-key"}`) var second struct { ID string `json:"id"` OpenKey string `json:"open_key"` Created bool `json:"created"` } json.NewDecoder(r.Body).Decode(&second) if second.ID != created.ID || second.Created || second.OpenKey != "wrapped-space-key" { t.Fatalf("second create must return the winner's row: %+v", second) } // GET now serves it to any member. r, _ = http.Get(ts.URL + "/api/space-thread?token=" + tok) json.NewDecoder(r.Body).Decode(&got) if got.ID != created.ID || got.OpenKey != "wrapped-space-key" { t.Fatalf("get after create: %+v", got) } } func TestSpaceThreadImplicitMembership(t *testing.T) { ts, srv := newTestServer(t) tokA, uidA, _ := joinedMember(t, srv, "Ana") tokB, uidB, _ := joinedMember(t, srv, "Bob") r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tokA+`","open_key":"k"}`) var created struct { ID string `json:"id"` } json.NewDecoder(r.Body).Decode(&created) sid := created.ID // EVERY member is a member β€” no rows, no joins: both can post and read. for _, tok := range []string{tokA, tokB} { if r := postJSON(t, ts.URL+"/api/messages?token="+tok, `{"thread_id":"`+sid+`","blob":"cipher"}`); r.StatusCode != http.StatusOK { t.Fatalf("member post to space thread: want 200, got %d", r.StatusCode) } if r, _ := http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + sid); r.StatusCode != http.StatusOK { t.Fatalf("member read of space thread: want 200, got %d", r.StatusCode) } } // State snapshots and scheduling ride the same implicit gate. if r := postJSON(t, ts.URL+"/api/threads/"+sid+"/state", ""); r.StatusCode == http.StatusOK { t.Fatalf("state without body must not 200") } put, _ := http.NewRequest("PUT", ts.URL+"/api/threads/"+sid+"/state", strings.NewReader(`{"token":"`+tokB+`","slot":"apps","blob":"snap","version":0}`)) put.Header.Set("Content-Type", "application/json") if r, err := http.DefaultClient.Do(put); err != nil || r.StatusCode != http.StatusOK { t.Fatalf("state put on space thread: %v %d", err, r.StatusCode) } if r, _ := http.Get(ts.URL + "/api/threads/" + sid + "/state?token=" + tokA + "&slot=apps"); r.StatusCode != http.StatusOK { t.Fatalf("state get on space thread: want 200, got %d", r.StatusCode) } if r := postJSON(t, ts.URL+"/api/schedule", fmt.Sprintf(`{"token":"%s","thread_id":"%s","deliver_at":%d,"blob":"b"}`, tokA, sid, time.Now().Unix()+60)); r.StatusCode != http.StatusOK { t.Fatalf("schedule on space thread: want 200, got %d", r.StatusCode) } // An AGENT is not implicitly a member: a bot reading the space log must be // a deliberate choice (an explicit membership row), never a silent default. agentID, _ := srv.db.createUser("Robo") srv.db.setUserAgent(agentID) if err := srv.db.createKeyInvite("inv-robo", agentID, "wrap", testClaimHash, 0); err != nil { t.Fatalf("agent invite: %v", err) } agentTok, ok, err := srv.db.claimKeyInvite("inv-robo", "Robo", "", testClaimSecret) if err != nil || !ok { t.Fatalf("agent claim: ok=%v err=%v", ok, err) } if r := postJSON(t, ts.URL+"/api/messages?token="+agentTok, `{"thread_id":"`+sid+`","blob":"cipher"}`); r.StatusCode != http.StatusForbidden { t.Fatalf("agent post to space thread: want 403, got %d", r.StatusCode) } if srv.db.threadMember(sid, agentID) { t.Fatalf("agent must not be an implicit member") } if err := srv.db.addThreadMember(sid, agentID, "wrapped-to-agent"); err != nil { t.Fatalf("explicitly adding the agent: %v", err) } if !srv.db.threadMember(sid, agentID) { t.Fatalf("an explicitly-added agent IS a member") } // memberUserIDs = every non-agent member plus explicit rows, deduped. ids, err := srv.db.memberUserIDs(sid) if err != nil { t.Fatalf("memberUserIDs: %v", err) } want := map[string]bool{uidA: true, uidB: true, agentID: true} got := map[string]bool{} for _, id := range ids { if got[id] { t.Fatalf("memberUserIDs returned %s twice", id) } got[id] = true } for id := range want { if !got[id] { t.Fatalf("memberUserIDs missing %s (got %v)", id, ids) } } // The space thread NEVER appears in a member's thread list β€” old clients // (and the native apps, until they learn the feature) must never see it. r, _ = http.Get(ts.URL + "/api/threads?token=" + tokA) var list struct { Threads []Thread `json:"threads"` } json.NewDecoder(r.Body).Decode(&list) for _, th := range list.Threads { if th.ID == sid || th.Kind == "space" { t.Fatalf("space thread leaked into /api/threads: %+v", th) } } // Nor does GET /api/space-thread hand the key blob to a (non-member) agent: // the sidecar holds the instance key, so open_key would unwrap the space // key it was deliberately excluded from. An EXPLICIT member agent (added // above) does get it β€” bot access is a disclosed choice. agent2, _ := srv.db.createUser("Robo2") srv.db.setUserAgent(agent2) if err := srv.db.createKeyInvite("inv-robo2", agent2, "wrap", testClaimHash, 0); err != nil { t.Fatalf("agent2 invite: %v", err) } agent2Tok, ok2, err := srv.db.claimKeyInvite("inv-robo2", "Robo2", "", testClaimSecret) if err != nil || !ok2 { t.Fatalf("agent2 claim: ok=%v err=%v", ok2, err) } r, _ = http.Get(ts.URL + "/api/space-thread?token=" + agent2Tok) var agentView struct { ID string `json:"id"` OpenKey string `json:"open_key"` } json.NewDecoder(r.Body).Decode(&agentView) if agentView.ID != "" || agentView.OpenKey != "" { t.Fatalf("a non-member agent must get an empty space-thread view, got %+v", agentView) } r, _ = http.Get(ts.URL + "/api/space-thread?token=" + agentTok) // the explicitly-added bot json.NewDecoder(r.Body).Decode(&agentView) if agentView.ID != sid { t.Fatalf("an explicitly-added bot gets the space thread, got %+v", agentView) } // Nor can an agent MINT the space thread β€” the creator wraps (and so // knows) the space key, which a sidecar must never hold by default. if r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+agent2Tok+`","open_key":"agent-minted"}`); r.StatusCode != http.StatusForbidden { t.Fatalf("agent create of space thread: want 403, got %d", r.StatusCode) } // Implicit membership must not open a scribble surface: the space thread // renders nowhere, so rename/photo writes are fenced off. if r := postJSON(t, ts.URL+"/api/threads/"+sid+"/name", `{"token":"`+tokA+`","name_blob":"junk"}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("space thread rename: want 400, got %d", r.StatusCode) } if r := postJSON(t, ts.URL+"/api/threads/"+sid+"/avatar", `{"token":"`+tokA+`","blob_id":"junk"}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("space thread avatar: want 400, got %d", r.StatusCode) } } // The scoped admin reset: the lever for a poisoned open_key (any member can // create the thread; the stored blob is never overwritten and can't be // validated server-side). It deletes ONLY the space thread β€” no instance-key // collateral β€” and the next install starts fresh. func TestAdminSpaceThreadReset(t *testing.T) { ts, srv := newTestServer(t) tok, _, _ := joinedMember(t, srv, "Ana") // A hostile/buggy first write claims the slot with garbage… r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"garbage-nobody-can-open"}`) var created struct { ID string `json:"id"` } json.NewDecoder(r.Body).Decode(&created) // …admin-gated reset clears it (member tokens can't). if r := postJSON(t, ts.URL+"/api/admin/space-thread/reset", `{}`); r.StatusCode != http.StatusUnauthorized { t.Fatalf("reset without admin: want 401, got %d", r.StatusCode) } if r := adminPost(t, ts.URL+"/api/admin/space-thread/reset", `{}`); r.StatusCode != http.StatusOK { t.Fatalf("admin reset: want 200, got %d", r.StatusCode) } if _, _, ok, _ := srv.db.spaceThread(); ok { t.Fatalf("space thread must be gone after the scoped reset") } // The instance key is untouched β€” this is the whole point of the scoped lever. if err := srv.db.grantInstanceKey("someone", "wrapped"); err != nil { t.Fatalf("grantInstanceKey: %v", err) } if w, ok, _ := srv.db.instanceKeyFor("someone"); !ok || w != "wrapped" { t.Fatalf("instance keys must survive the scoped reset") } // A fresh install claims a fresh thread. r = postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"good-key"}`) var again struct { ID string `json:"id"` OpenKey string `json:"open_key"` Created bool `json:"created"` } json.NewDecoder(r.Body).Decode(&again) if !again.Created || again.ID == created.ID || again.OpenKey != "good-key" { t.Fatalf("recreate after scoped reset: %+v (old id %s)", again, created.ID) } } func TestSpaceThreadKindFencedElsewhere(t *testing.T) { ts, srv := newTestServer(t) tok, uid, _ := joinedMember(t, srv, "Ana") // The generic thread endpoint refuses kind "space" β€” the one space thread // is minted only through POST /api/space-thread's single-row transaction. if r := postJSON(t, ts.URL+"/api/threads", `{"token":"`+tok+`","kind":"space","members":[{"user_id":"`+uid+`","wrapped":"w"}]}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("postThread kind=space: want 400, got %d", r.StatusCode) } // And the DB-level unique index holds even against a direct second insert. if _, _, created, err := srv.db.createSpaceThread("k1"); err != nil || !created { t.Fatalf("first createSpaceThread: created=%v err=%v", created, err) } id2, key2, created2, err := srv.db.createSpaceThread("k2") if err != nil || created2 { t.Fatalf("second createSpaceThread must lose the race: created=%v err=%v", created2, err) } if key2 != "k1" { t.Fatalf("the stored open_key must never be overwritten: got %q", key2) } if id2 == "" { t.Fatalf("the loser still learns the winner's id") } } func TestResetInstanceKeyDeletesSpaceThread(t *testing.T) { ts, srv := newTestServer(t) tok, _, _ := joinedMember(t, srv, "Ana") r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"k"}`) var created struct { ID string `json:"id"` } json.NewDecoder(r.Body).Decode(&created) sid := created.ID if r := postJSON(t, ts.URL+"/api/messages?token="+tok, `{"thread_id":"`+sid+`","blob":"cipher"}`); r.StatusCode != http.StatusOK { t.Fatalf("seed message: got %d", r.StatusCode) } // The break-glass reset orphans everything sealed under the instance key β€” // including the space thread's open_key, its ONLY key path (members hold no // per-member wraps). The thread and its rows go with it. if err := srv.db.resetInstanceKey(); err != nil { t.Fatalf("resetInstanceKey: %v", err) } if _, _, ok, _ := srv.db.spaceThread(); ok { t.Fatalf("space thread must be deleted by the instance-key reset") } var msgs int srv.db.sql.QueryRow("SELECT COUNT(*) FROM messages WHERE thread_id = ?", sid).Scan(&msgs) if msgs != 0 { t.Fatalf("space thread messages must be deleted, %d remain", msgs) } // The next space-wide install starts fresh. r = postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"k-new"}`) var again struct { ID string `json:"id"` Created bool `json:"created"` } json.NewDecoder(r.Body).Decode(&again) if !again.Created || again.ID == sid { t.Fatalf("recreate after reset: %+v (old id %s)", again, sid) } } // The space-install policy gate: default off (everyone may add), admin-set, // round-trips through the settings API, and reaches clients as page meta. // Client-honored policy β€” the E2EE registry means the server can't referee β€” // so the server's whole job is storing and serving the flag honestly. func TestSpaceInstallsAdminsGate(t *testing.T) { ts, srv := newTestServer(t) if srv.db.spaceInstallsAdmins() { t.Fatalf("policy must default to everyone-may-add") } if r := adminPost(t, ts.URL+"/api/admin/settings", `{"space_installs_admins":true}`); r.StatusCode != http.StatusOK { t.Fatalf("set policy: want 200, got %d", r.StatusCode) } if !srv.db.spaceInstallsAdmins() { t.Fatalf("policy must persist") } page, _ := http.Get(ts.URL + "/") b, _ := io.ReadAll(page.Body) if !strings.Contains(string(b), `name="space-installs-admins" content="1"`) { t.Fatalf("page meta must carry the policy") } }