package bot import ( "encoding/json" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "lchat/internal/e2ee" ) // Tests for research sessions (research.go): one consent tap → sealed read // grants → a bounded read-only tool loop → one posted answer. The security // properties pinned here: write ops never enter the palette, expired grants // never reach the provider, a grant sealed to another bot stays silent, and // no token ever appears in what the model is sent. // waitNonTyping skips the typing beats the research loop emits and returns // the next real payload a thread member would decrypt. func (h *connectHarness) waitNonTyping(t *testing.T) map[string]any { t.Helper() deadline := time.After(10 * time.Second) for { select { case p := <-h.replies: if p["type"] == "chat.typing" { continue } return p case <-deadline: t.Fatal("no payload arrived") return nil } } } // expectSilence asserts nothing (typing aside) lands within the window. func (h *connectHarness) expectSilence(t *testing.T, d time.Duration) { t.Helper() deadline := time.After(d) for { select { case p := <-h.replies: if p["type"] == "chat.typing" { continue } t.Fatalf("unexpected payload: %v", p) case <-deadline: return } } } // scriptedClaude answers each request in order with the given raw bodies and // records every request body for assertions. func scriptedClaude(t *testing.T, bodies ...string) *[][]byte { t.Helper() var reqs [][]byte i := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { raw, _ := io.ReadAll(r.Body) reqs = append(reqs, raw) w.Header().Set("Content-Type", "application/json") if i < len(bodies) { io.WriteString(w, bodies[i]) i++ return } io.WriteString(w, `{"content":[{"type":"text","text":"(script exhausted)"}],"stop_reason":"end_turn"}`) })) t.Cleanup(srv.Close) t.Setenv("ANTHROPIC_BASE_URL", srv.URL) return &reqs } func TestResearchEndToEnd(t *testing.T) { gh := newFakeGitHub(t, `[{"full_name":"paul/lchat"}]`) reqs := scriptedClaude(t, `{"content":[{"type":"tool_use","id":"tu1","name":"github__repos_list","input":{}}],"stop_reason":"tool_use"}`, `{"content":[{"type":"text","text":"You have one repo: paul/lchat."}],"stop_reason":"end_turn"}`, ) h := newConnectHarness(t) h.rt.ai = newAnthropicClient("test-key", nil) grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_research", "exp": time.Now().Add(time.Minute).UnixMilli(), "access": "read"}) h.rt.handleResearch("t1", h.key, &researchPayload{ Type: "connect.research", Goal: "what repos do I have?", Grants: map[string]string{"github": grant}, RID: "rr1", UID: "u1", Name: "Paul", }) ack := h.waitNonTyping(t) if ack["type"] != "connect.result" || ack["rid"] != "rr1" || ack["data"] != "started" { t.Fatalf("ack: %v", ack) } answer := h.waitNonTyping(t) if txt, _ := answer["text"].(string); !strings.Contains(txt, "paul/lchat") { t.Fatalf("answer: %v", answer) } if gh.hits != 1 || gh.auth != "Bearer ghp_research" { t.Fatalf("provider saw hits=%d auth=%q", gh.hits, gh.auth) } // The palette offered to the model: read ops only, and never a token. if len(*reqs) != 2 { t.Fatalf("model calls: %d", len(*reqs)) } first := string((*reqs)[0]) if !strings.Contains(first, "github__repos_list") { t.Error("read op missing from the palette") } if strings.Contains(first, "issue_create") { t.Error("write op leaked into the research palette") } for i, raw := range *reqs { if strings.Contains(string(raw), "ghp_research") { t.Errorf("request %d carries the provider token — credentials must never reach the model", i) } } // The op result is fenced as a tool_result on the second call. var second struct { Messages []struct { Role string `json:"role"` Content json.RawMessage `json:"content"` } `json:"messages"` } if err := json.Unmarshal((*reqs)[1], &second); err != nil { t.Fatal(err) } last := second.Messages[len(second.Messages)-1] if last.Role != "user" || !strings.Contains(string(last.Content), "tool_result") || !strings.Contains(string(last.Content), "paul/lchat") { t.Fatalf("tool_result turn: %s", last.Content) } } func TestResearchForeignGrantStaysSilent(t *testing.T) { newFakeGitHub(t, `[]`) h := newConnectHarness(t) h.rt.ai = newAnthropicClient("test-key", nil) other, err := e2ee.GenerateUserKeypair() if err != nil { t.Fatal(err) } sealed, err := e2ee.SealForPub(other.PublicKey, map[string]any{"service": "github", "token": "x", "access": "read"}) if err != nil { t.Fatal(err) } h.rt.handleResearch("t1", h.key, &researchPayload{ Type: "connect.research", Goal: "anything", Grants: map[string]string{"github": sealed}, RID: "rr2", }) h.expectSilence(t, 400*time.Millisecond) } func TestResearchExpiredGrantNeverReachesProvider(t *testing.T) { gh := newFakeGitHub(t, `[]`) reqs := scriptedClaude(t, `{"content":[{"type":"tool_use","id":"tu1","name":"github__repos_list","input":{}}],"stop_reason":"tool_use"}`, `{"content":[{"type":"text","text":"That look expired."}],"stop_reason":"end_turn"}`, ) h := newConnectHarness(t) h.rt.ai = newAnthropicClient("test-key", nil) grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "exp": time.Now().Add(-time.Minute).UnixMilli(), "access": "read"}) h.rt.handleResearch("t1", h.key, &researchPayload{ Type: "connect.research", Goal: "repos?", Grants: map[string]string{"github": grant}, RID: "rr3", }) h.waitNonTyping(t) // ack h.waitNonTyping(t) // answer if gh.hits != 0 { t.Fatalf("expired grant reached the provider (%d hits)", gh.hits) } if !strings.Contains(string((*reqs)[1]), "expired") { t.Error("the model wasn't told the authorization expired") } } func TestResearchWithoutModelAnswersHonestly(t *testing.T) { h := newConnectHarness(t) // ai == nil grant := h.seal(t, map[string]any{"service": "github", "token": "ghp_x", "access": "read"}) h.rt.handleResearch("t1", h.key, &researchPayload{ Type: "connect.research", Goal: "repos?", Grants: map[string]string{"github": grant}, RID: "rr4", }) rep := h.waitNonTyping(t) if rep["rid"] != "rr4" || rep["error"] == nil { t.Fatalf("want an honest rid-matched error, got %v", rep) } } func TestActionsFromToolUseServiceResearch(t *testing.T) { content := []anthBlock{ {Type: "tool_use", Name: "service_research", Input: map[string]any{ "services": []any{"github", "dnsimple", "github", "not-a-service"}, "goal": "which domains point at GitHub Pages?", }}, } got := actionsFromToolUse(content) if len(got) != 1 { t.Fatalf("actions: %+v", got) } a := got[0] if a["type"] != "service_research" || a["goal"] != "which domains point at GitHub Pages?" { t.Fatalf("chip: %+v", a) } services, _ := a["services"].([]string) if len(services) != 2 || services[0] != "github" || services[1] != "dnsimple" { t.Fatalf("services (deduped, registry-checked): %v", services) } if label, _ := a["label"].(string); !strings.Contains(label, "GitHub & DNSimple") || !strings.Contains(label, "read-only") { t.Fatalf("label: %q", label) } // A goal-less or unknown-services-only proposal never becomes a chip. bad := actionsFromToolUse([]anthBlock{ {Type: "tool_use", Name: "service_research", Input: map[string]any{"services": []any{"github"}, "goal": " "}}, {Type: "tool_use", Name: "service_research", Input: map[string]any{"services": []any{"nope"}, "goal": "x"}}, }) if len(bad) != 0 { t.Fatalf("bad proposals became chips: %+v", bad) } }