Files
suaveplan/packages/api-stack/api/api-plugin-example/tests/wasm/shape-conformance.test.ts
2026-05-27 23:29:53 +01:00

326 lines
9.6 KiB
TypeScript

/**
* Trusted vs wasm parity test. For every overlapping route, dispatches
* the same logical request through:
* 1. The trusted-tier handler in `src/trusted/routes/routes.ts`
* 2. The wasm-tier `plugin.handleRequest` in `src/wasm/index.ts`
* and asserts the responses have the same HTTP status and JSON body.
*
* The streaming endpoint `/example/stream/large-report` is skipped with
* an explanatory `it.skip` — chunked `ReadableStream` responses cannot
* be expressed under the WASI-stdio contract.
*
* Both surfaces consume the SAME `domain/invoice-rules.ts` module, so
* the parity contract reduces to "both adapters serialise the same
* domain output identically".
*/
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { buildRoutes, createInMemoryLedger } from "../../src/trusted/index.js";
import { plugin } from "../../src/wasm/index.js";
import { resetState } from "../../src/wasm/wasm-state.js";
interface ParityInput {
readonly name: string;
readonly method: string;
readonly path: string;
readonly scopes: readonly string[];
readonly body?: unknown;
}
const ADMIN_SCOPES = ["example:read", "example:write"] as const;
const URL_BASE = "http://t";
function encodeBase64Url(text: string): string {
let binary = "";
const bytes = new TextEncoder().encode(text);
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i] ?? 0);
}
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function decodeBase64Url(b64url: string): string {
if (b64url.length === 0) return "";
const padLen = (4 - (b64url.length % 4)) % 4;
const padded =
b64url.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat(padLen);
return atob(padded);
}
interface TrustedTable {
readonly table: ReturnType<typeof buildRoutes>;
}
function freshTrusted(): TrustedTable {
const ledger = createInMemoryLedger();
const table = buildRoutes({
ledger,
publisher: {
publish() {
// Publishing is a trusted-tier-only concern; the wasm tier
// does not emit events. The parity test asserts response
// equality, not side effects, so we discard.
},
},
});
return { table };
}
interface NormalisedResponse {
readonly status: number;
readonly bodyText: string;
}
async function runTrusted(
table: TrustedTable["table"],
input: ParityInput
): Promise<NormalisedResponse> {
const method = input.method;
const fullPath = input.path;
// Look up via path pattern: trusted uses `:id` segments.
const lookupPath = toPattern(method, fullPath);
const route = table.find(
(r) => r.method === method && r.path === lookupPath
);
if (route === undefined) {
throw new Error(`trusted route not found: ${method} ${lookupPath}`);
}
const params = extractParams(lookupPath, fullPath);
const principal = {
id: "p",
scopes: input.scopes as unknown as ReadonlyArray<
"example:read" | "example:write"
>,
claims: {},
};
const url = new URL(`${URL_BASE}${fullPath}`);
const query: Record<string, string> = {};
url.searchParams.forEach((value, key) => {
query[key] = value;
});
const ctxBase = {
principal,
tenant: null,
request: new Request(`${URL_BASE}${fullPath}`, {
method,
headers:
input.body !== undefined
? { "content-type": "application/json" }
: {},
body:
input.body !== undefined
? JSON.stringify(input.body)
: undefined,
}),
body: input.body,
query,
params,
};
const out = await route.handler(ctxBase);
if (out instanceof Response) {
return {
status: out.status,
bodyText: await out.text(),
};
}
return { status: 200, bodyText: JSON.stringify(out) };
}
async function runWasm(input: ParityInput): Promise<NormalisedResponse> {
const res = await plugin.handleRequest({
url: `${URL_BASE}${input.path}`,
method: input.method,
headers:
input.body !== undefined
? [["content-type", "application/json"]]
: [],
bodyBase64:
input.body !== undefined
? encodeBase64Url(JSON.stringify(input.body))
: "",
principalId: null,
tenantId: null,
capabilities: input.scopes,
requestId: "r",
timestamp: 0,
});
return { status: res.status, bodyText: decodeBase64Url(res.bodyBase64) };
}
function toPattern(method: string, path: string): string {
// /example/invoices/inv-1 -> /example/invoices/:id
if (path.startsWith("/example/invoices/") && path !== "/example/invoices") {
const tail = path.slice("/example/invoices/".length);
if (tail.endsWith("/finalise")) {
return "/example/invoices/:id/finalise";
}
if (!tail.includes("/")) return "/example/invoices/:id";
}
// Strip query string for matching.
const q = path.indexOf("?");
if (q !== -1) return path.slice(0, q);
return path;
}
function extractParams(
pattern: string,
actual: string
): Record<string, string> {
const patternParts = pattern.split("/");
const actualPath = actual.split("?")[0] ?? actual;
const actualParts = actualPath.split("/");
const params: Record<string, string> = {};
for (let i = 0; i < patternParts.length; i += 1) {
const p = patternParts[i];
if (p?.startsWith(":")) {
params[p.slice(1)] = actualParts[i] ?? "";
}
}
return params;
}
beforeEach(() => {
resetState();
});
afterEach(() => {
resetState();
});
const SHARED_BODIES: readonly ParityInput[] = [
{
name: "public ping",
method: "GET",
path: "/example/public/ping",
scopes: [],
},
{
name: "health",
method: "GET",
path: "/example/health",
scopes: [],
},
{
name: "rate-limited",
method: "GET",
path: "/example/rate-limited",
scopes: [],
},
{
name: "list invoices (no scope -> 403)",
method: "GET",
path: "/example/invoices",
scopes: [],
},
{
name: "list invoices (with scope -> 200)",
method: "GET",
path: "/example/invoices",
scopes: ADMIN_SCOPES,
},
{
name: "createInvoice (bad body -> 400)",
method: "POST",
path: "/example/invoices",
scopes: ADMIN_SCOPES,
body: { customerId: "" },
},
{
name: "createInvoice (no scope -> 403)",
method: "POST",
path: "/example/invoices",
scopes: [],
body: { customerId: "c", amountCents: 1, currency: "USD" },
},
{
name: "fail-response default",
method: "GET",
path: "/_/test/fail-response",
scopes: [],
},
{
name: "fail-response with flag",
method: "GET",
path: "/_/test/fail-response?failResponse=1",
scopes: [],
},
];
describe("trusted vs wasm parity for every shared route", () => {
for (const input of SHARED_BODIES) {
it(input.name, { timeout: 5000 }, async () => {
resetState();
const trustedHandle = freshTrusted();
const [t, w] = await Promise.all([
runTrusted(trustedHandle.table, input),
runWasm(input),
]);
expect(w.status).toBe(t.status);
expect(w.bodyText).toBe(t.bodyText);
});
}
it("createInvoice happy path body matches across surfaces", {
timeout: 5000,
}, async () => {
resetState();
const trustedHandle = freshTrusted();
const input: ParityInput = {
name: "createInvoice",
method: "POST",
path: "/example/invoices",
scopes: ADMIN_SCOPES,
body: { customerId: "c", amountCents: 1500, currency: "USD" },
};
const t = await runTrusted(trustedHandle.table, input);
const w = await runWasm(input);
expect(w.status).toBe(t.status);
expect(w.bodyText).toBe(t.bodyText);
});
it("getInvoice happy path body matches", { timeout: 5000 }, async () => {
resetState();
const trustedHandle = freshTrusted();
const createBody = {
customerId: "c",
amountCents: 1,
currency: "USD",
};
const create: ParityInput = {
name: "create",
method: "POST",
path: "/example/invoices",
scopes: ADMIN_SCOPES,
body: createBody,
};
await runTrusted(trustedHandle.table, create);
await runWasm(create);
const get: ParityInput = {
name: "get",
method: "GET",
path: "/example/invoices/inv-1",
scopes: ADMIN_SCOPES,
};
const t = await runTrusted(trustedHandle.table, get);
const w = await runWasm(get);
expect(w.status).toBe(t.status);
expect(w.bodyText).toBe(t.bodyText);
});
it.skip("streamLargeReport is skipped — streaming responses are out of scope for the wasm tier", {
timeout: 5000,
}, () => {
// Documented omission: the wasm subpath omits this endpoint
// because Javy/QuickJS lacks ReadableStream and the
// WASI-stdio envelope has no chunked-response shape.
});
});