/** * Two real plugins on disk, one providing a service and one requiring it, * driven through the actual PluginLoader. * * The service ("testplugin.greet") and the permission gating it * ("testplugin.greet.use") are fixture-owned, so nothing about the shipped * plugins is under test here even though the fixtures borrow their ids. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import fs from "node:fs"; import path from "node:path"; import type { Fixture } from "./fixture-plugin.js"; const state = vi.hoisted(() => ({ /** Permissions the acting user currently holds. Mutated mid-test. */ granted: new Set(), auditEntries: [] as Record[], /** Records every delegated call, to prove the gate runs before the provider. */ providerCalls: [] as string[], })); vi.mock("../../utils/logger.js", () => ({ pluginLogger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), success: vi.fn(), }, })); vi.mock("../../utils/audit-logger.js", () => ({ logAudit: vi.fn(async (params: Record) => { state.auditEntries.push(params); }), getAuditUsername: vi.fn(async (userId: string) => `user:${userId}`), })); vi.mock("../../utils/permission-manager.js", () => ({ PermissionManager: { getInstance: () => ({ hasPermission: async (_userId: string, permission: string) => state.granted.has(permission), invalidateUserPermissionCache: vi.fn(), }), }, })); const { PluginLoader } = await import("../../plugins/loader.js"); const { clearServiceRegistry, getRegistration, PluginServicePermissionError, PluginServiceUnavailableError, } = await import("../../plugins/service-registry.js"); const { pluginEvents } = await import("../../plugins/events.js"); const { createFixturePlugin } = await import("./fixture-plugin.js"); const SERVICE = "testplugin.greet"; // Registered as ., so it carries the provider fixture id. const PERMISSION = "ssh-terminal.greet.use"; /** * The provider publishes a greet service and records every delegated call to * a file, so the test can prove a denied call never reached it. A module-level * variable would not work: the plugin is imported by URL into this process and * lives in its own ESM module instance. */ function providerSource(callLog: string): string { const literal = JSON.stringify(callLog); return ` import fs from "node:fs"; export async function activate(ctx) { ctx.services.provide(${JSON.stringify(SERVICE)}, { hello: async (name) => { fs.appendFileSync(${literal}, name + "\\n"); return "hello " + name; }, }); } `; } /** * The consumer takes a handle at activation and stashes it on globalThis so * the test can call it as a specific user afterwards. */ const CONSUMER_SOURCE = ` export async function activate(ctx) { globalThis.__consumerHandle = ctx.services.get(${JSON.stringify(SERVICE)}); } `; function providerFixture(): Fixture { return createFixturePlugin({ id: "ssh-terminal", capabilities: ["kv:own"], manifestOverrides: { category: "Terminal", provides: [ { service: SERVICE, version: "1.2.0", permission: PERMISSION }, ], contributes: { permissions: [ { name: "greet.use", titleKey: "permissions.greet.use.title", descriptionKey: "permissions.greet.use.description", }, ], }, }, }); } function consumerFixture( requires: Array> = [ { service: SERVICE, versionRange: "^1.2.0" }, ], ): Fixture { return createFixturePlugin({ id: "docker", capabilities: ["kv:own"], backendSource: CONSUMER_SOURCE, manifestOverrides: { category: "Infrastructure", requires }, }); } type GreetHandle = { hello: (name: string) => Promise; asUser: (userId: string) => { hello: (name: string) => Promise }; }; function handle(): GreetHandle { return (globalThis as Record) .__consumerHandle as GreetHandle; } describe("plugin service contracts", () => { let provider: Fixture | null = null; let consumer: Fixture | null = null; let loader: InstanceType | null = null; let callLog = ""; beforeEach(() => { state.granted = new Set([PERMISSION]); state.auditEntries = []; state.providerCalls = []; clearServiceRegistry(); pluginEvents.clear(); }); afterEach(async () => { await loader?.shutdown(); loader = null; provider?.cleanup(); consumer?.cleanup(); provider = null; consumer = null; delete (globalThis as Record).__consumerHandle; clearServiceRegistry(); pluginEvents.clear(); }); async function activateBoth( consumerRequires?: Array>, ) { provider = providerFixture(); callLog = path.join(provider.root, "calls.txt"); fs.writeFileSync( path.join(provider.dir, "backend", "index.mjs"), providerSource(callLog), ); consumer = consumerRequires ? consumerFixture(consumerRequires) : consumerFixture(); loader = new PluginLoader(); const providerPlugin = await loader.load(provider.dir); await loader.activate(providerPlugin.id); const consumerPlugin = await loader.load(consumer.dir); await loader.activate(consumerPlugin.id); return { providerPlugin, consumerPlugin }; } function providerCallCount(): number { if (!fs.existsSync(callLog)) return 0; return fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean) .length; } it("resolves structurally when both plugins are active", async () => { const { providerPlugin, consumerPlugin } = await activateBoth(); expect(providerPlugin.state).toBe("active"); expect(consumerPlugin.state).toBe("active"); const registration = getRegistration(SERVICE); expect(registration?.version).toBe("1.2.0"); expect(registration?.pluginId).toBe("ssh-terminal"); expect(registration?.permission).toBe(PERMISSION); }); it("fails activation when a hard requirement is unsatisfied", async () => { consumer = consumerFixture([{ service: SERVICE, versionRange: "^9.0.0" }]); loader = new PluginLoader(); const plugin = await loader.load(consumer.dir); await expect(loader.activate(plugin.id)).rejects.toThrow( /no active plugin provides|provides 1\.2\.0/, ); expect(plugin.state).toBe("failed"); }); it("activates anyway when an unsatisfied requirement is optional", async () => { consumer = consumerFixture([ { service: "testplugin.absent", versionRange: "^1.0.0", optional: true }, ]); loader = new PluginLoader(); const plugin = await loader.load(consumer.dir); await loader.activate(plugin.id); expect(plugin.state).toBe("active"); }); it("allows a call from a user who holds the permission", async () => { await activateBoth(); await expect(handle().asUser("alice").hello("world")).resolves.toBe( "hello world", ); expect(providerCallCount()).toBe(1); const entry = state.auditEntries.at(-1); expect(entry?.success).toBe(true); expect(entry?.username).toBe("plugin:docker"); expect(entry?.userId).toBe("alice"); expect(entry?.action).toBe("plugin_service_testplugin_greet_hello"); expect(entry?.resourceId).toBe("ssh-terminal"); }); it("denies a user without the permission, with the standard shape", async () => { await activateBoth(); state.granted.delete(PERMISSION); await handle() .asUser("mallory") .hello("world") .then( () => expect.unreachable("should have been denied"), (error: InstanceType) => { expect(error.name).toBe("PluginServicePermissionError"); expect(error.status).toBe(403); expect(error.body).toEqual({ error: "Insufficient permissions", required: PERMISSION, }); expect(error.message).toContain("do not have access"); }, ); // The gate runs before delegation: the provider never saw the call. expect(providerCallCount()).toBe(0); const entry = state.auditEntries.at(-1); expect(entry?.success).toBe(false); expect(entry?.userId).toBe("mallory"); expect(String(entry?.errorMessage)).toContain(PERMISSION); }); it("honours a revoke mid-session on the very next call", async () => { await activateBoth(); const live = handle().asUser("alice"); await expect(live.hello("first")).resolves.toBe("hello first"); // No restart, no reinstall, same handle: the check is per call, which is // exactly why it lives in the proxy rather than at handle creation. state.granted.delete(PERMISSION); await expect(live.hello("second")).rejects.toThrow( PluginServicePermissionError, ); expect(providerCallCount()).toBe(1); // And granting it back takes effect just as immediately. state.granted.add(PERMISSION); await expect(live.hello("third")).resolves.toBe("hello third"); expect(providerCallCount()).toBe(2); }); it("surfaces a clear error once the provider is deactivated, without tearing the consumer down", async () => { const { consumerPlugin } = await activateBoth(); const live = handle().asUser("alice"); await expect(live.hello("before")).resolves.toBe("hello before"); await loader!.deactivate("ssh-terminal"); expect(getRegistration(SERVICE)).toBeUndefined(); // Documented behaviour: the consumer stays active and decides for itself // what to do. Deactivating one plugin must not silently stop others. expect(consumerPlugin.state).toBe("active"); let thrown: unknown; try { await live.hello("after"); } catch (error) { thrown = error; } expect(thrown).toBeInstanceOf(PluginServiceUnavailableError); expect((thrown as { code: string }).code).toBe("EPLUGINSVCGONE"); expect((thrown as Error).message).toContain(SERVICE); expect(providerCallCount()).toBe(1); }); it("refuses to provide a service the manifest never declared", async () => { provider = createFixturePlugin({ id: "ssh-terminal", capabilities: ["kv:own"], backendSource: ` export async function activate(ctx) { ctx.services.provide("testplugin.undeclared", { hello: async () => "hi" }); } `, manifestOverrides: { category: "Terminal" }, }); loader = new PluginLoader(); const plugin = await loader.load(provider.dir); await expect(loader.activate(plugin.id)).rejects.toThrow( /not declared in the manifest's provides array/, ); }); });