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:
riyadhafraa
2026-05-03 14:59:23 +00:00
parent 1c7c91e8f2
commit f810afe0cd
5 changed files with 240 additions and 23 deletions
@@ -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>' +
+7 -1
View File
@@ -1408,7 +1408,13 @@
"archivesHeading": "ملفات PDF المؤرشفة",
"noArchives": "لا توجد ملفات PDF مؤرشفة لهذا التاريخ بعد.",
"downloadArchive": "تنزيل",
"legacyArchive": "نسخة قديمة"
"legacyArchive": "نسخة قديمة",
"title": "قائمة بأسماء حضور الاجتماعات",
"no": "م",
"meeting": "الاجتماع",
"attendees": "الحضور",
"time": "الوقت",
"none": "لا توجد اجتماعات في هذا اليوم."
},
"fontSettingsPage": {
"heading": "إعدادات الخط",
+7 -1
View File
@@ -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",