import { describe, it, expect, vi, beforeEach } from "vitest"; import express from "express"; import request from "supertest"; /** * Test suite for error response security and consistency (TDD) * * Issues being fixed: * 1. Convert routes leak internal error messages via err.message * 2. Templates route leaks error details * 3. Convert routes don't handle PDF_TIMEOUT (should be 504) * 4. Inconsistent QUEUE_FULL status codes (should be 503, not 429) */ describe("Error Response Security - Convert Routes", () => { let app: express.Express; beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); const { renderPdf } = await import("../services/browser.js"); vi.mocked(renderPdf).mockResolvedValue({ pdf: Buffer.from("%PDF-1.4 mock"), durationMs: 10 }); const { convertRouter } = await import("../routes/convert.js"); app = express(); app.use(express.json({ limit: "500kb" })); app.use("/v1/convert", convertRouter); }); describe("QUEUE_FULL handling", () => { it("returns 503 (not 429) for QUEUE_FULL on /html", async () => { const { renderPdf } = await import("../services/browser.js"); vi.mocked(renderPdf).mockRejectedValue(new Error("QUEUE_FULL")); const res = await request(app) .post("/v1/convert/html") .set("content-type", "application/json") .send({ html: "

Test

" }); expect(res.status).toBe(503); expect(res.body.error).toBe("Server busy — too many concurrent PDF generations. Please try again in a few seconds."); }); it("returns 503 (not 429) for QUEUE_FULL on /markdown", async () => { const { renderPdf } = await import("../services/browser.js"); vi.mocked(renderPdf).mockRejectedValue(new Error("QUEUE_FULL")); const res = await request(app) .post("/v1/convert/markdown") .set("content-type", "application/json") .send({ markdown: "# Test" }); expect(res.status).toBe(503); expect(res.body.error).toBe("Server busy — too many concurrent PDF generations. Please try again in a few seconds."); }); it("returns 503 (not 429) for QUEUE_FULL on /url", async () => { vi.mock("node:dns/promises", () => ({ default: { lookup: vi.fn().mockResolvedValue({ address: "93.184.216.34", family: 4 }) }, })); const { renderUrlPdf } = await import("../services/browser.js"); vi.mocked(renderUrlPdf).mockRejectedValue(new Error("QUEUE_FULL")); const res = await request(app) .post("/v1/convert/url") .set("content-type", "application/json") .send({ url: "https://example.com" }); expect(res.status).toBe(503); expect(res.body.error).toBe("Server busy — too many concurrent PDF generations. Please try again in a few seconds."); }); }); describe("PDF_TIMEOUT handling", () => { it("returns 504 for PDF_TIMEOUT on /html", async () => { const { renderPdf } = await import("../services/browser.js"); vi.mocked(renderPdf).mockRejectedValue(new Error("PDF_TIMEOUT")); const res = await request(app) .post("/v1/convert/html") .set("content-type", "application/json") .send({ html: "

Test

" }); expect(res.status).toBe(504); expect(res.body.error).toBe("PDF generation timed out."); }); it("returns 504 for PDF_TIMEOUT on /markdown", async () => { const { renderPdf } = await import("../services/browser.js"); vi.mocked(renderPdf).mockRejectedValue(new Error("PDF_TIMEOUT")); const res = await request(app) .post("/v1/convert/markdown") .set("content-type", "application/json") .send({ markdown: "# Test" }); expect(res.status).toBe(504); expect(res.body.error).toBe("PDF generation timed out."); }); it("returns 504 for PDF_TIMEOUT on /url", async () => { vi.mock("node:dns/promises", () => ({ default: { lookup: vi.fn().mockResolvedValue({ address: "93.184.216.34", family: 4 }) }, })); const { renderUrlPdf } = await import("../services/browser.js"); vi.mocked(renderUrlPdf).mockRejectedValue(new Error("PDF_TIMEOUT")); const res = await request(app) .post("/v1/convert/url") .set("content-type", "application/json") .send({ url: "https://example.com" }); expect(res.status).toBe(504); expect(res.body.error).toBe("PDF generation timed out."); }); }); describe("Generic error handling (no information disclosure)", () => { it("does not expose internal error message on /html", async () => { const { renderPdf } = await import("../services/browser.js"); const internalError = new Error("Puppeteer crashed: SIGSEGV in Chrome process"); vi.mocked(renderPdf).mockRejectedValue(internalError); const res = await request(app) .post("/v1/convert/html") .set("content-type", "application/json") .send({ html: "

Test

" }); expect(res.status).toBe(500); expect(res.body.error).toBe("PDF generation failed."); expect(res.body.error).not.toContain("Puppeteer"); expect(res.body.error).not.toContain("SIGSEGV"); expect(res.body.error).not.toContain("Chrome"); }); it("does not expose internal error message on /markdown", async () => { const { renderPdf } = await import("../services/browser.js"); const internalError = new Error("Page.evaluate() failed: Cannot read property 'x' of undefined"); vi.mocked(renderPdf).mockRejectedValue(internalError); const res = await request(app) .post("/v1/convert/markdown") .set("content-type", "application/json") .send({ markdown: "# Test" }); expect(res.status).toBe(500); expect(res.body.error).toBe("PDF generation failed."); expect(res.body.error).not.toContain("evaluate"); expect(res.body.error).not.toContain("undefined"); }); it("does not expose internal error message on /url", async () => { vi.mock("node:dns/promises", () => ({ default: { lookup: vi.fn().mockResolvedValue({ address: "93.184.216.34", family: 4 }) }, })); const { renderUrlPdf } = await import("../services/browser.js"); const internalError = new Error("Browser context crashed with exit code 137"); vi.mocked(renderUrlPdf).mockRejectedValue(internalError); const res = await request(app) .post("/v1/convert/url") .set("content-type", "application/json") .send({ url: "https://example.com" }); expect(res.status).toBe(500); expect(res.body.error).toBe("PDF generation failed."); expect(res.body.error).not.toContain("context crashed"); expect(res.body.error).not.toContain("exit code"); }); }); }); describe("Error Response Security - Templates Route", () => { let app: express.Express; beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); const { renderPdf } = await import("../services/browser.js"); vi.mocked(renderPdf).mockResolvedValue({ pdf: Buffer.from("%PDF-1.4 mock"), durationMs: 10 }); const { templatesRouter } = await import("../routes/templates.js"); app = express(); app.use(express.json({ limit: "500kb" })); app.use("/v1/templates", templatesRouter); }); it("does not expose error details (no 'detail' field)", async () => { const { renderPdf } = await import("../services/browser.js"); const internalError = new Error("Handlebars compilation failed: Unexpected token"); vi.mocked(renderPdf).mockRejectedValue(internalError); const res = await request(app) .post("/v1/templates/invoice/render") .set("content-type", "application/json") .send({ invoiceNumber: "INV-001", date: "2026-03-07", from: { name: "Test Company" }, to: { name: "Customer" }, items: [{ description: "Test", quantity: 1, unitPrice: 100 }] }); expect(res.status).toBe(500); expect(res.body.error).toBe("Template rendering failed"); expect(res.body).not.toHaveProperty("detail"); expect(JSON.stringify(res.body)).not.toContain("Handlebars"); expect(JSON.stringify(res.body)).not.toContain("Unexpected token"); }); }); describe("Error Response Security - Admin Cleanup", () => { let app: express.Express; beforeEach(async () => { vi.clearAllMocks(); vi.resetModules(); // Mock auth middlewares const mockAuthMiddleware = (req: any, res: any, next: any) => next(); const mockAdminAuth = (req: any, res: any, next: any) => next(); // Mock database functions vi.mock("../services/db.js", () => ({ cleanupStaleData: vi.fn(), })); const { cleanupStaleData } = await import("../services/db.js"); vi.mocked(cleanupStaleData).mockResolvedValue({ expiredVerifications: 3, orphanedUsage: 2 }); // Create minimal app app = express(); app.use(express.json()); // Mock the cleanup endpoint directly app.post("/admin/cleanup", mockAuthMiddleware, mockAdminAuth, async (_req: any, res: any) => { try { const results = await cleanupStaleData(); res.json({ status: "ok", cleaned: results }); } catch (err: any) { // This should match the fixed behavior res.status(500).json({ error: "Cleanup failed" }); } }); }); it("does not expose error message (no 'message' field)", async () => { const { cleanupStaleData } = await import("../services/db.js"); const internalError = new Error("Database connection pool exhausted"); vi.mocked(cleanupStaleData).mockRejectedValue(internalError); const res = await request(app) .post("/admin/cleanup") .set("content-type", "application/json") .send({}); expect(res.status).toBe(500); expect(res.body.error).toBe("Cleanup failed"); expect(res.body).not.toHaveProperty("message"); expect(JSON.stringify(res.body)).not.toContain("Database"); expect(JSON.stringify(res.body)).not.toContain("exhausted"); }); });