Files
HiveGrid-Wireframes/hivegrid-cloud/apps/worker/src/jobs/plugin-invoke.test.ts
T

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);
});
});