import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import type { WasmSandboxHandle } from "@suaveplan/api-sandbox-wasm"; import { createSandboxedWasmPluginModule } from "@suaveplan/api-sandbox-wasm-host-bridge"; import { describe, expect, it } from "@suaveplan/testing/runner"; import { compilePluginToWasm } from "../../src/index.js"; const HERE = dirname(fileURLToPath(import.meta.url)); const EXAMPLE_ENTRY = resolve( HERE, "../../../api-plugin-example/src/wasm/index.ts" ); const MANIFEST = { name: "@suaveplan/api-plugin-example", version: "0.1.0", apiHost: "@suaveplan/api-host@0" as const, isolation: "sandboxed" as const, trustLevel: "first-party" as const, requiredCapabilities: ["example:read", "example:write"], }; function createSandboxStub(): WasmSandboxHandle { let disposed = false; return { pluginName: MANIFEST.name, instance: { exports: {} } as unknown as WebAssembly.Instance, preopens: [], outbound: { allow: () => false } as never, dispose: () => { disposed = true; }, get disposed() { return disposed; }, } as unknown as WasmSandboxHandle; } describe("api-plugin-example (KEYSTONE Stage-8 gate 2.8)", () => { it("compiled WASM deploys via wasi-stdio bridge", { timeout: 120000, }, async () => { const outDir = mkdtempSync(join(tmpdir(), "example-wasm-")); const result = await compilePluginToWasm({ pluginEntry: EXAMPLE_ENTRY, outDir, pluginName: "@suaveplan/api-plugin-example", }); // Source-declared routes (see api-plugin-example/src/wasm/routes.ts) const expectedPaths = [ "/example/public/ping", "/example/health", "/example/invoices", "/example/invoices/:id", "/example/invoices", "/example/invoices/:id/finalise", "/example/rate-limited", "/example/subscription-required", "/_/test/fail-response", ]; const havePaths = result.manifest.routes.map((r) => r.path); for (const p of expectedPaths) { expect(havePaths).toContain(p); } expect(result.manifest.routes.length).toBeGreaterThanOrEqual(9); expect(result.manifest.abiVariant).toBe("wasi-stdio"); const wasmBytes = new Uint8Array(readFileSync(result.wasmPath)); const wasmModule = await WebAssembly.compile(wasmBytes); const sandbox = createSandboxStub(); const install = createSandboxedWasmPluginModule({ abiVariant: "wasi-stdio", sandbox, manifest: MANIFEST, wasmModule, }); expect(install.routes.length).toBeGreaterThanOrEqual(9); install.lifecycle.start(); // 1. Public ping — no auth required const pingRoute = install.routes.find( (r) => r.path === "/example/public/ping" ); expect(pingRoute).toBeDefined(); const pingResp = await ( pingRoute as never as { handler: (c: unknown) => Promise; } ).handler(buildCtx("https://example.com/example/public/ping", "GET")); expect(pingResp.status).toBe(200); const pingBody = await pingResp.json(); expect(pingBody.ok).toBe(true); // 2. Health const healthRoute = install.routes.find( (r) => r.path === "/example/health" ); const healthResp = await ( healthRoute as never as { handler: (c: unknown) => Promise; } ).handler(buildCtx("https://example.com/example/health", "GET")); expect(healthResp.status).toBe(200); // 3. RBAC-protected list (missing scope → 403) const listRoute = install.routes.find( (r) => r.path === "/example/invoices" && r.method === "GET" ); const denyResp = await ( listRoute as never as { handler: (c: unknown) => Promise; } ).handler(buildCtx("https://example.com/example/invoices", "GET")); expect(denyResp.status).toBe(403); // 4. RBAC-protected list with scope → 200 with empty array const grantResp = await ( listRoute as never as { handler: (c: unknown) => Promise; } ).handler( buildCtx("https://example.com/example/invoices", "GET", undefined, [ "example:read", ]) ); expect(grantResp.status).toBe(200); const grantBody = await grantResp.json(); expect(Array.isArray(grantBody.invoices)).toBe(true); install.lifecycle.dispose(); rmSync(outDir, { recursive: true, force: true }); }); }); function buildCtx( url: string, method: string, body?: string, capabilities: readonly string[] = [] ): unknown { const init: RequestInit = { method }; if (body !== undefined) init.body = body; // The bridge's marshal reads `Object.keys(ctx.capabilities)` to // produce the serialized array — model the host's // RequestContext shape (capability map) accordingly. const capMap: Record = {}; for (const c of capabilities) capMap[c] = true; return { request: new Request(url, init), params: {}, plugin: { name: MANIFEST.name }, principal: null, tenant: null, capabilities: capMap, requestId: "ex-req-1", timestamp: Date.now(), }; }