Blame · Line-by-line history
api-error-format.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 4abf6f1 | 1 | /** |
| 2 | * Machine surfaces must fail as JSON, not HTML. | |
| 3 | * | |
| 4 | * The global app.notFound / app.onError handlers render the shared HTML | |
| 5 | * error pages. Before this was gated, an unmatched /api/* path returned a | |
| 6 | * full HTML document with `content-type: text/html`, so REST, MCP and OCI | |
| 7 | * registry clients hit a JSON.parse error instead of a readable envelope. | |
| 8 | * The production-readiness gate (scripts/production-readiness.mjs, "api-json") | |
| 9 | * checks exactly this against the live site. | |
| 10 | */ | |
| 11 | ||
| 12 | import { describe, expect, it } from "bun:test"; | |
| 13 | import app from "../app"; | |
| 14 | ||
| 15 | async function get(path: string, headers: Record<string, string> = {}) { | |
| 16 | return app.fetch(new Request(`http://localhost${path}`, { headers })); | |
| 17 | } | |
| 18 | ||
| 19 | describe("unmatched machine-surface paths return JSON", () => { | |
| 20 | // `/v2/*` (OCI registry) and `/npm/*` are deliberately absent: their own | |
| 21 | // routers claim the whole subtree and emit their own spec-shaped JSON | |
| 22 | // errors, so the app-level fallback never fires for them. They stay in | |
| 23 | // JSON_ROOTS purely as a safety net. | |
| 24 | const jsonPaths = [ | |
| 25 | "/api/definitely-not-real", | |
| 26 | "/api/v2/definitely-not-real", | |
| 27 | "/mcp/definitely-not-real", | |
| 28 | "/graphql/definitely-not-real", | |
| 29 | "/scim/definitely-not-real", | |
| 30 | "/.well-known/definitely-not-real", | |
| 31 | ]; | |
| 32 | ||
| 33 | for (const path of jsonPaths) { | |
| 34 | it(`${path} → 404 application/json`, async () => { | |
| 35 | const res = await get(path); | |
| 36 | expect(res.status).toBe(404); | |
| 37 | expect(res.headers.get("content-type")).toContain("application/json"); | |
| 38 | const body = await res.json(); | |
| 39 | expect(body.error).toBe("Not found"); | |
| 40 | expect(body.path).toBe(path); | |
| 41 | }); | |
| 42 | } | |
| 43 | ||
| 44 | it("a deep unmatched API path is JSON too", async () => { | |
| 45 | const res = await get("/api/v2/repos/nope/nope/nope/nope"); | |
| 46 | expect(res.status).toBe(404); | |
| 47 | expect(res.headers.get("content-type")).toContain("application/json"); | |
| 48 | }); | |
| 49 | }); | |
| 50 | ||
| 51 | describe("HTML surfaces are unaffected", () => { | |
| 52 | it("a path that merely starts with an API root's letters stays HTML", async () => { | |
| 53 | // "/mcpanything" must NOT be captured by the "/mcp" machine surface — | |
| 54 | // it is a one-segment path and belongs to the /:owner profile route. | |
| 55 | const res = await get("/mcpanything", { accept: "text/html" }); | |
| 56 | expect(res.headers.get("content-type")).toContain("text/html"); | |
| 57 | }); | |
| 58 | ||
| 59 | it("a normal repo path still renders HTML", async () => { | |
| 60 | const res = await get("/someowner/somerepo", { accept: "text/html" }); | |
| 61 | expect(res.headers.get("content-type")).toContain("text/html"); | |
| 62 | }); | |
| 63 | }); |