379 lines
14 KiB
TypeScript
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();
|
|
});
|
|
});
|