mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +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>
330 lines
13 KiB
Go
330 lines
13 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// insertRuntimeProfileFixture creates a runtime_profile in testWorkspaceID and
|
|
// returns its id, registering cleanup.
|
|
func insertRuntimeProfileFixture(t *testing.T, ctx context.Context, displayName, protocolFamily, commandName string) string {
|
|
t.Helper()
|
|
var profileID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO runtime_profile (workspace_id, display_name, protocol_family, command_name, created_by)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING id
|
|
`, testWorkspaceID, displayName, protocolFamily, commandName, testUserID).Scan(&profileID); err != nil {
|
|
t.Fatalf("insert runtime_profile fixture: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM runtime_profile WHERE id = $1`, profileID)
|
|
})
|
|
return profileID
|
|
}
|
|
|
|
// insertProfileRuntimeFixture creates an agent_runtime instance bound to the
|
|
// given profile (so profile_id is set), returning its id.
|
|
func insertProfileRuntimeFixture(t *testing.T, ctx context.Context, profileID, name, provider string) string {
|
|
t.Helper()
|
|
var runtimeID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO agent_runtime (
|
|
workspace_id, daemon_id, name, runtime_mode, provider, status,
|
|
device_info, metadata, owner_id, profile_id, last_seen_at
|
|
)
|
|
VALUES ($1, NULL, $2, 'local', $3, 'online', $4, '{}'::jsonb, $5, $6, now())
|
|
RETURNING id
|
|
`, testWorkspaceID, name, provider, name+" device", testUserID, profileID).Scan(&runtimeID); err != nil {
|
|
t.Fatalf("insert profile runtime fixture: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM agent WHERE runtime_id = $1`, runtimeID)
|
|
testPool.Exec(context.Background(), `DELETE FROM agent_runtime WHERE id = $1`, runtimeID)
|
|
})
|
|
return runtimeID
|
|
}
|
|
|
|
// TestDeleteRuntimeProfile_ArchivedAgentCascade is the regression guard for the
|
|
// FK-RESTRICT 500: a profile whose only remaining agent is ARCHIVED must still
|
|
// delete cleanly. agent.runtime_id is ON DELETE RESTRICT, so without the
|
|
// per-runtime archived-agent teardown the DELETE on agent_runtime would raise a
|
|
// raw FK error and the handler would 500. The cascade must hard-delete the
|
|
// archived agent, the runtime row, and the profile.
|
|
func TestDeleteRuntimeProfile_ArchivedAgentCascade(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
profileID := insertRuntimeProfileFixture(t, ctx, "Cascade Profile Archived", "codex", "company-codex-arch")
|
|
runtimeID := insertProfileRuntimeFixture(t, ctx, profileID, "Cascade Profile Runtime", "codex")
|
|
agentID := createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade Profile Archived Agent")
|
|
|
|
// Archive the agent — the active-agent guard passes, but the FK still pins
|
|
// the runtime row until the archived cascade clears it.
|
|
if _, err := testPool.Exec(ctx, `UPDATE agent SET archived_at = now() WHERE id = $1`, agentID); err != nil {
|
|
t.Fatalf("archive agent: %v", err)
|
|
}
|
|
|
|
// Give the archived agent a channel installation (+ a dependent binding).
|
|
// channel_* has no FK to agent (MUL-3515 §4), so this DeleteRuntimeProfile
|
|
// teardown entry point must sweep it explicitly — otherwise the bot's app_id
|
|
// slot stays occupied after the agent is hard-deleted, and the bot can never
|
|
// rebind anywhere (#4810). This pins that sweep on the profile-delete path.
|
|
const rpApp = "cli_rp_cascade"
|
|
const rpChat = "cc000000-0000-4000-8000-0000000000f7"
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM channel_installation WHERE config->>'app_id' = $1`, rpApp)
|
|
_, _ = testPool.Exec(ctx, `DELETE FROM channel_chat_session_binding WHERE chat_session_id = $1`, rpChat)
|
|
var rpInstallID string
|
|
if err := testPool.QueryRow(ctx, `
|
|
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id, status)
|
|
VALUES ($1, $2, 'feishu', jsonb_build_object('app_id', $3::text), $4, 'active')
|
|
RETURNING id`, testWorkspaceID, agentID, rpApp, testUserID).Scan(&rpInstallID); err != nil {
|
|
t.Fatalf("seed channel installation: %v", err)
|
|
}
|
|
if _, err := testPool.Exec(ctx, `
|
|
INSERT INTO channel_chat_session_binding (chat_session_id, installation_id, channel_type, channel_chat_id, chat_type)
|
|
VALUES ($1, $2, 'feishu', 'oc_rp', 'p2p')`, rpChat, rpInstallID); err != nil {
|
|
t.Fatalf("seed chat-session binding: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM channel_installation WHERE config->>'app_id' = $1`, rpApp)
|
|
_, _ = testPool.Exec(context.Background(), `DELETE FROM channel_chat_session_binding WHERE chat_session_id = $1`, rpChat)
|
|
})
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/runtime-profiles/"+profileID, nil)
|
|
req = withURLParams(req, "id", testWorkspaceID, "profileId", profileID)
|
|
testHandler.DeleteRuntimeProfile(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var profileRows, rtRows, agentRows int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM runtime_profile WHERE id = $1`, profileID).Scan(&profileRows); err != nil {
|
|
t.Fatalf("count profile rows: %v", err)
|
|
}
|
|
if profileRows != 0 {
|
|
t.Fatalf("expected profile deleted, found %d", profileRows)
|
|
}
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
|
|
t.Fatalf("count runtime rows: %v", err)
|
|
}
|
|
if rtRows != 0 {
|
|
t.Fatalf("expected runtime row deleted by cascade, found %d", rtRows)
|
|
}
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent WHERE id = $1`, agentID).Scan(&agentRows); err != nil {
|
|
t.Fatalf("count agent rows: %v", err)
|
|
}
|
|
if agentRows != 0 {
|
|
t.Fatalf("expected archived agent hard-deleted by cascade, found %d", agentRows)
|
|
}
|
|
|
|
var instRows, bindingRows int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM channel_installation WHERE id = $1`, rpInstallID).Scan(&instRows); err != nil {
|
|
t.Fatalf("count channel installation: %v", err)
|
|
}
|
|
if instRows != 0 {
|
|
t.Fatalf("archived agent's channel installation not swept on runtime-profile delete: %d rows (its bot's app_id slot stays occupied, #4810)", instRows)
|
|
}
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM channel_chat_session_binding WHERE installation_id = $1`, rpInstallID).Scan(&bindingRows); err != nil {
|
|
t.Fatalf("count channel chat-session binding: %v", err)
|
|
}
|
|
if bindingRows != 0 {
|
|
t.Fatalf("channel chat-session binding not swept: %d dangling rows", bindingRows)
|
|
}
|
|
}
|
|
|
|
// TestDeleteRuntimeProfile_ActiveAgentBlocks confirms the guard still refuses
|
|
// (409) while an ACTIVE agent is bound to one of the profile's runtimes, and
|
|
// leaves the profile + runtime intact.
|
|
func TestDeleteRuntimeProfile_ActiveAgentBlocks(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
profileID := insertRuntimeProfileFixture(t, ctx, "Cascade Profile Active", "codex", "company-codex-active")
|
|
runtimeID := insertProfileRuntimeFixture(t, ctx, profileID, "Cascade Profile Active Runtime", "codex")
|
|
_ = createCascadeFixtureAgent(t, ctx, runtimeID, "Cascade Profile Active Agent")
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/runtime-profiles/"+profileID, nil)
|
|
req = withURLParams(req, "id", testWorkspaceID, "profileId", profileID)
|
|
testHandler.DeleteRuntimeProfile(w, req)
|
|
|
|
if w.Code != http.StatusConflict {
|
|
t.Fatalf("expected 409, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var profileRows, rtRows int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM runtime_profile WHERE id = $1`, profileID).Scan(&profileRows); err != nil {
|
|
t.Fatalf("count profile rows: %v", err)
|
|
}
|
|
if profileRows != 1 {
|
|
t.Fatalf("expected profile to survive 409, found %d", profileRows)
|
|
}
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
|
|
t.Fatalf("count runtime rows: %v", err)
|
|
}
|
|
if rtRows != 1 {
|
|
t.Fatalf("expected runtime to survive 409, found %d", rtRows)
|
|
}
|
|
}
|
|
|
|
func TestDeleteRuntimeProfile_MissingProfileWithOrphanRuntimesCleansUp(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
profileID := insertRuntimeProfileFixture(t, ctx, "Orphaned Profile Cleanup", "codex", "orphaned-profile-codex")
|
|
runtimeID := insertProfileRuntimeFixture(t, ctx, profileID, "Orphaned Profile Runtime", "codex")
|
|
if _, err := testPool.Exec(ctx, `DELETE FROM runtime_profile WHERE id = $1`, profileID); err != nil {
|
|
t.Fatalf("delete profile row: %v", err)
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/runtime-profiles/"+profileID, nil)
|
|
req = withURLParams(req, "id", testWorkspaceID, "profileId", profileID)
|
|
testHandler.DeleteRuntimeProfile(w, req)
|
|
|
|
if w.Code != http.StatusNoContent {
|
|
t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var rtRows int
|
|
if err := testPool.QueryRow(ctx, `SELECT count(*) FROM agent_runtime WHERE id = $1`, runtimeID).Scan(&rtRows); err != nil {
|
|
t.Fatalf("count runtime rows: %v", err)
|
|
}
|
|
if rtRows != 0 {
|
|
t.Fatalf("expected orphaned runtime row deleted, found %d", rtRows)
|
|
}
|
|
}
|
|
|
|
func TestDeleteRuntimeProfile_MissingProfileNoOrphansStillReturns404(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
|
|
missingProfileID := "00000000-0000-0000-0000-000000415800"
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/workspaces/"+testWorkspaceID+"/runtime-profiles/"+missingProfileID, nil)
|
|
req = withURLParams(req, "id", testWorkspaceID, "profileId", missingProfileID)
|
|
testHandler.DeleteRuntimeProfile(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestCreateRuntimeProfile_ForcesWorkspaceVisibility is the regression guard
|
|
// for the visibility leak: visibility=private is not user-settable in v1
|
|
// because the read paths don't enforce it. A client that POSTs
|
|
// visibility:"private" must get a profile stored as 'workspace' — never
|
|
// private — so a "private" profile can't leak to other members or be
|
|
// registered by other daemons. Belt-and-suspenders: also assert the row in
|
|
// the DB is 'workspace'.
|
|
func TestCreateRuntimeProfile_ForcesWorkspaceVisibility(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/runtime-profiles", map[string]any{
|
|
"display_name": "Visibility Forced Profile",
|
|
"protocol_family": "codex",
|
|
"command_name": "vis-forced-codex",
|
|
"visibility": "private", // must be ignored
|
|
})
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
testHandler.CreateRuntimeProfile(w, req)
|
|
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp RuntimeProfileResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM runtime_profile WHERE id = $1`, resp.ID)
|
|
})
|
|
|
|
if resp.Visibility != "workspace" {
|
|
t.Fatalf("response visibility = %q, want workspace (private must be forced to workspace)", resp.Visibility)
|
|
}
|
|
var dbVis string
|
|
if err := testPool.QueryRow(ctx, `SELECT visibility FROM runtime_profile WHERE id = $1`, resp.ID).Scan(&dbVis); err != nil {
|
|
t.Fatalf("read stored visibility: %v", err)
|
|
}
|
|
if dbVis != "workspace" {
|
|
t.Fatalf("stored visibility = %q, want workspace", dbVis)
|
|
}
|
|
}
|
|
|
|
func TestCreateRuntimeProfile_ValidatesCommandAndFixedArgs(t *testing.T) {
|
|
if testHandler == nil {
|
|
t.Skip("database not available")
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
commandName string
|
|
fixedArgs []string
|
|
wantStatus int
|
|
}{
|
|
{
|
|
name: "split command and args accepted",
|
|
commandName: "agent",
|
|
fixedArgs: []string{"--model", "composer-2.5"},
|
|
wantStatus: http.StatusCreated,
|
|
},
|
|
{
|
|
name: "command line rejected",
|
|
commandName: "agent --model composer-2.5",
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
{
|
|
name: "nul arg rejected",
|
|
commandName: "agent",
|
|
fixedArgs: []string{"bad\x00arg"},
|
|
wantStatus: http.StatusBadRequest,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/workspaces/"+testWorkspaceID+"/runtime-profiles", map[string]any{
|
|
"display_name": "Validation " + tc.name,
|
|
"protocol_family": "codex",
|
|
"command_name": tc.commandName,
|
|
"fixed_args": tc.fixedArgs,
|
|
})
|
|
req = withURLParam(req, "id", testWorkspaceID)
|
|
testHandler.CreateRuntimeProfile(w, req)
|
|
if w.Code != tc.wantStatus {
|
|
t.Fatalf("status = %d, want %d: %s", w.Code, tc.wantStatus, w.Body.String())
|
|
}
|
|
if w.Code == http.StatusCreated {
|
|
var resp RuntimeProfileResponse
|
|
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
|
t.Fatalf("decode response: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
testPool.Exec(context.Background(), `DELETE FROM runtime_profile WHERE id = $1`, resp.ID)
|
|
})
|
|
if got := strings.Join(resp.FixedArgs, " "); got != strings.Join(tc.fixedArgs, " ") {
|
|
t.Fatalf("fixed_args = %v, want %v", resp.FixedArgs, tc.fixedArgs)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|