Files
HiveGrid-Wireframes/hivegrid-cloud/packages/plugin-runtime/src/capability-gateway.test.ts
T

333 lines
12 KiB
TypeScript

import { describe, expect, test, vi } from "vitest";
import { CapabilityDeniedError, createCapabilityGateway } from "./capability-gateway.js";
import type { PluginManifest } from "./manifest.js";
const DEFERRED_HOST_BINDINGS = [
"http.outbound",
"secret.read",
"db.read",
"db.write",
"storage.read",
"storage.write",
"tenant.info",
] as const;
function fixture(capabilities: PluginManifest["capabilities"] = []): 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,
hooks: [{ name: "x", export: "x" }],
};
}
describe("createCapabilityGateway", () => {
test("invokes a host-fn whose capability is declared", async () => {
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "log" }]),
hostFns: {
log: (msg: unknown) => `logged:${String(msg)}`,
},
});
const result = await gateway.invoke<string>("log", "hi");
expect(result).toBe("logged:hi");
});
test("denies undeclared capabilities with not_declared_in_manifest", async () => {
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "log" }]),
hostFns: {
"http.outbound": async () => "should not run",
},
});
await expect(gateway.invoke("http.outbound")).rejects.toThrow(CapabilityDeniedError);
await expect(gateway.invoke("http.outbound")).rejects.toMatchObject({
reason: "not_declared_in_manifest",
});
});
test("denies declared-but-unimplemented capabilities", async () => {
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "secret.read" }]),
hostFns: {},
});
await expect(gateway.invoke("secret.read")).rejects.toMatchObject({
reason: "no_host_implementation",
});
});
test.each(DEFERRED_HOST_BINDINGS)("fails closed for deferred %s bindings with no_host_implementation", async (capability) => {
const hostFn = vi.fn(async () => "should not run");
const gateway = createCapabilityGateway({
manifest: fixture([{ name: capability }]),
approvedGrants: [{ capabilityName: capability, scopeJson: null }],
hostFns: {
log: () => "ok",
audit: () => "ok",
[capability]: hostFn,
},
});
await expect(gateway.invoke(capability)).rejects.toMatchObject({
reason: "no_host_implementation",
});
expect(hostFn).not.toHaveBeenCalled();
});
test("denies manifest-declared capabilities without tenant-approved grants", async () => {
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "log" }, { name: "audit" }]),
approvedGrants: [{ capabilityName: "log", scopeJson: null }],
hostFns: {
log: () => "ok",
audit: () => "should not run",
},
});
await expect(gateway.invoke("log")).resolves.toBe("ok");
await expect(gateway.invoke("audit")).rejects.toMatchObject({
reason: "not_granted_for_tenant",
});
});
test("denies tenant grants whose scope exceeds the manifest scope", async () => {
const hostFn = vi.fn(async () => "should not run");
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }]),
approvedGrants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com", "internal.example"] } }],
hostFns: {
"http.outbound": hostFn,
},
});
await expect(gateway.invoke("http.outbound", "https://api.example.com")).rejects.toMatchObject({
reason: "not_granted_for_tenant",
});
expect(hostFn).not.toHaveBeenCalled();
});
test("charges fuel per call (defaults to 100)", async () => {
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "log" }]),
hostFns: { log: () => "ok" },
});
expect(gateway.fuelCharged()).toBe(0);
await gateway.invoke("log");
expect(gateway.fuelCharged()).toBe(100);
await gateway.invoke("log");
expect(gateway.fuelCharged()).toBe(200);
});
test("respects fuelCostPerCall override and resetFuel", async () => {
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "log" }]),
hostFns: { log: () => "ok" },
fuelCostPerCall: 25,
});
await gateway.invoke("log");
await gateway.invoke("log");
expect(gateway.fuelCharged()).toBe(50);
gateway.resetFuel();
expect(gateway.fuelCharged()).toBe(0);
});
test("calls onDenied with the deny reason for both deny paths", async () => {
const denials: Array<{ capability: string; reason: string }> = [];
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "secret.read" }]),
hostFns: {},
onDenied: (info) => denials.push(info),
});
await expect(gateway.invoke("http.outbound")).rejects.toThrow();
await expect(gateway.invoke("secret.read")).rejects.toThrow();
expect(denials.map((d) => d.reason)).toEqual([
"not_declared_in_manifest",
"no_host_implementation",
]);
});
test("records onCall outcomes for success, denial, and host-fn errors", async () => {
const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = [];
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "log" }, { name: "audit" }]),
hostFns: {
log: () => "ok",
audit: () => {
throw new TypeError("audit sink unavailable");
},
},
onCall: (info) => calls.push(info),
});
await expect(gateway.invoke("log")).resolves.toBe("ok");
await expect(gateway.invoke("http.outbound")).rejects.toMatchObject({
reason: "not_declared_in_manifest",
});
await expect(gateway.invoke("db.read")).rejects.toMatchObject({
reason: "not_declared_in_manifest",
});
await expect(gateway.invoke("audit")).rejects.toThrow("audit sink unavailable");
expect(calls).toMatchObject([
{ capability: "log", outcome: "success" },
{ capability: "http.outbound", outcome: "denied", errorCode: "not_declared_in_manifest" },
{ capability: "db.read", outcome: "denied", errorCode: "not_declared_in_manifest" },
{ capability: "audit", outcome: "error", errorCode: "TypeError" },
]);
});
test("records deterministic payload hashes without exposing raw args", async () => {
const calls: Array<Record<string, unknown>> = [];
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "log" }]),
hostFns: {
log: () => "ok",
},
onCall: (info) => calls.push(info),
});
await gateway.invoke("log", "same-value");
await gateway.invoke("log", "same-value");
await gateway.invoke("log", "other-value");
expect(calls[0]?.payloadHash).toMatch(/^[a-f0-9]{64}$/u);
expect(calls[1]?.payloadHash).toBe(calls[0]?.payloadHash);
expect(calls[2]?.payloadHash).not.toBe(calls[0]?.payloadHash);
expect(JSON.stringify(calls)).not.toContain("same-value");
expect(JSON.stringify(calls)).not.toContain("other-value");
});
test("records no_host_implementation through onCall", async () => {
const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = [];
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "secret.read" }]),
hostFns: {},
onCall: (info) => calls.push(info),
});
await expect(gateway.invoke("secret.read")).rejects.toMatchObject({
reason: "no_host_implementation",
});
expect(calls).toEqual([
expect.objectContaining({
capability: "secret.read",
outcome: "denied",
errorCode: "no_host_implementation",
}),
]);
});
test("keeps future scoped host bindings unavailable before dispatch", async () => {
const hostFn = vi.fn(async () => "should not run");
const denials: Array<{ capability: string; reason: string }> = [];
const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = [];
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "http.outbound", scope: { allowedHosts: ["api.example.com"] } }]),
approvedGrants: [{ capabilityName: "http.outbound", scopeJson: { allowedHosts: ["api.example.com"] } }],
hostFns: {
"http.outbound": hostFn,
},
onDenied: (info) => denials.push(info),
onCall: (info) => calls.push(info),
});
await expect(gateway.invoke("http.outbound", { url: "https://evil.example.com/v1" })).rejects.toMatchObject({
reason: "no_host_implementation",
});
expect(hostFn).not.toHaveBeenCalled();
expect(denials).toEqual([{ capability: "http.outbound", reason: "no_host_implementation" }]);
expect(calls).toEqual([
expect.objectContaining({
capability: "http.outbound",
outcome: "denied",
errorCode: "no_host_implementation",
}),
]);
});
test("ignores onCall recorder failures", async () => {
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "log" }]),
hostFns: { log: () => "ok" },
onCall: () => {
throw new Error("audit sink down");
},
});
await expect(gateway.invoke("log")).resolves.toBe("ok");
});
test("propagates async reviewed host-fn results unchanged", async () => {
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "audit" }]),
hostFns: {
audit: async () => ({ recorded: true }),
},
});
const out = await gateway.invoke<{ recorded: boolean }>("audit");
expect(out).toEqual({ recorded: true });
});
test.each(DEFERRED_HOST_BINDINGS)("invokeSync fails closed for deferred %s bindings even when wired", (capability) => {
const hostFn = vi.fn(() => "should not run");
const gateway = createCapabilityGateway({
manifest: fixture([{ name: capability }]),
approvedGrants: [{ capabilityName: capability, scopeJson: null }],
hostFns: {
[capability]: hostFn,
},
});
expect(() => gateway.invokeSync(capability)).toThrow(CapabilityDeniedError);
expect(() => gateway.invokeSync(capability)).toThrow(`capability denied: ${capability} (no_host_implementation)`);
expect(hostFn).not.toHaveBeenCalled();
});
test("invokeSync gates synchronous host imports and records calls", () => {
const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = [];
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "log" }]),
hostFns: {
log: (msg: unknown) => `logged:${String(msg)}`,
},
onCall: (info) => calls.push(info),
});
expect(gateway.invokeSync("log", "hi")).toBe("logged:hi");
expect(gateway.fuelCharged()).toBe(100);
expect(() => gateway.invokeSync("audit", { action: "x" })).toThrow(CapabilityDeniedError);
expect(calls).toMatchObject([
{ capability: "log", outcome: "success" },
{ capability: "audit", outcome: "denied", errorCode: "not_declared_in_manifest" },
]);
});
test("invokeSync rejects async host functions before reporting success", () => {
const calls: Array<{ capability: string; outcome: string; errorCode?: string }> = [];
const gateway = createCapabilityGateway({
manifest: fixture([{ name: "audit" }]),
hostFns: {
audit: async () => "later",
},
onCall: (info) => calls.push(info),
});
expect(() => gateway.invokeSync("audit")).toThrow("async_host_fn_not_supported");
expect(calls).toEqual([
expect.objectContaining({
capability: "audit",
outcome: "error",
errorCode: "Error",
}),
]);
});
});