333 lines
12 KiB
TypeScript
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",
|
|
}),
|
|
]);
|
|
});
|
|
});
|