import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "bun:test"; import { PLUGIN_INGRESS_MANIFEST_RELPATH, PLUGIN_WEBHOOK_PREFIX, PluginIngressCache, discoverPluginIngress, ingressRoutePaths, parsePluginIngressManifest, pluginWebhookPath, } from "./plugin-ingress.js"; const created: string[] = []; afterEach(() => { while (created.length > 0) { const dir = created.pop(); if (dir) rmSync(dir, { recursive: true, force: true }); } }); function makeWorkspace(): string { const dir = mkdtempSync(join(tmpdir(), "plugin-ingress-")); created.push(dir); return dir; } /** Write a plugin's ingress manifest (raw string, so invalid JSON is testable). */ function writeManifest( workspaceDir: string, plugin: string, contents: string, ): string { const pluginDir = join(workspaceDir, "plugins", plugin); mkdirSync(join(pluginDir, "channels"), { recursive: true }); writeFileSync(join(pluginDir, PLUGIN_INGRESS_MANIFEST_RELPATH), contents); return pluginDir; } const VALID = JSON.stringify({ routes: [ { path: "realtime", kind: "websocket", description: "realtime events" }, ], }); describe("pluginWebhookPath", () => { it("composes under the reserved namespace", () => { expect(pluginWebhookPath("meeting-bot", "realtime")).toBe( `${PLUGIN_WEBHOOK_PREFIX}/meeting-bot/realtime`, ); }); it("normalizes a leading slash rather than emitting a double slash", () => { expect(pluginWebhookPath("acme", "/hook")).toBe( `${PLUGIN_WEBHOOK_PREFIX}/acme/hook`, ); }); }); describe("parsePluginIngressManifest", () => { it("accepts a well-formed declaration", () => { const manifest = parsePluginIngressManifest(JSON.parse(VALID)); expect(manifest.routes).toHaveLength(1); expect(manifest.routes[0]!.path).toBe("realtime"); }); it("rejects an absolute path", () => { expect(() => parsePluginIngressManifest({ routes: [{ path: "/hook", kind: "http", description: "d" }], }), ).toThrow(); }); it("rejects traversal that would clean out of the plugin's namespace", () => { // Velay percent-decodes and path.Cleans before matching its allowlist, // so `../other/steal` would otherwise escape the namespace. for (const path of ["../other/steal", "a/../../b", "./hook"]) { expect(() => parsePluginIngressManifest({ routes: [{ path, kind: "http", description: "d" }], }), ).toThrow(); } }); it("rejects a trailing slash", () => { expect(() => parsePluginIngressManifest({ routes: [{ path: "hook/", kind: "http", description: "d" }], }), ).toThrow(); }); it("rejects query strings and fragments", () => { for (const path of ["hook?x=1", "hook#frag"]) { expect(() => parsePluginIngressManifest({ routes: [{ path, kind: "http", description: "d" }], }), ).toThrow(); } }); it("rejects an unknown transport kind", () => { expect(() => parsePluginIngressManifest({ routes: [{ path: "hook", kind: "grpc", description: "d" }], }), ).toThrow(); }); it("rejects duplicate paths", () => { expect(() => parsePluginIngressManifest({ routes: [ { path: "hook", kind: "http", description: "one" }, { path: "hook", kind: "http", description: "two" }, ], }), ).toThrow(/duplicate route/); }); it("rejects an empty route list", () => { expect(() => parsePluginIngressManifest({ routes: [] })).toThrow(); }); it("strips unknown fields rather than carrying them through", () => { const manifest = parsePluginIngressManifest({ routes: [ { path: "hook", kind: "http", description: "d", auth: "query-token" }, ], }); expect(manifest.routes[0]).not.toHaveProperty("auth"); }); }); describe("discoverPluginIngress", () => { it("returns empty when there is no plugins directory", () => { const workspaceDir = makeWorkspace(); expect(discoverPluginIngress({ workspaceDir })).toEqual({ plugins: [], problems: [], }); }); it("discovers a declaring plugin and composes its paths", () => { const workspaceDir = makeWorkspace(); writeManifest(workspaceDir, "meeting-bot", VALID); const { plugins, problems } = discoverPluginIngress({ workspaceDir }); expect(problems).toEqual([]); expect(plugins).toHaveLength(1); expect(ingressRoutePaths(plugins[0]!)).toEqual([ "/webhooks/plugins/meeting-bot/realtime", ]); }); it("ignores plugins that declare nothing", () => { const workspaceDir = makeWorkspace(); mkdirSync(join(workspaceDir, "plugins", "quiet"), { recursive: true }); writeManifest(workspaceDir, "meeting-bot", VALID); const { plugins } = discoverPluginIngress({ workspaceDir }); expect(plugins.map((p) => p.plugin)).toEqual(["meeting-bot"]); }); it("skips a disabled plugin so its routes do not stay live", () => { const workspaceDir = makeWorkspace(); const pluginDir = writeManifest(workspaceDir, "meeting-bot", VALID); writeFileSync(join(pluginDir, ".disabled"), ""); expect(discoverPluginIngress({ workspaceDir }).plugins).toEqual([]); }); it("reports a malformed declaration without dropping the others", () => { const workspaceDir = makeWorkspace(); writeManifest(workspaceDir, "broken", "{ not json"); writeManifest( workspaceDir, "invalid", JSON.stringify({ routes: [{ path: "/absolute", kind: "http" }] }), ); writeManifest(workspaceDir, "meeting-bot", VALID); const { plugins, problems } = discoverPluginIngress({ workspaceDir }); // One bad manifest must not take down discovery for every plugin. expect(plugins.map((p) => p.plugin)).toEqual(["meeting-bot"]); expect(problems.map((p) => p.plugin).sort()).toEqual(["broken", "invalid"]); }); it("does not trust the manifest's own view of validity", () => { // A plugin repo validating its own file is a convenience for its // authors, not a guarantee to us — traversal is rejected here too. const workspaceDir = makeWorkspace(); writeManifest( workspaceDir, "sneaky", JSON.stringify({ routes: [ { path: "../meeting-bot/realtime", kind: "http", description: "d" }, ], }), ); const { plugins, problems } = discoverPluginIngress({ workspaceDir }); expect(plugins).toEqual([]); expect(problems).toHaveLength(1); }); }); describe("PluginIngressCache", () => { it("serves a cached snapshot within the TTL and re-scans after it", () => { const workspaceDir = makeWorkspace(); const cache = new PluginIngressCache({ workspaceDir, ttlMs: 10_000 }); expect(cache.get().plugins).toEqual([]); writeManifest(workspaceDir, "meeting-bot", VALID); // Still inside the TTL — the new plugin is not visible yet. expect(cache.get().plugins).toEqual([]); // Invalidating stands in for the TTL elapsing, so the test does not // have to sleep. cache.invalidate(); expect(cache.get().plugins.map((p) => p.plugin)).toEqual(["meeting-bot"]); }); it("force re-scans regardless of the TTL", () => { const workspaceDir = makeWorkspace(); const cache = new PluginIngressCache({ workspaceDir, ttlMs: 10_000 }); expect(cache.get().plugins).toEqual([]); writeManifest(workspaceDir, "meeting-bot", VALID); expect(cache.get({ force: true }).plugins.map((p) => p.plugin)).toEqual([ "meeting-bot", ]); }); });