eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// 🤖 Tests for the space thread (spacethread.go): the hidden all-members
4// thread backing space-wide app installs. What must hold: creation is
5// member-gated, single, and idempotent under races (the stored open_key never
6// changes); membership is IMPLICIT for every non-agent member (messages,
7// state, scheduling all work with no rows) while agents stay out unless
8// explicitly added; the thread never appears in /api/threads; kind "space"
9// can't be minted through the generic thread endpoint; the instance-key
10// break-glass reset deletes it (its open_key was the only path to its key).
11
12import (
13 "encoding/json"
14 "fmt"
15 "io"
16 "net/http"
17 "strings"
18 "testing"
19 "time"
20)
21
22func TestSpaceThreadCreateAndGet(t *testing.T) {
23 ts, srv := newTestServer(t)
24 tok, _, _ := joinedMember(t, srv, "Ana")
25
26 // Member-gated on both verbs.
27 if r, _ := http.Get(ts.URL + "/api/space-thread?token=bogus"); r.StatusCode != http.StatusForbidden {
28 t.Fatalf("get bad token: want 403, got %d", r.StatusCode)
29 }
30 if r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"bogus","open_key":"k"}`); r.StatusCode != http.StatusForbidden {
31 t.Fatalf("post bad token: want 403, got %d", r.StatusCode)
32 }
33
34 // Before anyone creates it: an empty object, not an error.
35 r, _ := http.Get(ts.URL + "/api/space-thread?token=" + tok)
36 var got struct {
37 ID string `json:"id"`
38 OpenKey string `json:"open_key"`
39 }
40 json.NewDecoder(r.Body).Decode(&got)
41 if r.StatusCode != http.StatusOK || got.ID != "" {
42 t.Fatalf("get before create: want 200 + empty, got %d %+v", r.StatusCode, got)
43 }
44
45 // A missing / oversize open_key is refused — the key blob is the point.
46 if r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`"}`); r.StatusCode != http.StatusBadRequest {
47 t.Fatalf("post no key: want 400, got %d", r.StatusCode)
48 }
49 if r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"`+strings.Repeat("x", 5000)+`"}`); r.StatusCode != http.StatusBadRequest {
50 t.Fatalf("post oversize key: want 400, got %d", r.StatusCode)
51 }
52
53 // First create mints THE space thread.
54 r = postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"wrapped-space-key"}`)
55 var created struct {
56 ID string `json:"id"`
57 OpenKey string `json:"open_key"`
58 Created bool `json:"created"`
59 }
60 json.NewDecoder(r.Body).Decode(&created)
61 if r.StatusCode != http.StatusOK || created.ID == "" || !created.Created || created.OpenKey != "wrapped-space-key" {
62 t.Fatalf("create: got %d %+v", r.StatusCode, created)
63 }
64
65 // A second create (a racing member with its own candidate key) gets the
66 // winner's row back — same id, the ORIGINAL open_key, created:false.
67 r = postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"a-losing-key"}`)
68 var second struct {
69 ID string `json:"id"`
70 OpenKey string `json:"open_key"`
71 Created bool `json:"created"`
72 }
73 json.NewDecoder(r.Body).Decode(&second)
74 if second.ID != created.ID || second.Created || second.OpenKey != "wrapped-space-key" {
75 t.Fatalf("second create must return the winner's row: %+v", second)
76 }
77
78 // GET now serves it to any member.
79 r, _ = http.Get(ts.URL + "/api/space-thread?token=" + tok)
80 json.NewDecoder(r.Body).Decode(&got)
81 if got.ID != created.ID || got.OpenKey != "wrapped-space-key" {
82 t.Fatalf("get after create: %+v", got)
83 }
84}
85
86func TestSpaceThreadImplicitMembership(t *testing.T) {
87 ts, srv := newTestServer(t)
88 tokA, uidA, _ := joinedMember(t, srv, "Ana")
89 tokB, uidB, _ := joinedMember(t, srv, "Bob")
90
91 r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tokA+`","open_key":"k"}`)
92 var created struct {
93 ID string `json:"id"`
94 }
95 json.NewDecoder(r.Body).Decode(&created)
96 sid := created.ID
97
98 // EVERY member is a member — no rows, no joins: both can post and read.
99 for _, tok := range []string{tokA, tokB} {
100 if r := postJSON(t, ts.URL+"/api/messages?token="+tok, `{"thread_id":"`+sid+`","blob":"cipher"}`); r.StatusCode != http.StatusOK {
101 t.Fatalf("member post to space thread: want 200, got %d", r.StatusCode)
102 }
103 if r, _ := http.Get(ts.URL + "/api/messages?token=" + tok + "&thread=" + sid); r.StatusCode != http.StatusOK {
104 t.Fatalf("member read of space thread: want 200, got %d", r.StatusCode)
105 }
106 }
107
108 // State snapshots and scheduling ride the same implicit gate.
109 if r := postJSON(t, ts.URL+"/api/threads/"+sid+"/state", ""); r.StatusCode == http.StatusOK {
110 t.Fatalf("state without body must not 200")
111 }
112 put, _ := http.NewRequest("PUT", ts.URL+"/api/threads/"+sid+"/state",
113 strings.NewReader(`{"token":"`+tokB+`","slot":"apps","blob":"snap","version":0}`))
114 put.Header.Set("Content-Type", "application/json")
115 if r, err := http.DefaultClient.Do(put); err != nil || r.StatusCode != http.StatusOK {
116 t.Fatalf("state put on space thread: %v %d", err, r.StatusCode)
117 }
118 if r, _ := http.Get(ts.URL + "/api/threads/" + sid + "/state?token=" + tokA + "&slot=apps"); r.StatusCode != http.StatusOK {
119 t.Fatalf("state get on space thread: want 200, got %d", r.StatusCode)
120 }
121 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 {
122 t.Fatalf("schedule on space thread: want 200, got %d", r.StatusCode)
123 }
124
125 // An AGENT is not implicitly a member: a bot reading the space log must be
126 // a deliberate choice (an explicit membership row), never a silent default.
127 agentID, _ := srv.db.createUser("Robo")
128 srv.db.setUserAgent(agentID)
129 if err := srv.db.createKeyInvite("inv-robo", agentID, "wrap", testClaimHash, 0); err != nil {
130 t.Fatalf("agent invite: %v", err)
131 }
132 agentTok, ok, err := srv.db.claimKeyInvite("inv-robo", "Robo", "", testClaimSecret)
133 if err != nil || !ok {
134 t.Fatalf("agent claim: ok=%v err=%v", ok, err)
135 }
136 if r := postJSON(t, ts.URL+"/api/messages?token="+agentTok, `{"thread_id":"`+sid+`","blob":"cipher"}`); r.StatusCode != http.StatusForbidden {
137 t.Fatalf("agent post to space thread: want 403, got %d", r.StatusCode)
138 }
139 if srv.db.threadMember(sid, agentID) {
140 t.Fatalf("agent must not be an implicit member")
141 }
142 if err := srv.db.addThreadMember(sid, agentID, "wrapped-to-agent"); err != nil {
143 t.Fatalf("explicitly adding the agent: %v", err)
144 }
145 if !srv.db.threadMember(sid, agentID) {
146 t.Fatalf("an explicitly-added agent IS a member")
147 }
148
149 // memberUserIDs = every non-agent member plus explicit rows, deduped.
150 ids, err := srv.db.memberUserIDs(sid)
151 if err != nil {
152 t.Fatalf("memberUserIDs: %v", err)
153 }
154 want := map[string]bool{uidA: true, uidB: true, agentID: true}
155 got := map[string]bool{}
156 for _, id := range ids {
157 if got[id] {
158 t.Fatalf("memberUserIDs returned %s twice", id)
159 }
160 got[id] = true
161 }
162 for id := range want {
163 if !got[id] {
164 t.Fatalf("memberUserIDs missing %s (got %v)", id, ids)
165 }
166 }
167
168 // The space thread NEVER appears in a member's thread list — old clients
169 // (and the native apps, until they learn the feature) must never see it.
170 r, _ = http.Get(ts.URL + "/api/threads?token=" + tokA)
171 var list struct {
172 Threads []Thread `json:"threads"`
173 }
174 json.NewDecoder(r.Body).Decode(&list)
175 for _, th := range list.Threads {
176 if th.ID == sid || th.Kind == "space" {
177 t.Fatalf("space thread leaked into /api/threads: %+v", th)
178 }
179 }
180
181 // Nor does GET /api/space-thread hand the key blob to a (non-member) agent:
182 // the sidecar holds the instance key, so open_key would unwrap the space
183 // key it was deliberately excluded from. An EXPLICIT member agent (added
184 // above) does get it — bot access is a disclosed choice.
185 agent2, _ := srv.db.createUser("Robo2")
186 srv.db.setUserAgent(agent2)
187 if err := srv.db.createKeyInvite("inv-robo2", agent2, "wrap", testClaimHash, 0); err != nil {
188 t.Fatalf("agent2 invite: %v", err)
189 }
190 agent2Tok, ok2, err := srv.db.claimKeyInvite("inv-robo2", "Robo2", "", testClaimSecret)
191 if err != nil || !ok2 {
192 t.Fatalf("agent2 claim: ok=%v err=%v", ok2, err)
193 }
194 r, _ = http.Get(ts.URL + "/api/space-thread?token=" + agent2Tok)
195 var agentView struct {
196 ID string `json:"id"`
197 OpenKey string `json:"open_key"`
198 }
199 json.NewDecoder(r.Body).Decode(&agentView)
200 if agentView.ID != "" || agentView.OpenKey != "" {
201 t.Fatalf("a non-member agent must get an empty space-thread view, got %+v", agentView)
202 }
203 r, _ = http.Get(ts.URL + "/api/space-thread?token=" + agentTok) // the explicitly-added bot
204 json.NewDecoder(r.Body).Decode(&agentView)
205 if agentView.ID != sid {
206 t.Fatalf("an explicitly-added bot gets the space thread, got %+v", agentView)
207 }
208 // Nor can an agent MINT the space thread — the creator wraps (and so
209 // knows) the space key, which a sidecar must never hold by default.
210 if r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+agent2Tok+`","open_key":"agent-minted"}`); r.StatusCode != http.StatusForbidden {
211 t.Fatalf("agent create of space thread: want 403, got %d", r.StatusCode)
212 }
213
214 // Implicit membership must not open a scribble surface: the space thread
215 // renders nowhere, so rename/photo writes are fenced off.
216 if r := postJSON(t, ts.URL+"/api/threads/"+sid+"/name", `{"token":"`+tokA+`","name_blob":"junk"}`); r.StatusCode != http.StatusBadRequest {
217 t.Fatalf("space thread rename: want 400, got %d", r.StatusCode)
218 }
219 if r := postJSON(t, ts.URL+"/api/threads/"+sid+"/avatar", `{"token":"`+tokA+`","blob_id":"junk"}`); r.StatusCode != http.StatusBadRequest {
220 t.Fatalf("space thread avatar: want 400, got %d", r.StatusCode)
221 }
222}
223
224// The scoped admin reset: the lever for a poisoned open_key (any member can
225// create the thread; the stored blob is never overwritten and can't be
226// validated server-side). It deletes ONLY the space thread — no instance-key
227// collateral — and the next install starts fresh.
228func TestAdminSpaceThreadReset(t *testing.T) {
229 ts, srv := newTestServer(t)
230 tok, _, _ := joinedMember(t, srv, "Ana")
231
232 // A hostile/buggy first write claims the slot with garbage…
233 r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"garbage-nobody-can-open"}`)
234 var created struct {
235 ID string `json:"id"`
236 }
237 json.NewDecoder(r.Body).Decode(&created)
238
239 // …admin-gated reset clears it (member tokens can't).
240 if r := postJSON(t, ts.URL+"/api/admin/space-thread/reset", `{}`); r.StatusCode != http.StatusUnauthorized {
241 t.Fatalf("reset without admin: want 401, got %d", r.StatusCode)
242 }
243 if r := adminPost(t, ts.URL+"/api/admin/space-thread/reset", `{}`); r.StatusCode != http.StatusOK {
244 t.Fatalf("admin reset: want 200, got %d", r.StatusCode)
245 }
246 if _, _, ok, _ := srv.db.spaceThread(); ok {
247 t.Fatalf("space thread must be gone after the scoped reset")
248 }
249 // The instance key is untouched — this is the whole point of the scoped lever.
250 if err := srv.db.grantInstanceKey("someone", "wrapped"); err != nil {
251 t.Fatalf("grantInstanceKey: %v", err)
252 }
253 if w, ok, _ := srv.db.instanceKeyFor("someone"); !ok || w != "wrapped" {
254 t.Fatalf("instance keys must survive the scoped reset")
255 }
256 // A fresh install claims a fresh thread.
257 r = postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"good-key"}`)
258 var again struct {
259 ID string `json:"id"`
260 OpenKey string `json:"open_key"`
261 Created bool `json:"created"`
262 }
263 json.NewDecoder(r.Body).Decode(&again)
264 if !again.Created || again.ID == created.ID || again.OpenKey != "good-key" {
265 t.Fatalf("recreate after scoped reset: %+v (old id %s)", again, created.ID)
266 }
267}
268
269func TestSpaceThreadKindFencedElsewhere(t *testing.T) {
270 ts, srv := newTestServer(t)
271 tok, uid, _ := joinedMember(t, srv, "Ana")
272
273 // The generic thread endpoint refuses kind "space" — the one space thread
274 // is minted only through POST /api/space-thread's single-row transaction.
275 if r := postJSON(t, ts.URL+"/api/threads",
276 `{"token":"`+tok+`","kind":"space","members":[{"user_id":"`+uid+`","wrapped":"w"}]}`); r.StatusCode != http.StatusBadRequest {
277 t.Fatalf("postThread kind=space: want 400, got %d", r.StatusCode)
278 }
279
280 // And the DB-level unique index holds even against a direct second insert.
281 if _, _, created, err := srv.db.createSpaceThread("k1"); err != nil || !created {
282 t.Fatalf("first createSpaceThread: created=%v err=%v", created, err)
283 }
284 id2, key2, created2, err := srv.db.createSpaceThread("k2")
285 if err != nil || created2 {
286 t.Fatalf("second createSpaceThread must lose the race: created=%v err=%v", created2, err)
287 }
288 if key2 != "k1" {
289 t.Fatalf("the stored open_key must never be overwritten: got %q", key2)
290 }
291 if id2 == "" {
292 t.Fatalf("the loser still learns the winner's id")
293 }
294}
295
296func TestResetInstanceKeyDeletesSpaceThread(t *testing.T) {
297 ts, srv := newTestServer(t)
298 tok, _, _ := joinedMember(t, srv, "Ana")
299
300 r := postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"k"}`)
301 var created struct {
302 ID string `json:"id"`
303 }
304 json.NewDecoder(r.Body).Decode(&created)
305 sid := created.ID
306 if r := postJSON(t, ts.URL+"/api/messages?token="+tok, `{"thread_id":"`+sid+`","blob":"cipher"}`); r.StatusCode != http.StatusOK {
307 t.Fatalf("seed message: got %d", r.StatusCode)
308 }
309
310 // The break-glass reset orphans everything sealed under the instance key —
311 // including the space thread's open_key, its ONLY key path (members hold no
312 // per-member wraps). The thread and its rows go with it.
313 if err := srv.db.resetInstanceKey(); err != nil {
314 t.Fatalf("resetInstanceKey: %v", err)
315 }
316 if _, _, ok, _ := srv.db.spaceThread(); ok {
317 t.Fatalf("space thread must be deleted by the instance-key reset")
318 }
319 var msgs int
320 srv.db.sql.QueryRow("SELECT COUNT(*) FROM messages WHERE thread_id = ?", sid).Scan(&msgs)
321 if msgs != 0 {
322 t.Fatalf("space thread messages must be deleted, %d remain", msgs)
323 }
324
325 // The next space-wide install starts fresh.
326 r = postJSON(t, ts.URL+"/api/space-thread", `{"token":"`+tok+`","open_key":"k-new"}`)
327 var again struct {
328 ID string `json:"id"`
329 Created bool `json:"created"`
330 }
331 json.NewDecoder(r.Body).Decode(&again)
332 if !again.Created || again.ID == sid {
333 t.Fatalf("recreate after reset: %+v (old id %s)", again, sid)
334 }
335}
336
337// The space-install policy gate: default off (everyone may add), admin-set,
338// round-trips through the settings API, and reaches clients as page meta.
339// Client-honored policy — the E2EE registry means the server can't referee —
340// so the server's whole job is storing and serving the flag honestly.
341func TestSpaceInstallsAdminsGate(t *testing.T) {
342 ts, srv := newTestServer(t)
343 if srv.db.spaceInstallsAdmins() {
344 t.Fatalf("policy must default to everyone-may-add")
345 }
346 if r := adminPost(t, ts.URL+"/api/admin/settings", `{"space_installs_admins":true}`); r.StatusCode != http.StatusOK {
347 t.Fatalf("set policy: want 200, got %d", r.StatusCode)
348 }
349 if !srv.db.spaceInstallsAdmins() {
350 t.Fatalf("policy must persist")
351 }
352 page, _ := http.Get(ts.URL + "/")
353 b, _ := io.ReadAll(page.Body)
354 if !strings.Contains(string(b), `name="space-installs-admins" content="1"`) {
355 t.Fatalf("page meta must carry the policy")
356 }
357}
358