package bot import ( "context" "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/coder/websocket" "lchat/internal/e2ee" ) // Tests for the registry-driven connect path (integrations.Execute behind // handleConnect): grants carry access read/write, ops come from the embedded // manifests, and the reply is the same rid-matched connect.result the legacy // dnsimple path sends. // connectHarness is a Runtime wired to a real (loopback) websocket whose // server end decrypts every msg frame with the thread key and hands the // payload back — so a test observes exactly what a client in the thread // would decrypt, nothing less. type connectHarness struct { rt *Runtime key []byte replies chan map[string]any } // newConnectHarness must run AFTER any t.Setenv the test needs, because // loadConnectConfig snapshots LCHAT_DNSIMPLE_BASE at construction. func newConnectHarness(t *testing.T) *connectHarness { t.Helper() kp, err := e2ee.GenerateUserKeypair() if err != nil { t.Fatal(err) } key, err := e2ee.GenerateKey() if err != nil { t.Fatal(err) } replies := make(chan map[string]any, 8) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { c, err := websocket.Accept(w, req, nil) if err != nil { return } defer c.CloseNow() for { _, data, err := c.Read(context.Background()) if err != nil { return } var frame struct { Type string `json:"type"` Blob string `json:"blob"` } if json.Unmarshal(data, &frame) != nil || frame.Type != "msg" { continue } var payload map[string]any if e2ee.DecryptJSON(key, frame.Blob, &payload) == nil { replies <- payload } } })) t.Cleanup(srv.Close) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http"), nil) if err != nil { t.Fatal(err) } t.Cleanup(func() { conn.CloseNow() }) rt := &Runtime{ Session: &Session{Token: "tok", Name: "Claude", UserPub: kp.PublicKey, UserPriv: kp.PrivateKey}, connect: loadConnectConfig(), conn: conn, } return &connectHarness{rt: rt, key: key, replies: replies} } // seal builds a capability grant sealed to the harness bot's own key — the // same blob shape a member's client seals (docs/connected-services.md). func (h *connectHarness) seal(t *testing.T, g map[string]any) string { t.Helper() sealed, err := e2ee.SealForPub(h.rt.Session.UserPub, g) if err != nil { t.Fatal(err) } return sealed } func (h *connectHarness) waitReply(t *testing.T) map[string]any { t.Helper() select { case p := <-h.replies: return p case <-time.After(5 * time.Second): t.Fatal("no connect.result arrived") return nil } } // fakeGitHub records the one request the executor sends and answers with a // fixed body; the env override points the github manifest at it. type fakeGitHub struct { method, uri, auth string body []byte hits int } func newFakeGitHub(t *testing.T, respBody string) *fakeGitHub { t.Helper() f := &fakeGitHub{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.hits++ f.method = r.Method f.uri = r.URL.RequestURI() f.auth = r.Header.Get("Authorization") f.body, _ = io.ReadAll(r.Body) io.WriteString(w, respBody) })) t.Cleanup(srv.Close) t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", srv.URL) return f } func TestConnectRegistryReadOpWithReadGrant(t *testing.T) { gh := newFakeGitHub(t, `[{"number":1}]`) h := newConnectHarness(t) grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "issues.list", Args: map[string]string{"owner": "paul", "repo": "lchat"}, Grant: grant, RID: "r1", }) rep := h.waitReply(t) if rep["error"] != nil { t.Fatalf("error: %v", rep["error"]) } if rep["service"] != "github" || rep["op"] != "issues.list" || rep["rid"] != "r1" { t.Errorf("reply envelope: %v", rep) } if rep["data"] != `[{"number":1}]` { t.Errorf("data: %v", rep["data"]) } if gh.uri != "/repos/paul/lchat/issues?state=open&per_page=50" { t.Errorf("uri: %q", gh.uri) } if gh.auth != "Bearer ghp_x" { t.Errorf("authorization: %q", gh.auth) } } func TestConnectWriteOpWithReadGrantRefused(t *testing.T) { gh := newFakeGitHub(t, `{}`) h := newConnectHarness(t) // A legacy grant (no access field) is read-only too — same refusal. grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "issue.create", Args: map[string]string{"owner": "paul", "repo": "lchat", "title": "Hi"}, Grant: grant, RID: "r2", }) rep := h.waitReply(t) if rep["error"] != "That authorization is read-only." { t.Errorf("error: %v", rep["error"]) } if gh.hits != 0 { t.Errorf("refused write still reached the service (%d hits)", gh.hits) } } func TestConnectWriteOpWithWriteGrant(t *testing.T) { gh := newFakeGitHub(t, `{"number":7}`) h := newConnectHarness(t) grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_w", "exp": 0, "access": "write"}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "issue.create", Args: map[string]string{"owner": "paul", "repo": "lchat", "title": "Hi"}, Grant: grant, RID: "r3", }) rep := h.waitReply(t) if rep["error"] != nil { t.Fatalf("error: %v", rep["error"]) } if rep["data"] != `{"number":7}` { t.Errorf("data: %v", rep["data"]) } if gh.method != "POST" || gh.uri != "/repos/paul/lchat/issues" { t.Errorf("request: %s %s", gh.method, gh.uri) } var sent map[string]any if err := json.Unmarshal(gh.body, &sent); err != nil { t.Fatalf("body %q: %v", gh.body, err) } if sent["title"] != "Hi" { t.Errorf("title: %v", sent) } // The optional "body" arg was absent: its pure-placeholder key is dropped. if _, present := sent["body"]; present { t.Errorf("absent optional arg sent: %v", sent) } } func TestConnectExpiredGrant(t *testing.T) { gh := newFakeGitHub(t, `{}`) h := newConnectHarness(t) grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": time.Now().Add(-time.Minute).UnixMilli(), "access": "read"}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "issues.list", Args: map[string]string{"owner": "p", "repo": "r"}, Grant: grant, RID: "r4", }) rep := h.waitReply(t) if e, _ := rep["error"].(string); !strings.Contains(e, "expired") { t.Errorf("error: %v", rep["error"]) } if gh.hits != 0 { t.Errorf("expired grant still reached the service (%d hits)", gh.hits) } } func TestConnectWrongServiceGrant(t *testing.T) { gh := newFakeGitHub(t, `{}`) h := newConnectHarness(t) grant := h.seal(t, map[string]any{"service": "stripe", "token": "sk_x", "exp": 0, "access": "read"}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "issues.list", Args: map[string]string{"owner": "p", "repo": "r"}, Grant: grant, RID: "r5", }) rep := h.waitReply(t) if e, _ := rep["error"].(string); !strings.Contains(e, "stripe") { t.Errorf("error: %v", rep["error"]) } if gh.hits != 0 { t.Errorf("wrong-service grant still reached the service (%d hits)", gh.hits) } } func TestConnectUnknownOp(t *testing.T) { gh := newFakeGitHub(t, `{}`) h := newConnectHarness(t) grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "bogus.op", Grant: grant, RID: "r6", }) rep := h.waitReply(t) if e, _ := rep["error"].(string); e == "" { t.Errorf("unknown op should error: %v", rep) } if gh.hits != 0 { t.Errorf("unknown op still reached the service (%d hits)", gh.hits) } } func TestConnectOversizedResultRefusedHonestly(t *testing.T) { // A result too big for one encrypted message must come back as an error, // never truncated bytes (broken JSON masquerading as data). var b strings.Builder b.WriteString(`[`) for i := 0; i < 400; i++ { if i > 0 { b.WriteString(",") } b.WriteString(`{"full_name":"` + strings.Repeat("x", 120) + `"}`) } b.WriteString(`]`) newFakeGitHub(t, b.String()) h := newConnectHarness(t) grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "repos.list", Grant: grant, RID: "r8", }) rep := h.waitReply(t) if e, _ := rep["error"].(string); !strings.Contains(e, "too large") { t.Errorf("error: %v", rep["error"]) } if rep["data"] != nil { t.Errorf("oversized data leaked through: %d bytes", len(rep["data"].(string))) } } // giveRoster points the harness runtime at a fake lchat server whose roster // holds one member ("u1" → the returned keypair's public key) — what the // refresh path needs to seal a rotated pair back to the member. func (h *connectHarness) giveRoster(t *testing.T) e2ee.Keypair { t.Helper() member, err := e2ee.GenerateUserKeypair() if err != nil { t.Fatal(err) } srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/roster" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{"roster": []map[string]string{ {"user_id": "u1", "public_key": member.PublicKey}, }}) })) t.Cleanup(srv.Close) h.rt.Base = srv.URL h.rt.client = &http.Client{Timeout: 5 * time.Second} return member } // fakeBroker serves POST /api/refresh with a fixed rotated pair, recording // how many rotations ran and what refresh token each used. type fakeBroker struct { hits int refresh string respErr bool auth string lastBody map[string]string } func newFakeBroker(t *testing.T) *fakeBroker { t.Helper() f := &fakeBroker{} srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/refresh" { http.NotFound(w, r) return } f.hits++ f.auth = r.Header.Get("Authorization") var body map[string]string json.NewDecoder(r.Body).Decode(&body) f.lastBody = body f.refresh = body["refresh_token"] if f.respErr { http.Error(w, `{"message":"refresh token already used"}`, http.StatusBadGateway) return } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"access_token":"ghu_new","refresh_token":"ghr_new","expires_in":28800}`)) })) t.Cleanup(srv.Close) t.Setenv("LCHAT_CONNECT_BROKER_URL", srv.URL) t.Setenv("LCHAT_CONNECT_BROKER_TOKEN", "btok") return f } // An expired pair is rotated BEFORE the provider is called: the op runs with // the fresh access token, and the whole new pair rides back on the result, // sealed to the member (never kept by the bot). func TestConnectRefreshesExpiredTokenBeforeOp(t *testing.T) { var gotAuth []string gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = append(gotAuth, r.Header.Get("Authorization")) io.WriteString(w, `[{"number":1}]`) })) t.Cleanup(gh.Close) t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", gh.URL) broker := newFakeBroker(t) h := newConnectHarness(t) // after Setenv — the config snapshots the broker env member := h.giveRoster(t) grant := h.seal(t, map[string]any{"service": "github", "token": "ghu_stale", "exp": 0, "access": "read", "refresh": "ghr_old", "token_expires_at": time.Now().Add(-time.Minute).UnixMilli()}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "issues.list", Args: map[string]string{"owner": "p", "repo": "r"}, Grant: grant, RID: "r9", UID: "u1", }) rep := h.waitReply(t) if rep["error"] != nil { t.Fatalf("error: %v", rep["error"]) } if broker.hits != 1 || broker.refresh != "ghr_old" || broker.auth != "Bearer btok" { t.Fatalf("broker saw hits=%d refresh=%q auth=%q", broker.hits, broker.refresh, broker.auth) } if len(gotAuth) != 1 || gotAuth[0] != "Bearer ghu_new" { t.Fatalf("provider auth: %v (stale token must never be sent)", gotAuth) } sealed, _ := rep["renewed"].(string) if sealed == "" { t.Fatal("result carries no renewed pair") } var renewed struct { Secret string `json:"secret"` Refresh string `json:"refresh"` ExpiresAt int64 `json:"expires_at"` } if err := e2ee.OpenSealed(sealed, member.PrivateKey, &renewed); err != nil { t.Fatalf("renewed pair not sealed to the member: %v", err) } if renewed.Secret != "ghu_new" || renewed.Refresh != "ghr_new" || renewed.ExpiresAt <= time.Now().UnixMilli() { t.Fatalf("renewed pair: %+v", renewed) } } // A 401 on a supposedly-live token (revoked and reissued, clock skew) rotates // once and retries; a second 401 does NOT loop. func TestConnectRefreshesOn401AndRetriesOnce(t *testing.T) { var gotAuth []string gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { gotAuth = append(gotAuth, r.Header.Get("Authorization")) if r.Header.Get("Authorization") != "Bearer ghu_new" { w.WriteHeader(http.StatusUnauthorized) io.WriteString(w, `{"message":"Bad credentials"}`) return } io.WriteString(w, `[]`) })) t.Cleanup(gh.Close) t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", gh.URL) broker := newFakeBroker(t) h := newConnectHarness(t) h.giveRoster(t) // The stamp says the token is alive — the 401 is the surprise. grant := h.seal(t, map[string]any{"service": "github", "token": "ghu_revoked", "exp": 0, "access": "read", "refresh": "ghr_old", "token_expires_at": time.Now().Add(time.Hour).UnixMilli()}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "repos.list", Grant: grant, RID: "r10", UID: "u1", }) rep := h.waitReply(t) if rep["error"] != nil { t.Fatalf("error: %v", rep["error"]) } if broker.hits != 1 { t.Fatalf("broker hits = %d, want exactly 1", broker.hits) } if len(gotAuth) != 2 || gotAuth[0] != "Bearer ghu_revoked" || gotAuth[1] != "Bearer ghu_new" { t.Fatalf("provider auth sequence: %v", gotAuth) } if rep["renewed"] == nil { t.Fatal("retried op must still hand the renewed pair back") } } // Rotation consumes the single-use refresh token, so it must be REFUSED when // the fresh pair couldn't be handed back (no resolvable member key) — better // a failed op than a silently severed connection. func TestConnectRefusesRefreshWithoutMemberKey(t *testing.T) { gh := newFakeGitHub(t, `[]`) broker := newFakeBroker(t) h := newConnectHarness(t) h.giveRoster(t) // roster only knows "u1" grant := h.seal(t, map[string]any{"service": "github", "token": "ghu_stale", "exp": 0, "access": "read", "refresh": "ghr_old", "token_expires_at": time.Now().Add(-time.Minute).UnixMilli()}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "repos.list", Grant: grant, RID: "r11", UID: "u-unknown", }) rep := h.waitReply(t) if e, _ := rep["error"].(string); !strings.Contains(e, "reconnect") { t.Fatalf("error: %v", rep["error"]) } if broker.hits != 0 { t.Fatalf("refresh ran anyway (%d broker hits) — the pair is now lost", broker.hits) } if gh.hits != 0 { t.Fatalf("expired token still reached the provider (%d hits)", gh.hits) } } // A FAILED chip op (src:"chip") is answered twice: the raw error on the // connect.result (the instant notice), then the brain's plain-language // explanation as a normal in-thread message. An untagged op (an app surface's // fetch) gets the error result only — no bot chatter. func TestConnectChipFailureGetsSpokenExplanation(t *testing.T) { gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotFound) io.WriteString(w, `{"message":"Not Found"}`) })) t.Cleanup(gh.Close) t.Setenv("LCHAT_INTEGRATIONS_BASE_GITHUB", gh.URL) var prompts []string claude := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { Messages []struct { Content string `json:"content"` } `json:"messages"` } json.NewDecoder(r.Body).Decode(&req) if len(req.Messages) > 0 { prompts = append(prompts, req.Messages[0].Content) } w.Header().Set("Content-Type", "application/json") 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"}`) })) t.Cleanup(claude.Close) t.Setenv("ANTHROPIC_BASE_URL", claude.URL) h := newConnectHarness(t) h.rt.ai = newAnthropicClient("test-key", nil) h.rt.model = defaultModel grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": 0, "access": "read"}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "issues.list", Args: map[string]string{"owner": "some", "repo": "where"}, Grant: grant, RID: "r-chip", Src: "chip", }) // Two messages arrive: the error result and the spoken explanation (order // can race — the explanation runs on its own goroutine). var sawResult, sawSpoken bool for i := 0; i < 2; i++ { p := h.waitReply(t) switch p["type"] { case "connect.result": if e, _ := p["error"].(string); !strings.Contains(e, "404") { t.Fatalf("result error: %v", p["error"]) } sawResult = true default: if txt, _ := p["text"].(string); strings.Contains(txt, "isn't installed") { sawSpoken = true } } } if !sawResult || !sawSpoken { t.Fatalf("want error result + spoken explanation, got result=%v spoken=%v", sawResult, sawSpoken) } if len(prompts) != 1 || !strings.Contains(prompts[0], "issues.list") || !strings.Contains(prompts[0], "404") { t.Fatalf("explain prompt: %v", prompts) } // The same failure UNTAGGED: error result only, and the brain is never asked. h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "github", Op: "issues.list", Args: map[string]string{"owner": "some", "repo": "where"}, Grant: grant, RID: "r-app", }) p := h.waitReply(t) if p["type"] != "connect.result" || p["error"] == nil { t.Fatalf("untagged failure: %v", p) } select { case extra := <-h.replies: t.Fatalf("untagged failure must not speak: %v", extra) case <-time.After(300 * time.Millisecond): } if len(prompts) != 1 { t.Fatalf("brain was asked %d times, want 1 (chip only)", len(prompts)) } } func TestConnectLegacyDNSimpleListStillWorks(t *testing.T) { // The manifest's dnsimple op is "domains.list" — bare "list" is the // legacy DNSimple-app protocol and must keep flowing through the old // dnsimpleClient path (whoami → domains, price lookups best-effort). dns := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/v2/whoami": io.WriteString(w, `{"data":{"account":{"id":1}}}`) case r.URL.Path == "/v2/1/domains": io.WriteString(w, `{"data":[{"name":"example.com","state":"registered","auto_renew":true,"expires_on":"2027-01-01","account_id":1}]}`) default: http.NotFound(w, r) // prices: best-effort, a 404 leaves price nil } })) t.Cleanup(dns.Close) t.Setenv("LCHAT_DNSIMPLE_BASE", dns.URL) h := newConnectHarness(t) // after Setenv: the config snapshots the base grant := h.seal(t, map[string]any{"service": "dnsimple", "token": "dns_x", "exp": 0}) h.rt.handleConnect("t1", h.key, &connectPayload{ Type: "connect.op", Service: "dnsimple", Op: "list", Grant: grant, RID: "r7", }) rep := h.waitReply(t) if rep["error"] != nil { t.Fatalf("error: %v", rep["error"]) } if rep["service"] != "dnsimple" || rep["op"] != "list" || rep["rid"] != "r7" { t.Errorf("reply envelope: %v", rep) } domains, ok := rep["domains"].([]any) if !ok || len(domains) != 1 { t.Fatalf("domains: %v", rep["domains"]) } if d, _ := domains[0].(map[string]any); d["name"] != "example.com" { t.Errorf("domain row: %v", domains[0]) } }