eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3import (
4 "context"
5 "encoding/json"
6 "io"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/coder/websocket"
14
15 "lchat/internal/e2ee"
16)
17
18// Tests for the registry-driven connect path (integrations.Execute behind
19// handleConnect): grants carry access read/write, ops come from the embedded
20// manifests, and the reply is the same rid-matched connect.result the legacy
21// dnsimple path sends.
22
23// connectHarness is a Runtime wired to a real (loopback) websocket whose
24// server end decrypts every msg frame with the thread key and hands the
25// payload back — so a test observes exactly what a client in the thread
26// would decrypt, nothing less.
27type connectHarness struct {
28 rt *Runtime
29 key []byte
30 replies chan map[string]any
31}
32
33// newConnectHarness must run AFTER any t.Setenv the test needs, because
34// loadConnectConfig snapshots LCHAT_DNSIMPLE_BASE at construction.
35func newConnectHarness(t *testing.T) *connectHarness {
36 t.Helper()
37 kp, err := e2ee.GenerateUserKeypair()
38 if err != nil {
39 t.Fatal(err)
40 }
41 key, err := e2ee.GenerateKey()
42 if err != nil {
43 t.Fatal(err)
44 }
45 replies := make(chan map[string]any, 8)
46 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
47 c, err := websocket.Accept(w, req, nil)
48 if err != nil {
49 return
50 }
51 defer c.CloseNow()
52 for {
53 _, data, err := c.Read(context.Background())
54 if err != nil {
55 return
56 }
57 var frame struct {
58 Type string `json:"type"`
59 Blob string `json:"blob"`
60 }
61 if json.Unmarshal(data, &frame) != nil || frame.Type != "msg" {
62 continue
63 }
64 var payload map[string]any
65 if e2ee.DecryptJSON(key, frame.Blob, &payload) == nil {
66 replies <- payload
67 }
68 }
69 }))
70 t.Cleanup(srv.Close)
71 ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
72 defer cancel()
73 conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil)
74 if err != nil {
75 t.Fatal(err)
76 }
77 t.Cleanup(func() { conn.CloseNow() })
78 rt := &Runtime{
79 Session: &Session{Token: "tok", Name: "Claude", UserPub: kp.PublicKey, UserPriv: kp.PrivateKey},
80 connect: loadConnectConfig(),
81 conn: conn,
82 }
83 return &connectHarness{rt: rt, key: key, replies: replies}
84}
85
86// seal builds a capability grant sealed to the harness bot's own key — the
87// same blob shape a member's client seals (docs/connected-services.md).
88func (h *connectHarness) seal(t *testing.T, g map[string]any) string {
89 t.Helper()
90 sealed, err := e2ee.SealForPub(h.rt.Session.UserPub, g)
91 if err != nil {
92 t.Fatal(err)
93 }
94 return sealed
95}
96
97func (h *connectHarness) waitReply(t *testing.T) map[string]any {
98 t.Helper()
99 select {
100 case p := <-h.replies:
101 return p
102 case <-time.After(5 * time.Second):
103 t.Fatal("no connect.result arrived")
104 return nil
105 }
106}
107
108// fakeGitHub records the one request the executor sends and answers with a
109// fixed body; the env override points the github manifest at it.
110type fakeGitHub struct {
111 method, uri, auth string
112 body []byte
113 hits int
114}
115
116func newFakeGitHub(t *testing.T, respBody string) *fakeGitHub {
117 t.Helper()
118 f := &fakeGitHub{}
119 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
120 f.hits++
121 f.method = r.Method
122 f.uri = r.URL.RequestURI()
123 f.auth = r.Header.Get("Authorization")
124 f.body, _ = io.ReadAll(r.Body)
125 io.WriteString(w, respBody)
126 }))
127 t.Cleanup(srv.Close)
128 t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", srv.URL)
129 return f
130}
131
132func TestConnectRegistryReadOpWithReadGrant(t *testing.T) {
133 gh := newFakeGitHub(t, `[{"number":1}]`)
134 h := newConnectHarness(t)
135 grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"})
136 h.rt.handleConnect("t1", h.key, &connectPayload{
137 Type: "connect.op", Service: "github", Op: "issues.list",
138 Args: map[string]string{"owner": "paul", "repo": "lchat"}, Grant: grant, RID: "r1",
139 })
140 rep := h.waitReply(t)
141 if rep["error"] != nil {
142 t.Fatalf("error: %v", rep["error"])
143 }
144 if rep["service"] != "github" || rep["op"] != "issues.list" || rep["rid"] != "r1" {
145 t.Errorf("reply envelope: %v", rep)
146 }
147 if rep["data"] != `[{"number":1}]` {
148 t.Errorf("data: %v", rep["data"])
149 }
150 if gh.uri != "/repos/paul/lchat/issues?state=open&per_page=50" {
151 t.Errorf("uri: %q", gh.uri)
152 }
153 if gh.auth != "Bearer ghp_x" {
154 t.Errorf("authorization: %q", gh.auth)
155 }
156}
157
158func TestConnectWriteOpWithReadGrantRefused(t *testing.T) {
159 gh := newFakeGitHub(t, `{}`)
160 h := newConnectHarness(t)
161 // A legacy grant (no access field) is read-only too — same refusal.
162 grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0})
163 h.rt.handleConnect("t1", h.key, &connectPayload{
164 Type: "connect.op", Service: "github", Op: "issue.create",
165 Args: map[string]string{"owner": "paul", "repo": "lchat", "title": "Hi"}, Grant: grant, RID: "r2",
166 })
167 rep := h.waitReply(t)
168 if rep["error"] != "That authorization is read-only." {
169 t.Errorf("error: %v", rep["error"])
170 }
171 if gh.hits != 0 {
172 t.Errorf("refused write still reached the service (%d hits)", gh.hits)
173 }
174}
175
176func TestConnectWriteOpWithWriteGrant(t *testing.T) {
177 gh := newFakeGitHub(t, `{"number":7}`)
178 h := newConnectHarness(t)
179 grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_w", "exp": 0, "access": "write"})
180 h.rt.handleConnect("t1", h.key, &connectPayload{
181 Type: "connect.op", Service: "github", Op: "issue.create",
182 Args: map[string]string{"owner": "paul", "repo": "lchat", "title": "Hi"}, Grant: grant, RID: "r3",
183 })
184 rep := h.waitReply(t)
185 if rep["error"] != nil {
186 t.Fatalf("error: %v", rep["error"])
187 }
188 if rep["data"] != `{"number":7}` {
189 t.Errorf("data: %v", rep["data"])
190 }
191 if gh.method != "POST" || gh.uri != "/repos/paul/lchat/issues" {
192 t.Errorf("request: %s %s", gh.method, gh.uri)
193 }
194 var sent map[string]any
195 if err := json.Unmarshal(gh.body, &sent); err != nil {
196 t.Fatalf("body %q: %v", gh.body, err)
197 }
198 if sent["title"] != "Hi" {
199 t.Errorf("title: %v", sent)
200 }
201 // The optional "body" arg was absent: its pure-placeholder key is dropped.
202 if _, present := sent["body"]; present {
203 t.Errorf("absent optional arg sent: %v", sent)
204 }
205}
206
207func TestConnectExpiredGrant(t *testing.T) {
208 gh := newFakeGitHub(t, `{}`)
209 h := newConnectHarness(t)
210 grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x",
211 "exp": time.Now().Add(-time.Minute).UnixMilli(), "access": "read"})
212 h.rt.handleConnect("t1", h.key, &connectPayload{
213 Type: "connect.op", Service: "github", Op: "issues.list",
214 Args: map[string]string{"owner": "p", "repo": "r"}, Grant: grant, RID: "r4",
215 })
216 rep := h.waitReply(t)
217 if e, _ := rep["error"].(string); !strings.Contains(e, "expired") {
218 t.Errorf("error: %v", rep["error"])
219 }
220 if gh.hits != 0 {
221 t.Errorf("expired grant still reached the service (%d hits)", gh.hits)
222 }
223}
224
225func TestConnectWrongServiceGrant(t *testing.T) {
226 gh := newFakeGitHub(t, `{}`)
227 h := newConnectHarness(t)
228 grant := h.seal(t, map[string]any{"service": "stripe", "token": "sk_x", "exp": 0, "access": "read"})
229 h.rt.handleConnect("t1", h.key, &connectPayload{
230 Type: "connect.op", Service: "github", Op: "issues.list",
231 Args: map[string]string{"owner": "p", "repo": "r"}, Grant: grant, RID: "r5",
232 })
233 rep := h.waitReply(t)
234 if e, _ := rep["error"].(string); !strings.Contains(e, "stripe") {
235 t.Errorf("error: %v", rep["error"])
236 }
237 if gh.hits != 0 {
238 t.Errorf("wrong-service grant still reached the service (%d hits)", gh.hits)
239 }
240}
241
242func TestConnectUnknownOp(t *testing.T) {
243 gh := newFakeGitHub(t, `{}`)
244 h := newConnectHarness(t)
245 grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"})
246 h.rt.handleConnect("t1", h.key, &connectPayload{
247 Type: "connect.op", Service: "github", Op: "bogus.op", Grant: grant, RID: "r6",
248 })
249 rep := h.waitReply(t)
250 if e, _ := rep["error"].(string); e == "" {
251 t.Errorf("unknown op should error: %v", rep)
252 }
253 if gh.hits != 0 {
254 t.Errorf("unknown op still reached the service (%d hits)", gh.hits)
255 }
256}
257
258func TestConnectOversizedResultRefusedHonestly(t *testing.T) {
259 // A result too big for one encrypted message must come back as an error,
260 // never truncated bytes (broken JSON masquerading as data).
261 var b strings.Builder
262 b.WriteString(`[`)
263 for i := 0; i < 400; i++ {
264 if i > 0 {
265 b.WriteString(",")
266 }
267 b.WriteString(`{"full_name":"` + strings.Repeat("x", 120) + `"}`)
268 }
269 b.WriteString(`]`)
270 newFakeGitHub(t, b.String())
271 h := newConnectHarness(t)
272 grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"})
273 h.rt.handleConnect("t1", h.key, &connectPayload{
274 Type: "connect.op", Service: "github", Op: "repos.list", Grant: grant, RID: "r8",
275 })
276 rep := h.waitReply(t)
277 if e, _ := rep["error"].(string); !strings.Contains(e, "too large") {
278 t.Errorf("error: %v", rep["error"])
279 }
280 if rep["data"] != nil {
281 t.Errorf("oversized data leaked through: %d bytes", len(rep["data"].(string)))
282 }
283}
284
285// giveRoster points the harness runtime at a fake lchat server whose roster
286// holds one member ("u1" → the returned keypair's public key) — what the
287// refresh path needs to seal a rotated pair back to the member.
288func (h *connectHarness) giveRoster(t *testing.T) e2ee.Keypair {
289 t.Helper()
290 member, err := e2ee.GenerateUserKeypair()
291 if err != nil {
292 t.Fatal(err)
293 }
294 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
295 if r.URL.Path != "/api/roster" {
296 http.NotFound(w, r)
297 return
298 }
299 w.Header().Set("Content-Type", "application/json")
300 json.NewEncoder(w).Encode(map[string]any{"roster": []map[string]string{
301 {"user_id": "u1", "public_key": member.PublicKey},
302 }})
303 }))
304 t.Cleanup(srv.Close)
305 h.rt.Base = srv.URL
306 h.rt.client = &http.Client{Timeout: 5 * time.Second}
307 return member
308}
309
310// fakeBroker serves POST /api/refresh with a fixed rotated pair, recording
311// how many rotations ran and what refresh token each used.
312type fakeBroker struct {
313 hits int
314 refresh string
315 respErr bool
316 auth string
317 lastBody map[string]string
318}
319
320func newFakeBroker(t *testing.T) *fakeBroker {
321 t.Helper()
322 f := &fakeBroker{}
323 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
324 if r.URL.Path != "/api/refresh" {
325 http.NotFound(w, r)
326 return
327 }
328 f.hits++
329 f.auth = r.Header.Get("Authorization")
330 var body map[string]string
331 json.NewDecoder(r.Body).Decode(&body)
332 f.lastBody = body
333 f.refresh = body["refresh_token"]
334 if f.respErr {
335 http.Error(w, `{"message":"refresh token already used"}`, http.StatusBadGateway)
336 return
337 }
338 w.Header().Set("Content-Type", "application/json")
339 w.Write([]byte(`{"access_token":"ghu_new","refresh_token":"ghr_new","expires_in":28800}`))
340 }))
341 t.Cleanup(srv.Close)
342 t.Setenv("LCHAT_CONNECT_BROKER_URL", srv.URL)
343 t.Setenv("LCHAT_CONNECT_BROKER_TOKEN", "btok")
344 return f
345}
346
347// An expired pair is rotated BEFORE the provider is called: the op runs with
348// the fresh access token, and the whole new pair rides back on the result,
349// sealed to the member (never kept by the bot).
350func TestConnectRefreshesExpiredTokenBeforeOp(t *testing.T) {
351 var gotAuth []string
352 gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
353 gotAuth = append(gotAuth, r.Header.Get("Authorization"))
354 io.WriteString(w, `[{"number":1}]`)
355 }))
356 t.Cleanup(gh.Close)
357 t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", gh.URL)
358 broker := newFakeBroker(t)
359 h := newConnectHarness(t) // after Setenv — the config snapshots the broker env
360 member := h.giveRoster(t)
361
362 grant := h.seal(t, map[string]any{"service": "github", "token": "ghu_stale", "exp": 0, "access": "read",
363 "refresh": "ghr_old", "token_expires_at": time.Now().Add(-time.Minute).UnixMilli()})
364 h.rt.handleConnect("t1", h.key, &connectPayload{
365 Type: "connect.op", Service: "github", Op: "issues.list",
366 Args: map[string]string{"owner": "p", "repo": "r"}, Grant: grant, RID: "r9", UID: "u1",
367 })
368 rep := h.waitReply(t)
369 if rep["error"] != nil {
370 t.Fatalf("error: %v", rep["error"])
371 }
372 if broker.hits != 1 || broker.refresh != "ghr_old" || broker.auth != "Bearer btok" {
373 t.Fatalf("broker saw hits=%d refresh=%q auth=%q", broker.hits, broker.refresh, broker.auth)
374 }
375 if len(gotAuth) != 1 || gotAuth[0] != "Bearer ghu_new" {
376 t.Fatalf("provider auth: %v (stale token must never be sent)", gotAuth)
377 }
378 sealed, _ := rep["renewed"].(string)
379 if sealed == "" {
380 t.Fatal("result carries no renewed pair")
381 }
382 var renewed struct {
383 Secret string `json:"secret"`
384 Refresh string `json:"refresh"`
385 ExpiresAt int64 `json:"expires_at"`
386 }
387 if err := e2ee.OpenSealed(sealed, member.PrivateKey, &renewed); err != nil {
388 t.Fatalf("renewed pair not sealed to the member: %v", err)
389 }
390 if renewed.Secret != "ghu_new" || renewed.Refresh != "ghr_new" || renewed.ExpiresAt <= time.Now().UnixMilli() {
391 t.Fatalf("renewed pair: %+v", renewed)
392 }
393}
394
395// A 401 on a supposedly-live token (revoked and reissued, clock skew) rotates
396// once and retries; a second 401 does NOT loop.
397func TestConnectRefreshesOn401AndRetriesOnce(t *testing.T) {
398 var gotAuth []string
399 gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
400 gotAuth = append(gotAuth, r.Header.Get("Authorization"))
401 if r.Header.Get("Authorization") != "Bearer ghu_new" {
402 w.WriteHeader(http.StatusUnauthorized)
403 io.WriteString(w, `{"message":"Bad credentials"}`)
404 return
405 }
406 io.WriteString(w, `[]`)
407 }))
408 t.Cleanup(gh.Close)
409 t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", gh.URL)
410 broker := newFakeBroker(t)
411 h := newConnectHarness(t)
412 h.giveRoster(t)
413
414 // The stamp says the token is alive — the 401 is the surprise.
415 grant := h.seal(t, map[string]any{"service": "github", "token": "ghu_revoked", "exp": 0, "access": "read",
416 "refresh": "ghr_old", "token_expires_at": time.Now().Add(time.Hour).UnixMilli()})
417 h.rt.handleConnect("t1", h.key, &connectPayload{
418 Type: "connect.op", Service: "github", Op: "repos.list", Grant: grant, RID: "r10", UID: "u1",
419 })
420 rep := h.waitReply(t)
421 if rep["error"] != nil {
422 t.Fatalf("error: %v", rep["error"])
423 }
424 if broker.hits != 1 {
425 t.Fatalf("broker hits = %d, want exactly 1", broker.hits)
426 }
427 if len(gotAuth) != 2 || gotAuth[0] != "Bearer ghu_revoked" || gotAuth[1] != "Bearer ghu_new" {
428 t.Fatalf("provider auth sequence: %v", gotAuth)
429 }
430 if rep["renewed"] == nil {
431 t.Fatal("retried op must still hand the renewed pair back")
432 }
433}
434
435// Rotation consumes the single-use refresh token, so it must be REFUSED when
436// the fresh pair couldn't be handed back (no resolvable member key) — better
437// a failed op than a silently severed connection.
438func TestConnectRefusesRefreshWithoutMemberKey(t *testing.T) {
439 gh := newFakeGitHub(t, `[]`)
440 broker := newFakeBroker(t)
441 h := newConnectHarness(t)
442 h.giveRoster(t) // roster only knows "u1"
443
444 grant := h.seal(t, map[string]any{"service": "github", "token": "ghu_stale", "exp": 0, "access": "read",
445 "refresh": "ghr_old", "token_expires_at": time.Now().Add(-time.Minute).UnixMilli()})
446 h.rt.handleConnect("t1", h.key, &connectPayload{
447 Type: "connect.op", Service: "github", Op: "repos.list", Grant: grant, RID: "r11", UID: "u-unknown",
448 })
449 rep := h.waitReply(t)
450 if e, _ := rep["error"].(string); !strings.Contains(e, "reconnect") {
451 t.Fatalf("error: %v", rep["error"])
452 }
453 if broker.hits != 0 {
454 t.Fatalf("refresh ran anyway (%d broker hits) — the pair is now lost", broker.hits)
455 }
456 if gh.hits != 0 {
457 t.Fatalf("expired token still reached the provider (%d hits)", gh.hits)
458 }
459}
460
461// A FAILED chip op (src:"chip") is answered twice: the raw error on the
462// connect.result (the instant notice), then the brain's plain-language
463// explanation as a normal in-thread message. An untagged op (an app surface's
464// fetch) gets the error result only — no bot chatter.
465func TestConnectChipFailureGetsSpokenExplanation(t *testing.T) {
466 gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
467 w.WriteHeader(http.StatusNotFound)
468 io.WriteString(w, `{"message":"Not Found"}`)
469 }))
470 t.Cleanup(gh.Close)
471 t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", gh.URL)
472 var prompts []string
473 claude := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
474 var req struct {
475 Messages []struct {
476 Content string `json:"content"`
477 } `json:"messages"`
478 }
479 json.NewDecoder(r.Body).Decode(&req)
480 if len(req.Messages) > 0 {
481 prompts = append(prompts, req.Messages[0].Content)
482 }
483 w.Header().Set("Content-Type", "application/json")
484 io.WriteString(w, `{"content":[{"type":"text","text":"That didn't work — the app isn't installed on that repo's org. Install it at github.com/apps/eleven-messenger and try again."}],"stop_reason":"end_turn"}`)
485 }))
486 t.Cleanup(claude.Close)
487 t.Setenv("ANTHROPIC_BASE_URL", claude.URL)
488
489 h := newConnectHarness(t)
490 h.rt.ai = newAnthropicClient("test-key", nil)
491 h.rt.model = defaultModel
492
493 grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"})
494 h.rt.handleConnect("t1", h.key, &connectPayload{
495 Type: "connect.op", Service: "github", Op: "issues.list",
496 Args: map[string]string{"owner": "some", "repo": "where"}, Grant: grant, RID: "r-chip", Src: "chip",
497 })
498 // Two messages arrive: the error result and the spoken explanation (order
499 // can race — the explanation runs on its own goroutine).
500 var sawResult, sawSpoken bool
501 for i := 0; i < 2; i++ {
502 p := h.waitReply(t)
503 switch p["type"] {
504 case "connect.result":
505 if e, _ := p["error"].(string); !strings.Contains(e, "404") {
506 t.Fatalf("result error: %v", p["error"])
507 }
508 sawResult = true
509 default:
510 if txt, _ := p["text"].(string); strings.Contains(txt, "isn't installed") {
511 sawSpoken = true
512 }
513 }
514 }
515 if !sawResult || !sawSpoken {
516 t.Fatalf("want error result + spoken explanation, got result=%v spoken=%v", sawResult, sawSpoken)
517 }
518 if len(prompts) != 1 || !strings.Contains(prompts[0], "issues.list") || !strings.Contains(prompts[0], "404") {
519 t.Fatalf("explain prompt: %v", prompts)
520 }
521
522 // The same failure UNTAGGED: error result only, and the brain is never asked.
523 h.rt.handleConnect("t1", h.key, &connectPayload{
524 Type: "connect.op", Service: "github", Op: "issues.list",
525 Args: map[string]string{"owner": "some", "repo": "where"}, Grant: grant, RID: "r-app",
526 })
527 p := h.waitReply(t)
528 if p["type"] != "connect.result" || p["error"] == nil {
529 t.Fatalf("untagged failure: %v", p)
530 }
531 select {
532 case extra := <-h.replies:
533 t.Fatalf("untagged failure must not speak: %v", extra)
534 case <-time.After(300 * time.Millisecond):
535 }
536 if len(prompts) != 1 {
537 t.Fatalf("brain was asked %d times, want 1 (chip only)", len(prompts))
538 }
539}
540
541func TestConnectLegacyDNSimpleListStillWorks(t *testing.T) {
542 // The manifest's dnsimple op is "domains.list" — bare "list" is the
543 // legacy DNSimple-app protocol and must keep flowing through the old
544 // dnsimpleClient path (whoami → domains, price lookups best-effort).
545 dns := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
546 switch {
547 case r.URL.Path == "/v2/whoami":
548 io.WriteString(w, `{"data":{"account":{"id":1}}}`)
549 case r.URL.Path == "/v2/1/domains":
550 io.WriteString(w, `{"data":[{"name":"example.com","state":"registered","auto_renew":true,"expires_on":"2027-01-01","account_id":1}]}`)
551 default:
552 http.NotFound(w, r) // prices: best-effort, a 404 leaves price nil
553 }
554 }))
555 t.Cleanup(dns.Close)
556 t.Setenv("LCHAT_DNSIMPLE_BASE", dns.URL)
557 h := newConnectHarness(t) // after Setenv: the config snapshots the base
558 grant := h.seal(t, map[string]any{"service": "dnsimple", "token": "dns_x", "exp": 0})
559 h.rt.handleConnect("t1", h.key, &connectPayload{
560 Type: "connect.op", Service: "dnsimple", Op: "list", Grant: grant, RID: "r7",
561 })
562 rep := h.waitReply(t)
563 if rep["error"] != nil {
564 t.Fatalf("error: %v", rep["error"])
565 }
566 if rep["service"] != "dnsimple" || rep["op"] != "list" || rep["rid"] != "r7" {
567 t.Errorf("reply envelope: %v", rep)
568 }
569 domains, ok := rep["domains"].([]any)
570 if !ok || len(domains) != 1 {
571 t.Fatalf("domains: %v", rep["domains"])
572 }
573 if d, _ := domains[0].(map[string]any); d["name"] != "example.com" {
574 t.Errorf("domain row: %v", domains[0])
575 }
576}
577