Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/modules/plugins/services/.server/PluginLifecycleService.test.ts
T

1105 lines
41 KiB
TypeScript

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<unknown>) =>
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<unknown>) =>
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<string, unknown>;
};
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();
});
});
});