Files
kandev/apps/backend/internal/slack/cookie_client_test.go
T
João Salavisa 7e0b46c227 feat(slack): cookie-mode integration that triages threads via a utility agent (#775)
* feat(slack): add cookie-mode integration that triages messages via utility agent

Adds an install-wide Slack integration that polls the connected user's
own messages for `!kandev <instruction>`, hands the thread context to a
configured utility agent with the Kandev MCP server attached, and
creates the resulting task. Auth uses the user's xoxc- token + d cookie
so it works on locked-down Enterprise Slack tenants where bot installs
aren't possible.

Shared infra:
- ACP host-utility executor accepts McpServers on session/new
- New MCP tool list_repositories_kandev so the triage agent can pick the
  right repo
- Template engine exposes Slack-specific variables for prompt injection
- secretadapter + healthpoll reused from jira/linear

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(slack): address PR review feedback round 9

- Drop dead `var _ = time.Second` no-op and its misleading comment in
  trigger_test.go.
- `RecordAuthHealth` now invalidates the cached client when the probe
  fails, so the poller self-recovers from credential rotation without
  requiring a re-save.
- Stable secondary sort key (`workspace_id DESC`) on the legacy →
  singleton migration so promoted-row selection is deterministic on
  `updated_at` ties.
- Extract `categorySlack` constant in `engine.go` and update the
  `VariableInfo.Category` doc-comment to list the full set of values.
- Realign `slack-integration/spec.md` with what shipped: cookie-mode +
  `!kandev` triage as v1, with bot install / User OAuth / reaction &
  slash & shortcut triggers moved under Future scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:03:42 +01:00

256 lines
8.7 KiB
Go

package slack
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newFakeServer returns an httptest.Server whose handler dispatches by URL path
// (the trailing API method name) to the supplied handlers map.
func newFakeServer(t *testing.T, handlers map[string]http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
method := strings.TrimPrefix(r.URL.Path, "/")
if h, ok := handlers[method]; ok {
h(w, r)
return
}
http.Error(w, "no handler for "+method, http.StatusNotFound)
}))
t.Cleanup(srv.Close)
return srv
}
func newFakeClient(srv *httptest.Server) *CookieClient {
c := NewCookieClient(nil, "xoxc-test", "d-test")
c.endpoint = srv.URL
return c
}
func TestCookieClient_AuthTest_Success(t *testing.T) {
var gotAuth, gotCookie string
srv := newFakeServer(t, map[string]http.HandlerFunc{
"auth.test": func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
gotCookie = r.Header.Get("Cookie")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"url":"https://acme.slack.com/","team":"Acme","user":"alice","team_id":"T0001","user_id":"U0001"}`))
},
})
client := newFakeClient(srv)
res, err := client.AuthTest(context.Background())
if err != nil {
t.Fatalf("AuthTest: %v", err)
}
if !res.OK || res.UserID != "U0001" || res.TeamID != "T0001" {
t.Errorf("unexpected result: %+v", res)
}
if gotAuth != "Bearer xoxc-test" {
t.Errorf("expected bearer auth header, got %q", gotAuth)
}
if gotCookie != "d=d-test" {
t.Errorf("expected d cookie header, got %q", gotCookie)
}
}
func TestCookieClient_AuthTest_SlackError(t *testing.T) {
srv := newFakeServer(t, map[string]http.HandlerFunc{
"auth.test": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
},
})
client := newFakeClient(srv)
res, err := client.AuthTest(context.Background())
if err != nil {
t.Fatalf("AuthTest returned err (should surface as result): %v", err)
}
if res.OK {
t.Errorf("expected OK=false, got %+v", res)
}
if !strings.Contains(res.Error, "invalid_auth") {
t.Errorf("expected slack error in result, got %q", res.Error)
}
}
func TestCookieClient_SearchMessages(t *testing.T) {
var gotQuery string
srv := newFakeServer(t, map[string]http.HandlerFunc{
"search.messages": func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotQuery = r.PostForm.Get("query")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"ok": true,
"messages": {"matches": [
{"ts":"100.000001","text":"!kandev one","user":"U1","permalink":"https://example/1","channel":{"id":"C1"}},
{"ts":"100.000002","text":"!kandev two","user":"U1","permalink":"https://example/2","channel":{"id":"C1"}}
]}
}`))
},
})
client := newFakeClient(srv)
msgs, err := client.SearchMessages(context.Background(), `from:U1 "!kandev"`)
if err != nil {
t.Fatalf("SearchMessages: %v", err)
}
if gotQuery != `from:U1 "!kandev"` {
t.Errorf("query passthrough failed, got %q", gotQuery)
}
if len(msgs) != 2 || msgs[0].TS != "100.000001" || msgs[0].ChannelID != "C1" {
t.Errorf("unexpected messages: %+v", msgs)
}
}
func TestCookieClient_ConversationsReplies(t *testing.T) {
srv := newFakeServer(t, map[string]http.HandlerFunc{
"conversations.replies": func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if r.PostForm.Get("ts") == "" {
http.Error(w, "ts required", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"ok": true,
"messages": [
{"ts":"100.0001","user":"U1","text":"first"},
{"ts":"100.0002","user":"U2","text":"second","thread_ts":"100.0001"}
]
}`))
},
})
client := newFakeClient(srv)
out, err := client.ConversationsReplies(context.Background(), "C1", "100.0001", "100.0001")
if err != nil {
t.Fatalf("ConversationsReplies: %v", err)
}
if len(out) != 2 || out[0].Text != "first" || out[1].ThreadTS != "100.0001" {
t.Errorf("unexpected thread: %+v", out)
}
}
func TestCookieClient_ConversationsReplies_PinsHistoryToTriggerTSWhenNoThread(t *testing.T) {
var got struct{ oldest, latest, inclusive, limit string }
srv := newFakeServer(t, map[string]http.HandlerFunc{
"conversations.history": func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
got.oldest = r.PostForm.Get("oldest")
got.latest = r.PostForm.Get("latest")
got.inclusive = r.PostForm.Get("inclusive")
got.limit = r.PostForm.Get("limit")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true,"messages":[{"ts":"200.0001","user":"U1","text":"loner"}]}`))
},
})
client := newFakeClient(srv)
out, err := client.ConversationsReplies(context.Background(), "C1", "", "200.0001")
if err != nil {
t.Fatalf("ConversationsReplies: %v", err)
}
// Both window bounds must be the trigger ts and inclusive=true; otherwise
// concurrent activity in the channel would land in the response instead
// of the actual trigger message.
if got.oldest != "200.0001" || got.latest != "200.0001" {
t.Errorf("expected oldest/latest pinned to trigger ts, got %+v", got)
}
if got.inclusive != "true" || got.limit != "1" {
t.Errorf("expected inclusive=true limit=1, got %+v", got)
}
if len(out) != 1 || out[0].Text != "loner" {
t.Errorf("unexpected fallback: %+v", out)
}
}
func TestCookieClient_ChatPostMessage(t *testing.T) {
var got struct{ channel, text, threadTS string }
srv := newFakeServer(t, map[string]http.HandlerFunc{
"chat.postMessage": func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
got.channel = r.PostForm.Get("channel")
got.text = r.PostForm.Get("text")
got.threadTS = r.PostForm.Get("thread_ts")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
},
})
client := newFakeClient(srv)
if err := client.ChatPostMessage(context.Background(), "C1", "100.0001", "hello"); err != nil {
t.Fatalf("ChatPostMessage: %v", err)
}
if got.channel != "C1" || got.text != "hello" || got.threadTS != "100.0001" {
t.Errorf("payload mismatch: %+v", got)
}
}
func TestCookieClient_ReactionsAdd(t *testing.T) {
var got struct{ channel, ts, name string }
srv := newFakeServer(t, map[string]http.HandlerFunc{
"reactions.add": func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
got.channel = r.PostForm.Get("channel")
got.ts = r.PostForm.Get("timestamp")
got.name = r.PostForm.Get("name")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":true}`))
},
})
client := newFakeClient(srv)
if err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes"); err != nil {
t.Fatalf("ReactionsAdd: %v", err)
}
if got.channel != "C1" || got.ts != "100.0001" || got.name != "eyes" {
t.Errorf("payload mismatch: %+v", got)
}
}
func TestCookieClient_ReactionsAdd_AlreadyReactedSwallowed(t *testing.T) {
srv := newFakeServer(t, map[string]http.HandlerFunc{
"reactions.add": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":false,"error":"already_reacted"}`))
},
})
client := newFakeClient(srv)
if err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes"); err != nil {
t.Errorf("expected already_reacted to be swallowed, got: %v", err)
}
}
func TestCookieClient_ReactionsAdd_OtherErrorBubbles(t *testing.T) {
srv := newFakeServer(t, map[string]http.HandlerFunc{
"reactions.add": func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"ok":false,"error":"channel_not_found"}`))
},
})
client := newFakeClient(srv)
err := client.ReactionsAdd(context.Background(), "C1", "100.0001", "eyes")
if err == nil || !strings.Contains(err.Error(), "channel_not_found") {
t.Errorf("expected channel_not_found error, got %v", err)
}
}
func TestCookieClient_RejectsEmptyCredentials(t *testing.T) {
// AuthTest absorbs ErrNotConfigured into the returned result rather than
// surfacing it as an error — assert on result.OK explicitly so the test
// catches the guard regression instead of vacuously passing on err==nil.
c := NewCookieClient(nil, "", "")
res, err := c.AuthTest(context.Background())
if err != nil {
t.Fatalf("AuthTest: %v", err)
}
if res == nil || res.OK {
t.Errorf("expected ok=false when both credentials are empty, got %+v", res)
}
c2 := NewCookieClient(nil, "xoxc-x", "")
_, err = c2.SearchMessages(context.Background(), "x")
if err == nil {
t.Error("expected error when cookie is missing")
}
}