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(), })); vi.mock("../services/plugin-registry.js", () => ({ pluginRegistryService: () => mockRegistry, })); vi.mock("../services/plugin-lifecycle.js", () => ({ pluginLifecycleManager: () => mockLifecycle, })); vi.mock("../services/activity-log.js", () => ({ logActivity: vi.fn(), })); vi.mock("../services/live-events.js", () => ({ publishGlobalLiveEvent: vi.fn(), })); async function createApp( actor: Record, loaderOverrides: Record = {}, bridgeDeps?: Record, ) { const [{ pluginRoutes }, { errorHandler }] = await Promise.all([ import("../routes/plugins.js"), import("../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({} as never, loader as never, undefined, undefined, undefined, bridgeDeps as never)); app.use(errorHandler); return { app, loader }; } describe("plugin install and upgrade authz", () => { beforeEach(() => { 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.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"], }, {}, { 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); });