/** * 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[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 }; } = {} ) { const registry = createBridgeRouteRegistry(); const dbProbe = options.dbProbe ?? { calls: [] }; const capabilities: Record< string, Record Promise> > = { bridge: registry.capability as unknown as Record< string, (...args: never[]) => Promise >, }; 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[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 = {}, body?: BodyInit, headers?: Record ): 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); 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 }; 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(); }); });