Initial Commit
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user