feat: validate PDF options with TDD tests
Some checks failed
Build & Deploy to Staging / Build & Deploy to Staging (push) Failing after 9m38s

This commit is contained in:
OpenClaw 2026-02-28 14:05:32 +01:00
parent 0e03e39ec7
commit f89a3181f7
4 changed files with 335 additions and 0 deletions

View file

@ -182,3 +182,66 @@ describe("POST /v1/convert/url", () => {
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");
});
});

View 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);
});
});
});

View file

@ -6,6 +6,7 @@ import logger from "../services/logger.js";
import { isPrivateIP } from "../utils/network.js";
import { sanitizeFilename } from "../utils/sanitize.js";
import { validatePdfOptions } from "../utils/pdf-options.js";
export const convertRouter = Router();
@ -94,6 +95,13 @@ convertRouter.post("/html", async (req: Request & { acquirePdfSlot?: () => Promi
return;
}
// Validate PDF options
const validation = validatePdfOptions(body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
// Acquire concurrency slot
if (req.acquirePdfSlot) {
await req.acquirePdfSlot();
@ -203,6 +211,13 @@ convertRouter.post("/markdown", async (req: Request & { acquirePdfSlot?: () => P
return;
}
// Validate PDF options
const validation = validatePdfOptions(body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
// Acquire concurrency slot
if (req.acquirePdfSlot) {
await req.acquirePdfSlot();
@ -339,6 +354,13 @@ convertRouter.post("/url", async (req: Request & { acquirePdfSlot?: () => Promis
return;
}
// Validate PDF options
const validation = validatePdfOptions(body);
if (!validation.valid) {
res.status(400).json({ error: validation.error });
return;
}
// Acquire concurrency slot
if (req.acquirePdfSlot) {
await req.acquirePdfSlot();

88
src/utils/pdf-options.ts Normal file
View 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 };
}