Skip to content
Merged
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
3 changes: 2 additions & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions src/routes/docs.production.test.ts
Original file line number Diff line number Diff line change
@@ -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\./);
});
});
43 changes: 41 additions & 2 deletions src/routes/docs.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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(/<script[^>]+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<string, Record<string, unknown>> };
const missing = collectRefs(openApiSpec).filter((ref) => {
Expand All @@ -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<string, Record<string, { operationId?: string }>>)) {
for (const methods of Object.values(
openApiSpec.paths as Record<string, Record<string, { operationId?: string }>>,
)) {
for (const op of Object.values(methods)) {
expect(op.operationId, JSON.stringify(op).slice(0, 80)).toBeTypeOf("string");
ids.push(op.operationId!);
Expand Down
52 changes: 41 additions & 11 deletions src/routes/docs.ts
Original file line number Diff line number Diff line change
@@ -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=<new-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.
Expand All @@ -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)) {
Expand All @@ -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)) {
Expand All @@ -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(`<!DOCTYPE html>
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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SynapsVault API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<link rel="stylesheet"
href="${cssUrl}"
integrity="${SWAGGER_UI_CSS_INTEGRITY}"
crossorigin="anonymous" />
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script src="${jsUrl}"
integrity="${SWAGGER_UI_JS_INTEGRITY}"
crossorigin="anonymous"></script>
<script>
SwaggerUIBundle({
url: "/openapi.json",
Expand All @@ -73,6 +102,7 @@ router.get("/docs", (_req, res) => {
</script>
</body>
</html>`);
});
});
}

export default router;
export default router;
Loading