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