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(/
+