docfast/src/__tests__/error-responses.test.ts
DocFast CEO 54316d45cf
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 21m52s
fix: resolve all TypeScript strict-mode errors in test files
- convert-sanitized: use 'as Record' cast for optional mock call args
- error-responses: fix module path (database.js → db.js) and mock return type
- recover-initial-db-fallback: fix mock return type (undefined → true)
- render-timing: remove non-existent .prepare property check
- usage-flush: cast mock request objects to any for test setup

Zero tsc --noEmit errors. 608 tests passing.
2026-03-09 17:12:22 +01:00

268 lines
10 KiB
TypeScript

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: "<h1>Test</h1>" });
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: "<h1>Test</h1>" });
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: "<h1>Test</h1>" });
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");
});
});