import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; const pluginTable = { findUnique: vi.fn(), findFirst: vi.fn(), findMany: vi.fn(), count: vi.fn(), create: vi.fn(), update: vi.fn(), }; const grantTable = { findMany: vi.fn(), upsert: vi.fn(), deleteMany: vi.fn(), }; const auditCreate = vi.fn().mockResolvedValue({ id: "aud-1" }); const auditOutbox = vi.fn().mockResolvedValue(undefined); const transaction = vi.fn(); vi.mock("~/utils/db.server", () => ({ db: { tenantPluginCapabilityGrant: grantTable, plugin: pluginTable, $transaction: (...args: unknown[]) => transaction(...args), }, })); vi.mock("@toolctrl/db/audit-events.server", () => ({ createAuditEvent: (...args: unknown[]) => auditCreate(...args), publishAuditEventViaOutbox: (...args: unknown[]) => auditOutbox(...args), })); vi.mock("~/utils/logger.server", () => ({ createScopedLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), }), })); function validManifest() { return { schemaVersion: 1, id: "com.acme.hello", version: "1.0.0", name: "Hello", entry: "plugin.wasm", wasmSha256: "a".repeat(64), signature: "b".repeat(128), signedBy: "c".repeat(64), allowedHosts: [], capabilities: [{ name: "log" }], hooks: [{ name: "tenant.created", export: "onTenantCreated" }], }; } const actor = { userId: "u-1", email: "admin@example.com", ip: "127.0.0.1" }; const TRUSTED_KEY = "c".repeat(64); const originalNodeEnv = process.env.NODE_ENV; const originalTrustedEnv = process.env.PLUGIN_TRUSTED_PUBLIC_KEYS; beforeEach(() => { pluginTable.findUnique.mockReset(); pluginTable.findFirst.mockReset(); pluginTable.findMany.mockReset(); pluginTable.count.mockReset(); pluginTable.count.mockResolvedValue(0); // F-009 default: below cap. pluginTable.create.mockReset(); pluginTable.update.mockReset(); grantTable.findMany.mockReset(); grantTable.upsert.mockReset(); grantTable.deleteMany.mockReset(); auditCreate.mockClear(); auditOutbox.mockClear(); transaction.mockReset(); transaction.mockImplementation(async (callback: (tx: unknown) => Promise) => callback({ plugin: { findFirst: (...args: unknown[]) => pluginTable.findFirst(...args), update: (...args: unknown[]) => pluginTable.update(...args), }, tenantPluginCapabilityGrant: { findMany: (...args: unknown[]) => grantTable.findMany(...args), upsert: (...args: unknown[]) => grantTable.upsert(...args), deleteMany: (...args: unknown[]) => grantTable.deleteMany(...args), }, }) ); // F-005 Phase A — the install path now rejects unknown publisher keys. // Seed the allowlist for the tests that exercise the happy path; the // dedicated negative tests below override this. process.env.PLUGIN_TRUSTED_PUBLIC_KEYS = TRUSTED_KEY; // F-009 — explicit cap so tests are not at the mercy of host env. process.env.PLUGIN_MAX_INSTALLS_PER_TENANT = "50"; // F-005 Phase B — skip the on-disk binary verify in tests that don't // exercise it. Dedicated F-005 Phase B tests below explicitly toggle // this to "false" and mock node:fs/promises. process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY = "true"; // F-001 — back-compat default: tests written before consent landed // expect enablePlugin to flip the flag without checking grants. The // dedicated F-001 tests below toggle this to "true" (the production // default). process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT = "false"; }); afterEach(() => { if (originalNodeEnv === undefined) { delete process.env.NODE_ENV; } else { process.env.NODE_ENV = originalNodeEnv; } if (originalTrustedEnv === undefined) { delete process.env.PLUGIN_TRUSTED_PUBLIC_KEYS; } else { process.env.PLUGIN_TRUSTED_PUBLIC_KEYS = originalTrustedEnv; } }); describe("PluginLifecycleService", () => { test("installPlugin parses manifest, writes row + audit", async () => { pluginTable.findUnique.mockResolvedValue(null); pluginTable.create.mockResolvedValue({ id: "pl-1", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), enabled: false, installedAt: new Date("2026-01-01"), updatedAt: new Date("2026-01-01"), }); const { installPlugin } = await import("./PluginLifecycleService.js"); const result = await installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), actor, }); expect(result.pluginId).toBe("com.acme.hello"); expect(result.enabled).toBe(false); expect(pluginTable.create).toHaveBeenCalledOnce(); expect(pluginTable.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ enabled: false }) })); expect(auditCreate).toHaveBeenCalledOnce(); const auditArgs = auditCreate.mock.calls[0]?.[0] as { action: string }; expect(auditArgs.action).toBe("plugin.installed"); }); test("installPlugin rejects already-installed plugin", async () => { pluginTable.findUnique.mockResolvedValue({ id: "existing" }); const { installPlugin, PluginLifecycleError } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), actor, }) ).rejects.toBeInstanceOf(PluginLifecycleError); }); test("installPlugin rejects invalid manifest", async () => { const { installPlugin, PluginLifecycleError } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: { notAManifest: true }, wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), actor, }) ).rejects.toBeInstanceOf(PluginLifecycleError); expect(pluginTable.create).not.toHaveBeenCalled(); }); test("installPlugin rejects manifests with unregistered hook names", async () => { const { installPlugin } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: { ...validManifest(), hooks: [{ name: "workflow.finished", export: "onWorkflowFinished" }], }, wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), actor, }) ).rejects.toMatchObject({ code: "manifest_invalid" }); expect(pluginTable.findUnique).not.toHaveBeenCalled(); expect(pluginTable.create).not.toHaveBeenCalled(); }); test("installPlugin rejects registered hooks that are not production-available", async () => { process.env.NODE_ENV = "production"; process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true"; process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY = "false"; process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT = "true"; const { registerHook } = await import("@toolctrl/plugin-runtime"); const { z } = await import("zod"); registerHook({ name: "billing.paid", outboxEventType: "plugin.hook.billing.paid", payloadSchema: z.object({ invoiceId: z.string() }), }); const { installPlugin } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: { ...validManifest(), hooks: [{ name: "billing.paid", export: "onBillingPaid" }], }, wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), actor, }) ).rejects.toMatchObject({ code: "hook_unavailable" }); expect(pluginTable.findUnique).not.toHaveBeenCalled(); expect(pluginTable.create).not.toHaveBeenCalled(); }); test("installPlugin rejects unavailable host capabilities in production", async () => { process.env.NODE_ENV = "production"; process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true"; process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY = "false"; process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT = "true"; const manifest = { ...validManifest(), capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }], }; const { installPlugin } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: manifest, wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), actor, }) ).rejects.toMatchObject({ code: "capability_unavailable" }); expect(pluginTable.create).not.toHaveBeenCalled(); }); test("enablePlugin flips enabled flag + audits", async () => { pluginTable.findFirst.mockResolvedValue({ id: "pl-1", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), enabled: false, installedAt: new Date(), updatedAt: new Date(), }); pluginTable.update.mockResolvedValue({ id: "pl-1", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), enabled: true, installedAt: new Date(), updatedAt: new Date(), }); const { enablePlugin } = await import("./PluginLifecycleService.js"); const result = await enablePlugin({ tenantId: "t-1", pluginRowId: "pl-1", actor }); expect(result.enabled).toBe(true); expect(transaction).toHaveBeenCalledOnce(); expect(pluginTable.update).toHaveBeenCalledOnce(); expect(auditOutbox).toHaveBeenCalledWith( expect.objectContaining({ plugin: expect.any(Object) }), expect.objectContaining({ action: "plugin.enabled", tenantId: "t-1", targetId: "pl-1", metadata: { pluginId: "com.acme.hello", version: "1.0.0" }, }) ); expect(auditCreate).not.toHaveBeenCalled(); }); test("enablePlugin rolls back when lifecycle audit enqueue fails", async () => { pluginTable.findFirst.mockResolvedValue({ id: "pl-1", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), enabled: false, installedAt: new Date(), updatedAt: new Date(), }); pluginTable.update.mockResolvedValue({ id: "pl-1", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), enabled: true, installedAt: new Date(), updatedAt: new Date(), }); auditOutbox.mockRejectedValueOnce(new Error("outbox unavailable")); const { enablePlugin } = await import("./PluginLifecycleService.js"); await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "pl-1", actor })).rejects.toThrow("outbox unavailable"); expect(transaction).toHaveBeenCalledOnce(); expect(pluginTable.update).toHaveBeenCalledOnce(); expect(auditOutbox).toHaveBeenCalledOnce(); }); test("disablePlugin rejects non-existent rows", async () => { pluginTable.findFirst.mockResolvedValue(null); const { disablePlugin, PluginLifecycleError } = await import("./PluginLifecycleService.js"); await expect(disablePlugin({ tenantId: "t-1", pluginRowId: "missing", actor })).rejects.toBeInstanceOf(PluginLifecycleError); }); test("listPlugins returns mapped items", async () => { pluginTable.findMany.mockResolvedValue([ { id: "pl-1", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), enabled: true, installedAt: new Date(), updatedAt: new Date(), }, ]); const { listPlugins } = await import("./PluginLifecycleService.js"); const items = await listPlugins({ tenantId: "t-1" }); expect(items).toHaveLength(1); expect(items[0]?.pluginId).toBe("com.acme.hello"); }); test("listPlugins rejects rows with invalid stored manifests", async () => { pluginTable.findMany.mockResolvedValue([ { id: "pl-1", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: { hooks: [{ name: "tenant.created", export: "onTenantCreated" }] }, wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), enabled: true, installedAt: new Date(), updatedAt: new Date(), }, ]); const { listPlugins } = await import("./PluginLifecycleService.js"); await expect(listPlugins({ tenantId: "t-1" })).rejects.toMatchObject({ code: "manifest_invalid" }); }); test("getPluginForTenant rejects rows with invalid stored manifests", async () => { pluginTable.findFirst.mockResolvedValue({ id: "pl-1", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: { hooks: [{ name: "tenant.created", export: "onTenantCreated" }] }, wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), enabled: true, installedAt: new Date(), updatedAt: new Date(), }); const { getPluginForTenant } = await import("./PluginLifecycleService.js"); await expect(getPluginForTenant({ tenantId: "t-1", pluginRowId: "pl-1" })).rejects.toMatchObject({ code: "manifest_invalid" }); }); test("listPluginCapabilityGrantsForPlugins batches tenant plugin grants", async () => { const grantedAt = new Date("2026-05-27T10:00:00.000Z"); grantTable.findMany.mockResolvedValue([ { id: "grant-1", tenantId: "t-1", pluginId: "com.acme.hello", capabilityName: "log", scopeJson: null, grantedAt, grantedByUserId: "u-1", }, ]); const { listPluginCapabilityGrantsForPlugins } = await import("./PluginLifecycleService.js"); const rows = await listPluginCapabilityGrantsForPlugins({ tenantId: "t-1", pluginIds: ["com.acme.hello", "com.acme.hello", "com.acme.audit"], }); expect(rows).toHaveLength(1); expect(grantTable.findMany).toHaveBeenCalledWith({ where: { tenantId: "t-1", pluginId: { in: ["com.acme.hello", "com.acme.audit"] } }, orderBy: { grantedAt: "desc" }, }); }); test("listPluginCapabilityGrantsForPlugins skips database reads for empty plugin sets", async () => { const { listPluginCapabilityGrantsForPlugins } = await import("./PluginLifecycleService.js"); await expect(listPluginCapabilityGrantsForPlugins({ tenantId: "t-1", pluginIds: [] })).resolves.toEqual([]); expect(grantTable.findMany).not.toHaveBeenCalled(); }); // F-005 Phase A — install-time trust-store gate. describe("install-time trust-store gate (F-005 Phase A)", () => { test("rejects install when signaturePublicKey is not in PLUGIN_TRUSTED_PUBLIC_KEYS", async () => { pluginTable.findUnique.mockResolvedValue(null); // Override the seeded allowlist so the submitted key is NOT trusted. process.env.PLUGIN_TRUSTED_PUBLIC_KEYS = "d".repeat(64); const { installPlugin, PluginLifecycleError } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), actor, }) ).rejects.toMatchObject({ code: "signature_key_not_trusted", }); expect(pluginTable.create).not.toHaveBeenCalled(); // Sanity: it actually IS a PluginLifecycleError. await expect( installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), actor, }) ).rejects.toBeInstanceOf(PluginLifecycleError); }); test("rejects install when manifest.signedBy does not match signaturePublicKey", async () => { pluginTable.findUnique.mockResolvedValue(null); const manifest = validManifest(); manifest.signedBy = "e".repeat(64); // Trust both keys so this test isolates the mismatch failure. process.env.PLUGIN_TRUSTED_PUBLIC_KEYS = `${TRUSTED_KEY},${"e".repeat(64)}`; const { installPlugin } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: manifest, wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, actor, }) ).rejects.toMatchObject({ code: "signature_key_mismatch", }); expect(pluginTable.create).not.toHaveBeenCalled(); }); test("rejects install when PLUGIN_TRUSTED_PUBLIC_KEYS is empty (fail-closed)", async () => { pluginTable.findUnique.mockResolvedValue(null); delete process.env.PLUGIN_TRUSTED_PUBLIC_KEYS; const { installPlugin } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, actor, }) ).rejects.toMatchObject({ code: "signature_key_not_trusted", }); }); test("accepts install when the key is uppercased in the env (case-insensitive match)", async () => { pluginTable.findUnique.mockResolvedValue(null); pluginTable.create.mockResolvedValue({ id: "pl-2", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), enabled: false, installedAt: new Date("2026-01-01"), updatedAt: new Date("2026-01-01"), }); process.env.PLUGIN_TRUSTED_PUBLIC_KEYS = "C".repeat(64); const { installPlugin } = await import("./PluginLifecycleService.js"); const result = await installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: "c".repeat(64), actor, }); expect(result.pluginId).toBe("com.acme.hello"); }); }); // F-009 — per-tenant install cap. describe("per-tenant install cap (F-009)", () => { test("rejects install when the tenant is at the cap", async () => { pluginTable.findUnique.mockResolvedValue(null); pluginTable.count.mockResolvedValue(50); process.env.PLUGIN_MAX_INSTALLS_PER_TENANT = "50"; const { installPlugin } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, actor, }) ).rejects.toMatchObject({ code: "tenant_install_limit_exceeded", }); expect(pluginTable.create).not.toHaveBeenCalled(); }); test("accepts install just under the cap", async () => { pluginTable.findUnique.mockResolvedValue(null); pluginTable.count.mockResolvedValue(49); pluginTable.create.mockResolvedValue({ id: "pl-edge", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, enabled: false, installedAt: new Date(), updatedAt: new Date(), }); process.env.PLUGIN_MAX_INSTALLS_PER_TENANT = "50"; const { installPlugin } = await import("./PluginLifecycleService.js"); const result = await installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, actor, }); expect(result.pluginId).toBe("com.acme.hello"); }); test("PLUGIN_MAX_INSTALLS_PER_TENANT=0 disables the cap", async () => { pluginTable.findUnique.mockResolvedValue(null); pluginTable.count.mockResolvedValue(10_000); pluginTable.create.mockResolvedValue({ id: "pl-many", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, enabled: false, installedAt: new Date(), updatedAt: new Date(), }); process.env.PLUGIN_MAX_INSTALLS_PER_TENANT = "0"; const { installPlugin } = await import("./PluginLifecycleService.js"); const result = await installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, actor, }); expect(result.pluginId).toBe("com.acme.hello"); // When the cap is disabled the count() short-circuit should NOT // be hit at all. expect(pluginTable.count).not.toHaveBeenCalled(); }); }); // F-005 Phase B — install-time binary signature verify against the // operator-pre-positioned .wasm on disk. describe("install-time binary verify (F-005 Phase B)", () => { let tempDir: string; beforeEach(async () => { const { mkdtemp } = await import("node:fs/promises"); const { tmpdir } = await import("node:os"); const path = await import("node:path"); tempDir = await mkdtemp(path.join(tmpdir(), "plugin-binary-verify-")); process.env.PLUGIN_BINARY_BASE_PATH = tempDir; // Activate the verify path for this describe block. process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY = "false"; }); afterEach(async () => { const { rm } = await import("node:fs/promises"); try { await rm(tempDir, { recursive: true, force: true }); } catch { // best-effort cleanup } delete process.env.PLUGIN_BINARY_BASE_PATH; }); async function writeBinary(pluginId: string, version: string, bytes: Uint8Array) { const { mkdir, writeFile } = await import("node:fs/promises"); const path = await import("node:path"); const dir = path.join(tempDir, pluginId, version); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, "plugin.wasm"), bytes); } test("rejects when the binary is missing on disk", async () => { pluginTable.findUnique.mockResolvedValue(null); const { installPlugin } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, actor, }) ).rejects.toMatchObject({ code: "binary_not_found", }); expect(pluginTable.create).not.toHaveBeenCalled(); }); test("rejects when the binary's sha256 doesn't match the manifest", async () => { pluginTable.findUnique.mockResolvedValue(null); // Write a binary that hashes to something OTHER than the manifest's // declared a-repeat-64 sha. await writeBinary("com.acme.hello", "1.0.0", new TextEncoder().encode("not the real plugin")); const { installPlugin } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, actor, }) ).rejects.toMatchObject({ code: "binary_verification_failed", }); }); test("rejects when submitted wasmSha256 doesn't match manifest.wasmSha256", async () => { pluginTable.findUnique.mockResolvedValue(null); const manifest = validManifest(); // Compute the actual sha256 of our bytes and put that in the // manifest, then submit a DIFFERENT sha to the install API. const { createHash } = await import("node:crypto"); const bytes = new TextEncoder().encode("test-bytes"); const sha = createHash("sha256").update(bytes).digest("hex"); manifest.wasmSha256 = sha; await writeBinary("com.acme.hello", "1.0.0", bytes); const { installPlugin } = await import("./PluginLifecycleService.js"); // The verify will pass binary-side (sha matches manifest), but the // submitted wasmSha256 disagrees with the manifest's so we reject. await expect( installPlugin({ tenantId: "t-1", manifestJson: manifest, wasmSha256: "z".repeat(64), signaturePublicKey: TRUSTED_KEY, actor, }) ).rejects.toMatchObject({ code: "binary_verification_failed", }); }); test("PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true bypasses the verify (dev escape hatch)", async () => { pluginTable.findUnique.mockResolvedValue(null); pluginTable.create.mockResolvedValue({ id: "pl-1", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, enabled: false, installedAt: new Date(), updatedAt: new Date(), }); process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY = "true"; // Don't write a binary — the install must still succeed because // the verify path is skipped. const { installPlugin } = await import("./PluginLifecycleService.js"); const result = await installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, actor, }); expect(result.pluginId).toBe("com.acme.hello"); }); test("PLUGIN_INSTALL_SKIP_BINARY_VERIFY=true is rejected in production", async () => { process.env.NODE_ENV = "production"; process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY = "true"; const { installPlugin } = await import("./PluginLifecycleService.js"); await expect( installPlugin({ tenantId: "t-1", manifestJson: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, actor, }) ).rejects.toMatchObject({ code: "production_guard_violation", }); expect(pluginTable.create).not.toHaveBeenCalled(); }); }); // F-001 — explicit per-tenant capability consent. describe("capability consent (F-001)", () => { const installedRow = { id: "pl-1", tenantId: "t-1", pluginId: "com.acme.hello", version: "1.0.0", manifest: validManifest(), wasmSha256: "a".repeat(64), signaturePublicKey: TRUSTED_KEY, enabled: false, installedAt: new Date(), updatedAt: new Date(), }; beforeEach(() => { grantTable.findMany.mockReset(); grantTable.upsert.mockReset(); grantTable.deleteMany.mockReset(); grantTable.findMany.mockResolvedValue([]); // default: no grants // Activate consent enforcement for these tests. process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT = "true"; }); test("enablePlugin rejects when no grants exist for declared capabilities", async () => { pluginTable.findFirst.mockResolvedValue(installedRow); const { enablePlugin } = await import("./PluginLifecycleService.js"); await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "pl-1", actor })).rejects.toMatchObject({ code: "capability_consent_missing", }); expect(pluginTable.update).not.toHaveBeenCalled(); }); test("enablePlugin succeeds once every declared capability has a grant", async () => { pluginTable.findFirst.mockResolvedValue(installedRow); grantTable.findMany.mockResolvedValue([ { id: "g-1", tenantId: "t-1", pluginId: "com.acme.hello", capabilityName: "log", scopeJson: null, grantedAt: new Date(), grantedByUserId: "u-1" }, { id: "g-2", tenantId: "t-1", pluginId: "com.acme.hello", capabilityName: "hook:tenant.created", scopeJson: null, grantedAt: new Date(), grantedByUserId: "u-1", }, ]); pluginTable.update.mockResolvedValue({ ...installedRow, enabled: true }); const { enablePlugin } = await import("./PluginLifecycleService.js"); const result = await enablePlugin({ tenantId: "t-1", pluginRowId: "pl-1", actor }); expect(result.enabled).toBe(true); }); test("enablePlugin requires capability grants from the enable transaction snapshot", async () => { pluginTable.findFirst.mockResolvedValue(installedRow); grantTable.findMany.mockResolvedValue([ { id: "g-outer", tenantId: "t-1", pluginId: "com.acme.hello", capabilityName: "log", scopeJson: null, grantedAt: new Date(), grantedByUserId: "u-1" }, { id: "g-hook-outer", tenantId: "t-1", pluginId: "com.acme.hello", capabilityName: "hook:tenant.created", scopeJson: null, grantedAt: new Date(), grantedByUserId: "u-1", }, ]); transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise) => callback({ plugin: { findFirst: (...args: unknown[]) => pluginTable.findFirst(...args), update: (...args: unknown[]) => pluginTable.update(...args), }, tenantPluginCapabilityGrant: { findMany: vi.fn().mockResolvedValue([]), upsert: (...args: unknown[]) => grantTable.upsert(...args), deleteMany: (...args: unknown[]) => grantTable.deleteMany(...args), }, }) ); const { enablePlugin } = await import("./PluginLifecycleService.js"); await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "pl-1", actor })).rejects.toMatchObject({ code: "capability_consent_missing", }); expect(pluginTable.update).not.toHaveBeenCalled(); }); test("grantPluginCapabilities upserts one row per requested capability", async () => { pluginTable.findUnique.mockResolvedValue(installedRow); grantTable.upsert.mockImplementation(async (args: { create: { capabilityName: string } }) => ({ id: "g-mock", tenantId: "t-1", pluginId: "com.acme.hello", capabilityName: args.create.capabilityName, scopeJson: null, grantedAt: new Date(), grantedByUserId: "u-1", })); const { grantPluginCapabilities } = await import("./PluginLifecycleService.js"); const result = await grantPluginCapabilities({ tenantId: "t-1", pluginId: "com.acme.hello", grants: [{ capabilityName: "log" }], actor, }); expect(result).toHaveLength(1); expect(grantTable.upsert).toHaveBeenCalledTimes(1); expect(transaction).toHaveBeenCalledOnce(); expect(auditOutbox.mock.calls.some(([, args]: [unknown, { action: string }]) => args.action === "plugin.capabilities.granted")).toBe(true); }); test("grantPluginCapabilities records nonrepudiable review metadata without manifest body", async () => { const manifest = { ...validManifest(), capabilities: [{ name: "log" }, { name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }], }; pluginTable.findUnique.mockResolvedValue({ ...installedRow, manifest, version: "1.2.3" }); grantTable.upsert.mockImplementation(async (args: { create: { capabilityName: string; scopeJson?: unknown } }) => ({ id: `g-${args.create.capabilityName}`, tenantId: "t-1", pluginId: "com.acme.hello", capabilityName: args.create.capabilityName, scopeJson: args.create.scopeJson ?? null, grantedAt: new Date(), grantedByUserId: "u-1", })); const { grantPluginCapabilities } = await import("./PluginLifecycleService.js"); await grantPluginCapabilities({ tenantId: "t-1", pluginId: "com.acme.hello", grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }, { capabilityName: "log" }], actor, capabilityReviewDigest: "digest_123", }); const auditArgs = auditOutbox.mock.calls.find(([, args]: [unknown, { action: string }]) => args.action === "plugin.capabilities.granted")?.[1] as { metadata: Record; }; expect(auditArgs.metadata).toEqual({ pluginId: "com.acme.hello", version: "1.2.3", capabilityReviewDigest: "digest_123", grantedCapabilities: ["http.outbound", "log"], grants: [ { capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }, { capabilityName: "log", scopeJson: null }, ], manifestCapabilities: [ { capabilityName: "http.outbound", declaredScope: { allowedHosts: ["api.example.com"] }, kind: "capability" }, { capabilityName: "log", declaredScope: null, kind: "capability" }, ], }); expect(JSON.stringify(auditArgs.metadata)).not.toContain("hooks"); expect(JSON.stringify(auditArgs.metadata)).not.toContain("signature"); }); test("grantPluginCapabilities rejects capabilities not declared in the manifest", async () => { pluginTable.findUnique.mockResolvedValue(installedRow); const { grantPluginCapabilities } = await import("./PluginLifecycleService.js"); await expect( grantPluginCapabilities({ tenantId: "t-1", pluginId: "com.acme.hello", grants: [{ capabilityName: "db.write" }], // not in manifest actor, }) ).rejects.toMatchObject({ code: "capability_not_in_manifest", }); expect(grantTable.upsert).not.toHaveBeenCalled(); }); test("grantPluginCapabilities rejects unavailable host bindings in production", async () => { process.env.NODE_ENV = "production"; process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true"; const manifest = { ...validManifest(), capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }], }; pluginTable.findUnique.mockResolvedValue({ ...installedRow, manifest }); const { grantPluginCapabilities } = await import("./PluginLifecycleService.js"); await expect( grantPluginCapabilities({ tenantId: "t-1", pluginId: "com.acme.hello", grants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }], actor, }) ).rejects.toMatchObject({ code: "capability_unavailable", }); expect(grantTable.upsert).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled(); }); test("grantPluginCapabilities rejects broadened runtime capability scopes", async () => { const scopedManifest = { ...validManifest(), capabilities: [ { name: "secret.read", scope: { keyPattern: "stripe_live_*" } }, { name: "db.write", scope: { tablePrefix: "plugin_" } }, ], }; pluginTable.findUnique.mockResolvedValue({ ...installedRow, manifest: scopedManifest }); const { grantPluginCapabilities } = await import("./PluginLifecycleService.js"); await expect( grantPluginCapabilities({ tenantId: "t-1", pluginId: "com.acme.hello", grants: [{ capabilityName: "secret.read", scopeJson: { keyPattern: "stripe_*" } }], actor, }) ).rejects.toMatchObject({ code: "capability_scope_invalid", }); await expect( grantPluginCapabilities({ tenantId: "t-1", pluginId: "com.acme.hello", grants: [{ capabilityName: "db.write", scopeJson: { tablePrefix: "tenant_" } }], actor, }) ).rejects.toMatchObject({ code: "capability_scope_invalid", }); expect(grantTable.upsert).not.toHaveBeenCalled(); }); test("enablePlugin rejects stale grants that broaden runtime capability scopes", async () => { const scopedManifest = { ...validManifest(), capabilities: [{ name: "secret.read", scope: { keyPattern: "stripe_live_*" } }], }; pluginTable.findFirst.mockResolvedValue({ ...installedRow, manifest: scopedManifest }); grantTable.findMany.mockResolvedValue([ { id: "g-1", tenantId: "t-1", pluginId: "com.acme.hello", capabilityName: "secret.read", scopeJson: { keyPattern: "stripe_*" }, grantedAt: new Date(), grantedByUserId: "u-1", }, ]); const { enablePlugin } = await import("./PluginLifecycleService.js"); await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "pl-1", actor })).rejects.toMatchObject({ code: "capability_consent_missing", }); expect(pluginTable.update).not.toHaveBeenCalled(); }); test("enablePlugin rejects unavailable host bindings in production even with matching grants", async () => { process.env.NODE_ENV = "production"; process.env.PLUGIN_RUNTIME_PRODUCTION_ENABLED = "true"; process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY = "false"; process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT = "true"; const manifest = { ...validManifest(), capabilities: [{ name: "secret.read", scope: { keyPattern: "stripe_live_*" } }], }; pluginTable.findFirst.mockResolvedValue({ ...installedRow, manifest }); grantTable.findMany.mockResolvedValue([ { id: "g-1", tenantId: "t-1", pluginId: "com.acme.hello", capabilityName: "secret.read", scopeJson: { keyPattern: "stripe_live_*" }, grantedAt: new Date(), grantedByUserId: "u-1", }, ]); const { enablePlugin } = await import("./PluginLifecycleService.js"); await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "pl-1", actor })).rejects.toMatchObject({ code: "capability_unavailable", }); expect(grantTable.findMany).not.toHaveBeenCalled(); expect(pluginTable.update).not.toHaveBeenCalled(); }); test("revokePluginCapabilities dedupes requested capabilities and auto-disables enabled plugins", async () => { pluginTable.findUnique.mockResolvedValue({ ...installedRow, enabled: true }); pluginTable.update.mockResolvedValue({ ...installedRow, enabled: false }); grantTable.deleteMany.mockResolvedValue({ count: 1 }); const { revokePluginCapabilities } = await import("./PluginLifecycleService.js"); await expect( revokePluginCapabilities({ tenantId: "t-1", pluginId: "com.acme.hello", capabilityNames: ["log", "log", ""], actor, }) ).resolves.toEqual({ revokedCapabilities: ["log"], autoDisabled: true }); expect(pluginTable.update).toHaveBeenCalledWith({ where: { id: "pl-1" }, data: { enabled: false }, }); expect(grantTable.deleteMany).toHaveBeenCalledWith({ where: { tenantId: "t-1", pluginId: "com.acme.hello", capabilityName: { in: ["log"] }, }, }); expect(transaction).toHaveBeenCalledOnce(); expect( auditOutbox.mock.calls.some(([, args]: [unknown, { metadata: unknown }]) => { return ( JSON.stringify(args.metadata) === JSON.stringify({ pluginId: "com.acme.hello", revokedCapabilities: ["log"], autoDisabled: true, }) ); }) ).toBe(true); }); test("disablePlugin works without grants (kill-switch parity)", async () => { pluginTable.findFirst.mockResolvedValue({ ...installedRow, enabled: true }); pluginTable.update.mockResolvedValue({ ...installedRow, enabled: false }); const { disablePlugin } = await import("./PluginLifecycleService.js"); const result = await disablePlugin({ tenantId: "t-1", pluginRowId: "pl-1", actor }); expect(result.enabled).toBe(false); // assertConsentSatisfied must NOT run on disable. expect(grantTable.findMany).not.toHaveBeenCalled(); }); test("PLUGIN_REQUIRE_CAPABILITY_CONSENT=false bypasses the gate (back-compat)", async () => { process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT = "false"; pluginTable.findFirst.mockResolvedValue(installedRow); pluginTable.update.mockResolvedValue({ ...installedRow, enabled: true }); const { enablePlugin } = await import("./PluginLifecycleService.js"); const result = await enablePlugin({ tenantId: "t-1", pluginRowId: "pl-1", actor }); expect(result.enabled).toBe(true); expect(grantTable.findMany).not.toHaveBeenCalled(); }); test("PLUGIN_REQUIRE_CAPABILITY_CONSENT=false is rejected in production", async () => { process.env.NODE_ENV = "production"; process.env.PLUGIN_INSTALL_SKIP_BINARY_VERIFY = "false"; process.env.PLUGIN_REQUIRE_CAPABILITY_CONSENT = "false"; const { enablePlugin } = await import("./PluginLifecycleService.js"); await expect(enablePlugin({ tenantId: "t-1", pluginRowId: "pl-1", actor })).rejects.toMatchObject({ code: "production_guard_violation", }); expect(pluginTable.findFirst).not.toHaveBeenCalled(); expect(pluginTable.update).not.toHaveBeenCalled(); }); }); });