import express from "express"; import request from "supertest"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockRegistry = vi.hoisted(() => ({ getById: vi.fn(), getByKey: vi.fn(), upsertConfig: vi.fn(), })); const mockLifecycle = vi.hoisted(() => ({ load: vi.fn(), upgrade: vi.fn(), unload: vi.fn(), enable: vi.fn(), disable: vi.fn(), })); function registerRouteMocks() { vi.doMock("../routes/authz.js", async () => vi.importActual("../routes/authz.js")); vi.doMock("../services/plugin-registry.js", () => ({ pluginRegistryService: () => mockRegistry, })); vi.doMock("../services/plugin-lifecycle.js", () => ({ pluginLifecycleManager: () => mockLifecycle, })); vi.doMock("../services/activity-log.js", () => ({ logActivity: vi.fn(), })); vi.doMock("../services/live-events.js", () => ({ publishGlobalLiveEvent: vi.fn(), })); } async function createApp( actor: Record, loaderOverrides: Record = {}, routeOverrides: { db?: unknown; jobDeps?: unknown; toolDeps?: unknown; bridgeDeps?: unknown; } = {}, ) { const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ vi.importActual("../routes/plugins.js"), vi.importActual("../middleware/index.js"), ]); const loader = { installPlugin: vi.fn(), ...loaderOverrides, }; const app = express(); app.use(express.json()); app.use((req, _res, next) => { req.actor = actor as typeof req.actor; next(); }); app.use("/api", pluginRoutes( (routeOverrides.db ?? {}) as never, loader as never, routeOverrides.jobDeps as never, undefined, routeOverrides.toolDeps as never, routeOverrides.bridgeDeps as never, )); app.use(errorHandler); return { app, loader }; } function createSelectQueueDb(rows: Array>>) { return { select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(() => Promise.resolve(rows.shift() ?? [])), })), })), })), }; } const companyA = "22222222-2222-4222-8222-222222222222"; const companyB = "33333333-3333-4333-8333-333333333333"; const agentA = "44444444-4444-4444-8444-444444444444"; const runA = "55555555-5555-4555-8555-555555555555"; const projectA = "66666666-6666-4666-8666-666666666666"; const pluginId = "11111111-1111-4111-8111-111111111111"; function boardActor(overrides: Record = {}) { return { type: "board", userId: "user-1", source: "session", isInstanceAdmin: false, companyIds: [companyA], ...overrides, }; } function readyPlugin() { mockRegistry.getById.mockResolvedValue({ id: pluginId, pluginKey: "paperclip.example", version: "1.0.0", status: "ready", }); } describe("plugin install and upgrade authz", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../services/issues.js"); vi.doUnmock("../services/plugin-config-validator.js"); vi.doUnmock("../services/plugin-loader.js"); vi.doUnmock("../services/plugin-registry.js"); vi.doUnmock("../services/plugin-lifecycle.js"); vi.doUnmock("../services/activity-log.js"); vi.doUnmock("../services/live-events.js"); vi.doUnmock("../routes/plugins.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerRouteMocks(); vi.resetAllMocks(); }); it("rejects plugin installation for non-admin board users", async () => { const { app, loader } = await createApp({ type: "board", userId: "user-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], }); const res = await request(app) .post("/api/plugins/install") .send({ packageName: "paperclip-plugin-example" }); expect(res.status).toBe(403); expect(loader.installPlugin).not.toHaveBeenCalled(); }, 20_000); it("allows instance admins to install plugins", async () => { const pluginId = "11111111-1111-4111-8111-111111111111"; const pluginKey = "paperclip.example"; const discovered = { manifest: { id: pluginKey, }, }; mockRegistry.getByKey.mockResolvedValue({ id: pluginId, pluginKey, packageName: "paperclip-plugin-example", version: "1.0.0", }); mockRegistry.getById.mockResolvedValue({ id: pluginId, pluginKey, packageName: "paperclip-plugin-example", version: "1.0.0", }); mockLifecycle.load.mockResolvedValue(undefined); const { app, loader } = await createApp( { type: "board", userId: "admin-1", source: "session", isInstanceAdmin: true, companyIds: [], }, { installPlugin: vi.fn().mockResolvedValue(discovered) }, ); const res = await request(app) .post("/api/plugins/install") .send({ packageName: "paperclip-plugin-example" }); expect(res.status).toBe(200); expect(loader.installPlugin).toHaveBeenCalledWith({ packageName: "paperclip-plugin-example", version: undefined, }); expect(mockLifecycle.load).toHaveBeenCalledWith(pluginId); }, 20_000); it("rejects plugin upgrades for non-admin board users", async () => { const pluginId = "11111111-1111-4111-8111-111111111111"; const { app } = await createApp({ type: "board", userId: "user-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], }); const res = await request(app) .post(`/api/plugins/${pluginId}/upgrade`) .send({}); expect(res.status).toBe(403); expect(mockRegistry.getById).not.toHaveBeenCalled(); expect(mockLifecycle.upgrade).not.toHaveBeenCalled(); }, 20_000); it.each([ ["delete", "delete", "/api/plugins/11111111-1111-4111-8111-111111111111", undefined], ["enable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/enable", {}], ["disable", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/disable", {}], ["config", "post", "/api/plugins/11111111-1111-4111-8111-111111111111/config", { configJson: {} }], ] as const)("rejects plugin %s for non-admin board users", async (_name, method, path, body) => { const { app } = await createApp({ type: "board", userId: "user-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], }); const req = method === "delete" ? request(app).delete(path) : request(app).post(path).send(body); const res = await req; expect(res.status).toBe(403); expect(mockRegistry.getById).not.toHaveBeenCalled(); expect(mockRegistry.upsertConfig).not.toHaveBeenCalled(); expect(mockLifecycle.unload).not.toHaveBeenCalled(); expect(mockLifecycle.enable).not.toHaveBeenCalled(); expect(mockLifecycle.disable).not.toHaveBeenCalled(); }, 20_000); it("allows instance admins to upgrade plugins", async () => { const pluginId = "11111111-1111-4111-8111-111111111111"; mockRegistry.getById.mockResolvedValue({ id: pluginId, pluginKey: "paperclip.example", version: "1.0.0", }); mockLifecycle.upgrade.mockResolvedValue({ id: pluginId, version: "1.1.0", }); const { app } = await createApp({ type: "board", userId: "admin-1", source: "session", isInstanceAdmin: true, companyIds: [], }); const res = await request(app) .post(`/api/plugins/${pluginId}/upgrade`) .send({ version: "1.1.0" }); expect(res.status).toBe(200); expect(mockLifecycle.upgrade).toHaveBeenCalledWith(pluginId, "1.1.0"); }, 20_000); }); describe("scoped plugin API routes", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../services/issues.js"); vi.doUnmock("../services/plugin-config-validator.js"); vi.doUnmock("../services/plugin-loader.js"); vi.doUnmock("../services/plugin-registry.js"); vi.doUnmock("../services/plugin-lifecycle.js"); vi.doUnmock("../services/activity-log.js"); vi.doUnmock("../services/live-events.js"); vi.doUnmock("../routes/plugins.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerRouteMocks(); vi.resetAllMocks(); }); it("dispatches manifest-declared scoped routes after company access checks", async () => { const pluginId = "11111111-1111-4111-8111-111111111111"; const workerManager = { call: vi.fn().mockResolvedValue({ status: 202, body: { ok: true }, }), }; mockRegistry.getById.mockResolvedValue(null); mockRegistry.getByKey.mockResolvedValue({ id: pluginId, pluginKey: "paperclip.example", version: "1.0.0", status: "ready", manifestJson: { id: "paperclip.example", capabilities: ["api.routes.register"], apiRoutes: [ { routeKey: "smoke", method: "GET", path: "/smoke", auth: "board-or-agent", capability: "api.routes.register", companyResolution: { from: "query", key: "companyId" }, }, ], }, }); const { app } = await createApp( { type: "board", userId: "admin-1", source: "session", isInstanceAdmin: false, companyIds: ["company-1"], }, {}, { bridgeDeps: { workerManager } }, ); const res = await request(app) .get("/api/plugins/paperclip.example/api/smoke") .query({ companyId: "company-1" }); expect(res.status).toBe(202); expect(res.body).toEqual({ ok: true }); expect(workerManager.call).toHaveBeenCalledWith( pluginId, "handleApiRequest", expect.objectContaining({ routeKey: "smoke", method: "GET", companyId: "company-1", query: { companyId: "company-1" }, }), ); }, 20_000); }); describe("plugin tool and bridge authz", () => { beforeEach(() => { vi.resetModules(); vi.doUnmock("../services/issues.js"); vi.doUnmock("../services/plugin-config-validator.js"); vi.doUnmock("../services/plugin-loader.js"); vi.doUnmock("../services/plugin-registry.js"); vi.doUnmock("../services/plugin-lifecycle.js"); vi.doUnmock("../services/activity-log.js"); vi.doUnmock("../services/live-events.js"); vi.doUnmock("../routes/plugins.js"); vi.doUnmock("../routes/authz.js"); vi.doUnmock("../middleware/index.js"); registerRouteMocks(); vi.resetAllMocks(); }); it("rejects tool execution when the board user cannot access runContext.companyId", async () => { const executeTool = vi.fn(); const getTool = vi.fn(); const { app } = await createApp(boardActor(), {}, { toolDeps: { toolDispatcher: { listToolsForAgent: vi.fn(), getTool, executeTool, }, }, }); const res = await request(app) .post("/api/plugins/tools/execute") .send({ tool: "paperclip.example:search", parameters: {}, runContext: { agentId: agentA, runId: runA, companyId: companyB, projectId: projectA, }, }); expect(res.status).toBe(403); expect(getTool).not.toHaveBeenCalled(); expect(executeTool).not.toHaveBeenCalled(); }); it.each([ [ "agentId", [ [{ companyId: companyB }], ], ], [ "runId company", [ [{ companyId: companyA }], [{ companyId: companyB, agentId: agentA }], ], ], [ "runId agent", [ [{ companyId: companyA }], [{ companyId: companyA, agentId: "77777777-7777-4777-8777-777777777777" }], ], ], [ "projectId", [ [{ companyId: companyA }], [{ companyId: companyA, agentId: agentA }], [{ companyId: companyB }], ], ], ])("rejects tool execution when runContext.%s is outside the company scope", async (_case, rows) => { const executeTool = vi.fn(); const { app } = await createApp(boardActor(), {}, { db: createSelectQueueDb(rows), toolDeps: { toolDispatcher: { listToolsForAgent: vi.fn(), getTool: vi.fn(() => ({ name: "paperclip.example:search" })), executeTool, }, }, }); const res = await request(app) .post("/api/plugins/tools/execute") .send({ tool: "paperclip.example:search", parameters: {}, runContext: { agentId: agentA, runId: runA, companyId: companyA, projectId: projectA, }, }); expect(res.status).toBe(403); expect(executeTool).not.toHaveBeenCalled(); }); it("allows tool execution when agent, run, and project all belong to runContext.companyId", async () => { const executeTool = vi.fn().mockResolvedValue({ content: "ok" }); const { app } = await createApp(boardActor(), {}, { db: createSelectQueueDb([ [{ companyId: companyA }], [{ companyId: companyA, agentId: agentA }], [{ companyId: companyA }], ]), toolDeps: { toolDispatcher: { listToolsForAgent: vi.fn(), getTool: vi.fn(() => ({ name: "paperclip.example:search" })), executeTool, }, }, }); const res = await request(app) .post("/api/plugins/tools/execute") .send({ tool: "paperclip.example:search", parameters: { q: "test" }, runContext: { agentId: agentA, runId: runA, companyId: companyA, projectId: projectA, }, }); expect(res.status).toBe(200); expect(executeTool).toHaveBeenCalledWith( "paperclip.example:search", { q: "test" }, { agentId: agentA, runId: runA, companyId: companyA, projectId: projectA, }, ); }); it.each([ ["legacy data", "post", `/api/plugins/${pluginId}/bridge/data`, { key: "health" }], ["legacy action", "post", `/api/plugins/${pluginId}/bridge/action`, { key: "sync" }], ["url data", "post", `/api/plugins/${pluginId}/data/health`, {}], ["url action", "post", `/api/plugins/${pluginId}/actions/sync`, {}], ] as const)("rejects %s bridge calls without companyId for non-admin users", async (_name, _method, path, body) => { readyPlugin(); const call = vi.fn(); const { app } = await createApp(boardActor(), {}, { bridgeDeps: { workerManager: { call }, }, }); const res = await request(app) .post(path) .send(body); expect(res.status).toBe(403); expect(call).not.toHaveBeenCalled(); }); it("allows omitted-company bridge calls for instance admins as global plugin actions", async () => { readyPlugin(); const call = vi.fn().mockResolvedValue({ ok: true }); const { app } = await createApp(boardActor({ userId: "admin-1", isInstanceAdmin: true, companyIds: [], }), {}, { bridgeDeps: { workerManager: { call }, }, }); const res = await request(app) .post(`/api/plugins/${pluginId}/actions/sync`) .send({}); expect(res.status).toBe(200); expect(call).toHaveBeenCalledWith(pluginId, "performAction", { key: "sync", params: {}, renderEnvironment: null, }); }); it("rejects manual job triggers for non-admin board users", async () => { const scheduler = { triggerJob: vi.fn() }; const jobStore = { getJobByIdForPlugin: vi.fn() }; const { app } = await createApp(boardActor(), {}, { jobDeps: { scheduler, jobStore }, }); const res = await request(app) .post(`/api/plugins/${pluginId}/jobs/job-1/trigger`) .send({}); expect(res.status).toBe(403); expect(scheduler.triggerJob).not.toHaveBeenCalled(); expect(jobStore.getJobByIdForPlugin).not.toHaveBeenCalled(); }, 15_000); it("allows manual job triggers for instance admins", async () => { readyPlugin(); const scheduler = { triggerJob: vi.fn().mockResolvedValue({ runId: "run-1", jobId: "job-1" }) }; const jobStore = { getJobByIdForPlugin: vi.fn().mockResolvedValue({ id: "job-1" }) }; const { app } = await createApp(boardActor({ userId: "admin-1", isInstanceAdmin: true, companyIds: [], }), {}, { jobDeps: { scheduler, jobStore }, }); const res = await request(app) .post(`/api/plugins/${pluginId}/jobs/job-1/trigger`) .send({}); expect(res.status).toBe(200); expect(res.body).toEqual({ runId: "run-1", jobId: "job-1" }); expect(scheduler.triggerJob).toHaveBeenCalledWith("job-1", "manual"); }); });