eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
13func 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.
86func 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.
117func 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.
177func 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.
228func 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.
255func 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.
309func 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