1105 lines
41 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|