feat: validate PDF options with TDD tests
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Failing after 9m38s
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Failing after 9m38s
This commit is contained in:
parent
0e03e39ec7
commit
f89a3181f7
4 changed files with 335 additions and 0 deletions
|
|
@ -182,3 +182,66 @@ describe("POST /v1/convert/url", () => {
|
||||||
expect(res.headers["content-type"]).toMatch(/application\/pdf/);
|
expect(res.headers["content-type"]).toMatch(/application\/pdf/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("PDF option validation (all endpoints)", () => {
|
||||||
|
const endpoints = [
|
||||||
|
{ path: "/v1/convert/html", body: { html: "<h1>Hi</h1>" } },
|
||||||
|
{ path: "/v1/convert/markdown", body: { markdown: "# Hi" } },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const { path, body } of endpoints) {
|
||||||
|
it(`${path} returns 400 for invalid scale`, async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(path)
|
||||||
|
.set("content-type", "application/json")
|
||||||
|
.send({ ...body, scale: 5 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("scale");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${path} returns 400 for invalid format`, async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(path)
|
||||||
|
.set("content-type", "application/json")
|
||||||
|
.send({ ...body, format: "B5" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("format");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${path} returns 400 for non-boolean landscape`, async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(path)
|
||||||
|
.set("content-type", "application/json")
|
||||||
|
.send({ ...body, landscape: "yes" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("landscape");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${path} returns 400 for invalid pageRanges`, async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(path)
|
||||||
|
.set("content-type", "application/json")
|
||||||
|
.send({ ...body, pageRanges: "abc" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("pageRanges");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`${path} returns 400 for invalid margin`, async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(path)
|
||||||
|
.set("content-type", "application/json")
|
||||||
|
.send({ ...body, margin: "1cm" });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("margin");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("/v1/convert/url returns 400 for invalid scale", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/v1/convert/url")
|
||||||
|
.set("content-type", "application/json")
|
||||||
|
.send({ url: "https://example.com", scale: 5 });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.error).toContain("scale");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
162
src/__tests__/pdf-options.test.ts
Normal file
162
src/__tests__/pdf-options.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { validatePdfOptions } from "../utils/pdf-options.js";
|
||||||
|
|
||||||
|
describe("validatePdfOptions", () => {
|
||||||
|
// --- Happy path ---
|
||||||
|
it("accepts empty options", () => {
|
||||||
|
const result = validatePdfOptions({});
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts undefined", () => {
|
||||||
|
const result = validatePdfOptions(undefined as any);
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts all valid options together", () => {
|
||||||
|
const result = validatePdfOptions({
|
||||||
|
scale: 1.5,
|
||||||
|
format: "A4",
|
||||||
|
landscape: true,
|
||||||
|
printBackground: false,
|
||||||
|
displayHeaderFooter: true,
|
||||||
|
preferCSSPageSize: false,
|
||||||
|
width: "210mm",
|
||||||
|
height: "297mm",
|
||||||
|
margin: { top: "1cm", right: "1cm", bottom: "1cm", left: "1cm" },
|
||||||
|
pageRanges: "1-5",
|
||||||
|
});
|
||||||
|
expect(result.valid).toBe(true);
|
||||||
|
if (result.valid) {
|
||||||
|
expect(result.sanitized.scale).toBe(1.5);
|
||||||
|
expect(result.sanitized.format).toBe("A4");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- scale ---
|
||||||
|
describe("scale", () => {
|
||||||
|
it("accepts 0.1", () => {
|
||||||
|
expect(validatePdfOptions({ scale: 0.1 }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("accepts 2.0", () => {
|
||||||
|
expect(validatePdfOptions({ scale: 2.0 }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("rejects 0.05", () => {
|
||||||
|
const r = validatePdfOptions({ scale: 0.05 });
|
||||||
|
expect(r.valid).toBe(false);
|
||||||
|
if (!r.valid) expect(r.error).toContain("scale");
|
||||||
|
});
|
||||||
|
it("rejects 2.5", () => {
|
||||||
|
expect(validatePdfOptions({ scale: 2.5 }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
it("rejects non-number", () => {
|
||||||
|
expect(validatePdfOptions({ scale: "big" as any }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- format ---
|
||||||
|
describe("format", () => {
|
||||||
|
const validFormats = ["Letter", "Legal", "Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A4", "A5", "A6"];
|
||||||
|
for (const f of validFormats) {
|
||||||
|
it(`accepts ${f}`, () => {
|
||||||
|
expect(validatePdfOptions({ format: f }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
it("accepts case-insensitive (a4)", () => {
|
||||||
|
const r = validatePdfOptions({ format: "a4" });
|
||||||
|
expect(r.valid).toBe(true);
|
||||||
|
if (r.valid) expect(r.sanitized.format).toBe("A4");
|
||||||
|
});
|
||||||
|
it("accepts case-insensitive (letter)", () => {
|
||||||
|
const r = validatePdfOptions({ format: "letter" });
|
||||||
|
expect(r.valid).toBe(true);
|
||||||
|
if (r.valid) expect(r.sanitized.format).toBe("Letter");
|
||||||
|
});
|
||||||
|
it("rejects invalid format", () => {
|
||||||
|
const r = validatePdfOptions({ format: "B5" });
|
||||||
|
expect(r.valid).toBe(false);
|
||||||
|
if (!r.valid) expect(r.error).toContain("format");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- booleans ---
|
||||||
|
for (const field of ["landscape", "printBackground", "displayHeaderFooter", "preferCSSPageSize"] as const) {
|
||||||
|
describe(field, () => {
|
||||||
|
it("accepts true", () => {
|
||||||
|
expect(validatePdfOptions({ [field]: true }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("accepts false", () => {
|
||||||
|
expect(validatePdfOptions({ [field]: false }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("rejects string", () => {
|
||||||
|
const r = validatePdfOptions({ [field]: "yes" as any });
|
||||||
|
expect(r.valid).toBe(false);
|
||||||
|
if (!r.valid) expect(r.error).toContain(field);
|
||||||
|
});
|
||||||
|
it("rejects number", () => {
|
||||||
|
expect(validatePdfOptions({ [field]: 1 as any }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- width/height ---
|
||||||
|
for (const field of ["width", "height"] as const) {
|
||||||
|
describe(field, () => {
|
||||||
|
it("accepts string", () => {
|
||||||
|
expect(validatePdfOptions({ [field]: "210mm" }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("rejects number", () => {
|
||||||
|
expect(validatePdfOptions({ [field]: 210 as any }).valid).toBe(false);
|
||||||
|
const r = validatePdfOptions({ [field]: 210 as any });
|
||||||
|
if (!r.valid) expect(r.error).toContain(field);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- margin ---
|
||||||
|
describe("margin", () => {
|
||||||
|
it("accepts valid margin object", () => {
|
||||||
|
expect(validatePdfOptions({ margin: { top: "1cm", bottom: "2cm" } }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("accepts empty margin object", () => {
|
||||||
|
expect(validatePdfOptions({ margin: {} }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("rejects non-object margin", () => {
|
||||||
|
expect(validatePdfOptions({ margin: "1cm" as any }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
it("rejects margin with non-string values", () => {
|
||||||
|
expect(validatePdfOptions({ margin: { top: 10 } as any }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
it("rejects margin with unknown keys", () => {
|
||||||
|
expect(validatePdfOptions({ margin: { top: "1cm", padding: "2cm" } as any }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- pageRanges ---
|
||||||
|
describe("pageRanges", () => {
|
||||||
|
it("accepts '1-5'", () => {
|
||||||
|
expect(validatePdfOptions({ pageRanges: "1-5" }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("accepts '1,3,5'", () => {
|
||||||
|
expect(validatePdfOptions({ pageRanges: "1,3,5" }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("accepts '2-'", () => {
|
||||||
|
expect(validatePdfOptions({ pageRanges: "2-" }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("accepts '1-3,5,7-9'", () => {
|
||||||
|
expect(validatePdfOptions({ pageRanges: "1-3,5,7-9" }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("accepts single page '3'", () => {
|
||||||
|
expect(validatePdfOptions({ pageRanges: "3" }).valid).toBe(true);
|
||||||
|
});
|
||||||
|
it("rejects non-string", () => {
|
||||||
|
expect(validatePdfOptions({ pageRanges: 5 as any }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
it("rejects invalid pattern", () => {
|
||||||
|
expect(validatePdfOptions({ pageRanges: "abc" }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
it("rejects 'all'", () => {
|
||||||
|
expect(validatePdfOptions({ pageRanges: "all" }).valid).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -6,6 +6,7 @@ import logger from "../services/logger.js";
|
||||||
import { isPrivateIP } from "../utils/network.js";
|
import { isPrivateIP } from "../utils/network.js";
|
||||||
|
|
||||||
import { sanitizeFilename } from "../utils/sanitize.js";
|
import { sanitizeFilename } from "../utils/sanitize.js";
|
||||||
|
import { validatePdfOptions } from "../utils/pdf-options.js";
|
||||||
|
|
||||||
export const convertRouter = Router();
|
export const convertRouter = Router();
|
||||||
|
|
||||||
|
|
@ -94,6 +95,13 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate PDF options
|
||||||
|
const validation = validatePdfOptions(body);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.status(400).json({ error: validation.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Acquire concurrency slot
|
// Acquire concurrency slot
|
||||||
if (req.acquirePdfSlot) {
|
if (req.acquirePdfSlot) {
|
||||||
await req.acquirePdfSlot();
|
await req.acquirePdfSlot();
|
||||||
|
|
@ -203,6 +211,13 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate PDF options
|
||||||
|
const validation = validatePdfOptions(body);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.status(400).json({ error: validation.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Acquire concurrency slot
|
// Acquire concurrency slot
|
||||||
if (req.acquirePdfSlot) {
|
if (req.acquirePdfSlot) {
|
||||||
await req.acquirePdfSlot();
|
await req.acquirePdfSlot();
|
||||||
|
|
@ -339,6 +354,13 @@ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promis
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate PDF options
|
||||||
|
const validation = validatePdfOptions(body);
|
||||||
|
if (!validation.valid) {
|
||||||
|
res.status(400).json({ error: validation.error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Acquire concurrency slot
|
// Acquire concurrency slot
|
||||||
if (req.acquirePdfSlot) {
|
if (req.acquirePdfSlot) {
|
||||||
await req.acquirePdfSlot();
|
await req.acquirePdfSlot();
|
||||||
|
|
|
||||||
88
src/utils/pdf-options.ts
Normal file
88
src/utils/pdf-options.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
const VALID_FORMATS = ["Letter", "Legal", "Tabloid", "Ledger", "A0", "A1", "A2", "A3", "A4", "A5", "A6"];
|
||||||
|
const FORMAT_MAP = new Map(VALID_FORMATS.map(f => [f.toLowerCase(), f]));
|
||||||
|
const PAGE_RANGES_RE = /^\d+(-\d*)?(\s*,\s*\d+(-\d*)?)*$/;
|
||||||
|
const MARGIN_KEYS = new Set(["top", "right", "bottom", "left"]);
|
||||||
|
|
||||||
|
type PdfInput = Record<string, any>;
|
||||||
|
type ValidResult = { valid: true; sanitized: Record<string, any> };
|
||||||
|
type InvalidResult = { valid: false; error: string };
|
||||||
|
|
||||||
|
export function validatePdfOptions(opts: PdfInput): ValidResult | InvalidResult {
|
||||||
|
if (!opts || typeof opts !== "object") return { valid: true, sanitized: {} };
|
||||||
|
|
||||||
|
const sanitized: Record<string, any> = {};
|
||||||
|
|
||||||
|
// scale
|
||||||
|
if (opts.scale !== undefined) {
|
||||||
|
if (typeof opts.scale !== "number" || opts.scale < 0.1 || opts.scale > 2.0) {
|
||||||
|
return { valid: false, error: "scale must be a number between 0.1 and 2.0" };
|
||||||
|
}
|
||||||
|
sanitized.scale = opts.scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
// format
|
||||||
|
if (opts.format !== undefined) {
|
||||||
|
if (typeof opts.format !== "string") {
|
||||||
|
return { valid: false, error: "format must be a string" };
|
||||||
|
}
|
||||||
|
const canonical = FORMAT_MAP.get(opts.format.toLowerCase());
|
||||||
|
if (!canonical) {
|
||||||
|
return { valid: false, error: `format must be one of: ${VALID_FORMATS.join(", ")}` };
|
||||||
|
}
|
||||||
|
sanitized.format = canonical;
|
||||||
|
}
|
||||||
|
|
||||||
|
// booleans
|
||||||
|
for (const field of ["landscape", "printBackground", "displayHeaderFooter", "preferCSSPageSize"]) {
|
||||||
|
if (opts[field] !== undefined) {
|
||||||
|
if (typeof opts[field] !== "boolean") {
|
||||||
|
return { valid: false, error: `${field} must be a boolean` };
|
||||||
|
}
|
||||||
|
sanitized[field] = opts[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// width/height
|
||||||
|
for (const field of ["width", "height"]) {
|
||||||
|
if (opts[field] !== undefined) {
|
||||||
|
if (typeof opts[field] !== "string") {
|
||||||
|
return { valid: false, error: `${field} must be a string (CSS dimension)` };
|
||||||
|
}
|
||||||
|
sanitized[field] = opts[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// margin
|
||||||
|
if (opts.margin !== undefined) {
|
||||||
|
if (typeof opts.margin !== "object" || opts.margin === null || Array.isArray(opts.margin)) {
|
||||||
|
return { valid: false, error: "margin must be an object with top/right/bottom/left string fields" };
|
||||||
|
}
|
||||||
|
for (const key of Object.keys(opts.margin)) {
|
||||||
|
if (!MARGIN_KEYS.has(key)) {
|
||||||
|
return { valid: false, error: `margin contains unknown key: ${key}` };
|
||||||
|
}
|
||||||
|
if (typeof opts.margin[key] !== "string") {
|
||||||
|
return { valid: false, error: `margin.${key} must be a string` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sanitized.margin = { ...opts.margin };
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageRanges
|
||||||
|
if (opts.pageRanges !== undefined) {
|
||||||
|
if (typeof opts.pageRanges !== "string") {
|
||||||
|
return { valid: false, error: "pageRanges must be a string" };
|
||||||
|
}
|
||||||
|
if (!PAGE_RANGES_RE.test(opts.pageRanges.trim())) {
|
||||||
|
return { valid: false, error: "pageRanges must match pattern like '1-5', '1,3,5', or '2-'" };
|
||||||
|
}
|
||||||
|
sanitized.pageRanges = opts.pageRanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass through non-validated fields
|
||||||
|
for (const key of ["headerTemplate", "footerTemplate"]) {
|
||||||
|
if (opts[key] !== undefined) sanitized[key] = opts[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true, sanitized };
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue