Task #349 follow-up: brand-logo embed test + PDF label module
Original task: Executive Meetings PDF improvements — respect user font prefs, render saved per-meeting rowColor, drop legacy isHighlighted baking, brand logo on left of PDF header with title "قائمة بأسماء حضور الاجتماعات", and a logo upload + font color picker in the font-settings page. Prior mark_task_complete was rejected for three issues. The first (stray backup files) was a false positive. This commit closes the remaining two: - Extract bilingual PDF labels into artifacts/api-server/src/lib/pdf-labels.ts and import it from the PDF route so the strings are no longer inlined inside the route handler. Mirror the same keys under executiveMeetings.pdf.* in the tx-os ar.json / en.json locales so the frontend stays in sync. - Add an end-to-end test that exercises the brand-logo path: sign an upload URL, PUT the bytes through the storage sidecar, save the path on the global font-settings row, render the PDF, and assert PDFKit emitted "/Subtype /Image" with "/Width 1". This test caught a real silent failure: PDFKit's png-js decoder rejected the canonical 67-byte base64 "smallest valid PNG", so the renderer was logging a warning and proceeding without a logo. The fixture now constructs a real 1x1 RGB PNG inline using zlib + a CRC32 routine (no new dependencies) and the assertion passes. Out of scope: three pre-existing tsc errors at lines 635/778/2921 of executive-meetings.ts (unrelated to PDF code) and a flaky "Reorder: POST /reorder" test that was already failing before these changes.
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
// Bilingual labels printed on the Executive Meetings PDF. Centralised
|
||||
// here (instead of hardcoded inside the route) so the title and column
|
||||
// headings have a single source of truth that matches the i18n keys
|
||||
// used by the frontend.
|
||||
//
|
||||
// IMPORTANT: keep these in lock-step with the matching keys under
|
||||
// `executiveMeetings.pdf.{title,no,meeting,attendees,time,none}`
|
||||
// in `artifacts/tx-os/src/locales/ar.json` and `en.json`. The route
|
||||
// reads from this module; the frontend reads from the JSON locales.
|
||||
// Both must agree.
|
||||
|
||||
export type PdfLang = "ar" | "en";
|
||||
|
||||
export type PdfLabels = {
|
||||
title: string;
|
||||
no: string;
|
||||
meeting: string;
|
||||
attendees: string;
|
||||
time: string;
|
||||
none: string;
|
||||
};
|
||||
|
||||
export const PDF_LABELS: Record<PdfLang, PdfLabels> = {
|
||||
ar: {
|
||||
// Verbatim from the printed sample the user supplied.
|
||||
title: "قائمة بأسماء حضور الاجتماعات",
|
||||
no: "م",
|
||||
meeting: "الاجتماع",
|
||||
attendees: "الحضور",
|
||||
time: "الوقت",
|
||||
none: "لا توجد اجتماعات في هذا اليوم.",
|
||||
},
|
||||
en: {
|
||||
title: "Meeting Attendance List",
|
||||
no: "#",
|
||||
meeting: "Meeting",
|
||||
attendees: "Attendees",
|
||||
time: "Time",
|
||||
none: "No meetings on this day.",
|
||||
},
|
||||
};
|
||||
|
||||
export function getPdfLabels(lang: PdfLang): PdfLabels {
|
||||
return PDF_LABELS[lang];
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
emitExecutiveMeetingAlertStateChanged,
|
||||
} from "../lib/realtime";
|
||||
import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer";
|
||||
import { getPdfLabels } from "../lib/pdf-labels";
|
||||
import { ObjectStorageService } from "../lib/objectStorage";
|
||||
import {
|
||||
recordExecutiveMeetingNotifications,
|
||||
@@ -2679,27 +2680,11 @@ router.get(
|
||||
|
||||
const { font, logoObjectPath } = await resolveFontPrefsForUser(userId);
|
||||
const logo = await loadLogoBytes(logoObjectPath);
|
||||
// The Arabic title comes verbatim from the printed sample the user
|
||||
// provided ("قائمة بأسماء حضور الاجتماعات"). The English label
|
||||
// mirrors its meaning for bilingual exports.
|
||||
const labels =
|
||||
lang === "ar"
|
||||
? {
|
||||
title: "قائمة بأسماء حضور الاجتماعات",
|
||||
no: "م",
|
||||
meeting: "الاجتماع",
|
||||
attendees: "الحضور",
|
||||
time: "الوقت",
|
||||
none: "لا توجد اجتماعات في هذا اليوم.",
|
||||
}
|
||||
: {
|
||||
title: "Meeting Attendance List",
|
||||
no: "#",
|
||||
meeting: "Meeting",
|
||||
attendees: "Attendees",
|
||||
time: "Time",
|
||||
none: "No meetings on this day.",
|
||||
};
|
||||
// Pull the bilingual PDF labels (title + column headings) from the
|
||||
// shared module instead of inlining literals here. The same keys
|
||||
// are mirrored under `executiveMeetings.pdf.*` in the tx-os
|
||||
// locales (ar.json / en.json) so the frontend stays in sync.
|
||||
const labels = getPdfLabels(lang);
|
||||
|
||||
let pdf: Buffer;
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import zlib from "node:zlib";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
@@ -1007,6 +1008,180 @@ test("PDF content: title label, rowColor tint, dropped isHighlighted, fontColor"
|
||||
void ascii; // kept for future ad-hoc debugging
|
||||
});
|
||||
|
||||
test("PDF embeds the brand logo when set in global font settings", async () => {
|
||||
// Task #349 done-criterion: when an admin uploads a brand logo via
|
||||
// the global font-settings, the rendered PDF must actually carry
|
||||
// the bytes as an embedded Image XObject (not just remember the
|
||||
// path). We assert on PDFKit's emitted PDF object dictionary
|
||||
// (`/Subtype /Image`) which is the contract PDFKit promises when
|
||||
// `doc.image()` succeeds.
|
||||
//
|
||||
// We construct a real 1x1 RGB PNG inline — PDFKit's bundled
|
||||
// png-js decoder is strict about CRCs and chunk layout, so the
|
||||
// canonical "smallest valid PNG" base64 strings floating around
|
||||
// online are rejected as "Incomplete or corrupt PNG file".
|
||||
// Building it from scratch keeps the assertion honest: if the
|
||||
// bytes don't end up in the rendered PDF, it isn't because the
|
||||
// fixture rotted.
|
||||
const u32 = (n) => {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n, 0);
|
||||
return b;
|
||||
};
|
||||
const crc32 = (buf) => {
|
||||
const table = new Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++)
|
||||
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
table[n] = c >>> 0;
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++)
|
||||
crc = (table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8)) >>> 0;
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
};
|
||||
const mkChunk = (type, data) => {
|
||||
const typeBuf = Buffer.from(type, "ascii");
|
||||
const crc = crc32(Buffer.concat([typeBuf, data]));
|
||||
return Buffer.concat([u32(data.length), typeBuf, data, u32(crc)]);
|
||||
};
|
||||
// IHDR: 1x1, depth=8, color type=2 (RGB), default compression/filter/interlace
|
||||
const ihdr = Buffer.concat([u32(1), u32(1), Buffer.from([8, 2, 0, 0, 0])]);
|
||||
// Single scanline: filter byte 0 + RGB pixel (0, 255, 0)
|
||||
const idat = zlib.deflateSync(Buffer.from([0, 0, 255, 0]));
|
||||
const tinyPng = Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
mkChunk("IHDR", ihdr),
|
||||
mkChunk("IDAT", idat),
|
||||
mkChunk("IEND", Buffer.alloc(0)),
|
||||
]);
|
||||
|
||||
// 1) Sign an upload URL via the server's storage endpoint. We use
|
||||
// the admin cookie so the requireAuth gate passes.
|
||||
const signRes = await api(
|
||||
adminCookie,
|
||||
"POST",
|
||||
"/api/storage/uploads/request-url",
|
||||
{
|
||||
name: "tx-os-test-logo.png",
|
||||
size: tinyPng.byteLength,
|
||||
contentType: "image/png",
|
||||
},
|
||||
);
|
||||
assert.equal(signRes.status, 200, "must be able to sign an upload URL");
|
||||
const { uploadURL, objectPath } = await signRes.json();
|
||||
assert.ok(/^\/objects\//.test(objectPath), `expected /objects/<id>, got ${objectPath}`);
|
||||
|
||||
// 2) PUT the PNG bytes through the signed URL. If the local sidecar
|
||||
// isn't reachable in this environment, skip the rest of the test
|
||||
// rather than failing — the assertion this guards is about PDF
|
||||
// output once the bytes are in storage, and CI environments
|
||||
// without the storage sidecar can't exercise it.
|
||||
// We only treat the upload as "skip-able" for clearly
|
||||
// environmental failures (the sidecar isn't reachable or returns
|
||||
// 5xx). Auth/signing regressions surface as 4xx, and we want
|
||||
// those to fail loudly — not silently skip.
|
||||
let putRes;
|
||||
try {
|
||||
putRes = await fetch(uploadURL, {
|
||||
method: "PUT",
|
||||
body: tinyPng,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Content-Length": String(tinyPng.byteLength),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[test] storage sidecar unreachable, skipping logo embed test: ${err.message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (putRes.status >= 500) {
|
||||
console.warn(
|
||||
`[test] storage sidecar returned ${putRes.status}, skipping logo embed test`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
assert.ok(
|
||||
putRes.ok,
|
||||
`signed PUT must succeed (got ${putRes.status} ${putRes.statusText})`,
|
||||
);
|
||||
|
||||
// 3) Save the path on the GLOBAL font-settings row (the only scope
|
||||
// that accepts a logo). Wrap the mutation in try/finally so we
|
||||
// always reset the global row, even if a later assertion fails
|
||||
// — otherwise leaked state poisons subsequent test runs.
|
||||
const setLogo = await api(
|
||||
adminCookie,
|
||||
"PUT",
|
||||
"/api/executive-meetings/font-settings",
|
||||
{
|
||||
scope: "global",
|
||||
fontFamily: "system",
|
||||
fontSize: 14,
|
||||
fontWeight: "regular",
|
||||
alignment: "start",
|
||||
fontColor: "#000000",
|
||||
logoObjectPath: objectPath,
|
||||
},
|
||||
);
|
||||
assert.equal(setLogo.status, 200, "admin must be able to save the global logo");
|
||||
|
||||
try {
|
||||
// 4) Render a PDF. Date doesn't need meetings — the header
|
||||
// (and therefore the logo) is drawn unconditionally.
|
||||
const pdfDate = "2099-05-04";
|
||||
const res = await api(
|
||||
adminCookie,
|
||||
"GET",
|
||||
`/api/executive-meetings/pdf?date=${pdfDate}&lang=ar`,
|
||||
);
|
||||
assert.equal(
|
||||
res.status,
|
||||
200,
|
||||
"PDF render must succeed with a logo configured",
|
||||
);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
const ascii = buf.toString("latin1");
|
||||
|
||||
// 5) Assert PDFKit emitted an Image XObject. PDFKit writes
|
||||
// image objects as `<<\n/Type /XObject\n/Subtype /Image\n…>>`
|
||||
// — the `/Subtype /Image` substring is the canonical marker
|
||||
// that the bytes were embedded (vs the path being silently
|
||||
// dropped at render time).
|
||||
assert.match(
|
||||
ascii,
|
||||
/\/Subtype\s*\/Image/,
|
||||
"uploaded brand logo must be embedded as an /Image XObject in the PDF",
|
||||
);
|
||||
// The 1x1 PNG should appear with /Width 1 in the image
|
||||
// dictionary — a stronger guarantee that the *uploaded* image
|
||||
// (and not some other XObject) made it through.
|
||||
assert.match(
|
||||
ascii,
|
||||
/\/Width\s+1[^0-9]/,
|
||||
"embedded image dictionary must reflect the 1x1 source PNG",
|
||||
);
|
||||
} finally {
|
||||
await api(
|
||||
adminCookie,
|
||||
"PUT",
|
||||
"/api/executive-meetings/font-settings",
|
||||
{
|
||||
scope: "global",
|
||||
fontFamily: "system",
|
||||
fontSize: 14,
|
||||
fontWeight: "regular",
|
||||
alignment: "start",
|
||||
fontColor: "#000000",
|
||||
logoObjectPath: null,
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Sanitization: rich text strips disallowed tags but keeps inline formatting", async () => {
|
||||
const dirty =
|
||||
'Hello <script>alert(1)</script><strong style="color:#ff0000">World</strong>' +
|
||||
|
||||
@@ -1408,7 +1408,13 @@
|
||||
"archivesHeading": "ملفات PDF المؤرشفة",
|
||||
"noArchives": "لا توجد ملفات PDF مؤرشفة لهذا التاريخ بعد.",
|
||||
"downloadArchive": "تنزيل",
|
||||
"legacyArchive": "نسخة قديمة"
|
||||
"legacyArchive": "نسخة قديمة",
|
||||
"title": "قائمة بأسماء حضور الاجتماعات",
|
||||
"no": "م",
|
||||
"meeting": "الاجتماع",
|
||||
"attendees": "الحضور",
|
||||
"time": "الوقت",
|
||||
"none": "لا توجد اجتماعات في هذا اليوم."
|
||||
},
|
||||
"fontSettingsPage": {
|
||||
"heading": "إعدادات الخط",
|
||||
|
||||
@@ -1274,7 +1274,13 @@
|
||||
"archivesHeading": "Archived PDFs",
|
||||
"noArchives": "No PDFs archived for this date yet.",
|
||||
"downloadArchive": "Download",
|
||||
"legacyArchive": "Legacy snapshot"
|
||||
"legacyArchive": "Legacy snapshot",
|
||||
"title": "Meeting Attendance List",
|
||||
"no": "#",
|
||||
"meeting": "Meeting",
|
||||
"attendees": "Attendees",
|
||||
"time": "Time",
|
||||
"none": "No meetings on this day."
|
||||
},
|
||||
"fontSettingsPage": {
|
||||
"heading": "Font Settings",
|
||||
|
||||
Reference in New Issue
Block a user