mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-22 17:49:48 +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>
140 lines
4.6 KiB
Go
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
|
|
}
|