| 1 | package 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 | |
| 12 | import ( |
| 13 | "encoding/json" |
| 14 | "fmt" |
| 15 | "io" |
| 16 | "net/http" |
| 17 | "strings" |
| 18 | "testing" |
| 19 | "time" |
| 20 | ) |
| 21 | |
| 22 | func 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 | |
| 86 | func 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. |
| 228 | func 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 | |
| 269 | func 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 | |
| 296 | func 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. |
| 341 | func 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 | |