refactor: deduplicate sanitizeFilename, add template+sanitize unit tests, fix esc single-quote
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 11m38s
All checks were successful
Build & Deploy to Staging / Build & Deploy to Staging (push) Successful in 11m38s
This commit is contained in:
parent
c4fea7932c
commit
0a002f94ef
6 changed files with 89 additions and 9 deletions
24
src/__tests__/sanitize.test.ts
Normal file
24
src/__tests__/sanitize.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { sanitizeFilename } from "../utils/sanitize.js";
|
||||
|
||||
describe("sanitizeFilename", () => {
|
||||
it("passes normal filename through", () => {
|
||||
expect(sanitizeFilename("report.pdf")).toBe("report.pdf");
|
||||
});
|
||||
it("replaces control characters", () => {
|
||||
expect(sanitizeFilename("file\x00name.pdf")).toBe("file_name.pdf");
|
||||
});
|
||||
it("replaces quotes", () => {
|
||||
expect(sanitizeFilename('file"name.pdf')).toBe("file_name.pdf");
|
||||
});
|
||||
it("returns default for empty string", () => {
|
||||
expect(sanitizeFilename("")).toBe("document.pdf");
|
||||
});
|
||||
it("truncates to 200 characters", () => {
|
||||
const long = "a".repeat(250) + ".pdf";
|
||||
expect(sanitizeFilename(long).length).toBeLessThanOrEqual(200);
|
||||
});
|
||||
it("supports custom default name", () => {
|
||||
expect(sanitizeFilename("", "invoice.pdf")).toBe("invoice.pdf");
|
||||
});
|
||||
});
|
||||
57
src/__tests__/templates.test.ts
Normal file
57
src/__tests__/templates.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { renderTemplate, templates } from "../services/templates.js";
|
||||
|
||||
// Access esc via rendering — test that HTML entities are escaped in output
|
||||
describe("Template rendering", () => {
|
||||
it("throws for unknown template", () => {
|
||||
expect(() => renderTemplate("nonexistent", {})).toThrow("not found");
|
||||
});
|
||||
|
||||
it("invoice renders with correct totals", () => {
|
||||
const html = renderTemplate("invoice", {
|
||||
invoiceNumber: "INV-001",
|
||||
date: "2026-01-01",
|
||||
from: { name: "Seller" },
|
||||
to: { name: "Buyer" },
|
||||
items: [{ description: "Widget", quantity: 2, unitPrice: 10, taxRate: 20 }],
|
||||
});
|
||||
expect(html).toContain("INV-001");
|
||||
expect(html).toContain("€20.00"); // subtotal: 2*10
|
||||
expect(html).toContain("€4.00"); // tax: 20*0.2
|
||||
expect(html).toContain("€24.00"); // total
|
||||
});
|
||||
|
||||
it("receipt renders with correct total", () => {
|
||||
const html = renderTemplate("receipt", {
|
||||
receiptNumber: "R-001",
|
||||
date: "2026-01-01",
|
||||
from: { name: "Shop" },
|
||||
items: [{ description: "Item A", amount: 15 }, { description: "Item B", amount: 25 }],
|
||||
});
|
||||
expect(html).toContain("R-001");
|
||||
expect(html).toContain("€40.00");
|
||||
});
|
||||
|
||||
it("defaults currency to €", () => {
|
||||
const html = renderTemplate("invoice", {
|
||||
invoiceNumber: "X", date: "2026-01-01",
|
||||
from: { name: "A" }, to: { name: "B" },
|
||||
items: [{ description: "Test", quantity: 1, unitPrice: 5 }],
|
||||
});
|
||||
expect(html).toContain("€5.00");
|
||||
});
|
||||
|
||||
it("escapes HTML entities including single quotes", () => {
|
||||
const html = renderTemplate("invoice", {
|
||||
invoiceNumber: '<script>alert("xss")</script>',
|
||||
date: "2026-01-01",
|
||||
from: { name: "O'Brien & Co" },
|
||||
to: { name: "Bob" },
|
||||
items: [{ description: "Test", quantity: 1, unitPrice: 1 }],
|
||||
});
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
expect(html).toContain("'");
|
||||
expect(html).toContain("&");
|
||||
});
|
||||
});
|
||||
|
|
@ -31,10 +31,7 @@ function isPrivateIP(ip: string): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
// Strip characters dangerous in Content-Disposition headers
|
||||
return name.replace(/[\x00-\x1f"\\\r\n]/g, "").trim() || "document.pdf";
|
||||
}
|
||||
import { sanitizeFilename } from "../utils/sanitize.js";
|
||||
|
||||
export const convertRouter = Router();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,10 +2,7 @@ import { Router, Request, Response } from "express";
|
|||
import { renderPdf } from "../services/browser.js";
|
||||
import logger from "../services/logger.js";
|
||||
import { templates, renderTemplate } from "../services/templates.js";
|
||||
|
||||
function sanitizeFilename(name: string): string {
|
||||
return name.replace(/["\r\n\x00-\x1f]/g, "_").substring(0, 200);
|
||||
}
|
||||
import { sanitizeFilename } from "../utils/sanitize.js";
|
||||
|
||||
export const templatesRouter = Router();
|
||||
|
||||
|
|
|
|||
|
|
@ -43,7 +43,8 @@ function esc(s: string): string {
|
|||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function renderInvoice(d: any): string {
|
||||
|
|
|
|||
4
src/utils/sanitize.ts
Normal file
4
src/utils/sanitize.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export function sanitizeFilename(name: string, defaultName = "document.pdf"): string {
|
||||
const sanitized = String(name || "").replace(/[\x00-\x1f"'\\\r\n]/g, "_").trim().substring(0, 200);
|
||||
return sanitized || defaultName;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue