import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginApprovalRequestPayload } from "../../infra/plugin-approvals.js"; import { ExecApprovalManager } from "../exec-approval-manager.js"; import { createPluginApprovalHandlers } from "./plugin-approval.js"; import type { GatewayRequestHandlerOptions } from "./types.js"; function createManager() { return new ExecApprovalManager(); } function createMockOptions( method: string, params: Record, overrides?: Partial, ): GatewayRequestHandlerOptions { return { req: { method, params, id: "req-1" }, params, client: { connect: { client: { id: "test-client", displayName: "Test Client" }, }, }, isWebchatConnect: () => false, respond: vi.fn(), context: { broadcast: vi.fn(), logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, hasExecApprovalClients: () => true, }, ...overrides, } as unknown as GatewayRequestHandlerOptions; } describe("createPluginApprovalHandlers", () => { let manager: ExecApprovalManager; beforeEach(() => { manager = createManager(); }); afterEach(() => { vi.restoreAllMocks(); }); it("returns handlers for all three plugin approval methods", () => { const handlers = createPluginApprovalHandlers(manager); expect(handlers).toHaveProperty("plugin.approval.request"); expect(handlers).toHaveProperty("plugin.approval.waitDecision"); expect(handlers).toHaveProperty("plugin.approval.resolve"); expect(typeof handlers["plugin.approval.request"]).toBe("function"); expect(typeof handlers["plugin.approval.waitDecision"]).toBe("function"); expect(typeof handlers["plugin.approval.resolve"]).toBe("function"); }); describe("plugin.approval.request", () => { it("rejects invalid params", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions("plugin.approval.request", {}); await handlers["plugin.approval.request"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ code: expect.any(String), }), ); }); it("creates and registers approval with twoPhase", async () => { const handlers = createPluginApprovalHandlers(manager); const respond = vi.fn(); const opts = createMockOptions( "plugin.approval.request", { title: "Sensitive action", description: "This tool modifies production data", severity: "warning", twoPhase: true, }, { respond }, ); // Don't await — the handler blocks waiting for the decision. // Instead, let it run and resolve the approval after the accepted response. const handlerPromise = handlers["plugin.approval.request"](opts); // Wait for the twoPhase "accepted" response await vi.waitFor(() => { expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ status: "accepted", id: expect.any(String) }), undefined, ); }); expect(opts.context.broadcast).toHaveBeenCalledWith( "plugin.approval.requested", expect.objectContaining({ id: expect.any(String) }), { dropIfSlow: true }, ); // Resolve the approval so the handler can complete const acceptedCall = respond.mock.calls.find( (c) => (c[1] as Record)?.status === "accepted", ); const approvalId = (acceptedCall?.[1] as Record)?.id as string; manager.resolve(approvalId, "allow-once"); await handlerPromise; // Final response with decision expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ id: approvalId, decision: "allow-once" }), undefined, ); }); it("expires immediately when no approval route", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions( "plugin.approval.request", { title: "Sensitive action", description: "Desc", }, { context: { broadcast: vi.fn(), logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, hasExecApprovalClients: () => false, } as unknown as GatewayRequestHandlerOptions["context"], }, ); await handlers["plugin.approval.request"](opts); expect(opts.respond).toHaveBeenCalledWith( true, expect.objectContaining({ decision: null }), undefined, ); }); it("passes caller connId to hasExecApprovalClients to exclude self", async () => { const handlers = createPluginApprovalHandlers(manager); const hasExecApprovalClients = vi.fn().mockReturnValue(false); const opts = createMockOptions( "plugin.approval.request", { title: "T", description: "D" }, { client: { connId: "backend-conn-42", connect: { client: { id: "test", displayName: "Test" } }, } as unknown as GatewayRequestHandlerOptions["client"], context: { broadcast: vi.fn(), logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, hasExecApprovalClients, } as unknown as GatewayRequestHandlerOptions["context"], }, ); await handlers["plugin.approval.request"](opts); expect(hasExecApprovalClients).toHaveBeenCalledWith("backend-conn-42"); }); it("keeps plugin approvals pending when the originating chat can handle /approve directly", async () => { vi.useFakeTimers(); try { const handlers = createPluginApprovalHandlers(manager); const respond = vi.fn(); const opts = createMockOptions( "plugin.approval.request", { title: "Sensitive action", description: "Desc", twoPhase: true, turnSourceChannel: "slack", turnSourceTo: "C123", }, { respond, context: { broadcast: vi.fn(), logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, hasExecApprovalClients: () => false, } as unknown as GatewayRequestHandlerOptions["context"], }, ); const requestPromise = handlers["plugin.approval.request"](opts); await vi.waitFor(() => { expect(respond).toHaveBeenCalledWith( true, expect.objectContaining({ status: "accepted", id: expect.any(String) }), undefined, ); }); const acceptedCall = respond.mock.calls.find( (call) => (call[1] as Record)?.status === "accepted", ); const approvalId = (acceptedCall?.[1] as Record)?.id as string; manager.resolve(approvalId, "allow-once"); await requestPromise; } finally { vi.useRealTimers(); } }); it("rejects invalid severity value", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions("plugin.approval.request", { title: "T", description: "D", severity: "extreme", }); await handlers["plugin.approval.request"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ code: expect.any(String) }), ); }); it("rejects title exceeding max length", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions("plugin.approval.request", { title: "x".repeat(81), description: "D", }); await handlers["plugin.approval.request"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ code: expect.any(String) }), ); }); it("rejects description exceeding max length", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions("plugin.approval.request", { title: "T", description: "x".repeat(257), }); await handlers["plugin.approval.request"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ code: expect.any(String) }), ); }); it("rejects timeoutMs exceeding max", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions("plugin.approval.request", { title: "T", description: "D", timeoutMs: 700_000, }); await handlers["plugin.approval.request"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ code: expect.any(String) }), ); }); it("generates plugin-prefixed IDs", async () => { const handlers = createPluginApprovalHandlers(manager); const respond = vi.fn(); const opts = createMockOptions( "plugin.approval.request", { title: "T", description: "D" }, { respond, context: { broadcast: vi.fn(), logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, hasExecApprovalClients: () => false, } as unknown as GatewayRequestHandlerOptions["context"], }, ); await handlers["plugin.approval.request"](opts); const result = respond.mock.calls[0]?.[1] as Record | undefined; expect(result?.id).toEqual(expect.stringMatching(/^plugin:/)); }); it("passes plugin-prefixed IDs directly to manager.create", async () => { const handlers = createPluginApprovalHandlers(manager); const createSpy = vi.spyOn(manager, "create"); const opts = createMockOptions( "plugin.approval.request", { title: "T", description: "D" }, { context: { broadcast: vi.fn(), logGateway: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }, hasExecApprovalClients: () => false, } as unknown as GatewayRequestHandlerOptions["context"], }, ); await handlers["plugin.approval.request"](opts); expect(createSpy).toHaveBeenCalledTimes(1); expect(createSpy.mock.calls[0]?.[2]).toEqual(expect.stringMatching(/^plugin:/)); }); it("rejects plugin-provided id field", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions("plugin.approval.request", { id: "plugin-provided-id", title: "T", description: "D", }); await handlers["plugin.approval.request"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("unexpected property") }), ); }); }); describe("plugin.approval.waitDecision", () => { it("rejects missing id", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions("plugin.approval.waitDecision", {}); await handlers["plugin.approval.waitDecision"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("id is required") }), ); }); it("returns not found for unknown id", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions("plugin.approval.waitDecision", { id: "unknown" }); await handlers["plugin.approval.waitDecision"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: expect.stringContaining("expired or not found") }), ); }); it("returns decision when resolved", async () => { const handlers = createPluginApprovalHandlers(manager); const record = manager.create({ title: "T", description: "D" }, 60_000); void manager.register(record, 60_000); // Resolve before waiting manager.resolve(record.id, "allow-once"); const opts = createMockOptions("plugin.approval.waitDecision", { id: record.id }); await handlers["plugin.approval.waitDecision"](opts); expect(opts.respond).toHaveBeenCalledWith( true, expect.objectContaining({ id: record.id, decision: "allow-once" }), undefined, ); }); }); describe("plugin.approval.resolve", () => { it("rejects invalid params", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions("plugin.approval.resolve", {}); await handlers["plugin.approval.resolve"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ code: expect.any(String), }), ); }); it("rejects invalid decision", async () => { const handlers = createPluginApprovalHandlers(manager); const record = manager.create({ title: "T", description: "D" }, 60_000); void manager.register(record, 60_000); const opts = createMockOptions("plugin.approval.resolve", { id: record.id, decision: "invalid", }); await handlers["plugin.approval.resolve"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ message: "invalid decision" }), ); }); it("resolves a pending approval", async () => { const handlers = createPluginApprovalHandlers(manager); const record = manager.create({ title: "T", description: "D" }, 60_000); void manager.register(record, 60_000); const opts = createMockOptions("plugin.approval.resolve", { id: record.id, decision: "deny", }); await handlers["plugin.approval.resolve"](opts); expect(opts.respond).toHaveBeenCalledWith(true, { ok: true }, undefined); expect(opts.context.broadcast).toHaveBeenCalledWith( "plugin.approval.resolved", expect.objectContaining({ id: record.id, decision: "deny" }), { dropIfSlow: true }, ); }); it("rejects unknown approval id", async () => { const handlers = createPluginApprovalHandlers(manager); const opts = createMockOptions("plugin.approval.resolve", { id: "nonexistent", decision: "allow-once", }); await handlers["plugin.approval.resolve"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ code: "INVALID_REQUEST", message: expect.stringContaining("unknown or expired"), details: expect.objectContaining({ reason: "APPROVAL_NOT_FOUND" }), }), ); }); it("accepts unique short id prefixes", async () => { const handlers = createPluginApprovalHandlers(manager); const record = manager.create({ title: "T", description: "D" }, 60_000, "abcdef-1234"); void manager.register(record, 60_000); const opts = createMockOptions("plugin.approval.resolve", { id: "abcdef", decision: "allow-always", }); await handlers["plugin.approval.resolve"](opts); expect(opts.respond).toHaveBeenCalledWith(true, { ok: true }, undefined); expect(manager.getSnapshot(record.id)?.decision).toBe("allow-always"); }); it("does not leak candidate ids when prefixes are ambiguous", async () => { const handlers = createPluginApprovalHandlers(manager); const recordA = manager.create({ title: "A", description: "D" }, 60_000, "plugin:abc-1111"); const recordB = manager.create({ title: "B", description: "D" }, 60_000, "plugin:abc-2222"); void manager.register(recordA, 60_000); void manager.register(recordB, 60_000); const opts = createMockOptions("plugin.approval.resolve", { id: "plugin:abc", decision: "deny", }); await handlers["plugin.approval.resolve"](opts); expect(opts.respond).toHaveBeenCalledWith( false, undefined, expect.objectContaining({ code: "INVALID_REQUEST", message: "unknown or expired approval id", }), ); }); }); });