| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "net/http" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // The Helpers catalog (docs/bots.md): agents the admin marked available appear in |
| 11 | // EVERY member's connections — the one exemption from the connections-only roster, |
| 12 | // so a new bot is discoverable before anyone shares a thread with it. |
| 13 | func TestHelpersCatalogInConnections(t *testing.T) { |
| 14 | db := newTestDB(t) |
| 15 | mk := func(name string) string { |
| 16 | id, err := db.createUser(name) |
| 17 | if err != nil { |
| 18 | t.Fatalf("createUser %s: %v", name, err) |
| 19 | } |
| 20 | if err := db.setUserPublicKey(id, "pub-"+name, ""); err != nil { |
| 21 | t.Fatalf("setUserPublicKey %s: %v", name, err) |
| 22 | } |
| 23 | return id |
| 24 | } |
| 25 | paul := mk("Paul") |
| 26 | bot := mk("Sunny") |
| 27 | if err := db.setUserAgent(bot); err != nil { |
| 28 | t.Fatalf("setUserAgent: %v", err) |
| 29 | } |
| 30 | if err := db.setBotConfig(bot, "", "", "", "", "Answers questions and summarises chats", true); err != nil { |
| 31 | t.Fatalf("setBotConfig: %v", err) |
| 32 | } |
| 33 | |
| 34 | find := func(conns []RosterEntry, id string) *RosterEntry { |
| 35 | for i := range conns { |
| 36 | if conns[i].UserID == id { |
| 37 | return &conns[i] |
| 38 | } |
| 39 | } |
| 40 | return nil |
| 41 | } |
| 42 | |
| 43 | // Paul shares no thread with the bot, yet it's in his roster — with the badge |
| 44 | // and the admin's blurb. |
| 45 | conns, err := db.connections(paul) |
| 46 | if err != nil { |
| 47 | t.Fatalf("connections: %v", err) |
| 48 | } |
| 49 | e := find(conns, bot) |
| 50 | if e == nil { |
| 51 | t.Fatalf("an available bot should appear in every member's connections: %+v", conns) |
| 52 | } |
| 53 | if !e.IsAgent || e.Description != "Answers questions and summarises chats" { |
| 54 | t.Fatalf("catalog entry should carry is_agent + description: %+v", e) |
| 55 | } |
| 56 | |
| 57 | // Admin flips availability off → the bot drops back out (still no shared thread). |
| 58 | if err := db.setBotConfig(bot, "", "", "", "", "Answers questions and summarises chats", false); err != nil { |
| 59 | t.Fatalf("setBotConfig off: %v", err) |
| 60 | } |
| 61 | conns, _ = db.connections(paul) |
| 62 | if find(conns, bot) != nil { |
| 63 | t.Fatal("an unavailable bot must NOT be visible without a shared thread") |
| 64 | } |
| 65 | |
| 66 | // A keyless bot (invite minted, sidecar hasn't claimed it) never shows, even |
| 67 | // when available — there's no public key to wrap a thread key to. |
| 68 | ghost, err := db.createUser("Ghost") |
| 69 | if err != nil { |
| 70 | t.Fatalf("createUser ghost: %v", err) |
| 71 | } |
| 72 | if err := db.setUserAgent(ghost); err != nil { |
| 73 | t.Fatalf("setUserAgent ghost: %v", err) |
| 74 | } |
| 75 | if err := db.setBotConfig(ghost, "", "", "", "", "not joined yet", true); err != nil { |
| 76 | t.Fatalf("setBotConfig ghost: %v", err) |
| 77 | } |
| 78 | conns, _ = db.connections(paul) |
| 79 | if find(conns, ghost) != nil { |
| 80 | t.Fatal("a keyless (unclaimed) bot must not appear in the catalog") |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // The embedded changelog: token-gated, entries in filename (date) order, and |
| 85 | // non-empty — the deploy announcer depends on this endpoint's shape. |
| 86 | func TestChangelogEndpoint(t *testing.T) { |
| 87 | ts, srv := newTestServer(t) |
| 88 | |
| 89 | r, _ := http.Get(ts.URL + "/api/changelog?token=nope") |
| 90 | if r.StatusCode != http.StatusForbidden { |
| 91 | t.Fatalf("bad token: want 403, got %d", r.StatusCode) |
| 92 | } |
| 93 | |
| 94 | uid, _ := srv.db.createUser("Paul") |
| 95 | srv.db.createKeyInvite("inv-cl", uid, "", testClaimHash, 0) |
| 96 | tok, _, _ := srv.db.claimKeyInvite("inv-cl", "Paul", "", testClaimSecret) |
| 97 | rr, _ := http.Get(ts.URL + "/api/changelog?token=" + tok) |
| 98 | var out struct { |
| 99 | Entries []ChangelogEntry `json:"entries"` |
| 100 | } |
| 101 | json.NewDecoder(rr.Body).Decode(&out) |
| 102 | if len(out.Entries) == 0 { |
| 103 | t.Fatal("expected at least one embedded changelog entry") |
| 104 | } |
| 105 | for i, e := range out.Entries { |
| 106 | if e.ID == "" || e.Text == "" { |
| 107 | t.Fatalf("entry %d should have id and text: %+v", i, e) |
| 108 | } |
| 109 | if i > 0 && out.Entries[i-1].ID > e.ID { |
| 110 | t.Fatalf("entries should be in filename order: %q before %q", out.Entries[i-1].ID, e.ID) |
| 111 | } |
| 112 | } |
| 113 | } |
| 114 | |
| 115 | // End to end over HTTP: the admin's "Add an agent" carries the catalog metadata, |
| 116 | // and once the sidecar joins, a stranger member sees the helper in /api/roster. |
| 117 | func TestHelpersCatalogHTTP(t *testing.T) { |
| 118 | ts, srv := newTestServer(t) |
| 119 | |
| 120 | // A member with no connection to the bot. |
| 121 | paul, _ := srv.db.createUser("Paul") |
| 122 | srv.db.createKeyInvite("inv-paul", paul, "", testClaimHash, 0) |
| 123 | tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "", testClaimSecret) |
| 124 | srv.db.setUserPublicKey(paul, "pub-paul", "") |
| 125 | |
| 126 | // Admin mints an agent invite with a description, shown to members. |
| 127 | r := adminPost(t, ts.URL+"/api/admin/key-invites", |
| 128 | `{"label":"Sunny","id":"inv-bot","wrapped":"","agent":true, |
| 129 | "model":"","system_prompt":"","docs":"", |
| 130 | "description":"A friendly helper","available_to_members":true,"claim_hash":"`+testClaimHash+`"}`) |
| 131 | if r.StatusCode != http.StatusOK { |
| 132 | t.Fatalf("create agent invite: got %d", r.StatusCode) |
| 133 | } |
| 134 | |
| 135 | // Not joined yet → not in anyone's roster. |
| 136 | fetchRoster := func() []RosterEntry { |
| 137 | rr, _ := http.Get(ts.URL + "/api/roster?token=" + tok) |
| 138 | var out struct { |
| 139 | Roster []RosterEntry `json:"roster"` |
| 140 | } |
| 141 | json.NewDecoder(rr.Body).Decode(&out) |
| 142 | return out.Roster |
| 143 | } |
| 144 | for _, e := range fetchRoster() { |
| 145 | if e.Label == "Sunny" { |
| 146 | t.Fatal("unclaimed bot should not be in the roster yet") |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | // The sidecar claims the invite and registers its key (as agent/provision.mjs |
| 151 | // does) → the helper appears for the unconnected member, badge + blurb intact. |
| 152 | botTok, ok, err := srv.db.claimKeyInvite("inv-bot", "Sunny", "", testClaimSecret) |
| 153 | if err != nil || !ok { |
| 154 | t.Fatalf("bot claim: ok=%v err=%v", ok, err) |
| 155 | } |
| 156 | botID, _ := srv.db.deviceTokenUser(botTok) |
| 157 | srv.db.setUserPublicKey(botID, "pub-sunny", "") |
| 158 | |
| 159 | var helper *RosterEntry |
| 160 | roster := fetchRoster() |
| 161 | for i := range roster { |
| 162 | if roster[i].UserID == botID { |
| 163 | helper = &roster[i] |
| 164 | } |
| 165 | } |
| 166 | if helper == nil { |
| 167 | t.Fatalf("joined available bot should be in a stranger's roster: %+v", roster) |
| 168 | } |
| 169 | if !helper.IsAgent || helper.Description != "A friendly helper" || helper.PublicKey != "pub-sunny" { |
| 170 | t.Fatalf("helper entry should carry badge, blurb and key: %+v", helper) |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | // Vetted bots (agent + available_to_members) read the FULL joined roster — a |
| 175 | // welcome bot must see members it shares no thread with. People and unvetted |
| 176 | // agents stay connections-only. |
| 177 | func TestVettedAgentSeesFullRoster(t *testing.T) { |
| 178 | ts, srv := newTestServer(t) |
| 179 | join := func(label, inv string) (string, string) { |
| 180 | uid, _ := srv.db.createUser(label) |
| 181 | srv.db.createKeyInvite(inv, uid, "", testClaimHash, 0) |
| 182 | tok, _, _ := srv.db.claimKeyInvite(inv, label, "", testClaimSecret) |
| 183 | srv.db.setUserPublicKey(uid, "pub-"+label, "") |
| 184 | return uid, tok |
| 185 | } |
| 186 | _, paulTok := join("Paul", "inv-p") |
| 187 | botID, botTok := join("Eleven", "inv-b") |
| 188 | srv.db.setUserAgent(botID) |
| 189 | if err := srv.db.setBotConfig(botID, "", "", "", "", "Your guide to the app", true); err != nil { |
| 190 | t.Fatalf("setBotConfig: %v", err) |
| 191 | } |
| 192 | |
| 193 | labels := func(tok string) map[string]bool { |
| 194 | rr, _ := http.Get(ts.URL + "/api/roster?token=" + tok) |
| 195 | var out struct { |
| 196 | Roster []RosterEntry `json:"roster"` |
| 197 | } |
| 198 | json.NewDecoder(rr.Body).Decode(&out) |
| 199 | got := map[string]bool{} |
| 200 | for _, e := range out.Roster { |
| 201 | got[e.Label] = true |
| 202 | } |
| 203 | return got |
| 204 | } |
| 205 | |
| 206 | // The vetted bot sees Paul despite sharing no thread with him. |
| 207 | if got := labels(botTok); !got["Paul"] || !got["Eleven"] { |
| 208 | t.Fatalf("vetted bot should see the full roster: %v", got) |
| 209 | } |
| 210 | // Paul sees the bot only through the catalog (it's available), and himself — |
| 211 | // the widening is one-way. |
| 212 | if got := labels(paulTok); !got["Eleven"] || !got["Paul"] { |
| 213 | t.Fatalf("member roster should be self + catalog bots: %v", got) |
| 214 | } |
| 215 | |
| 216 | // Unvetted: flip availability off — the bot drops back to connections-only. |
| 217 | if err := srv.db.setBotConfig(botID, "", "", "", "", "Your guide to the app", false); err != nil { |
| 218 | t.Fatalf("setBotConfig off: %v", err) |
| 219 | } |
| 220 | if got := labels(botTok); got["Paul"] { |
| 221 | t.Fatalf("unvetted agent must not see strangers: %v", got) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | // The listen mode (@mention-only vs bare-name) set at agent-invite time must |
| 226 | // round-trip to /api/bot-config — the sidecar's reply gating depends on it — |
| 227 | // and unknown values must be rejected at the API boundary. |
| 228 | func TestBotConfigListen(t *testing.T) { |
| 229 | ts, srv := newTestServer(t) |
| 230 | uid, _ := srv.db.createUser("Sunny") |
| 231 | srv.db.createKeyInvite("inv-listen", uid, "", testClaimHash, 0) |
| 232 | tok, _, _ := srv.db.claimKeyInvite("inv-listen", "Sunny", "", testClaimSecret) |
| 233 | srv.db.setUserAgent(uid) |
| 234 | if err := srv.db.setBotConfig(uid, "", "", "", "name", "A helper", true); err != nil { |
| 235 | t.Fatalf("setBotConfig: %v", err) |
| 236 | } |
| 237 | |
| 238 | rr, _ := http.Get(ts.URL + "/api/bot-config?token=" + tok) |
| 239 | var c BotConfig |
| 240 | json.NewDecoder(rr.Body).Decode(&c) |
| 241 | if c.Listen != "name" { |
| 242 | t.Fatalf("listen should round-trip to the sidecar: %+v", c) |
| 243 | } |
| 244 | |
| 245 | // The invite endpoint only accepts the known modes. |
| 246 | resp := adminPost(t, ts.URL+"/api/admin/key-invites", |
| 247 | `{"id":"inv-bad-listen","label":"Bad","agent":true,"listen":"watcher"}`) |
| 248 | if resp.StatusCode != http.StatusBadRequest { |
| 249 | t.Fatalf("unknown listen mode: want 400, got %d", resp.StatusCode) |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | // Admin bot-config editing: GET returns the full config (zero-value before any |
| 254 | // row exists), POST updates it in place, and both refuse non-agents. |
| 255 | func TestAdminEditBotConfig(t *testing.T) { |
| 256 | ts, srv := newTestServer(t) |
| 257 | person, _ := srv.db.createUser("Paul") |
| 258 | bot, _ := srv.db.createUser("Sunny") |
| 259 | srv.db.setUserAgent(bot) |
| 260 | |
| 261 | if r := adminPost(t, ts.URL+"/api/admin/bots/"+person+"/config", `{"model":""}`); r.StatusCode != http.StatusBadRequest { |
| 262 | t.Fatalf("person: want 400, got %d", r.StatusCode) |
| 263 | } |
| 264 | |
| 265 | get := func() FullBotConfig { |
| 266 | req, _ := http.NewRequest("GET", ts.URL+"/api/admin/bots/"+bot+"/config", nil) |
| 267 | req.Header.Set("Authorization", "Bearer admintok") |
| 268 | resp, _ := http.DefaultClient.Do(req) |
| 269 | if resp.StatusCode != http.StatusOK { |
| 270 | t.Fatalf("get config: %d", resp.StatusCode) |
| 271 | } |
| 272 | var c FullBotConfig |
| 273 | json.NewDecoder(resp.Body).Decode(&c) |
| 274 | return c |
| 275 | } |
| 276 | |
| 277 | // Pre-console bot: no config row yet — GET still succeeds with zero values. |
| 278 | if c := get(); c.Model != "" || c.Available { |
| 279 | t.Fatalf("expected zero-value config, got %+v", c) |
| 280 | } |
| 281 | |
| 282 | if r := adminPost(t, ts.URL+"/api/admin/bots/"+bot+"/config", |
| 283 | `{"model":"claude-opus-4-8","system_prompt":"be brief","docs":"house rules","listen":"name","description":"A test bot","available_to_members":true}`); r.StatusCode != http.StatusOK { |
| 284 | t.Fatalf("update: want 200, got %d", r.StatusCode) |
| 285 | } |
| 286 | c := get() |
| 287 | if c.Model != "claude-opus-4-8" || c.SystemPrompt != "be brief" || c.Docs != "house rules" || |
| 288 | c.Listen != "name" || c.Description != "A test bot" || !c.Available { |
| 289 | t.Fatalf("config after update: %+v", c) |
| 290 | } |
| 291 | |
| 292 | // The edit endpoint enforces the same listen allowlist as invites. |
| 293 | if r := adminPost(t, ts.URL+"/api/admin/bots/"+bot+"/config", `{"listen":"watcher"}`); r.StatusCode != http.StatusBadRequest { |
| 294 | t.Fatalf("bad listen on edit: want 400, got %d", r.StatusCode) |
| 295 | } |
| 296 | |
| 297 | // The sidecar-facing view picks up the same row. |
| 298 | bc, ok, _ := srv.db.botConfig(bot) |
| 299 | if !ok || bc.SystemPrompt != "be brief" { |
| 300 | t.Fatalf("sidecar view: ok=%v %+v", ok, bc) |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // A sidecar self-declares its identity's kind ("ai"/"bot") with its OWN device |
| 305 | // token; it surfaces on the admin users list to drive the AI/BOT badge. The |
| 306 | // endpoint accepts only an agent's own valid token and the two known kinds, and |
| 307 | // kind never clobbers (nor is clobbered by) the admin config write — both are |
| 308 | // narrow upserts. Welcome text round-trips to the sidecar-facing config. |
| 309 | func TestBotKindDeclareAndWelcome(t *testing.T) { |
| 310 | ts, srv := newTestServer(t) |
| 311 | uid, _ := srv.db.createUser("Eleven") |
| 312 | srv.db.createKeyInvite("inv-k", uid, "", testClaimHash, 0) |
| 313 | tok, _, _ := srv.db.claimKeyInvite("inv-k", "Eleven", "", testClaimSecret) |
| 314 | srv.db.setUserPublicKey(uid, "pub-eleven", "") |
| 315 | srv.db.setUserAgent(uid) |
| 316 | |
| 317 | declare := func(token, kind string) int { |
| 318 | r, _ := http.Post(ts.URL+"/api/bot-config", "application/json", |
| 319 | strings.NewReader(`{"token":"`+token+`","kind":"`+kind+`"}`)) |
| 320 | return r.StatusCode |
| 321 | } |
| 322 | kindOf := func(id string) string { |
| 323 | users, _ := srv.db.listUsers() |
| 324 | for _, u := range users { |
| 325 | if u.ID == id { |
| 326 | return u.Kind |
| 327 | } |
| 328 | } |
| 329 | return "?" |
| 330 | } |
| 331 | |
| 332 | // Default before any declare: an agent row reads as "ai". |
| 333 | if k := kindOf(uid); k != "ai" { |
| 334 | t.Fatalf("default agent kind: want ai, got %q", k) |
| 335 | } |
| 336 | // Declare bot (Eleven running commands-only) → badge flips to BOT. |
| 337 | if s := declare(tok, "bot"); s != http.StatusOK || kindOf(uid) != "bot" { |
| 338 | t.Fatalf("declare bot: status/kind = %d/%q", s, kindOf(uid)) |
| 339 | } |
| 340 | // Re-declare ai (its model brain came online) → badge flips back. |
| 341 | if s := declare(tok, "ai"); s != http.StatusOK || kindOf(uid) != "ai" { |
| 342 | t.Fatalf("re-declare ai: status/kind = %d/%q", s, kindOf(uid)) |
| 343 | } |
| 344 | |
| 345 | // Guards: unknown kind, a non-agent's token, and a bogus token are all refused. |
| 346 | if s := declare(tok, "watcher"); s != http.StatusBadRequest { |
| 347 | t.Fatalf("bad kind: want 400, got %d", s) |
| 348 | } |
| 349 | person, _ := srv.db.createUser("Paul") |
| 350 | srv.db.createKeyInvite("inv-p", person, "", testClaimHash, 0) |
| 351 | ptok, _, _ := srv.db.claimKeyInvite("inv-p", "Paul", "", testClaimSecret) |
| 352 | if s := declare(ptok, "bot"); s != http.StatusBadRequest { |
| 353 | t.Fatalf("non-agent declare: want 400, got %d", s) |
| 354 | } |
| 355 | if s := declare("nope", "bot"); s != http.StatusForbidden { |
| 356 | t.Fatalf("bogus token: want 403, got %d", s) |
| 357 | } |
| 358 | |
| 359 | // kind and the admin config write don't clobber each other (narrow upserts), |
| 360 | // and the admin-set welcome reaches the sidecar-facing config. |
| 361 | declare(tok, "bot") |
| 362 | if r := adminPost(t, ts.URL+"/api/admin/bots/"+uid+"/config", |
| 363 | `{"system_prompt":"be brief","welcome":"Hi {name}, welcome aboard!"}`); r.StatusCode != http.StatusOK { |
| 364 | t.Fatalf("admin edit: %d", r.StatusCode) |
| 365 | } |
| 366 | if k := kindOf(uid); k != "bot" { |
| 367 | t.Fatalf("admin edit clobbered kind: got %q", k) |
| 368 | } |
| 369 | bc, ok, _ := srv.db.botConfig(uid) |
| 370 | if !ok || bc.Welcome != "Hi {name}, welcome aboard!" { |
| 371 | t.Fatalf("welcome round-trip: ok=%v %+v", ok, bc) |
| 372 | } |
| 373 | } |
| 374 | |