Files
2026-05-27 23:29:53 +01:00

379 lines
14 KiB
TypeScript

/**
* Integration test for `@suaveplan/api-sandbox-vm-host-bridge`.
*
* Drives a real `VmSandboxHandle` (production `node:vm` —
* `--experimental-vm-modules` enabled in this package's vitest
* config) wrapped by `createSandboxedVmPluginModule(...)` to exercise
* the full marshal-invoke-unmarshal cycle the host's request
* pipeline would drive in production. Asserts the spec's
* "Verified by tests/integration/full-flow.test.ts" scenarios.
*/
import { createVmSandbox } from "@suaveplan/api-sandbox-vm";
import { createPermissionChecker } from "@suaveplan/api-sandbox-vm/permissions/checker";
import type { RequestContext } from "@suaveplan/api-types";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { LoggerLike } from "@suaveplan/types";
import { createSandboxedVmPluginModule } from "../../src/bridge/bridge.js";
import { createBridgeRouteRegistry } from "../../src/route-discovery/route-discovery.js";
const SILENT: LoggerLike = {
debug: () => undefined,
info: () => undefined,
warn: () => undefined,
error: () => undefined,
};
const MANIFEST = {
name: "example",
version: "0.1.0",
apiHost: "^0.1.0",
isolation: "sandboxed",
trustLevel: "third-party",
} as unknown as Parameters<typeof createSandboxedVmPluginModule>[0]["manifest"];
interface ExtraCaps {
readonly db?: {
readonly query: (sql: string) => Promise<{ readonly rows: number }>;
};
}
function buildBridge(
source: string,
options: {
readonly checkCapability?: (id: string) => {
allow: boolean;
reason?: string;
};
readonly capabilities?: ExtraCaps;
readonly dbProbe?: { calls: Array<readonly [string]> };
} = {}
) {
const registry = createBridgeRouteRegistry();
const dbProbe = options.dbProbe ?? { calls: [] };
const capabilities: Record<
string,
Record<string, (...args: never[]) => Promise<unknown>>
> = {
bridge: registry.capability as unknown as Record<
string,
(...args: never[]) => Promise<unknown>
>,
};
if (options.capabilities?.db) {
const db = options.capabilities.db;
capabilities["db"] = {
query: async (...args: readonly unknown[]) => {
dbProbe.calls.push([args[0] as string]);
return db.query(args[0] as string);
},
};
}
const checker = options.checkCapability
? {
checkFs: () => ({ allow: true as const }),
checkFetch: () => ({ allow: true as const }),
checkCapability: options.checkCapability,
}
: createPermissionChecker({ policy: {} });
const sandbox = createVmSandbox({
pluginId: "example",
logger: SILENT,
checker: checker as Parameters<typeof createVmSandbox>[0]["checker"],
pluginModule: {
kind: "source",
identifier: "example.mjs",
source,
},
proxies: {
fs: { readFile: async () => "", writeFile: async () => undefined },
fetch: {
request: async () => ({ status: 204, headers: {}, body: "" }),
},
capabilities: capabilities as unknown as Parameters<
typeof createVmSandbox
>[0]["proxies"]["capabilities"],
},
});
return { sandbox, registry, dbProbe };
}
function makeCtx(
method: string,
path: string,
overrides: Partial<RequestContext> = {},
body?: BodyInit,
headers?: Record<string, string>
): RequestContext {
const init: RequestInit = { method };
if (body !== undefined && method !== "GET" && method !== "HEAD") {
init.body = body;
}
if (headers !== undefined) init.headers = headers;
return {
request: new Request(`https://api.example.com${path}`, init),
principal: null,
tenantId: null,
tenant: null,
requestId: "req_1",
capabilities: {},
plugin: { name: "example", version: "0.1.0" },
instruments: {},
abortSignal: new AbortController().signal,
db: null,
secrets: {},
span: {
setAttribute: () => undefined,
setStatus: () => undefined,
recordException: () => undefined,
end: () => undefined,
},
...overrides,
} as unknown as RequestContext;
}
describe("integration: full bridged plugin flow", () => {
it("host registers sandboxed routes via installPlugin (shape check)", {
timeout: 30000,
}, async () => {
const { sandbox, registry } = buildBridge(`
await Promise.all([
__suaveplan.capabilities.bridge.registerRoute({
method: "GET", path: "/foo",
handler: async () => ({ status: 200, headers: [], bodyBase64: "Zm9v" }),
}),
__suaveplan.capabilities.bridge.registerRoute({
method: "POST", path: "/bar",
handler: async () => ({ status: 201, headers: [], bodyBase64: "" }),
}),
]);
`);
const bridged = await createSandboxedVmPluginModule({
sandbox,
manifest: MANIFEST,
registry,
});
// The shape `installPlugin` consumes: module + manifest + routes.
const install = {
module: bridged.module,
manifest: MANIFEST,
manifestHash: `sha256:${"0".repeat(64)}`,
specifier: "file:///example.mjs",
routes: bridged.routes,
};
expect(install.module.name).toBe("example");
expect(install.routes).toHaveLength(2);
expect(install.routes.map((r) => `${r.method} ${r.path}`)).toEqual([
"GET /foo",
"POST /bar",
]);
bridged.dispose();
});
it("POST body and custom header traverse the bridge", {
timeout: 30000,
}, async () => {
const { sandbox, registry } = buildBridge(`
await __suaveplan.capabilities.bridge.registerRoute({
method: "POST", path: "/echo",
handler: async (req) => {
const traceHeader = req.headers.find(([k]) => k === "x-trace");
const traceValue = traceHeader ? traceHeader[1] : "none";
return {
status: 200,
headers: [["x-trace", traceValue]],
bodyBase64: req.bodyBase64,
};
},
});
`);
const bridged = await createSandboxedVmPluginModule({
sandbox,
manifest: MANIFEST,
registry,
});
const handler = bridged.routes[0]?.handler;
if (!handler) throw new Error("no handler");
const ctx = makeCtx("POST", "/echo", {}, new Uint8Array([65, 66, 67]), {
"x-trace": "trace-9",
});
const resp = (await handler(ctx)) as Response;
expect(resp.status).toBe(200);
expect(resp.headers.get("x-trace")).toBe("trace-9");
const bytes = new Uint8Array(await resp.arrayBuffer());
expect(Array.from(bytes)).toEqual([65, 66, 67]);
bridged.dispose();
});
it("principal and tenant reach the sandbox handler", {
timeout: 30000,
}, async () => {
const { sandbox, registry } = buildBridge(`
await __suaveplan.capabilities.bridge.registerRoute({
method: "GET", path: "/who",
handler: async (req) => {
const payload = JSON.stringify({
principal: req.principalId,
tenant: req.tenantId,
});
const bytes = new TextEncoder().encode(payload);
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let bodyBase64 = "";
let i = 0;
for (; i + 2 < bytes.length; i += 3) {
const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2];
bodyBase64 += ALPHABET[a >> 2];
bodyBase64 += ALPHABET[((a & 3) << 4) | (b >> 4)];
bodyBase64 += ALPHABET[((b & 15) << 2) | (c >> 6)];
bodyBase64 += ALPHABET[c & 63];
}
if (i < bytes.length) {
const a = bytes[i];
bodyBase64 += ALPHABET[a >> 2];
if (i + 1 < bytes.length) {
const b = bytes[i + 1];
bodyBase64 += ALPHABET[((a & 3) << 4) | (b >> 4)];
bodyBase64 += ALPHABET[(b & 15) << 2];
} else {
bodyBase64 += ALPHABET[(a & 3) << 4];
}
}
return {
status: 200,
headers: [],
bodyBase64,
};
},
});
`);
const bridged = await createSandboxedVmPluginModule({
sandbox,
manifest: MANIFEST,
registry,
});
const handler = bridged.routes[0]?.handler;
if (!handler) throw new Error("no handler");
const ctx = makeCtx("GET", "/who", {
principal: {
id: "u1",
scopes: [],
claims: {},
},
tenantId: "t1",
tenant: { id: "t1", slug: "acme" },
} as unknown as Partial<RequestContext>);
const resp = (await handler(ctx)) as Response;
const parsed = (await resp.json()) as {
principal: string | null;
tenant: string | null;
};
expect(parsed.principal).toBe("u1");
expect(parsed.tenant).toBe("t1");
bridged.dispose();
});
it("allowed capability reaches host db adapter", {
timeout: 30000,
}, async () => {
const dbProbe = { calls: [] as Array<readonly [string]> };
const { sandbox, registry } = buildBridge(
`
await __suaveplan.capabilities.bridge.registerRoute({
method: "GET", path: "/users",
handler: async () => {
const result = await __suaveplan.capabilities.db.query("select 1");
const payload = JSON.stringify(result);
const bytes = new TextEncoder().encode(payload);
const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let bodyBase64 = "";
let i = 0;
for (; i + 2 < bytes.length; i += 3) {
const a = bytes[i], b = bytes[i + 1], c = bytes[i + 2];
bodyBase64 += ALPHABET[a >> 2];
bodyBase64 += ALPHABET[((a & 3) << 4) | (b >> 4)];
bodyBase64 += ALPHABET[((b & 15) << 2) | (c >> 6)];
bodyBase64 += ALPHABET[c & 63];
}
if (i < bytes.length) {
const a = bytes[i];
bodyBase64 += ALPHABET[a >> 2];
if (i + 1 < bytes.length) {
const b = bytes[i + 1];
bodyBase64 += ALPHABET[((a & 3) << 4) | (b >> 4)];
bodyBase64 += ALPHABET[(b & 15) << 2];
} else {
bodyBase64 += ALPHABET[(a & 3) << 4];
}
}
return { status: 200, headers: [], bodyBase64 };
},
});
`,
{
capabilities: {
db: { query: async () => ({ rows: 7 }) },
},
dbProbe,
}
);
const bridged = await createSandboxedVmPluginModule({
sandbox,
manifest: MANIFEST,
registry,
});
const handler = bridged.routes[0]?.handler;
if (!handler) throw new Error("no handler");
const resp = (await handler(makeCtx("GET", "/users"))) as Response;
const parsed = (await resp.json()) as { rows: number };
expect(parsed.rows).toBe(7);
expect(dbProbe.calls).toEqual([["select 1"]]);
bridged.dispose();
});
it("denied capability surfaces a permission-denied cause", {
timeout: 30000,
}, async () => {
const { sandbox, registry } = buildBridge(
`
await __suaveplan.capabilities.bridge.registerRoute({
method: "GET", path: "/forbidden",
handler: async () => {
await __suaveplan.capabilities.db.query("select 1");
return { status: 200, headers: [], bodyBase64: "" };
},
});
`,
{
capabilities: {
db: { query: async () => ({ rows: 0 }) },
},
checkCapability: (id) =>
id === "db"
? { allow: false, reason: "denied" }
: { allow: true },
}
);
const bridged = await createSandboxedVmPluginModule({
sandbox,
manifest: MANIFEST,
registry,
});
const handler = bridged.routes[0]?.handler;
if (!handler) throw new Error("no handler");
let caught: unknown;
try {
await handler(makeCtx("GET", "/forbidden"));
} catch (err) {
caught = err;
}
expect(caught).toBeDefined();
const e = caught as { code?: string; cause?: { code?: string } };
expect(e.code).toBe("SAND_VM_BRIDGE.HANDLER_REJECTED");
const causeCode = String(e.cause?.code ?? "");
const isPermissionDenied =
causeCode === "SAND_VM.PERMISSION_DENIED" ||
causeCode === "SAND_PERM.DENIED";
expect(isPermissionDenied).toBe(true);
bridged.dispose();
});
});