Files
multica/server/internal/integrations/slack/install_test.go
Bohan Jiang ccacce60a1 fix(channels): auto-reclaim orphaned IM-bot installations + accurate rebind conflict copy (#4810) MUL-3937 (#5103)
* 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>
2026-07-09 15:07:11 +08:00

140 lines
4.6 KiB
Go

package slack
import (
"context"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/internal/util/secretbox"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
func testBox(t *testing.T) *secretbox.Box {
t.Helper()
key := make([]byte, secretbox.KeySize)
for i := range key {
key[i] = byte(i + 1)
}
box, err := secretbox.New(key)
if err != nil {
t.Fatalf("secretbox.New: %v", err)
}
return box
}
func mustUUID(t *testing.T, s string) pgtype.UUID {
t.Helper()
u, err := util.ParseUUID(s)
if err != nil {
t.Fatalf("parse uuid %q: %v", s, err)
}
return u
}
type fakeInstallQueries struct {
// existing, when set, is the agent's current row; UpsertChannelInstallation
// returns it (an UPDATE) so a reconnect reuses the same row id.
existing *db.ChannelInstallation
// appIDTaken makes UpsertChannelInstallation report a unique-constraint
// violation on the (channel_type, app_id) routing index — i.e. the pasted app
// is already connected to a LIVE owner (the reclaim has run by then).
appIDTaken bool
upsertParams db.UpsertChannelInstallationParams
upsertCalled bool
rowID pgtype.UUID
// reclaimedID, when set, is returned by ReclaimDeadChannelInstallationByAppID
// to model a dead prior owner having been cleared; otherwise it reports
// pgx.ErrNoRows (nothing was dead). reclaimCalled records that the install
// path ran the reclaim before upserting.
reclaimedID *pgtype.UUID
reclaimCalled bool
// ownerWorkspaceID / ownerArchived / ownerMissing drive the live-owner lookup
// that classifies an appIDTaken conflict into the right sentinel.
ownerWorkspaceID pgtype.UUID
ownerArchived bool
ownerMissing bool
}
// WithTx returns the same fake — the fake tx is a no-op token.
func (f *fakeInstallQueries) WithTx(_ pgx.Tx) installQueries { return f }
func (f *fakeInstallQueries) ReclaimDeadChannelInstallationByAppID(_ context.Context, _ db.ReclaimDeadChannelInstallationByAppIDParams) (pgtype.UUID, error) {
f.reclaimCalled = true
if f.reclaimedID != nil {
return *f.reclaimedID, nil
}
return pgtype.UUID{}, pgx.ErrNoRows
}
func (f *fakeInstallQueries) GetChannelInstallationOwnerByAppID(_ context.Context, _ db.GetChannelInstallationOwnerByAppIDParams) (db.GetChannelInstallationOwnerByAppIDRow, error) {
if f.ownerMissing {
return db.GetChannelInstallationOwnerByAppIDRow{}, pgx.ErrNoRows
}
return db.GetChannelInstallationOwnerByAppIDRow{
WorkspaceID: f.ownerWorkspaceID,
AgentArchivedAt: pgtype.Timestamptz{Valid: f.ownerArchived},
}, nil
}
func (f *fakeInstallQueries) UpsertChannelInstallation(_ context.Context, arg db.UpsertChannelInstallationParams) (db.ChannelInstallation, error) {
f.upsertCalled = true
f.upsertParams = arg
if f.appIDTaken {
return db.ChannelInstallation{}, &pgconn.PgError{Code: "23505"}
}
id := f.rowID
if f.existing != nil {
id = f.existing.ID // reconnect updates the agent's existing row in place
}
return db.ChannelInstallation{
ID: id,
WorkspaceID: arg.WorkspaceID,
AgentID: arg.AgentID,
ChannelType: arg.ChannelType,
Config: arg.Config,
InstallerUserID: arg.InstallerUserID,
Status: "active",
}, nil
}
func (f *fakeInstallQueries) ListChannelInstallationsByWorkspace(_ context.Context, _ db.ListChannelInstallationsByWorkspaceParams) ([]db.ChannelInstallation, error) {
return nil, nil
}
func (f *fakeInstallQueries) GetChannelInstallationInWorkspace(_ context.Context, _ db.GetChannelInstallationInWorkspaceParams) (db.ChannelInstallation, error) {
return db.ChannelInstallation{}, nil
}
func (f *fakeInstallQueries) SetChannelInstallationStatus(_ context.Context, _ db.SetChannelInstallationStatusParams) error {
return nil
}
// fakeTx is a no-op pgx.Tx: embedding the interface satisfies it, and the
// install paths only ever call Commit / Rollback. committed records whether the
// install committed (the happy path) vs rolled back (a rejected install).
type fakeTx struct {
pgx.Tx
committed bool
}
func (t *fakeTx) Commit(context.Context) error { t.committed = true; return nil }
func (t *fakeTx) Rollback(context.Context) error { return nil }
type fakeTxStarter struct{ tx *fakeTx }
func (f *fakeTxStarter) Begin(context.Context) (pgx.Tx, error) { return f.tx, nil }
func newTestInstallService(t *testing.T, q installQueries) *InstallService {
t.Helper()
svc, err := newInstallService(q, &fakeTxStarter{tx: &fakeTx{}}, testBox(t), nil)
if err != nil {
t.Fatalf("newInstallService: %v", err)
}
return svc
}