diff --git a/src/app.ts b/src/app.ts index cda9630..150c8dc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -54,7 +54,8 @@ export function createApp(): Express { app.use(metricsRouter); app.use(adminRouter); - // OpenAPI spec + Swagger UI (all envs; UI is CDN-based, no extra package needed) + // OpenAPI spec (/openapi.json, /docs/json) — all envs. + // Swagger UI (/docs) — non-production only (#54); gate is inside docsRouter. app.use(docsRouter); // 404 handler (after all routes) diff --git a/src/routes/docs.production.test.ts b/src/routes/docs.production.test.ts new file mode 100644 index 0000000..535ff0f --- /dev/null +++ b/src/routes/docs.production.test.ts @@ -0,0 +1,42 @@ +/** + * Production-gate tests for the docs router (#54). + * + * This file must stay separate from docs.test.ts because vi.mock() is hoisted + * to the top of each test *file*, giving each file its own module registry. + * Setting NODE_ENV="production" here means docs.ts is loaded with the gate + * closed (the `if` block skipped) for every test in this file. + */ +import { describe, it, expect, vi } from "vitest"; +import express from "express"; +import request from "supertest"; + +// This mock is hoisted before any imports, so docs.ts sees NODE_ENV=production +// when it evaluates `if (config.NODE_ENV !== "production")`. +vi.mock("../config.js", () => ({ + config: { NODE_ENV: "production" }, +})); + +import docsRouter from "./docs.js"; + +const app = express().use(docsRouter); + +describe("docs router — production gate (#54)", () => { + it("GET /docs returns 404 when NODE_ENV=production", async () => { + const res = await request(app).get("/docs"); + // The route is not registered in production, so Express falls through to a 404. + expect(res.status).toBe(404); + }); + + it("GET /openapi.json still returns 200 in production", async () => { + const res = await request(app).get("/openapi.json"); + // The OpenAPI spec endpoint is always available regardless of NODE_ENV. + expect(res.status).toBe(200); + expect(res.body.openapi).toMatch(/^3\./); + }); + + it("GET /docs/json still returns 200 in production", async () => { + const res = await request(app).get("/docs/json"); + expect(res.status).toBe(200); + expect(res.body.openapi).toMatch(/^3\./); + }); +}); diff --git a/src/routes/docs.test.ts b/src/routes/docs.test.ts index 144486e..5d45228 100644 --- a/src/routes/docs.test.ts +++ b/src/routes/docs.test.ts @@ -1,6 +1,13 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import express from "express"; import request from "supertest"; + +// This mock is hoisted before any import, so docs.ts sees NODE_ENV="test" +// (non-production) and registers the /docs route. +vi.mock("../config.js", () => ({ + config: { NODE_ENV: "test" }, +})); + import docsRouter from "./docs.js"; import { openApiSpec } from "../openapi.js"; @@ -47,6 +54,36 @@ describe("docs routes", () => { expect(res.text).toContain("/openapi.json"); }); + // ── Acceptance criterion: SRI integrity attributes (#54) ────────────────── + + it("GET /docs HTML contains an integrity attribute for the CSS asset (#54)", async () => { + const res = await request(app).get("/docs"); + expect(res.status).toBe(200); + expect(res.text).toMatch(/rel="stylesheet"[^>]*integrity="sha384-/); + }); + + it("GET /docs HTML contains an integrity attribute for the JS asset (#54)", async () => { + const res = await request(app).get("/docs"); + expect(res.status).toBe(200); + expect(res.text).toMatch(/]+integrity="sha384-/); + }); + + it("GET /docs HTML carries crossorigin='anonymous' on both assets (#54)", async () => { + const res = await request(app).get("/docs"); + expect(res.status).toBe(200); + const matches = res.text.match(/crossorigin="anonymous"/g) ?? []; + expect(matches.length).toBeGreaterThanOrEqual(2); + }); + + it("GET /docs HTML uses an exact pinned version, not a floating range (#54)", async () => { + const res = await request(app).get("/docs"); + expect(res.status).toBe(200); + // Must not have a bare major version like @5/ or @5" + expect(res.text).not.toMatch(/swagger-ui-dist@\d+[/"]/); + // Must have a full semver like @5.34.0 + expect(res.text).toMatch(/swagger-ui-dist@\d+\.\d+\.\d+/); + }); + it("every $ref resolves to a defined component", () => { const spec = openApiSpec as unknown as { components: Record> }; const missing = collectRefs(openApiSpec).filter((ref) => { @@ -58,7 +95,9 @@ describe("docs routes", () => { it("documents every operation with a unique operationId", () => { const ids: string[] = []; - for (const methods of Object.values(openApiSpec.paths as Record>)) { + for (const methods of Object.values( + openApiSpec.paths as Record>, + )) { for (const op of Object.values(methods)) { expect(op.operationId, JSON.stringify(op).slice(0, 80)).toBeTypeOf("string"); ids.push(op.operationId!); diff --git a/src/routes/docs.ts b/src/routes/docs.ts index 0533f29..f98c8f2 100644 --- a/src/routes/docs.ts +++ b/src/routes/docs.ts @@ -1,8 +1,23 @@ import { Router, type Router as RouterType } from "express"; import { openApiSpec } from "../openapi.js"; +import { config } from "../config.js"; const router: RouterType = Router(); +// ── Pinned Swagger UI version ───────────────────────────────────────────────── +// Update these constants together whenever Swagger UI is upgraded. +// Run the following to recompute the SRI hashes after bumping the version: +// VERSION= +// curl -fsSL https://unpkg.com/swagger-ui-dist@$VERSION/swagger-ui.css \ +// | openssl dgst -sha384 -binary | openssl base64 -A +// curl -fsSL https://unpkg.com/swagger-ui-dist@$VERSION/swagger-ui-bundle.js \ +// | openssl dgst -sha384 -binary | openssl base64 -A +const SWAGGER_UI_VERSION = "5.34.0"; +const SWAGGER_UI_CSS_INTEGRITY = + "sha384-4c3vsE5f+trhFyHrFrLM1fFGUn531NIaC7dcqEWIJdoRQJ5CkJbSuSCwXLpaS1KF"; +const SWAGGER_UI_JS_INTEGRITY = + "sha384-9Td7MKiIr2S83E1kZyyVi8KdJw/S8CpAvu0llLyMeg0xq/hDGiZJfVn0fV4llCmK"; + /** * Validates that the OpenAPI spec object is well-formed enough to serve. * Returns true when the spec looks usable, false otherwise. @@ -17,7 +32,8 @@ function isWellFormedSpec(spec: unknown): boolean { } /** - * GET /openapi.json — machine-readable OpenAPI 3.0 spec (canonical endpoint) + * GET /openapi.json — machine-readable OpenAPI 3.0 spec (canonical endpoint). + * Served in all environments. */ router.get("/openapi.json", (_req, res) => { if (!isWellFormedSpec(openApiSpec)) { @@ -31,7 +47,8 @@ router.get("/openapi.json", (_req, res) => { }); /** - * GET /docs/json — alias for the canonical OpenAPI spec endpoint + * GET /docs/json — alias for the canonical OpenAPI spec endpoint. + * Served in all environments. */ router.get("/docs/json", (_req, res) => { if (!isWellFormedSpec(openApiSpec)) { @@ -45,22 +62,34 @@ router.get("/docs/json", (_req, res) => { }); /** - * GET /docs — Swagger UI (only mounted in non-production environments) - * Uses the official Swagger UI CDN so no extra npm package is required. + * GET /docs — Swagger UI. + * + * Only mounted outside production (#54). The CDN assets are pinned to an + * exact version and carry Subresource Integrity (SRI) hashes so the browser + * rejects any tampered file served from the CDN. */ -router.get("/docs", (_req, res) => { - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.send(` +if (config.NODE_ENV !== "production") { + router.get("/docs", (_req, res) => { + const cssUrl = `https://unpkg.com/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui.css`; + const jsUrl = `https://unpkg.com/swagger-ui-dist@${SWAGGER_UI_VERSION}/swagger-ui-bundle.js`; + + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.send(` SynapsVault API Docs - +
- + `); -}); + }); +} -export default router; \ No newline at end of file +export default router;