Files
multica/server/internal/handler/lark_test.go
J 7af0de4e8c fix(channel): skip orphaned installations in hub-boot active scan
Preflight deploy review: channel_installation dropped the workspace/agent FK (MUL-3515 §4), so unlike lark_installation it does not cascade away when its workspace is deleted or its agent is hard-deleted (e.g. runtime teardown). The hub-boot query then keeps opening a WebSocket for a bot whose owner is gone.

JOIN ListActiveChannelInstallations to live workspace + agent so an orphaned installation is never connected, uniformly for every deletion path. The JOIN matches the old ON DELETE CASCADE semantics (row existence, not agent archival), so an archived-but-present agent's installation is still listed; the orphaned row's encrypted secret is thereby never decrypted/used.

Tests: a real-DB handler test asserts a deleted-workspace/agent installation and a non-Feishu one are both excluded; the lark scope test's active-list assertion moved there since the JOIN now needs real workspace/agent fixtures. (Physically deleting dormant orphaned channel rows on workspace/agent deletion is a separate app-layer-cleanup follow-up.)

MUL-3515

Co-authored-by: multica-agent <github@multica.ai>
2026-06-23 16:54:18 +08:00

221 lines
8.5 KiB
Go

package handler
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/integrations/lark"
)
// Lark-handler unit tests focus on the no-config short-circuits —
// verifying that a self-host deployment without MULTICA_LARK_SECRET_KEY
// does NOT serve revoke / redeem / install, and that list degrades
// gracefully to an empty response so the Integrations tab still
// renders. Happy-path flows (begin device-flow + poll status; token
// mint + redeem) need a real DB and land alongside the WS hub
// integration tests in a follow-up commit.
func TestRevokeLarkInstallation_NotConfigured(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodDelete, "/api/workspaces/x/lark/installations/y", nil)
w := httptest.NewRecorder()
h.RevokeLarkInstallation(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
}
func TestRedeemLarkBindingToken_NotConfigured(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/api/lark/binding/redeem", strings.NewReader(`{"token":"x"}`))
w := httptest.NewRecorder()
h.RedeemLarkBindingToken(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
}
func TestBeginLarkInstall_NotConfigured(t *testing.T) {
// When the device-flow registration service is nil (no at-rest
// key, or the stub APIClient is the only one wired), the begin
// endpoint must short-circuit to 503 — silently returning a
// "configured: false" envelope would hide a real misconfiguration
// from the operator. The UI hides the bind button in that case
// so this should not be reached through the normal flow.
h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/api/workspaces/x/lark/install/begin?agent_id=y", nil)
w := httptest.NewRecorder()
h.BeginLarkInstall(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d body=%s", w.Code, w.Body.String())
}
}
func TestGetLarkInstallStatus_NotConfigured(t *testing.T) {
h := &Handler{}
req := httptest.NewRequest(http.MethodGet, "/api/workspaces/x/lark/install/sess_y/status", nil)
w := httptest.NewRecorder()
h.GetLarkInstallStatus(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
}
func TestListLarkInstallations_NotConfiguredReturnsEmpty(t *testing.T) {
// Listing is intentionally a "soft" endpoint: when lark is not
// configured we return an empty list + configured:false rather
// than a 503, so the Integrations tab renders normally with a
// "not connected" empty state instead of an error banner.
h := &Handler{}
req := httptest.NewRequest(http.MethodGet, "/api/workspaces/x/lark/installations", nil)
w := httptest.NewRecorder()
h.ListLarkInstallations(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String())
}
var resp struct {
Installations []any `json:"installations"`
Configured bool `json:"configured"`
InstallSupported bool `json:"install_supported"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Configured {
t.Fatalf("configured should be false when LarkInstallations is nil")
}
if resp.InstallSupported {
t.Fatalf("install_supported should be false when LarkInstallations is nil")
}
if len(resp.Installations) != 0 {
t.Fatalf("expected empty installations list, got %d", len(resp.Installations))
}
}
// TestListLarkInstallations_StubClientReportsInstallNotSupported pins
// the front-half of the "don't expose a doomed install flow"
// guarantee: even when the at-rest key + registration service are set,
// install_supported flips false if the underlying APIClient is the
// stub. The stub cannot complete the post-poll GetBotInfo call that
// finalizes a device-flow install, so the UI must hide install entry
// points until a real client is wired.
func TestListLarkInstallations_StubClientReportsInstallNotSupported(t *testing.T) {
stubLogger := slog.New(slog.NewTextHandler(httptest.NewRecorder(), nil))
h := &Handler{
LarkAPIClient: lark.NewStubAPIClient(stubLogger),
}
req := httptest.NewRequest(http.MethodGet, "/api/workspaces/x/lark/installations", nil)
w := httptest.NewRecorder()
h.ListLarkInstallations(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Configured bool `json:"configured"`
InstallSupported bool `json:"install_supported"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.InstallSupported {
t.Fatalf("install_supported must be false while only stub APIClient is wired")
}
}
// TestListLarkInstallations_NotConfigured_HardCodedInstallSupportedFalse
// pins the invariant for the early-return branch: when
// LarkInstallations is nil (the deployment has no at-rest encryption
// key wired), the response MUST return both configured:false AND
// install_supported:false regardless of what APIClient is in place.
// A real APIClient on a not-configured deployment must not flip
// install_supported via the APIClient path — that path is not
// consulted in the early-return branch.
func TestListLarkInstallations_NotConfigured_HardCodedInstallSupportedFalse(t *testing.T) {
stubLogger := slog.New(slog.NewTextHandler(httptest.NewRecorder(), nil))
h := &Handler{
LarkInstallations: nil, // triggers the not-configured early return.
LarkAPIClient: lark.NewStubAPIClient(stubLogger),
}
req := httptest.NewRequest(http.MethodGet, "/api/workspaces/x/lark/installations", nil)
w := httptest.NewRecorder()
h.ListLarkInstallations(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Configured bool `json:"configured"`
InstallSupported bool `json:"install_supported"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Configured {
t.Fatalf("configured must be false when LarkInstallations is nil")
}
if resp.InstallSupported {
t.Fatalf("install_supported must be false in the early-return branch even with a non-nil APIClient")
}
}
// TestListActiveLarkInstallations_SkipsOrphans pins the MUL-3515 hub-boot
// guard: ListActiveChannelInstallations is JOINed to live workspace + agent,
// so an active channel_installation whose workspace or agent has been deleted
// (channel_* has no FK cascade) is never returned — otherwise the Hub would
// keep opening a WebSocket for a bot whose owner is gone. It also stays
// channel_type='feishu'-scoped. Runs against the real test DB.
func TestListActiveLarkInstallations_SkipsOrphans(t *testing.T) {
ctx := context.Background()
agentID := createHandlerTestAgent(t, "LarkActiveScopeAgent", []byte("[]"))
const (
liveApp = "cli_active_live"
orphanApp = "cli_active_orphan"
slackApp = "cli_active_slack"
// Deliberately non-existent workspace/agent so the JOIN drops the row.
orphanWS = "5d0a0000-0000-4000-8000-000000000001"
orphanAg = "5d0a0000-0000-4000-8000-000000000002"
)
clean := func() {
_, _ = testPool.Exec(context.Background(),
`DELETE FROM channel_installation WHERE config->>'app_id' = ANY($1)`,
[]string{liveApp, orphanApp, slackApp})
}
clean()
t.Cleanup(clean)
seed := func(ws, ag, channelType, app string) {
if _, err := testPool.Exec(ctx, `
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id, status)
VALUES ($1, $2, $3, jsonb_build_object('app_id', $4::text), $5, 'active')
`, ws, ag, channelType, app, testUserID); err != nil {
t.Fatalf("seed %s installation: %v", app, err)
}
}
seed(testWorkspaceID, agentID, "feishu", liveApp) // live workspace + agent -> listed
seed(orphanWS, orphanAg, "feishu", orphanApp) // deleted workspace + agent -> dropped
seed(testWorkspaceID, agentID, "slack", slackApp) // wrong channel_type -> dropped
active, err := lark.NewChannelStore(testHandler.Queries).ListActiveLarkInstallations(ctx)
if err != nil {
t.Fatalf("ListActiveLarkInstallations: %v", err)
}
seen := map[string]bool{}
for _, inst := range active {
seen[inst.AppID] = true
}
if !seen[liveApp] {
t.Fatal("expected the live-workspace/agent Feishu installation to be listed")
}
if seen[orphanApp] {
t.Fatal("orphaned installation (deleted workspace/agent) must not be listed — the hub would connect a dead bot")
}
if seen[slackApp] {
t.Fatal("non-Feishu installation must not be listed by the Feishu hub")
}
}