From 2d6fc9bba066c69a43a0772a49107b61420699e4 Mon Sep 17 00:00:00 2001 From: Real Dev Vortex Date: Tue, 29 Sep 2026 21:23:48 +0100 Subject: [PATCH] security: Crawl every documented OpenAPI path through createApp (#1321) --- scripts/validate-openapi-contract.mjs | 72 +++++++++++++ tests/contract/openapi-runtime.test.ts | 140 ++++++++++++++++++++++++- 2 files changed, 211 insertions(+), 1 deletion(-) diff --git a/scripts/validate-openapi-contract.mjs b/scripts/validate-openapi-contract.mjs index 07de5ba6..b05f86eb 100644 --- a/scripts/validate-openapi-contract.mjs +++ b/scripts/validate-openapi-contract.mjs @@ -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")); @@ -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):`, diff --git a/tests/contract/openapi-runtime.test.ts b/tests/contract/openapi-runtime.test.ts index 9061e4e9..d9e2121a 100644 --- a/tests/contract/openapi-runtime.test.ts +++ b/tests/contract/openapi-runtime.test.ts @@ -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"; @@ -31,6 +37,7 @@ import { validateWebhookUrl, WebhookValidationError, } from "../../src/webhooks/webhook.validator.js"; +import { createApp } from "../../src/app.js"; type OpenApiDocument = { openapi: string; @@ -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 = { + 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()); @@ -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"); @@ -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(); + 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)) @@ -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"]), ); });