mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-27 21:33:41 +02:00
* fix(channels): auto-reclaim orphaned bot installations + accurate rebind conflict copy channel_installation has no FK to workspace/agent (MUL-3515 §4), so deleting a workspace or hard-deleting an agent left the row behind, occupying the (channel_type, app_id) routing slot forever — the bot could never be rebound and the UI had no way to clear it (#4810). The 409 also always blamed "a different Multica workspace" even when the real owner sat in the same workspace. Auto-reclaim on delete: - DeleteWorkspace and the runtime-teardown paths now sweep the workspace's / archived agents' channel installations and every dependent row in-tx. - The shared install path (Feishu + Slack) reclaims a DEAD prior owner — a revoked placeholder or an orphan whose workspace/agent is gone — before the upsert, healing installations stranded before this fix. A live owner (active agent, including an archived one) is left in place, not stolen. Accurate conflict copy: - A rebind refused by a LIVE owner now distinguishes same-workspace / another agent, an archived agent, and a genuinely different workspace, for both Slack (typed sentinels) and Feishu (registration message). MUL-3937 Co-authored-by: multica-agent <github@multica.ai> * fix(channels): reclaim cross-workspace revoked bots + sweep card/dedup/audit (#4810) Address the #5103 review (yyclaw + Steve): - Reclaim: a REVOKED installation in ANY workspace is now dead (except the caller's own row), not just same-workspace. Disconnect never hard-deletes the row and there is no release UI, so a cross-workspace revoked row would pin a bot's app_id slot forever, with the misleading "connected to another workspace" copy resurfacing. A new binder proves control by holding the app credentials, so reclaiming is safe. Live ACTIVE owners (incl. archived) are still refused. - Sweep the two dependent tables the cleanups missed, in all three paths (reclaim / DeleteWorkspace / runtime teardown): channel_outbound_card_message (no reaper, so a permanent orphan otherwise) and channel_inbound_message_dedup (PurgeChannelInboundDedup has no caller). - Audit rows: PURGE on the hard-delete paths instead of detaching them into permanently unattributable NULL rows; keep DETACH on reclaim, where the workspace survives and the row stays useful for triage. - Tests: flip cross-ws revoked to reclaimed + add cross-ws active preserved; extend the reclaim and both delete-path cleanup tests for card/dedup and the audit purge/detach split; assert the channel sweep on the DeleteRuntimeProfile entry point. MUL-3937 Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
342 lines
12 KiB
Go
342 lines
12 KiB
Go
package slack
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// slackMock parameterizes the install-time Slack API stub. botAppID defaults to
|
|
// the app id embedded in byoParams' xapp token (so the same-app check passes).
|
|
type slackMock struct {
|
|
authOK bool // auth.test result
|
|
botAppID string // bots.info -> bot.app_id
|
|
appTokenOK bool // apps.connections.open result
|
|
}
|
|
|
|
// slackMockServer stubs the three Web API calls RegisterBYO makes: auth.test
|
|
// (bot token), bots.info (bot id -> owning app id), apps.connections.open (app
|
|
// token live check).
|
|
func slackMockServer(t *testing.T, m slackMock) *httptest.Server {
|
|
t.Helper()
|
|
if m.botAppID == "" {
|
|
m.botAppID = "A0BCXGVCS7R"
|
|
}
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
switch r.URL.Path {
|
|
case "/auth.test":
|
|
if !m.authOK {
|
|
_, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"ok":true,"team_id":"T999","user_id":"UBOTBYO","bot_id":"B0BOT","team":"Acme Inc","url":"https://acme.slack.com/"}`))
|
|
case "/bots.info":
|
|
_, _ = w.Write([]byte(fmt.Sprintf(`{"ok":true,"bot":{"id":"B0BOT","app_id":%q,"user_id":"UBOTBYO"}}`, m.botAppID)))
|
|
case "/apps.connections.open":
|
|
if !m.appTokenOK {
|
|
_, _ = w.Write([]byte(`{"ok":false,"error":"invalid_auth"}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{"ok":true,"url":"wss://example.test/link"}`))
|
|
default:
|
|
_, _ = w.Write([]byte(`{"ok":false,"error":"unknown_method"}`))
|
|
}
|
|
}))
|
|
}
|
|
|
|
// authTestServer is the happy-path stub (valid bot token, matching app id, live
|
|
// app token) unless ok=false, which makes auth.test reject the bot token.
|
|
func authTestServer(t *testing.T, ok bool) *httptest.Server {
|
|
return slackMockServer(t, slackMock{authOK: ok, appTokenOK: true})
|
|
}
|
|
|
|
func byoParams(ws, agent string) RegisterBYOParams {
|
|
return RegisterBYOParams{
|
|
WorkspaceID: pgtypeUUID(ws),
|
|
AgentID: pgtypeUUID(agent),
|
|
InitiatorID: pgtypeUUID("33333333-3333-3333-3333-333333333333"),
|
|
BotToken: "xoxb-real-bot-token",
|
|
AppToken: "xapp-1-A0BCXGVCS7R-111-appsecret",
|
|
}
|
|
}
|
|
|
|
// pgtypeUUID is a test-local UUID parse that panics on bad input (test data is
|
|
// always valid), so byoParams stays a plain literal.
|
|
func pgtypeUUID(s string) pgtype.UUID {
|
|
var u pgtype.UUID
|
|
if err := u.Scan(s); err != nil {
|
|
panic(err)
|
|
}
|
|
return u
|
|
}
|
|
|
|
func TestParseSlackAppID(t *testing.T) {
|
|
cases := []struct {
|
|
token string
|
|
want string
|
|
wantErr bool
|
|
}{
|
|
{"xapp-1-A0BCXGVCS7R-111-secret", "A0BCXGVCS7R", false},
|
|
{"xapp-1-A12345-9-abc", "A12345", false},
|
|
{"xoxb-not-an-app-token", "", true},
|
|
{"xapp-1-", "", true},
|
|
{"xapp-1-B123-9-abc", "", true}, // app ids start with A
|
|
{"", "", true},
|
|
}
|
|
for _, c := range cases {
|
|
got, err := parseSlackAppID(c.token)
|
|
if c.wantErr {
|
|
if err == nil {
|
|
t.Errorf("parseSlackAppID(%q) = %q, want error", c.token, got)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil || got != c.want {
|
|
t.Errorf("parseSlackAppID(%q) = %q, %v; want %q", c.token, got, err, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegisterBYO_PersistsEncryptedTokensKeyedByAppID(t *testing.T) {
|
|
srv := authTestServer(t, true)
|
|
defer srv.Close()
|
|
|
|
q := &fakeInstallQueries{rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444")}
|
|
svc := newTestInstallService(t, q) // BYO needs NO OAuth creds
|
|
svc.apiURL = srv.URL + "/"
|
|
|
|
row, err := svc.RegisterBYO(context.Background(), byoParams(
|
|
"11111111-1111-1111-1111-111111111111",
|
|
"22222222-2222-2222-2222-222222222222",
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("RegisterBYO: %v", err)
|
|
}
|
|
if row.ID != q.rowID {
|
|
t.Errorf("row id = %v, want %v", row.ID, q.rowID)
|
|
}
|
|
if !q.upsertCalled || q.upsertParams.ChannelType != string(TypeSlack) {
|
|
t.Fatalf("upsert not called for slack: %+v", q.upsertParams)
|
|
}
|
|
|
|
var cfg installConfig
|
|
if err := json.Unmarshal(q.upsertParams.Config, &cfg); err != nil {
|
|
t.Fatalf("decode upserted config: %v", err)
|
|
}
|
|
// Keyed by the REAL app id (parsed from the xapp token), NOT the team id —
|
|
// this is what lets several BYO apps share one Slack workspace.
|
|
if cfg.AppID != "A0BCXGVCS7R" {
|
|
t.Errorf("config app_id = %q, want the real app id A0BCXGVCS7R", cfg.AppID)
|
|
}
|
|
if cfg.TeamID != "T999" || cfg.BotUserID != "UBOTBYO" {
|
|
t.Errorf("config team/bot = %q/%q, want T999/UBOTBYO", cfg.TeamID, cfg.BotUserID)
|
|
}
|
|
// Both tokens stored encrypted (never plaintext) and both decrypt back.
|
|
if cfg.BotTokenEncrypted == "" || cfg.AppTokenEncrypted == "" {
|
|
t.Fatalf("both tokens must be stored: %+v", cfg)
|
|
}
|
|
if strings.Contains(cfg.BotTokenEncrypted, "xoxb-") || strings.Contains(cfg.AppTokenEncrypted, "xapp-") {
|
|
t.Error("tokens must be stored encrypted, not plaintext")
|
|
}
|
|
botTok, err := decryptToken(cfg.BotTokenEncrypted, svc.box.Open)
|
|
if err != nil || botTok != "xoxb-real-bot-token" {
|
|
t.Errorf("decrypted bot token = %q, %v", botTok, err)
|
|
}
|
|
appTok, err := decryptToken(cfg.AppTokenEncrypted, svc.box.Open)
|
|
if err != nil || appTok != "xapp-1-A0BCXGVCS7R-111-appsecret" {
|
|
t.Errorf("decrypted app token = %q, %v", appTok, err)
|
|
}
|
|
}
|
|
|
|
func TestRegisterBYO_InvalidTokens(t *testing.T) {
|
|
q := &fakeInstallQueries{}
|
|
svc := newTestInstallService(t, q)
|
|
|
|
// Bad bot token prefix — rejected before any network call or upsert.
|
|
p := byoParams("11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222")
|
|
p.BotToken = "nope-not-a-bot-token"
|
|
if _, err := svc.RegisterBYO(context.Background(), p); err != ErrInvalidBotToken {
|
|
t.Errorf("bad bot token = %v, want ErrInvalidBotToken", err)
|
|
}
|
|
// Bad app token.
|
|
p = byoParams("11111111-1111-1111-1111-111111111111", "22222222-2222-2222-2222-222222222222")
|
|
p.AppToken = "xapp-broken"
|
|
if _, err := svc.RegisterBYO(context.Background(), p); err != ErrInvalidAppToken {
|
|
t.Errorf("bad app token = %v, want ErrInvalidAppToken", err)
|
|
}
|
|
if q.upsertCalled {
|
|
t.Error("malformed tokens must be rejected before the upsert")
|
|
}
|
|
}
|
|
|
|
func TestRegisterBYO_AuthTestFailure(t *testing.T) {
|
|
srv := authTestServer(t, false) // Slack rejects the bot token
|
|
defer srv.Close()
|
|
q := &fakeInstallQueries{}
|
|
svc := newTestInstallService(t, q)
|
|
svc.apiURL = srv.URL + "/"
|
|
|
|
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
|
"11111111-1111-1111-1111-111111111111",
|
|
"22222222-2222-2222-2222-222222222222",
|
|
)); err == nil {
|
|
t.Fatal("expected an error when auth.test rejects the bot token")
|
|
}
|
|
if q.upsertCalled {
|
|
t.Error("a failed auth.test must not persist an installation")
|
|
}
|
|
}
|
|
|
|
func TestRegisterBYO_AppConnectedToAnotherWorkspace_Rejected(t *testing.T) {
|
|
srv := authTestServer(t, true)
|
|
defer srv.Close()
|
|
// The pasted app is live-owned by an agent in a DIFFERENT Multica workspace,
|
|
// so after the dead-owner reclaim the (channel_type, app_id) routing index
|
|
// still rejects the upsert. We must refuse, not steal it — and name the real
|
|
// case (another workspace), not the old catch-all.
|
|
q := &fakeInstallQueries{
|
|
rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
|
|
appIDTaken: true,
|
|
ownerWorkspaceID: mustUUID(t, "99999999-9999-9999-9999-999999999999"),
|
|
}
|
|
svc := newTestInstallService(t, q)
|
|
svc.apiURL = srv.URL + "/"
|
|
|
|
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
|
"11111111-1111-1111-1111-111111111111",
|
|
"22222222-2222-2222-2222-222222222222",
|
|
)); err != ErrTeamOwnedByAnotherWorkspace {
|
|
t.Fatalf("app already connected = %v, want ErrTeamOwnedByAnotherWorkspace", err)
|
|
}
|
|
if !q.reclaimCalled {
|
|
t.Error("install must attempt a dead-owner reclaim before the upsert")
|
|
}
|
|
}
|
|
|
|
func TestRegisterBYO_AppConnectedToAnotherAgentSameWorkspace_Rejected(t *testing.T) {
|
|
srv := authTestServer(t, true)
|
|
defer srv.Close()
|
|
// The pasted app is live-owned by a DIFFERENT (non-archived) agent in the
|
|
// SAME workspace. The old catch-all wrongly blamed "another workspace"; this
|
|
// must surface the same-workspace sentinel so the UI points at the Disconnect
|
|
// the user can actually reach (#4810).
|
|
q := &fakeInstallQueries{
|
|
rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
|
|
appIDTaken: true,
|
|
ownerWorkspaceID: mustUUID(t, "11111111-1111-1111-1111-111111111111"),
|
|
}
|
|
svc := newTestInstallService(t, q)
|
|
svc.apiURL = srv.URL + "/"
|
|
|
|
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
|
"11111111-1111-1111-1111-111111111111",
|
|
"22222222-2222-2222-2222-222222222222",
|
|
)); err != ErrTeamOwnedBySameWorkspace {
|
|
t.Fatalf("app owned by another agent in this workspace = %v, want ErrTeamOwnedBySameWorkspace", err)
|
|
}
|
|
}
|
|
|
|
func TestRegisterBYO_AppConnectedToArchivedAgent_Rejected(t *testing.T) {
|
|
srv := authTestServer(t, true)
|
|
defer srv.Close()
|
|
// The pasted app's owning agent is archived — a live-but-reversible owner —
|
|
// so the reclaim leaves it in place and the upsert is refused. The user is
|
|
// told to restore the agent or disconnect its bot, not that it's gone.
|
|
q := &fakeInstallQueries{
|
|
rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
|
|
appIDTaken: true,
|
|
ownerWorkspaceID: mustUUID(t, "11111111-1111-1111-1111-111111111111"),
|
|
ownerArchived: true,
|
|
}
|
|
svc := newTestInstallService(t, q)
|
|
svc.apiURL = srv.URL + "/"
|
|
|
|
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
|
"11111111-1111-1111-1111-111111111111",
|
|
"22222222-2222-2222-2222-222222222222",
|
|
)); err != ErrTeamOwnedByArchivedAgent {
|
|
t.Fatalf("app owned by an archived agent = %v, want ErrTeamOwnedByArchivedAgent", err)
|
|
}
|
|
}
|
|
|
|
func TestRegisterBYO_ReconnectSameAgent_UpdatesRowInPlace(t *testing.T) {
|
|
srv := authTestServer(t, true)
|
|
defer srv.Close()
|
|
// The agent already has a Slack row (e.g. a previously-disconnected app).
|
|
// Re-connecting it — even with a NEW app — must UPDATE that same row in place
|
|
// (keyed by workspace+agent), not error on the (workspace, agent, channel)
|
|
// unique. The fake returns the existing row id on the upsert.
|
|
existingID := mustUUID(t, "55555555-5555-5555-5555-555555555555")
|
|
q := &fakeInstallQueries{
|
|
rowID: mustUUID(t, "44444444-4444-4444-4444-444444444444"),
|
|
existing: &db.ChannelInstallation{
|
|
ID: existingID,
|
|
WorkspaceID: mustUUID(t, "11111111-1111-1111-1111-111111111111"),
|
|
AgentID: mustUUID(t, "22222222-2222-2222-2222-222222222222"),
|
|
},
|
|
}
|
|
svc := newTestInstallService(t, q)
|
|
svc.apiURL = srv.URL + "/"
|
|
|
|
row, err := svc.RegisterBYO(context.Background(), byoParams(
|
|
"11111111-1111-1111-1111-111111111111",
|
|
"22222222-2222-2222-2222-222222222222",
|
|
))
|
|
if err != nil {
|
|
t.Fatalf("RegisterBYO: %v", err)
|
|
}
|
|
if row.ID != existingID {
|
|
t.Errorf("reconnect should reuse the agent's existing row %v, got %v", existingID, row.ID)
|
|
}
|
|
}
|
|
|
|
func TestRegisterBYO_TokenAppMismatch(t *testing.T) {
|
|
// The bot token belongs to a DIFFERENT app (bots.info -> A0OTHER) than the
|
|
// app id embedded in the xapp token (A0BCXGVCS7R) — must be rejected so we
|
|
// never persist a broken installation (Niko review).
|
|
srv := slackMockServer(t, slackMock{authOK: true, botAppID: "A0OTHERAPP", appTokenOK: true})
|
|
defer srv.Close()
|
|
q := &fakeInstallQueries{}
|
|
svc := newTestInstallService(t, q)
|
|
svc.apiURL = srv.URL + "/"
|
|
|
|
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
|
"11111111-1111-1111-1111-111111111111",
|
|
"22222222-2222-2222-2222-222222222222",
|
|
)); err != ErrTokenAppMismatch {
|
|
t.Fatalf("mismatched tokens = %v, want ErrTokenAppMismatch", err)
|
|
}
|
|
if q.upsertCalled {
|
|
t.Error("mismatched bot/app tokens must be rejected before the upsert")
|
|
}
|
|
}
|
|
|
|
func TestRegisterBYO_AppTokenNotLive(t *testing.T) {
|
|
// auth.test + same-app check pass, but apps.connections.open rejects the app
|
|
// token — we must not persist a token that will never receive events.
|
|
srv := slackMockServer(t, slackMock{authOK: true, appTokenOK: false})
|
|
defer srv.Close()
|
|
q := &fakeInstallQueries{}
|
|
svc := newTestInstallService(t, q)
|
|
svc.apiURL = srv.URL + "/"
|
|
|
|
if _, err := svc.RegisterBYO(context.Background(), byoParams(
|
|
"11111111-1111-1111-1111-111111111111",
|
|
"22222222-2222-2222-2222-222222222222",
|
|
)); err == nil {
|
|
t.Fatal("expected an error when the app-level token is not live")
|
|
}
|
|
if q.upsertCalled {
|
|
t.Error("an invalid app token must not persist an installation")
|
|
}
|
|
}
|