Files
multica/server/internal/handler/lark_test.go
Bohan Jiang fd3216fd6b feat(lark): let an agent's owner bind/manage its Lark bot (MUL-4213) (#5079)
* feat(lark): let an agent's owner bind/manage its Lark bot (MUL-4213)

Scan-to-bind was authorized by workspace role only, so a non-admin
member could not bind a Lark bot even to an agent they own. Authorize
the device-flow install, status poll, and revoke by the same rule that
governs every other agent-management op — canManageAgent: the agent's
owner OR a workspace owner/admin.

Backend:
- router: begin/status/revoke drop to workspace-member level; the
  per-agent check moves into the handlers (agent_id is a query param /
  installation id, which the role middleware can't see).
- BeginLarkInstall + RevokeLarkInstallation load the target agent and
  run canManageAgent.
- GetLarkInstallStatus scopes the read to the session initiator or a
  workspace owner/admin; others get 404 (no existence leak). Session
  state now carries InitiatorID for this.

Frontend:
- LarkAgentBindButton takes agentOwnerId and lets the agent owner
  through (mirrors canEditAgent).
- Agent Integrations tab gates Lark per-agent (owner or admin) while
  Slack stays workspace-admin-only, since its routes are unchanged.

Tests: begin/status/revoke authorization (owner, agent owner, unrelated
member) on the backend; agent-owner bind visibility on the frontend.

Co-authored-by: multica-agent <github@multica.ai>

* fix(lark): keep orphan installation revoke available to workspace admins (MUL-4213)

RevokeLarkInstallation loaded the bound agent and ran canManageAgent
unconditionally, so once the agent was hard-deleted the load 404'd and
a workspace owner/admin could no longer disconnect the orphan Lark
installation — a documented cleanup path (ListByWorkspace lists orphans;
the active-connection query filters them; Settings surfaces "Unknown
Agent" Disconnect).

Fall back to workspace owner/admin-only revoke when GetAgentInWorkspace
finds no agent; agents that still exist keep the owner-OR-admin
canManageAgent check. A plain member gains no orphan-row cleanup rights.
No FK/cascade — resolved in the application layer.

Adds a backend regression test: orphan installation is revocable by a
workspace owner but not a plain member.

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-08 16:19:41 +08:00

472 lines
18 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"
"github.com/multica-ai/multica/server/internal/util/secretbox"
)
// 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")
}
}
// wireLarkInstallServices attaches a live InstallationService and a
// RegistrationService (pointed at a hermetic fake device-flow server) to the
// shared testHandler, restoring the previous values on cleanup. The fake
// answers the RFC 8628 begin call with a canned device_code so an authorized
// BeginLarkInstall reaches a 200; poll calls get authorization_pending so the
// background poller stays quiet for the life of the test.
func wireLarkInstallServices(t *testing.T) {
t.Helper()
if testHandler == nil {
t.Skip("database not available")
}
box, err := secretbox.New(make([]byte, secretbox.KeySize))
if err != nil {
t.Fatalf("secretbox.New: %v", err)
}
installSvc, err := lark.NewInstallationService(testHandler.Queries, box)
if err != nil {
t.Fatalf("NewInstallationService: %v", err)
}
fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
w.Header().Set("Content-Type", "application/json")
if r.FormValue("action") == "begin" {
_ = json.NewEncoder(w).Encode(map[string]any{
"device_code": "dc_test",
"verification_uri_complete": "https://accounts.feishu.cn/oauth/v1/qrcode?code=abc",
"verification_uri": "https://accounts.feishu.cn/oauth/v1/qrcode",
"user_code": "ABCD-EFGH",
"interval": 1,
"expire_in": 2,
})
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"error": "authorization_pending"})
}))
t.Cleanup(fake.Close)
regSvc, err := lark.NewRegistrationService(
lark.RegistrationServiceConfig{},
lark.NewRegistrationClient(lark.RegistrationConfig{Domain: fake.URL}),
lark.NewHTTPAPIClient(lark.HTTPClientConfig{}),
testHandler.Queries,
testPool,
installSvc,
lark.NewBindingTokenService(testHandler.Queries, testPool),
)
if err != nil {
t.Fatalf("NewRegistrationService: %v", err)
}
prevInstall, prevReg := testHandler.LarkInstallations, testHandler.LarkRegistration
testHandler.LarkInstallations = installSvc
testHandler.LarkRegistration = regSvc
t.Cleanup(func() {
testHandler.LarkInstallations = prevInstall
testHandler.LarkRegistration = prevReg
})
}
func beginLarkInstallAs(userID, agentID string) *httptest.ResponseRecorder {
req := newRequestAs(userID, http.MethodPost,
"/api/workspaces/"+testWorkspaceID+"/lark/install/begin?agent_id="+agentID, nil)
req = withURLParams(req, "id", testWorkspaceID)
w := httptest.NewRecorder()
testHandler.BeginLarkInstall(w, req)
return w
}
// TestBeginLarkInstall_AuthorizesAgentOwnerAndAdmins is the core of MUL-4213:
// the device-flow scan-to-bind is authorized by canManageAgent, so the agent's
// owner (a plain workspace member) and workspace owner/admins may begin an
// install, while a member who is neither is forbidden.
func TestBeginLarkInstall_AuthorizesAgentOwnerAndAdmins(t *testing.T) {
wireLarkInstallServices(t)
agentID, ownerID, memberID := privateAgentTestFixture(t)
cases := []struct {
name string
userID string
want int
}{
{"workspace owner (admin path)", testUserID, http.StatusOK},
{"agent owner (plain member)", ownerID, http.StatusOK},
{"unrelated plain member", memberID, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w := beginLarkInstallAs(tc.userID, agentID)
if w.Code != tc.want {
t.Fatalf("BeginLarkInstall as %s: want %d, got %d: %s",
tc.name, tc.want, w.Code, w.Body.String())
}
if tc.want == http.StatusOK {
var resp BeginLarkInstallResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode begin response: %v", err)
}
if resp.SessionID == "" || resp.QRCodeURL == "" {
t.Fatalf("expected a session id and QR URL, got %+v", resp)
}
}
})
}
}
// TestGetLarkInstallStatus_ScopedToInitiatorOrAdmin verifies the status poll is
// readable by the session's initiator (the agent owner who began it) and by a
// workspace owner/admin, but returns 404 (no existence leak) to an unrelated
// member (MUL-4213).
func TestGetLarkInstallStatus_ScopedToInitiatorOrAdmin(t *testing.T) {
wireLarkInstallServices(t)
agentID, ownerID, memberID := privateAgentTestFixture(t)
// The agent owner begins the install, so they are the session initiator.
begin := beginLarkInstallAs(ownerID, agentID)
if begin.Code != http.StatusOK {
t.Fatalf("begin as agent owner: want 200, got %d: %s", begin.Code, begin.Body.String())
}
var beginResp BeginLarkInstallResponse
if err := json.Unmarshal(begin.Body.Bytes(), &beginResp); err != nil {
t.Fatalf("decode begin response: %v", err)
}
status := func(userID string) int {
req := newRequestAs(userID, http.MethodGet,
"/api/workspaces/"+testWorkspaceID+"/lark/install/"+beginResp.SessionID+"/status", nil)
req = withURLParams(req, "id", testWorkspaceID, "sessionId", beginResp.SessionID)
w := httptest.NewRecorder()
testHandler.GetLarkInstallStatus(w, req)
return w.Code
}
if code := status(ownerID); code != http.StatusOK {
t.Fatalf("status as initiator (agent owner): want 200, got %d", code)
}
if code := status(testUserID); code != http.StatusOK {
t.Fatalf("status as workspace owner/admin: want 200, got %d", code)
}
if code := status(memberID); code != http.StatusNotFound {
t.Fatalf("status as unrelated member: want 404, got %d", code)
}
}
// TestRevokeLarkInstallation_AuthorizesAgentOwnerAndAdmins mirrors the bind
// authorization on the unbind path: the bound agent's owner (a plain member)
// and workspace owner/admins may revoke, an unrelated member may not (MUL-4213).
func TestRevokeLarkInstallation_AuthorizesAgentOwnerAndAdmins(t *testing.T) {
wireLarkInstallServices(t)
agentID, ownerID, memberID := privateAgentTestFixture(t)
// A unique (workspace_id, agent_id, channel_type) constraint means at
// most one Feishu row per agent, so each subcase starts from a clean
// active row (delete any prior, then insert).
seedInstallation := func() string {
if _, err := testPool.Exec(context.Background(),
`DELETE FROM channel_installation WHERE workspace_id = $1 AND agent_id = $2 AND channel_type = 'feishu'`,
testWorkspaceID, agentID); err != nil {
t.Fatalf("clear prior installation: %v", err)
}
var instID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id, status)
VALUES ($1, $2, 'feishu', jsonb_build_object('app_id', 'cli_revoke_test'), $3, 'active')
RETURNING id
`, testWorkspaceID, agentID, ownerID).Scan(&instID); err != nil {
t.Fatalf("seed installation: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM channel_installation WHERE id = $1`, instID)
})
return instID
}
revoke := func(userID, instID string) int {
req := newRequestAs(userID, http.MethodDelete,
"/api/workspaces/"+testWorkspaceID+"/lark/installations/"+instID, nil)
req = withURLParams(req, "id", testWorkspaceID, "installationId", instID)
w := httptest.NewRecorder()
testHandler.RevokeLarkInstallation(w, req)
return w.Code
}
// Unrelated member: forbidden.
if code := revoke(memberID, seedInstallation()); code != http.StatusForbidden {
t.Fatalf("revoke as unrelated member: want 403, got %d", code)
}
// Agent owner: allowed.
if code := revoke(ownerID, seedInstallation()); code != http.StatusNoContent {
t.Fatalf("revoke as agent owner: want 204, got %d", code)
}
// Workspace owner/admin: allowed.
if code := revoke(testUserID, seedInstallation()); code != http.StatusNoContent {
t.Fatalf("revoke as workspace owner: want 204, got %d", code)
}
}
// TestRevokeLarkInstallation_OrphanCleanableByAdminNotMember pins the
// orphan-cleanup path (Elon review on MUL-4213 / PR #5079): when the bound
// agent has been hard-deleted, the agent-owner authorization has no agent to
// resolve, so revoke must fall back to workspace owner/admin — a workspace
// owner can still disconnect the orphan (the documented cleanup entry point),
// while a plain member cannot.
func TestRevokeLarkInstallation_OrphanCleanableByAdminNotMember(t *testing.T) {
wireLarkInstallServices(t)
// Only need a plain member; the installation binds a deleted agent.
_, _, memberID := privateAgentTestFixture(t)
// agent_id references no agent row (hard-deleted / never existed). There
// is no FK, so the row persists as an orphan — exactly the ListByWorkspace
// case the active-connection query filters out.
const orphanAgent = "5d0a0000-0000-4000-8000-0000000000aa"
seedOrphan := func() string {
if _, err := testPool.Exec(context.Background(),
`DELETE FROM channel_installation WHERE workspace_id = $1 AND agent_id = $2 AND channel_type = 'feishu'`,
testWorkspaceID, orphanAgent); err != nil {
t.Fatalf("clear prior orphan: %v", err)
}
var instID string
if err := testPool.QueryRow(context.Background(), `
INSERT INTO channel_installation (workspace_id, agent_id, channel_type, config, installer_user_id, status)
VALUES ($1, $2, 'feishu', jsonb_build_object('app_id', 'cli_orphan'), $3, 'active')
RETURNING id
`, testWorkspaceID, orphanAgent, testUserID).Scan(&instID); err != nil {
t.Fatalf("seed orphan installation: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM channel_installation WHERE id = $1`, instID)
})
return instID
}
revoke := func(userID, instID string) int {
req := newRequestAs(userID, http.MethodDelete,
"/api/workspaces/"+testWorkspaceID+"/lark/installations/"+instID, nil)
req = withURLParams(req, "id", testWorkspaceID, "installationId", instID)
w := httptest.NewRecorder()
testHandler.RevokeLarkInstallation(w, req)
return w.Code
}
// Plain member: no agent to own, so orphan cleanup is denied.
if code := revoke(memberID, seedOrphan()); code != http.StatusForbidden {
t.Fatalf("orphan revoke as plain member: want 403, got %d", code)
}
// Workspace owner/admin: can disconnect the orphan.
if code := revoke(testUserID, seedOrphan()); code != http.StatusNoContent {
t.Fatalf("orphan revoke as workspace owner: want 204, got %d", code)
}
}