|
| 1 | +import * as fs from "fs"; |
| 2 | +import * as path from "path"; |
| 3 | +import * as yaml from "js-yaml"; |
1 | 4 | import { resetOpenApiCache, getOpenApiSpec } from "../src/openapi/builder"; |
2 | 5 |
|
3 | 6 | type Method = "get" | "post" | "put" | "patch" | "delete" | "head" | "options"; |
@@ -31,6 +34,7 @@ const EXPECTED_ROUTES: RouteEntry[] = [ |
31 | 34 | { method: "get", path: "/api/admin/audit" }, |
32 | 35 | { method: "get", path: "/api/audit/counts" }, |
33 | 36 | { method: "get", path: "/api/admin/users/{address}" }, |
| 37 | + { method: "post", path: "/api/admin/users/{address}/impersonate" }, |
34 | 38 | { method: "get", path: "/api/admin/feature-flags" }, |
35 | 39 | { method: "post", path: "/api/admin/feature-flags" }, |
36 | 40 | { method: "get", path: "/api/admin/feature-flags/{key}" }, |
@@ -150,8 +154,45 @@ function main(): number { |
150 | 154 | exitCode = 1; |
151 | 155 | } |
152 | 156 |
|
| 157 | + // The checked-in YAML must be byte-for-byte reproducible from the registry. |
| 158 | + // This catches manual edits and stale generated artifacts before deployment. |
| 159 | + const generated = yaml.dump(spec, { |
| 160 | + indent: 2, |
| 161 | + lineWidth: 120, |
| 162 | + noRefs: false, |
| 163 | + sortKeys: false, |
| 164 | + }); |
| 165 | + const artifactPath = path.resolve(__dirname, "..", "openapi.yaml"); |
| 166 | + const checkedIn = fs.readFileSync(artifactPath, "utf8"); |
| 167 | + if (generated !== checkedIn) { |
| 168 | + console.error("FAIL: openapi.yaml is stale; run npm run openapi:generate and commit the result"); |
| 169 | + exitCode = 1; |
| 170 | + } |
| 171 | + |
| 172 | + // Representative contract invariants: paginated endpoints must describe |
| 173 | + // both cursor/limit inputs and a validation error, while protected routes |
| 174 | + // must carry the bearer security requirement. |
| 175 | + const paths = spec.paths as Record<string, Record<string, any>>; |
| 176 | + for (const route of ["/api/users", "/api/users/{address}/predictions"]) { |
| 177 | + const operation = paths[route]?.get; |
| 178 | + const parameterNames = new Set((operation?.parameters ?? []).map((p: any) => p.name)); |
| 179 | + if (!parameterNames.has("cursor") || !parameterNames.has("limit") || !operation?.responses?.["400"]) { |
| 180 | + console.error(`FAIL: ${route} must document cursor, limit, and a 400 validation response`); |
| 181 | + exitCode = 1; |
| 182 | + } |
| 183 | + } |
| 184 | + for (const [route, item] of Object.entries(paths)) { |
| 185 | + for (const [method, operation] of Object.entries(item)) { |
| 186 | + if (!["get", "post", "put", "patch", "delete"].includes(method)) continue; |
| 187 | + if (operation.security && operation.security.length > 0 && !operation.responses?.["401"] && !operation.responses?.["403"]) { |
| 188 | + console.error(`FAIL: protected ${method.toUpperCase()} ${route} must document an auth error response`); |
| 189 | + exitCode = 1; |
| 190 | + } |
| 191 | + } |
| 192 | + } |
| 193 | + |
153 | 194 | if (exitCode === 0) { |
154 | | - console.log(`OK: all ${EXPECTED_ROUTES.length} routes documented correctly`); |
| 195 | + console.log(`OK: routes, reproducible artifact, and representative contracts validated`); |
155 | 196 | } |
156 | 197 |
|
157 | 198 | return exitCode; |
|
0 commit comments