Initial Commit
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
import { createTestHost } from "@suaveplan/api-host/testing";
|
||||
import type { Principal } from "@suaveplan/api-types/principal";
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import {
|
||||
buildRoutes,
|
||||
createInMemoryLedger,
|
||||
EXAMPLE_CODES,
|
||||
EXAMPLE_INVOICE_CREATED_TOPIC,
|
||||
examplePluginModule,
|
||||
USERS_USER_DELETED_TOPIC,
|
||||
} from "../../src/index.js";
|
||||
import { flushTestMetrics } from "../setup/polyfills.js";
|
||||
|
||||
const PRINCIPAL: Principal = {
|
||||
id: "p-int",
|
||||
scopes: ["example:read", "example:write"] as unknown as Principal["scopes"],
|
||||
claims: {},
|
||||
};
|
||||
|
||||
const TENANT_ID = "t-int" as TenantId;
|
||||
|
||||
let host: Awaited<ReturnType<typeof createTestHost>>;
|
||||
let ledger: ReturnType<typeof createInMemoryLedger>;
|
||||
|
||||
beforeEach(async () => {
|
||||
ledger = createInMemoryLedger();
|
||||
host = await createTestHost({
|
||||
seed: {
|
||||
principal: PRINCIPAL,
|
||||
tenants: [
|
||||
{
|
||||
id: TENANT_ID,
|
||||
slug: "tenant-int",
|
||||
status: "active",
|
||||
createdAt: new Date("2026-01-01T00:00:00Z").toISOString(),
|
||||
},
|
||||
],
|
||||
secrets: {
|
||||
EXAMPLE_STRIPE_KEY: "sk_test_int",
|
||||
EXAMPLE_HMAC_SIGNING_KEY: "hmac_test_int",
|
||||
EXAMPLE_WEBHOOK_TOKEN: "webhook_test_int",
|
||||
},
|
||||
},
|
||||
});
|
||||
await host.installFixturePlugin({
|
||||
spec: examplePluginModule,
|
||||
routes: buildRoutes({
|
||||
ledger,
|
||||
publisher: {
|
||||
publish(topic, payload) {
|
||||
host.adapters.eventBus.publish(topic, payload);
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await host.dispose();
|
||||
});
|
||||
|
||||
const URL_PREFIX = "http://test.local";
|
||||
|
||||
describe("api-plugin-example — full integration flow", () => {
|
||||
it("plugin boots and serves /example/health", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const res = await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/health`)
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("REST surface — public bypasses auth", { timeout: 30000 }, async () => {
|
||||
const res = await host.fetchAs(
|
||||
null,
|
||||
new Request(`${URL_PREFIX}/example/public/ping`)
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("REST surface — RBAC denies missing scope", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const noScope: Principal = {
|
||||
id: "z",
|
||||
scopes: [] as unknown as Principal["scopes"],
|
||||
claims: {},
|
||||
};
|
||||
const res = await host.fetchAs(
|
||||
noScope,
|
||||
new Request(`${URL_PREFIX}/example/invoices`)
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
const body = (await res.json()) as { code: string };
|
||||
expect(body.code).toBe("RBAC_MISSING_SCOPE");
|
||||
});
|
||||
|
||||
it("REST surface — Zod rejects malformed body", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const res = await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/invoices`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ customerId: "c-1" }),
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("REST surface — happy path persists + emits event + increments metric", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const eventWait = host.waitForEvent(
|
||||
EXAMPLE_INVOICE_CREATED_TOPIC,
|
||||
5000
|
||||
);
|
||||
const res = await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/invoices`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customerId: "c-1",
|
||||
amountCents: 1500,
|
||||
currency: "USD",
|
||||
}),
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const created = (await res.json()) as { id: string; status: string };
|
||||
expect(created.status).toBe("open");
|
||||
const event = (await eventWait) as { id: string; amountCents: number };
|
||||
expect(event.id).toBe(created.id);
|
||||
expect(event.amountCents).toBe(1500);
|
||||
});
|
||||
|
||||
it("REST surface — finalise transitions to paid + records OTel metrics", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/invoices`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customerId: "c-1",
|
||||
amountCents: 1,
|
||||
currency: "USD",
|
||||
}),
|
||||
})
|
||||
);
|
||||
const finalise = await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/invoices/inv-1/finalise`, {
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
expect(finalise.status).toBe(200);
|
||||
const out = (await finalise.json()) as { status: string };
|
||||
expect(out.status).toBe("paid");
|
||||
|
||||
const metricsCollected = await flushTestMetrics();
|
||||
const allMetrics = metricsCollected.flatMap((rm) =>
|
||||
rm.scopeMetrics.flatMap((sm) => sm.metrics)
|
||||
);
|
||||
const names = new Set(allMetrics.map((m) => m.descriptor.name));
|
||||
expect(names.has("example.invoices_finalised_total")).toBe(true);
|
||||
expect(names.has("example.stripe.call_duration_ms")).toBe(true);
|
||||
expect(names.has("example.outstanding_invoice_count")).toBe(true);
|
||||
});
|
||||
|
||||
it("REST surface — streaming delivers chunked NDJSON", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const res = await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/stream/large-report`)
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const text = await res.text();
|
||||
const lines = text.trim().split("\n");
|
||||
expect(lines.length).toBe(3);
|
||||
});
|
||||
|
||||
it("custom error code surfaces with manifest-mapped status", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const res = await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/subscription-required`)
|
||||
);
|
||||
expect(res.status).toBe(EXAMPLE_CODES.SUBSCRIPTION_INACTIVE ? 409 : 0);
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("INVOICE_NOT_FOUND surfaces 404", { timeout: 30000 }, async () => {
|
||||
const res = await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/invoices/missing`)
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("INVALID_INVOICE_STATE surfaces 422 on double-finalise", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/invoices`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
customerId: "c",
|
||||
amountCents: 1,
|
||||
currency: "USD",
|
||||
}),
|
||||
})
|
||||
);
|
||||
await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/invoices/inv-1/finalise`, {
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
const res = await host.fetch(
|
||||
new Request(`${URL_PREFIX}/example/invoices/inv-1/finalise`, {
|
||||
method: "POST",
|
||||
})
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("subscriber handler runs on incoming users.user.deleted", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const sink = { seen: new Set<string>() };
|
||||
host.adapters.eventBus.on(USERS_USER_DELETED_TOPIC, (raw) => {
|
||||
const p = raw as { userId: string };
|
||||
sink.seen.add(p.userId);
|
||||
});
|
||||
host.adapters.eventBus.publish(USERS_USER_DELETED_TOPIC, {
|
||||
userId: "u-1",
|
||||
tenantId: null,
|
||||
});
|
||||
await host.waitForEvent(USERS_USER_DELETED_TOPIC, 5000);
|
||||
expect(sink.seen.has("u-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("every declared secret reads successfully", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const secrets = host.adapters.host.secrets;
|
||||
const a = await secrets.require("EXAMPLE_STRIPE_KEY");
|
||||
const b = await secrets.require("EXAMPLE_HMAC_SIGNING_KEY");
|
||||
const c = await secrets.require("EXAMPLE_WEBHOOK_TOKEN");
|
||||
expect(a).toBe("sk_test_int");
|
||||
expect(b).toBe("hmac_test_int");
|
||||
expect(c).toBe("webhook_test_int");
|
||||
});
|
||||
|
||||
it("response-validation route returns violating payload with ?failResponse=1", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const res = await host.fetch(
|
||||
new Request(`${URL_PREFIX}/_/test/fail-response?failResponse=1`)
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
// Intentionally violates the schema — exercises the warn-mode
|
||||
// path. Enforce mode would require host-side responseValidation
|
||||
// wiring which is not yet present in the test-host.
|
||||
expect(body.status).toBe("exploded");
|
||||
});
|
||||
|
||||
it("response-validation route returns valid payload without the flag", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const res = await host.fetch(
|
||||
new Request(`${URL_PREFIX}/_/test/fail-response`)
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("open");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Test setup for @suaveplan/api-plugin-example.
|
||||
*
|
||||
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module imports so the meter resolved on first call binds to it. The
|
||||
* integration tests then call `flushTestMetrics()` to read the emitted
|
||||
* datapoints and assert against the manifest-declared instruments.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Root-export shape snapshot. Pins the canonical exports of
|
||||
* `@suaveplan/api-plugin-example` so the dual-shape refactor cannot
|
||||
* accidentally drop or rename a public surface.
|
||||
*
|
||||
* Uses TypeScript `typeof` + a structural `Equal<>` helper rather than
|
||||
* a runtime structural diff so the assertion fires at typecheck time —
|
||||
* any drift in the exported type's signature breaks the build before
|
||||
* the test even runs.
|
||||
*/
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import * as RootBarrel from "../../src/index.js";
|
||||
|
||||
type Equal<A, B> =
|
||||
(<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
|
||||
? true
|
||||
: false;
|
||||
|
||||
// Pin the canonical exports — assigning to a `true` constant means the
|
||||
// build fails if the structural compatibility breaks.
|
||||
type _Pin_EXAMPLE_CODES = Equal<
|
||||
typeof RootBarrel.EXAMPLE_CODES,
|
||||
{
|
||||
readonly SUBSCRIPTION_INACTIVE: "PLUGIN.EXAMPLE.SUBSCRIPTION_INACTIVE";
|
||||
readonly INVOICE_NOT_FOUND: "PLUGIN.EXAMPLE.INVOICE_NOT_FOUND";
|
||||
readonly INVALID_INVOICE_STATE: "PLUGIN.EXAMPLE.INVALID_INVOICE_STATE";
|
||||
readonly RESPONSE_VALIDATION_TRIGGER: "PLUGIN.EXAMPLE.RESPONSE_VALIDATION_TRIGGER";
|
||||
}
|
||||
>;
|
||||
const pinExampleCodes: _Pin_EXAMPLE_CODES = true;
|
||||
|
||||
type _Pin_examplePluginModule_name = Equal<
|
||||
typeof RootBarrel.examplePluginModule.name,
|
||||
"@suaveplan/api-plugin-example"
|
||||
>;
|
||||
const pinPluginName: _Pin_examplePluginModule_name = true;
|
||||
|
||||
type _Pin_buildRoutes = Equal<
|
||||
Parameters<typeof RootBarrel.buildRoutes>[0]["ledger"]["nextId"],
|
||||
() => string
|
||||
>;
|
||||
const pinBuildRoutes: _Pin_buildRoutes = true;
|
||||
|
||||
describe("root exports preserve existing types and names", () => {
|
||||
it("exports the canonical EXAMPLE_CODES constants", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(RootBarrel.EXAMPLE_CODES.INVOICE_NOT_FOUND).toBe(
|
||||
"PLUGIN.EXAMPLE.INVOICE_NOT_FOUND"
|
||||
);
|
||||
expect(RootBarrel.EXAMPLE_CODES.SUBSCRIPTION_INACTIVE).toBe(
|
||||
"PLUGIN.EXAMPLE.SUBSCRIPTION_INACTIVE"
|
||||
);
|
||||
expect(RootBarrel.EXAMPLE_CODES.INVALID_INVOICE_STATE).toBe(
|
||||
"PLUGIN.EXAMPLE.INVALID_INVOICE_STATE"
|
||||
);
|
||||
expect(RootBarrel.EXAMPLE_CODES.RESPONSE_VALIDATION_TRIGGER).toBe(
|
||||
"PLUGIN.EXAMPLE.RESPONSE_VALIDATION_TRIGGER"
|
||||
);
|
||||
});
|
||||
|
||||
it("exports ExamplePluginError as a public-constructor class", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const e = new RootBarrel.ExamplePluginError(
|
||||
RootBarrel.EXAMPLE_CODES.INVOICE_NOT_FOUND,
|
||||
"msg"
|
||||
);
|
||||
expect(e.code).toBe("PLUGIN.EXAMPLE.INVOICE_NOT_FOUND");
|
||||
});
|
||||
|
||||
it("exports examplePluginModule with the canonical name + version", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(RootBarrel.examplePluginModule.name).toBe(
|
||||
"@suaveplan/api-plugin-example"
|
||||
);
|
||||
expect(RootBarrel.examplePluginModule.version).toBe("0.1.0");
|
||||
});
|
||||
|
||||
it("exports buildRoutes + createInMemoryLedger", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ledger = RootBarrel.createInMemoryLedger();
|
||||
const routes = RootBarrel.buildRoutes({
|
||||
ledger,
|
||||
publisher: { publish() {} },
|
||||
});
|
||||
expect(routes.length).toBeGreaterThan(0);
|
||||
expect(typeof ledger.nextId).toBe("function");
|
||||
});
|
||||
|
||||
it("exports the canonical event topics and payload schemas", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(RootBarrel.EXAMPLE_INVOICE_CREATED_TOPIC).toBe(
|
||||
"example.invoice.created"
|
||||
);
|
||||
expect(RootBarrel.USERS_USER_DELETED_TOPIC).toBe("users.user.deleted");
|
||||
expect(RootBarrel.ExampleInvoiceCreatedPayload).toBeDefined();
|
||||
expect(RootBarrel.UsersUserDeletedPayload).toBeDefined();
|
||||
});
|
||||
|
||||
it("exports the request/response Zod schemas", { timeout: 5000 }, () => {
|
||||
expect(RootBarrel.CreateInvoiceRequest).toBeDefined();
|
||||
expect(RootBarrel.CreateInvoiceResponse).toBeDefined();
|
||||
expect(RootBarrel.InvoiceResponse).toBeDefined();
|
||||
expect(RootBarrel.PingResponse).toBeDefined();
|
||||
expect(RootBarrel.StreamChunk).toBeDefined();
|
||||
});
|
||||
|
||||
it("exports instrument helpers", { timeout: 5000 }, () => {
|
||||
expect(typeof RootBarrel.getExampleInstruments).toBe("function");
|
||||
expect(typeof RootBarrel.resetExampleInstrumentsForTest).toBe(
|
||||
"function"
|
||||
);
|
||||
});
|
||||
|
||||
it("type-level pins satisfy structural equality", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(pinExampleCodes).toBe(true);
|
||||
expect(pinPluginName).toBe(true);
|
||||
expect(pinBuildRoutes).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Browser-bundle gate. Confirms that the `./wasm` subpath entry point
|
||||
* compiles cleanly under `Bun.build({ target: "browser", format: "esm" })`
|
||||
* AND that the produced bundle does not pull in any `node:*` specifier,
|
||||
* Hono, or OTel-SDK dependency.
|
||||
*
|
||||
* The lint script `scripts/lint/check-wasm-subpath-deps.ts` is the
|
||||
* static-analysis counterpart; this test is the runtime confirmation
|
||||
* via the actual Bun bundler.
|
||||
*/
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("wasm subpath bundles for browser target", () => {
|
||||
it("Bun.build emits a valid ESM bundle with no banned specifiers", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const entry = resolve(__dirname, "..", "..", "src", "wasm", "index.ts");
|
||||
const result = await Bun.build({
|
||||
entrypoints: [entry],
|
||||
target: "browser",
|
||||
format: "esm",
|
||||
external: [
|
||||
"@suaveplan/error",
|
||||
"@suaveplan/api-types",
|
||||
"@opentelemetry/api",
|
||||
"zod",
|
||||
"tslib",
|
||||
],
|
||||
});
|
||||
if (!result.success) {
|
||||
const log = result.logs.map((l) => String(l)).join("\n");
|
||||
throw new Error(`Bun.build failed: ${log}`);
|
||||
}
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.outputs.length).toBeGreaterThan(0);
|
||||
const text = await result.outputs[0]!.text();
|
||||
// No node:* specifier in the produced bundle.
|
||||
expect(text.includes("node:")).toBe(false);
|
||||
// No Hono import in the produced bundle.
|
||||
expect(/from\s+["']hono["']/.test(text)).toBe(false);
|
||||
expect(/from\s+["']@hono\//.test(text)).toBe(false);
|
||||
// No OTel SDK in the produced bundle.
|
||||
expect(/from\s+["']@opentelemetry\/sdk-/.test(text)).toBe(false);
|
||||
// No api-host import.
|
||||
expect(text.includes("@suaveplan/api-host")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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