167 lines
5.7 KiB
TypeScript
167 lines
5.7 KiB
TypeScript
import { createWasmSandbox } from "@suaveplan/api-sandbox-wasm";
|
|
import type { PluginManifest } from "@suaveplan/api-types/manifest";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
SAND_WASM_BRIDGE_CODES,
|
|
SandboxWasmBridgeError,
|
|
} from "../../src/error-codes/error-codes.js";
|
|
import { createSandboxedWasmPluginModule } from "../../src/index.js";
|
|
import { buildFixtureWasm, WITNESS_OFFSET } from "../fixtures/build-wasm.js";
|
|
|
|
const MANIFEST: PluginManifest = {
|
|
name: "full-flow",
|
|
version: "0.1.0",
|
|
apiHost: "@suaveplan/api-host@0",
|
|
isolation: "sandboxed",
|
|
trustLevel: "third-party",
|
|
requiredCapabilities: [],
|
|
};
|
|
const ALLOW = { checkCapability: () => ({ allow: true as const }) };
|
|
|
|
async function buildInstall(
|
|
fixture: Parameters<typeof buildFixtureWasm>[0] = {}
|
|
) {
|
|
const sandbox = await createWasmSandbox({
|
|
pluginName: MANIFEST.name,
|
|
policy: { fs: [], fetch: [], budgets: {} },
|
|
capabilities: [],
|
|
checker: ALLOW,
|
|
wasmBytes: buildFixtureWasm(fixture),
|
|
});
|
|
return createSandboxedWasmPluginModule({ sandbox, manifest: MANIFEST });
|
|
}
|
|
|
|
function ctx(method: string, path: string, body?: string): never {
|
|
const url = `https://app.example.com${path}`;
|
|
const request =
|
|
body === undefined
|
|
? new Request(url, { method })
|
|
: new Request(url, {
|
|
method,
|
|
body,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
return {
|
|
request,
|
|
principal: null,
|
|
tenant: null,
|
|
capabilities: {},
|
|
requestId: "rid",
|
|
} as never;
|
|
}
|
|
|
|
describe("full-flow integration", () => {
|
|
it("WASM-exported routes register with the host", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const routesJson = JSON.stringify([
|
|
{ method: "GET", path: "/items" },
|
|
{ method: "POST", path: "/items" },
|
|
]);
|
|
const install = await buildInstall({ routesJson });
|
|
try {
|
|
expect(install.routes.map((r) => r.method)).toEqual([
|
|
"GET",
|
|
"POST",
|
|
]);
|
|
expect(install.routes.map((r) => r.path)).toEqual([
|
|
"/items",
|
|
"/items",
|
|
]);
|
|
expect(install.routes.every((r) => r.plugin === "full-flow")).toBe(
|
|
true
|
|
);
|
|
} finally {
|
|
install.module.stop();
|
|
}
|
|
});
|
|
|
|
it("1 KiB body round-trips through WASM bridge", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const install = await buildInstall();
|
|
try {
|
|
install.module.start();
|
|
const route = install.routes[0];
|
|
if (route === undefined) throw new Error("no route");
|
|
// Build a 1 KiB body of ASCII bytes — easy to assert against.
|
|
const body = "x".repeat(1024);
|
|
const response = (await route.handler(
|
|
ctx("POST", "/hello", body)
|
|
)) as Response;
|
|
expect(response.status).toBe(200);
|
|
// Handler observed the bytes: witness range carries the
|
|
// marshalled JSON which embeds the base64 body. We verify the
|
|
// serialised JSON reached the guest by scanning for the URL.
|
|
const memory = install.sandbox.instance.exports
|
|
.memory as WebAssembly.Memory;
|
|
const witness = new TextDecoder().decode(
|
|
new Uint8Array(memory.buffer, WITNESS_OFFSET, 1024)
|
|
);
|
|
expect(witness).toContain('"url":"https://app.example.com/hello"');
|
|
expect(witness).toContain('"method":"POST"');
|
|
// Response body reached the caller intact.
|
|
expect(await response.text()).toBe("ok");
|
|
} finally {
|
|
install.module.stop();
|
|
}
|
|
});
|
|
|
|
it("WASM unreachable instruction surfaces HANDLER_TRAPPED", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const install = await buildInstall({ handlerTraps: true });
|
|
try {
|
|
install.module.start();
|
|
const route = install.routes[0];
|
|
if (route === undefined) throw new Error("no route");
|
|
await expect(
|
|
route.handler(ctx("GET", "/hello"))
|
|
).rejects.toMatchObject({
|
|
code: SAND_WASM_BRIDGE_CODES.HANDLER_TRAPPED,
|
|
context: { trapKind: "unreachable" },
|
|
});
|
|
} finally {
|
|
install.module.stop();
|
|
}
|
|
});
|
|
|
|
it("denied capability surfaces SAND_PERM.DENIED through bridge", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
const responseJson = JSON.stringify({
|
|
status: 403,
|
|
headers: [],
|
|
bodyBase64: "",
|
|
errorCode: "SAND_PERM.DENIED",
|
|
errorMessage: "fs.write denied",
|
|
});
|
|
const install = await buildInstall({ responseJson });
|
|
try {
|
|
install.module.start();
|
|
const route = install.routes[0];
|
|
if (route === undefined) throw new Error("no route");
|
|
await expect(
|
|
route.handler(ctx("GET", "/hello"))
|
|
).rejects.toMatchObject({
|
|
code: "SAND_PERM.DENIED",
|
|
});
|
|
} finally {
|
|
install.module.stop();
|
|
}
|
|
});
|
|
|
|
it("ABI validator rejects missing __suaveplan_handle_request at bridge-init", {
|
|
timeout: 30000,
|
|
}, async () => {
|
|
try {
|
|
await buildInstall({ dropExports: ["__suaveplan_handle_request"] });
|
|
throw new Error("expected throw");
|
|
} catch (err) {
|
|
expect(err).toBeInstanceOf(SandboxWasmBridgeError);
|
|
const e = err as SandboxWasmBridgeError;
|
|
expect(e.code).toBe(SAND_WASM_BRIDGE_CODES.INVALID_PLUGIN_SURFACE);
|
|
}
|
|
});
|
|
});
|