Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions scripts/validate-openapi-contract.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";

const specPath = resolve(process.cwd(), "docs/openapi.json");
const spec = JSON.parse(readFileSync(specPath, "utf8"));
Expand Down Expand Up @@ -184,6 +185,77 @@ for (const [name, schema] of Object.entries(spec.components?.schemas ?? {})) {
inspectSchema(schema, `components.schemas.${name}`);
}

async function checkRuntimeMounts() {
let createApp;
try {
const appModule = await import(
pathToFileURL(resolve(process.cwd(), "src/app.ts")).href
);
createApp = appModule.createApp ?? appModule.default;
} catch (error) {
warn(
`runtime: unable to load createApp from src/app.ts (${error?.message ?? error})`,
);
return;
}
if (typeof createApp !== "function") {
warn("runtime: createApp is not exported from src/app.ts");
return;
}

let app;
try {
app = createApp();
} catch (error) {
fail(`runtime: createApp() threw (${error?.message ?? error})`);
return;
}

const documented = new Set();
for (const [path, pathItem] of Object.entries(spec.paths ?? {})) {
for (const method of Object.keys(pathItem)) {
if (!methods.has(method)) continue;
documented.add(`${method.toUpperCase()} ${path}`);
const requestPath = path.replace(/\{([^}]+)\}/g, "__contract__");
let response;
try {
response = await app.request(requestPath, { method: method.toUpperCase() });
} catch (error) {
fail(
`runtime: ${method.toUpperCase()} ${path} threw (${error?.message ?? error})`,
);
continue;
}
if (response.status === 404) {
fail(
`runtime: ${method.toUpperCase()} ${path} returned 404 from createApp (documented path is not mounted)`,
);
}
}
}

const mounted = new Set();
const router = app?.router ?? app?._router;
const stack = router?.stack ?? [];
for (const layer of stack) {
const layerPath = layer?.route?.path ?? layer?.path;
if (typeof layerPath !== "string") continue;
const layerMethods = layer?.route?.methods
? Object.keys(layer.route.methods)
: ["get"];
for (const method of layerMethods) {
mounted.add(`${method.toUpperCase()} ${layerPath}`);
}
}
for (const entry of mounted) {
if (!documented.has(entry)) {
warn(`runtime: mounted but undocumented path ${entry}`);
}
}
}

await checkRuntimeMounts();

if (failures.length > 0) {
console.error(
`OpenAPI contract validation failed with ${failures.length} issue(s):`,
Expand Down
140 changes: 139 additions & 1 deletion tests/contract/openapi-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
* harnesses so CI does not need a database, a wallet, an upstream service, or
* a DNS resolver. The final assertions also enforce the canonical response
* envelope used by the full app.
*
* The assembled-app contract suite below builds the real `createApp()`
* instance and issues a request for every documented path in `docs/openapi.json`,
* failing when a documented path is not mounted (the app returns 404). This
* catches the class of bug where a router is tested in isolation but never
* installed in the assembled app.
*/
import fs from "node:fs";
import path from "node:path";
Expand All @@ -31,6 +37,7 @@ import {
validateWebhookUrl,
WebhookValidationError,
} from "../../src/webhooks/webhook.validator.js";
import { createApp } from "../../src/app.js";

type OpenApiDocument = {
openapi: string;
Expand All @@ -43,6 +50,40 @@ const spec = JSON.parse(fs.readFileSync(specPath, "utf8")) as OpenApiDocument;

const validWallet = "G" + "A".repeat(55);

const HTTP_METHODS = [
"get",
"post",
"put",
"patch",
"delete",
"options",
"head",
"trace",
] as const;

type HttpMethod = (typeof HTTP_METHODS)[number];

/**
* Parameter values that satisfy the documented path templates. The goal is
* to reach the mounted router so the app responds with anything other than
* 404. Authentication and validation failures (4xx) are acceptable because
* they prove the route is wired into the app.
*/
const PATH_PARAMETER_VALUES: Record<string, string> = {
id: "contract-test-id",
userId: "contract-test-user",
walletAddress: validWallet,
address: validWallet,
tenantId: "contract-test-tenant",
flagKey: "contract-test-flag",
key: "contract-test-key",
jobId: "contract-test-job",
webhookId: "contract-test-webhook",
refundId: "contract-test-refund",
};

const DEFAULT_PATH_PARAMETER_VALUE = "contract-test";

function buildSchemaApp(schema: z.ZodSchema) {
const app = express();
app.use(express.json());
Expand Down Expand Up @@ -102,6 +143,52 @@ function assertErrorEnvelope(body: unknown, code?: string) {
}
}

function isHttpMethod(method: string): method is HttpMethod {
return (HTTP_METHODS is readonly string[]).includes(method);
}

function describePath(pathTemplate: string) {
return pathTemplate.replace(/{([^}]+)}/g, (_match, name: string) => {
const value = PATH_PARAMETER_VALUES[name] ?? DEFAULT_PATH_PARAMETER_VALUE;
return encodeURIComponent(value);
});
}

function documentedRoutes() {
const routes: Array<{ pathTemplate: string; method: HttpMethod }> = [];
for (const [pathTemplate, pathItem] of Object.entries(spec.paths)) {
for (const method of Object.keys(pathItem)) {
if (isHttpMethod(method)) {
routes.push({ pathTemplate, method });
}
}
}
return routes;
}

/**
* Request bodies for documented POST/PUT/PATCH operations. The bodies are
* intentionally minimal: the goal is to exercise the mounted router, not to
* satisfy business validation. A 400 response is a successful contract
* assertion because it proves the route is mounted.
*/
function requestBodyFor(pathTemplate: string, method: HttpMethod): unknown | undefined {
if (!method.match(/^(post|put|patch)$/)) return undefined;
if (pathTemplate.includes("/auth/")) {
if (pathTemplate.endsWith("/login")) {
return {
walletAddress: validWallet,
signature: "contract-test-signature",
message: "contract-test-message",
};
}
if (pathTemplate.endsWith("/refresh")) {
return { refreshToken: "contract-test-refresh-token" };
}
}
return {};
}

describe("OpenAPI document integrity", () => {
it("is OpenAPI 3.1 and exposes the canonical JSON contract", () => {
expect(spec.openapi).toBe("3.1.0");
Expand Down Expand Up @@ -147,6 +234,57 @@ describe("OpenAPI document integrity", () => {
});
});

describe("assembled app OpenAPI contract", () => {
const routes = documentedRoutes();

it("documents at least one operation per documented path", () => {
expect(routes.length).toBeGreaterThan(0);
});

it("mounts every documented path in createApp()", async () => {
const app = createApp();
const missing: string[] = [];

for (const { pathTemplate, method } of routes) {
const url = describePath(pathTemplate);
const body = requestBodyFor(pathTemplate, method);
const call = request(app)[method](url);
if (body !== undefined) {
call.send(body as object);
}
const response = await call;
if (response.status === 404) {
missing.push(`${method.toUpperCase()} ${pathTemplate}`);
}
}

expect(missing).toEqual([]);
});

it("reports undocumented mounted paths as warnings", () => {
const app = createApp();
const documented = new Set(
routes.map(({ pathTemplate, method }) => `${method} ${pathTemplate}`),
);
const mounted = new Set<string>();
const stack = (app as unknown as { _router?: { stack?: Array<{ route?: unknown }> } })._router
?.stack;
for (const layer of stack ?? []) {
const route = layer.route;
if (typeof route === "string") {
mounted.add(`GET ${route}`);
}
}
const undocumented = [...mounted].filter((entry) => !documented.has(entry));
if (undocumented.length > 0) {
console.warn(
`Undocumented mounted paths (${undocumented.length}): ${undocumented.join(", ")}`,
);
}
expect(Array.isArray(undocumented)).toBe(true);
});
});

describe("auth request contracts at runtime", () => {
it("accepts the complete wallet login request and returns a success envelope", async () => {
const response = await request(buildSchemaApp(walletLoginSchema))
Expand Down Expand Up @@ -295,7 +433,7 @@ describe("billing and proxy response contracts", () => {

it("keeps auth, billing, webhook, and proxy contract surfaces represented by tests", () => {
const testedSurfaces = new Set(["auth", "billing", "webhook", "proxy"]);
expect([...testedSurfaces]).toEqual(
expect(['testedSurfaces]).toEqual(
expect.arrayContaining(["auth", "billing", "webhook", "proxy"]),
);
});
Expand Down