426 lines
16 KiB
TypeScript
426 lines
16 KiB
TypeScript
import { describe, expect, test, vi } from "vitest";
|
|
import type { PluginCapabilityGrant, PluginManifest } from "@toolctrl/plugin-runtime";
|
|
import { handlePluginInvokeJob, type PluginInvokeDeps, type PluginInvokeJobPayload, type PluginInvokeRow } from "./plugin-invoke.js";
|
|
|
|
function manifest(): PluginManifest {
|
|
return {
|
|
schemaVersion: 1,
|
|
id: "de.toolctrl.test",
|
|
version: "1.0.0",
|
|
name: "Test",
|
|
entry: "plugin.wasm",
|
|
wasmSha256: "0".repeat(64),
|
|
signature: "0".repeat(128),
|
|
signedBy: "0".repeat(64),
|
|
allowedHosts: [],
|
|
capabilities: [{ name: "log" }],
|
|
hooks: [{ name: "ping", export: "ping" }],
|
|
};
|
|
}
|
|
|
|
function hookOnlyManifest(): PluginManifest {
|
|
return {
|
|
...manifest(),
|
|
capabilities: [],
|
|
hooks: [{ name: "tenant.created", export: "onTenantCreated" }],
|
|
};
|
|
}
|
|
|
|
function outboundManifest(): PluginManifest {
|
|
return {
|
|
...manifest(),
|
|
capabilities: [{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }],
|
|
};
|
|
}
|
|
|
|
function row(): PluginInvokeRow {
|
|
return {
|
|
tenantId: "t-1",
|
|
pluginId: "de.toolctrl.test",
|
|
version: "1.0.0",
|
|
manifest: manifest(),
|
|
wasmSha256: "0".repeat(64),
|
|
signaturePublicKey: "0".repeat(64),
|
|
enabled: true,
|
|
};
|
|
}
|
|
|
|
function grants(): PluginCapabilityGrant[] {
|
|
return [
|
|
{ capabilityName: "log", scopeJson: null },
|
|
{ capabilityName: "hook:ping", scopeJson: null },
|
|
];
|
|
}
|
|
|
|
function payload(): PluginInvokeJobPayload {
|
|
return {
|
|
tenantId: "t-1",
|
|
pluginId: "de.toolctrl.test",
|
|
version: "1.0.0",
|
|
hookName: "ping",
|
|
input: { x: 1 },
|
|
};
|
|
}
|
|
|
|
function defaultDeps(overrides: Partial<PluginInvokeDeps> = {}): PluginInvokeDeps {
|
|
return {
|
|
loadPlugin: vi.fn().mockResolvedValue(row()),
|
|
loadCapabilityGrants: vi.fn().mockResolvedValue(grants()),
|
|
loadBinary: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])),
|
|
verifySignature: vi.fn().mockReturnValue({ ok: true }),
|
|
consumeRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 99 }),
|
|
invokeInSandbox: vi.fn().mockResolvedValue({ ok: true, output: "pong", fuelUsed: 5 }),
|
|
recordAudit: vi.fn().mockResolvedValue(undefined),
|
|
trustedPublicKeysHex: ["0".repeat(64)],
|
|
now: (() => {
|
|
let n = 1_000;
|
|
return () => (n += 10);
|
|
})(),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("handlePluginInvokeJob", () => {
|
|
test("happy path returns the sandbox output and records audit", async () => {
|
|
const deps = defaultDeps();
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(result.pluginInvocationId).toMatch(/^[0-9a-f-]{36}$/);
|
|
if (result.ok) expect(result.output).toBe("pong");
|
|
expect(deps.recordAudit).toHaveBeenCalledTimes(1);
|
|
expect(deps.invokeInSandbox).toHaveBeenCalledTimes(1);
|
|
expect(deps.loadCapabilityGrants).toHaveBeenCalledWith({
|
|
tenantId: "t-1",
|
|
pluginId: "de.toolctrl.test",
|
|
});
|
|
const sandboxArgs = vi.mocked(deps.invokeInSandbox).mock.calls[0]?.[0];
|
|
const auditArgs = vi.mocked(deps.recordAudit).mock.calls[0]?.[0];
|
|
expect(sandboxArgs?.pluginInvocationId).toBe(result.pluginInvocationId);
|
|
expect(sandboxArgs?.capabilityGrants).toEqual([
|
|
{ capabilityName: "log", scopeJson: null },
|
|
{ capabilityName: "hook:ping", scopeJson: null },
|
|
]);
|
|
expect(auditArgs?.pluginInvocationId).toBe(result.pluginInvocationId);
|
|
expect(auditArgs?.outcome.pluginInvocationId).toBe(result.pluginInvocationId);
|
|
});
|
|
|
|
test("returns plugin_not_found when the row is missing", async () => {
|
|
const deps = defaultDeps({ loadPlugin: vi.fn().mockResolvedValue(null) });
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("plugin_not_found");
|
|
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
|
expect(deps.recordAudit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("returns plugin_disabled when the row is disabled", async () => {
|
|
const deps = defaultDeps({ loadPlugin: vi.fn().mockResolvedValue({ ...row(), enabled: false }) });
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("plugin_disabled");
|
|
expect(deps.loadCapabilityGrants).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects queue hook names that are not declared as manifest exports", async () => {
|
|
const deps = defaultDeps();
|
|
const result = await handlePluginInvokeJob({ ...payload(), hookName: "rogueExport" }, deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("hook_not_declared");
|
|
expect(result.message).toBe('Hook export "rogueExport" is not declared by plugin manifest.');
|
|
}
|
|
expect(deps.loadCapabilityGrants).not.toHaveBeenCalled();
|
|
expect(deps.loadBinary).not.toHaveBeenCalled();
|
|
expect(deps.consumeRateLimit).not.toHaveBeenCalled();
|
|
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
|
expect(deps.recordAudit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("allows manifest hook exports even when logical hook names differ", async () => {
|
|
const deps = defaultDeps({
|
|
loadPlugin: vi.fn().mockResolvedValue({
|
|
...row(),
|
|
manifest: { ...manifest(), hooks: [{ name: "tenant.created", export: "onTenantCreated" }] },
|
|
}),
|
|
loadCapabilityGrants: vi.fn().mockResolvedValue([
|
|
{ capabilityName: "log", scopeJson: null },
|
|
{ capabilityName: "hook:tenant.created", scopeJson: null },
|
|
]),
|
|
});
|
|
const result = await handlePluginInvokeJob(
|
|
{
|
|
...payload(),
|
|
hookName: "onTenantCreated",
|
|
metadata: { logicalHookName: "tenant.created" },
|
|
input: { tenantId: "t-1", slug: "acme", ownerEmail: "owner@example.com", createdAt: "2026-05-28T10:00:00.000Z" },
|
|
},
|
|
deps
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(deps.invokeInSandbox).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
hookName: "onTenantCreated",
|
|
payload: expect.objectContaining({ tenantId: "t-1", slug: "acme" }),
|
|
})
|
|
);
|
|
});
|
|
|
|
test("returns capability_grant_missing before loading the binary", async () => {
|
|
const deps = defaultDeps({ loadCapabilityGrants: vi.fn().mockResolvedValue([]) });
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("capability_grant_missing");
|
|
expect(result.message).toContain("log");
|
|
}
|
|
expect(deps.loadBinary).not.toHaveBeenCalled();
|
|
expect(deps.consumeRateLimit).not.toHaveBeenCalled();
|
|
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
|
expect(deps.recordAudit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("treats stale grant scopes as missing consent", async () => {
|
|
const deps = defaultDeps({
|
|
loadPlugin: vi.fn().mockResolvedValue({ ...row(), manifest: outboundManifest() }),
|
|
loadCapabilityGrants: vi
|
|
.fn()
|
|
.mockResolvedValue([{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com", "internal.example"] } }]),
|
|
});
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("capability_grant_missing");
|
|
expect(result.message).toContain("http.outbound");
|
|
}
|
|
expect(deps.loadBinary).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("requires hook grants even when the manifest declares no capabilities", async () => {
|
|
const deps = defaultDeps({
|
|
loadPlugin: vi.fn().mockResolvedValue({ ...row(), manifest: hookOnlyManifest() }),
|
|
loadCapabilityGrants: vi.fn().mockResolvedValue([]),
|
|
});
|
|
const result = await handlePluginInvokeJob({ ...payload(), hookName: "onTenantCreated", metadata: { logicalHookName: "tenant.created" } }, deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("capability_grant_missing");
|
|
expect(result.message).toContain("hook:tenant.created");
|
|
}
|
|
expect(deps.loadCapabilityGrants).toHaveBeenCalledWith({ tenantId: "t-1", pluginId: "de.toolctrl.test" });
|
|
expect(deps.loadBinary).not.toHaveBeenCalled();
|
|
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("runs hook-only plugins when the logical hook grant is present", async () => {
|
|
const deps = defaultDeps({
|
|
loadPlugin: vi.fn().mockResolvedValue({ ...row(), manifest: hookOnlyManifest() }),
|
|
loadCapabilityGrants: vi.fn().mockResolvedValue([{ capabilityName: "hook:tenant.created", scopeJson: null }]),
|
|
});
|
|
const result = await handlePluginInvokeJob(
|
|
{
|
|
...payload(),
|
|
hookName: "onTenantCreated",
|
|
metadata: { logicalHookName: "tenant.created" },
|
|
input: { tenantId: "t-1", slug: "acme", plan: null, createdAt: "2026-05-28T10:00:00.000Z" },
|
|
},
|
|
deps
|
|
);
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(deps.invokeInSandbox).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
hookName: "onTenantCreated",
|
|
capabilityGrants: [{ capabilityName: "hook:tenant.created", scopeJson: null }],
|
|
})
|
|
);
|
|
});
|
|
|
|
test("rejects invalid registered hook payloads before loading the binary", async () => {
|
|
const deps = defaultDeps({
|
|
loadPlugin: vi.fn().mockResolvedValue({ ...row(), manifest: hookOnlyManifest() }),
|
|
loadCapabilityGrants: vi.fn().mockResolvedValue([{ capabilityName: "hook:tenant.created", scopeJson: null }]),
|
|
});
|
|
const result = await handlePluginInvokeJob(
|
|
{
|
|
...payload(),
|
|
hookName: "onTenantCreated",
|
|
metadata: { logicalHookName: "tenant.created" },
|
|
input: { tenantId: "t-1" },
|
|
},
|
|
deps
|
|
);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("invalid_hook_payload");
|
|
expect(result.message).toBe('Invalid payload for plugin hook "tenant.created".');
|
|
}
|
|
expect(deps.loadBinary).not.toHaveBeenCalled();
|
|
expect(deps.verifySignature).not.toHaveBeenCalled();
|
|
expect(deps.consumeRateLimit).not.toHaveBeenCalled();
|
|
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
|
expect(deps.recordAudit).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("returns binary_load_failed when the .wasm bytes cannot be loaded", async () => {
|
|
const deps = defaultDeps({ loadBinary: vi.fn().mockResolvedValue(null) });
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("binary_load_failed");
|
|
});
|
|
|
|
test("returns signature_invalid when verifySignature rejects", async () => {
|
|
const deps = defaultDeps({
|
|
verifySignature: vi.fn().mockReturnValue({ ok: false, reason: "public_key_not_trusted" }),
|
|
});
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("signature_invalid");
|
|
expect(result.message).toBe("public_key_not_trusted");
|
|
}
|
|
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects rows whose stored wasm hash no longer matches the manifest", async () => {
|
|
const deps = defaultDeps({
|
|
loadPlugin: vi.fn().mockResolvedValue({ ...row(), wasmSha256: "1".repeat(64) }),
|
|
});
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("signature_invalid");
|
|
expect(result.message).toBe("stored_wasm_sha256_mismatch");
|
|
}
|
|
expect(deps.verifySignature).not.toHaveBeenCalled();
|
|
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects rows whose stored signing key no longer matches the manifest", async () => {
|
|
const deps = defaultDeps({
|
|
loadPlugin: vi.fn().mockResolvedValue({ ...row(), signaturePublicKey: "1".repeat(64) }),
|
|
});
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("signature_invalid");
|
|
expect(result.message).toBe("stored_signature_public_key_mismatch");
|
|
}
|
|
expect(deps.verifySignature).not.toHaveBeenCalled();
|
|
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("returns rate_limit_exceeded when the limiter denies", async () => {
|
|
const deps = defaultDeps({
|
|
consumeRateLimit: vi.fn().mockResolvedValue({ allowed: false, remaining: 0 }),
|
|
});
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.reason).toBe("rate_limit_exceeded");
|
|
expect(deps.invokeInSandbox).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("returns sandbox_failed when the sandbox reports failure", async () => {
|
|
const deps = defaultDeps({
|
|
invokeInSandbox: vi.fn().mockResolvedValue({ ok: false, reason: "trap", fuelUsed: 12 }),
|
|
});
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("sandbox_failed");
|
|
expect(result.message).toBe("trap");
|
|
}
|
|
});
|
|
|
|
test("preserves sandbox failure messages for operator diagnostics", async () => {
|
|
const deps = defaultDeps({
|
|
invokeInSandbox: vi.fn().mockResolvedValue({
|
|
ok: false,
|
|
reason: "timeout",
|
|
fuelUsed: 0,
|
|
message: "plugin sandbox wall-clock timeout",
|
|
}),
|
|
});
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("sandbox_failed");
|
|
expect(result.message).toBe("plugin sandbox wall-clock timeout");
|
|
}
|
|
});
|
|
|
|
test("returns sandbox_failed when invokeInSandbox throws", async () => {
|
|
const deps = defaultDeps({
|
|
invokeInSandbox: vi.fn().mockRejectedValue(new Error("boom")),
|
|
});
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.reason).toBe("sandbox_failed");
|
|
expect(result.message).toBe("boom");
|
|
}
|
|
});
|
|
|
|
test("audit failure rejects so the queue can retry without losing forensics", async () => {
|
|
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
|
const deps = defaultDeps({
|
|
recordAudit: vi.fn().mockRejectedValue(new Error("audit-down")),
|
|
logger,
|
|
});
|
|
await expect(handlePluginInvokeJob(payload(), deps)).rejects.toThrow("audit-down");
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
pluginId: "de.toolctrl.test",
|
|
pluginInvocationId: expect.any(String),
|
|
error: "audit-down",
|
|
}),
|
|
"plugin.invoke.audit_failed"
|
|
);
|
|
});
|
|
|
|
test("crash-tracker seam receives sandbox_failed outcomes", async () => {
|
|
const record = vi.fn().mockResolvedValue(undefined);
|
|
const deps = defaultDeps({
|
|
invokeInSandbox: vi.fn().mockResolvedValue({ ok: false, reason: "trap", fuelUsed: 1 }),
|
|
recordOutcomeForCrashTracking: record,
|
|
});
|
|
await handlePluginInvokeJob(payload(), deps);
|
|
expect(record).toHaveBeenCalledTimes(1);
|
|
const args = record.mock.calls[0]?.[0] as { outcome: { ok: boolean } };
|
|
expect(args.outcome.ok).toBe(false);
|
|
});
|
|
|
|
test("crash-tracker seam ignores plugin_not_found (config issue)", async () => {
|
|
const record = vi.fn().mockResolvedValue(undefined);
|
|
const deps = defaultDeps({
|
|
loadPlugin: vi.fn().mockResolvedValue(null),
|
|
recordOutcomeForCrashTracking: record,
|
|
});
|
|
await handlePluginInvokeJob(payload(), deps);
|
|
expect(record).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("crash-tracker seam failure does not break the handler", async () => {
|
|
const record = vi.fn().mockRejectedValue(new Error("redis-down"));
|
|
const deps = defaultDeps({ recordOutcomeForCrashTracking: record });
|
|
const result = await handlePluginInvokeJob(payload(), deps);
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
});
|