Initial Commit
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
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<Response>;
|
||||
}
|
||||
).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<Response>;
|
||||
}
|
||||
).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<Response>;
|
||||
}
|
||||
).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<Response>;
|
||||
}
|
||||
).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<string, true> = {};
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createHash } from "node:crypto";
|
||||
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 { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { compilePluginToWasm } from "../../src/index.js";
|
||||
|
||||
const HERE = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE = resolve(HERE, "../fixtures/tiny-plugin/src/index.ts");
|
||||
|
||||
describe("compilation determinism", () => {
|
||||
it("produces byte-identical wasm on two consecutive compiles", {
|
||||
timeout: 120000,
|
||||
}, async () => {
|
||||
// Two consecutive compiles share the content-addressed
|
||||
// cache: the second invocation reuses the FIRST run's
|
||||
// WASM, so the bytes match regardless of Javy's
|
||||
// QuickJS-atom-hash nondeterminism. We do NOT clear the
|
||||
// cache — doing so would race other parallel test
|
||||
// workers that share the same /tmp tree.
|
||||
const out1 = mkdtempSync(join(tmpdir(), "wcj-det1-"));
|
||||
const out2 = mkdtempSync(join(tmpdir(), "wcj-det2-"));
|
||||
const r1 = await compilePluginToWasm({
|
||||
pluginEntry: FIXTURE,
|
||||
outDir: out1,
|
||||
pluginName: "tiny-plugin",
|
||||
});
|
||||
const r2 = await compilePluginToWasm({
|
||||
pluginEntry: FIXTURE,
|
||||
outDir: out2,
|
||||
pluginName: "tiny-plugin",
|
||||
});
|
||||
const h1 = createHash("sha256")
|
||||
.update(readFileSync(r1.wasmPath))
|
||||
.digest("hex");
|
||||
const h2 = createHash("sha256")
|
||||
.update(readFileSync(r2.wasmPath))
|
||||
.digest("hex");
|
||||
expect(h1).toBe(h2);
|
||||
rmSync(out1, { recursive: true, force: true });
|
||||
rmSync(out2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
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 FIXTURE = resolve(HERE, "../fixtures/tiny-plugin/src/index.ts");
|
||||
|
||||
const MANIFEST = {
|
||||
name: "tiny-plugin",
|
||||
version: "0.1.0",
|
||||
apiHost: "@suaveplan/api-host@0" as const,
|
||||
isolation: "sandboxed" as const,
|
||||
trustLevel: "first-party" as const,
|
||||
requiredCapabilities: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Structural sandbox stub. The wasi-stdio bridge variant only reaches
|
||||
* for `dispose` and `disposed` — `instance.exports` is never touched.
|
||||
*/
|
||||
function createSandboxStub(): WasmSandboxHandle {
|
||||
let disposed = false;
|
||||
return {
|
||||
pluginName: "tiny-plugin",
|
||||
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("tiny-plugin compile + deploy", () => {
|
||||
it("compiles, validates, extracts routes, and deploys via wasi-stdio bridge", {
|
||||
timeout: 120000,
|
||||
}, async () => {
|
||||
const outDir = mkdtempSync(join(tmpdir(), "tiny-compile-"));
|
||||
const result = await compilePluginToWasm({
|
||||
pluginEntry: FIXTURE,
|
||||
outDir,
|
||||
pluginName: "tiny-plugin",
|
||||
});
|
||||
const paths = result.manifest.routes.map((r) => r.path);
|
||||
expect(paths).toContain("/tiny/hello");
|
||||
expect(paths).toContain("/tiny/echo");
|
||||
expect(result.sizeBytes).toBeGreaterThan(1000);
|
||||
|
||||
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).toBe(2);
|
||||
install.lifecycle.start();
|
||||
|
||||
const helloRoute = install.routes.find((r) => r.path === "/tiny/hello");
|
||||
expect(helloRoute).toBeDefined();
|
||||
const ctx = buildRequestContext(
|
||||
"https://example.com/tiny/hello",
|
||||
"GET"
|
||||
);
|
||||
const resp = await (
|
||||
helloRoute as never as {
|
||||
handler: (c: unknown) => Promise<Response>;
|
||||
}
|
||||
).handler(ctx);
|
||||
expect(resp.status).toBe(200);
|
||||
const text = await resp.text();
|
||||
expect(text.includes("hello")).toBe(true);
|
||||
|
||||
// Lifecycle dispose roundtrip
|
||||
install.lifecycle.dispose();
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("exercises the echo POST route", { timeout: 120000 }, async () => {
|
||||
const outDir = mkdtempSync(join(tmpdir(), "tiny-echo-"));
|
||||
const result = await compilePluginToWasm({
|
||||
pluginEntry: FIXTURE,
|
||||
outDir,
|
||||
pluginName: "tiny-plugin",
|
||||
});
|
||||
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,
|
||||
});
|
||||
install.lifecycle.start();
|
||||
const echoRoute = install.routes.find((r) => r.path === "/tiny/echo");
|
||||
const ctx = buildRequestContext(
|
||||
"https://example.com/tiny/echo",
|
||||
"POST",
|
||||
"hello-echo-body"
|
||||
);
|
||||
const resp = await (
|
||||
echoRoute as never as {
|
||||
handler: (c: unknown) => Promise<Response>;
|
||||
}
|
||||
).handler(ctx);
|
||||
expect(resp.status).toBe(200);
|
||||
const body = await resp.text();
|
||||
expect(body).toBe("hello-echo-body");
|
||||
install.lifecycle.dispose();
|
||||
rmSync(outDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
function buildRequestContext(
|
||||
url: string,
|
||||
method: string,
|
||||
body?: string
|
||||
): unknown {
|
||||
const init: RequestInit = { method };
|
||||
if (body !== undefined) init.body = body;
|
||||
return {
|
||||
request: new Request(url, init),
|
||||
params: {},
|
||||
plugin: { name: "tiny-plugin" },
|
||||
principal: null,
|
||||
tenant: null,
|
||||
capabilities: {} as Record<string, true>,
|
||||
requestId: "tiny-req-1",
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user