| 1 | package bot |
| 2 | |
| 3 | import ( |
| 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. |
| 27 | type 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. |
| 35 | func 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). |
| 88 | func (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 | |
| 97 | func (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. |
| 110 | type fakeGitHub struct { |
| 111 | method, uri, auth string |
| 112 | body []byte |
| 113 | hits int |
| 114 | } |
| 115 | |
| 116 | func 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 | |
| 132 | func 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 | |
| 158 | func 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 | |
| 176 | func 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 | |
| 207 | func 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 | |
| 225 | func 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 | |
| 242 | func 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 | |
| 258 | func 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. |
| 288 | func (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. |
| 312 | type fakeBroker struct { |
| 313 | hits int |
| 314 | refresh string |
| 315 | respErr bool |
| 316 | auth string |
| 317 | lastBody map[string]string |
| 318 | } |
| 319 | |
| 320 | func 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). |
| 350 | func 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. |
| 397 | func 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. |
| 438 | func 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. |
| 465 | func 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 | |
| 541 | func 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 | |