package main import ( "encoding/json" "net/http" "strings" "testing" ) // The Helpers catalog (docs/bots.md): agents the admin marked available appear in // EVERY member's connections — the one exemption from the connections-only roster, // so a new bot is discoverable before anyone shares a thread with it. func TestHelpersCatalogInConnections(t *testing.T) { db := newTestDB(t) mk := func(name string) string { id, err := db.createUser(name) if err != nil { t.Fatalf("createUser %s: %v", name, err) } if err := db.setUserPublicKey(id, "pub-"+name, ""); err != nil { t.Fatalf("setUserPublicKey %s: %v", name, err) } return id } paul := mk("Paul") bot := mk("Sunny") if err := db.setUserAgent(bot); err != nil { t.Fatalf("setUserAgent: %v", err) } if err := db.setBotConfig(bot, "", "", "", "", "Answers questions and summarises chats", true); err != nil { t.Fatalf("setBotConfig: %v", err) } find := func(conns []RosterEntry, id string) *RosterEntry { for i := range conns { if conns[i].UserID == id { return &conns[i] } } return nil } // Paul shares no thread with the bot, yet it's in his roster — with the badge // and the admin's blurb. conns, err := db.connections(paul) if err != nil { t.Fatalf("connections: %v", err) } e := find(conns, bot) if e == nil { t.Fatalf("an available bot should appear in every member's connections: %+v", conns) } if !e.IsAgent || e.Description != "Answers questions and summarises chats" { t.Fatalf("catalog entry should carry is_agent + description: %+v", e) } // Admin flips availability off → the bot drops back out (still no shared thread). if err := db.setBotConfig(bot, "", "", "", "", "Answers questions and summarises chats", false); err != nil { t.Fatalf("setBotConfig off: %v", err) } conns, _ = db.connections(paul) if find(conns, bot) != nil { t.Fatal("an unavailable bot must NOT be visible without a shared thread") } // A keyless bot (invite minted, sidecar hasn't claimed it) never shows, even // when available — there's no public key to wrap a thread key to. ghost, err := db.createUser("Ghost") if err != nil { t.Fatalf("createUser ghost: %v", err) } if err := db.setUserAgent(ghost); err != nil { t.Fatalf("setUserAgent ghost: %v", err) } if err := db.setBotConfig(ghost, "", "", "", "", "not joined yet", true); err != nil { t.Fatalf("setBotConfig ghost: %v", err) } conns, _ = db.connections(paul) if find(conns, ghost) != nil { t.Fatal("a keyless (unclaimed) bot must not appear in the catalog") } } // The embedded changelog: token-gated, entries in filename (date) order, and // non-empty — the deploy announcer depends on this endpoint's shape. func TestChangelogEndpoint(t *testing.T) { ts, srv := newTestServer(t) r, _ := http.Get(ts.URL + "/api/changelog?token=nope") if r.StatusCode != http.StatusForbidden { t.Fatalf("bad token: want 403, got %d", r.StatusCode) } uid, _ := srv.db.createUser("Paul") srv.db.createKeyInvite("inv-cl", uid, "", testClaimHash, 0) tok, _, _ := srv.db.claimKeyInvite("inv-cl", "Paul", "", testClaimSecret) rr, _ := http.Get(ts.URL + "/api/changelog?token=" + tok) var out struct { Entries []ChangelogEntry `json:"entries"` } json.NewDecoder(rr.Body).Decode(&out) if len(out.Entries) == 0 { t.Fatal("expected at least one embedded changelog entry") } for i, e := range out.Entries { if e.ID == "" || e.Text == "" { t.Fatalf("entry %d should have id and text: %+v", i, e) } if i > 0 && out.Entries[i-1].ID > e.ID { t.Fatalf("entries should be in filename order: %q before %q", out.Entries[i-1].ID, e.ID) } } } // End to end over HTTP: the admin's "Add an agent" carries the catalog metadata, // and once the sidecar joins, a stranger member sees the helper in /api/roster. func TestHelpersCatalogHTTP(t *testing.T) { ts, srv := newTestServer(t) // A member with no connection to the bot. paul, _ := srv.db.createUser("Paul") srv.db.createKeyInvite("inv-paul", paul, "", testClaimHash, 0) tok, _, _ := srv.db.claimKeyInvite("inv-paul", "Paul", "", testClaimSecret) srv.db.setUserPublicKey(paul, "pub-paul", "") // Admin mints an agent invite with a description, shown to members. r := adminPost(t, ts.URL+"/api/admin/key-invites", `{"label":"Sunny","id":"inv-bot","wrapped":"","agent":true, "model":"","system_prompt":"","docs":"", "description":"A friendly helper","available_to_members":true,"claim_hash":"`+testClaimHash+`"}`) if r.StatusCode != http.StatusOK { t.Fatalf("create agent invite: got %d", r.StatusCode) } // Not joined yet → not in anyone's roster. fetchRoster := func() []RosterEntry { rr, _ := http.Get(ts.URL + "/api/roster?token=" + tok) var out struct { Roster []RosterEntry `json:"roster"` } json.NewDecoder(rr.Body).Decode(&out) return out.Roster } for _, e := range fetchRoster() { if e.Label == "Sunny" { t.Fatal("unclaimed bot should not be in the roster yet") } } // The sidecar claims the invite and registers its key (as agent/provision.mjs // does) → the helper appears for the unconnected member, badge + blurb intact. botTok, ok, err := srv.db.claimKeyInvite("inv-bot", "Sunny", "", testClaimSecret) if err != nil || !ok { t.Fatalf("bot claim: ok=%v err=%v", ok, err) } botID, _ := srv.db.deviceTokenUser(botTok) srv.db.setUserPublicKey(botID, "pub-sunny", "") var helper *RosterEntry roster := fetchRoster() for i := range roster { if roster[i].UserID == botID { helper = &roster[i] } } if helper == nil { t.Fatalf("joined available bot should be in a stranger's roster: %+v", roster) } if !helper.IsAgent || helper.Description != "A friendly helper" || helper.PublicKey != "pub-sunny" { t.Fatalf("helper entry should carry badge, blurb and key: %+v", helper) } } // Vetted bots (agent + available_to_members) read the FULL joined roster — a // welcome bot must see members it shares no thread with. People and unvetted // agents stay connections-only. func TestVettedAgentSeesFullRoster(t *testing.T) { ts, srv := newTestServer(t) join := func(label, inv string) (string, string) { uid, _ := srv.db.createUser(label) srv.db.createKeyInvite(inv, uid, "", testClaimHash, 0) tok, _, _ := srv.db.claimKeyInvite(inv, label, "", testClaimSecret) srv.db.setUserPublicKey(uid, "pub-"+label, "") return uid, tok } _, paulTok := join("Paul", "inv-p") botID, botTok := join("Eleven", "inv-b") srv.db.setUserAgent(botID) if err := srv.db.setBotConfig(botID, "", "", "", "", "Your guide to the app", true); err != nil { t.Fatalf("setBotConfig: %v", err) } labels := func(tok string) map[string]bool { rr, _ := http.Get(ts.URL + "/api/roster?token=" + tok) var out struct { Roster []RosterEntry `json:"roster"` } json.NewDecoder(rr.Body).Decode(&out) got := map[string]bool{} for _, e := range out.Roster { got[e.Label] = true } return got } // The vetted bot sees Paul despite sharing no thread with him. if got := labels(botTok); !got["Paul"] || !got["Eleven"] { t.Fatalf("vetted bot should see the full roster: %v", got) } // Paul sees the bot only through the catalog (it's available), and himself — // the widening is one-way. if got := labels(paulTok); !got["Eleven"] || !got["Paul"] { t.Fatalf("member roster should be self + catalog bots: %v", got) } // Unvetted: flip availability off — the bot drops back to connections-only. if err := srv.db.setBotConfig(botID, "", "", "", "", "Your guide to the app", false); err != nil { t.Fatalf("setBotConfig off: %v", err) } if got := labels(botTok); got["Paul"] { t.Fatalf("unvetted agent must not see strangers: %v", got) } } // The listen mode (@mention-only vs bare-name) set at agent-invite time must // round-trip to /api/bot-config — the sidecar's reply gating depends on it — // and unknown values must be rejected at the API boundary. func TestBotConfigListen(t *testing.T) { ts, srv := newTestServer(t) uid, _ := srv.db.createUser("Sunny") srv.db.createKeyInvite("inv-listen", uid, "", testClaimHash, 0) tok, _, _ := srv.db.claimKeyInvite("inv-listen", "Sunny", "", testClaimSecret) srv.db.setUserAgent(uid) if err := srv.db.setBotConfig(uid, "", "", "", "name", "A helper", true); err != nil { t.Fatalf("setBotConfig: %v", err) } rr, _ := http.Get(ts.URL + "/api/bot-config?token=" + tok) var c BotConfig json.NewDecoder(rr.Body).Decode(&c) if c.Listen != "name" { t.Fatalf("listen should round-trip to the sidecar: %+v", c) } // The invite endpoint only accepts the known modes. resp := adminPost(t, ts.URL+"/api/admin/key-invites", `{"id":"inv-bad-listen","label":"Bad","agent":true,"listen":"watcher"}`) if resp.StatusCode != http.StatusBadRequest { t.Fatalf("unknown listen mode: want 400, got %d", resp.StatusCode) } } // Admin bot-config editing: GET returns the full config (zero-value before any // row exists), POST updates it in place, and both refuse non-agents. func TestAdminEditBotConfig(t *testing.T) { ts, srv := newTestServer(t) person, _ := srv.db.createUser("Paul") bot, _ := srv.db.createUser("Sunny") srv.db.setUserAgent(bot) if r := adminPost(t, ts.URL+"/api/admin/bots/"+person+"/config", `{"model":""}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("person: want 400, got %d", r.StatusCode) } get := func() FullBotConfig { req, _ := http.NewRequest("GET", ts.URL+"/api/admin/bots/"+bot+"/config", nil) req.Header.Set("Authorization", "Bearer admintok") resp, _ := http.DefaultClient.Do(req) if resp.StatusCode != http.StatusOK { t.Fatalf("get config: %d", resp.StatusCode) } var c FullBotConfig json.NewDecoder(resp.Body).Decode(&c) return c } // Pre-console bot: no config row yet — GET still succeeds with zero values. if c := get(); c.Model != "" || c.Available { t.Fatalf("expected zero-value config, got %+v", c) } if r := adminPost(t, ts.URL+"/api/admin/bots/"+bot+"/config", `{"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 { t.Fatalf("update: want 200, got %d", r.StatusCode) } c := get() if c.Model != "claude-opus-4-8" || c.SystemPrompt != "be brief" || c.Docs != "house rules" || c.Listen != "name" || c.Description != "A test bot" || !c.Available { t.Fatalf("config after update: %+v", c) } // The edit endpoint enforces the same listen allowlist as invites. if r := adminPost(t, ts.URL+"/api/admin/bots/"+bot+"/config", `{"listen":"watcher"}`); r.StatusCode != http.StatusBadRequest { t.Fatalf("bad listen on edit: want 400, got %d", r.StatusCode) } // The sidecar-facing view picks up the same row. bc, ok, _ := srv.db.botConfig(bot) if !ok || bc.SystemPrompt != "be brief" { t.Fatalf("sidecar view: ok=%v %+v", ok, bc) } } // A sidecar self-declares its identity's kind ("ai"/"bot") with its OWN device // token; it surfaces on the admin users list to drive the AI/BOT badge. The // endpoint accepts only an agent's own valid token and the two known kinds, and // kind never clobbers (nor is clobbered by) the admin config write — both are // narrow upserts. Welcome text round-trips to the sidecar-facing config. func TestBotKindDeclareAndWelcome(t *testing.T) { ts, srv := newTestServer(t) uid, _ := srv.db.createUser("Eleven") srv.db.createKeyInvite("inv-k", uid, "", testClaimHash, 0) tok, _, _ := srv.db.claimKeyInvite("inv-k", "Eleven", "", testClaimSecret) srv.db.setUserPublicKey(uid, "pub-eleven", "") srv.db.setUserAgent(uid) declare := func(token, kind string) int { r, _ := http.Post(ts.URL+"/api/bot-config", "application/json", strings.NewReader(`{"token":"`+token+`","kind":"`+kind+`"}`)) return r.StatusCode } kindOf := func(id string) string { users, _ := srv.db.listUsers() for _, u := range users { if u.ID == id { return u.Kind } } return "?" } // Default before any declare: an agent row reads as "ai". if k := kindOf(uid); k != "ai" { t.Fatalf("default agent kind: want ai, got %q", k) } // Declare bot (Eleven running commands-only) → badge flips to BOT. if s := declare(tok, "bot"); s != http.StatusOK || kindOf(uid) != "bot" { t.Fatalf("declare bot: status/kind = %d/%q", s, kindOf(uid)) } // Re-declare ai (its model brain came online) → badge flips back. if s := declare(tok, "ai"); s != http.StatusOK || kindOf(uid) != "ai" { t.Fatalf("re-declare ai: status/kind = %d/%q", s, kindOf(uid)) } // Guards: unknown kind, a non-agent's token, and a bogus token are all refused. if s := declare(tok, "watcher"); s != http.StatusBadRequest { t.Fatalf("bad kind: want 400, got %d", s) } person, _ := srv.db.createUser("Paul") srv.db.createKeyInvite("inv-p", person, "", testClaimHash, 0) ptok, _, _ := srv.db.claimKeyInvite("inv-p", "Paul", "", testClaimSecret) if s := declare(ptok, "bot"); s != http.StatusBadRequest { t.Fatalf("non-agent declare: want 400, got %d", s) } if s := declare("nope", "bot"); s != http.StatusForbidden { t.Fatalf("bogus token: want 403, got %d", s) } // kind and the admin config write don't clobber each other (narrow upserts), // and the admin-set welcome reaches the sidecar-facing config. declare(tok, "bot") if r := adminPost(t, ts.URL+"/api/admin/bots/"+uid+"/config", `{"system_prompt":"be brief","welcome":"Hi {name}, welcome aboard!"}`); r.StatusCode != http.StatusOK { t.Fatalf("admin edit: %d", r.StatusCode) } if k := kindOf(uid); k != "bot" { t.Fatalf("admin edit clobbered kind: got %q", k) } bc, ok, _ := srv.db.botConfig(uid) if !ok || bc.Welcome != "Hi {name}, welcome aboard!" { t.Fatalf("welcome round-trip: ok=%v %+v", ok, bc) } }