fix(audit-wave-9): PDF correctness + brand asset hardening (pdf-auditor)
Address the pdf-auditor findings that survived the 2026-05-12 PDF stack overhaul (pdfme → react-pdf). Items C-2/C-3 (tiptap-to-pdfme bugs) were resolved when that 571-LOC bridge was deleted; remaining items: - **M-7 wrong-port brand fallback** — replace `'Port Nimara'` defaults in PDF-rendering services. `reports.service` and `expense-export` throw when the port row is missing (the job is FK-keyed on a real port, so absence = broken state, must not stamp a competitor brand). `record-export` uses `'(port)'` as the visible placeholder. - **M-2 silent field drift in fill-eoi-form** — promote the always-silent catch in `setText` / `setCheckbox` to log a structured warning per missing field (mirroring the existing `setBerthRange` pattern). A re-cut template with drifted AcroForm field names now surfaces in ops logs instead of shipping with empty values. - **M-3 form not flattened** — `fillEoiFormFields` now flattens the AcroForm before save. Documenso pathway flattens server-side; this brings the in-app pathway to parity, so the signer can't edit pre-filled yacht dimensions / address / berth number after the fact. - **M-1 PDF metadata** — set Title / Author / Subject / Lang / Producer / Creator on the generated EOI PDF for downstream readers and a11y tooling. - **M-4 noisy berth-range warnings** — downgrade per-mooring warn to debug; emit a single summary warn per call when any passthrough occurred. Multi-berth EOIs with archived/legacy moorings no longer spam the log on every render. - **M-6 source PDF sha pinning** — pin `assets/eoi-template.pdf` sha256 via `EXPECTED_EOI_SHA256` (exported for tests); `loadEoiTemplatePdf` warns once per process when the bytes drift without an explicit hash bump. Documented the intentional-update workflow in `assets/README.md`. Tests updated in `tests/unit/pdf/fill-eoi-form.test.ts` to reflect flatten + metadata (form fields are gone after flatten; pdf-lib has no getLanguage so we assert the other setters round-trip). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -82,24 +82,33 @@ function makeContext(overrides: Partial<EoiContext> = {}): EoiContext {
|
||||
// ─── fillEoiFormFields ────────────────────────────────────────────────────────
|
||||
|
||||
describe('fillEoiFormFields', () => {
|
||||
it('populates every text field and checkbox using EoiContext', async () => {
|
||||
it('flattens the form and writes metadata (Title/Author/Lang)', async () => {
|
||||
const sourcePdf = await buildSyntheticEoiPdf();
|
||||
const filled = await fillEoiFormFields(sourcePdf, makeContext());
|
||||
|
||||
const out = await PDFDocument.load(filled);
|
||||
const form = out.getForm();
|
||||
// Flatten removes the interactive fields from the form — the values
|
||||
// are baked into the page content stream so the signer can't edit
|
||||
// them after the fact.
|
||||
expect(out.getForm().getFields()).toEqual([]);
|
||||
|
||||
expect(form.getTextField('Name').getText()).toBe('Alice Smith');
|
||||
expect(form.getTextField('Email').getText()).toBe('alice@example.com');
|
||||
expect(form.getTextField('Address').getText()).toBe('123 Main St, Austin, USA');
|
||||
expect(form.getTextField('Yacht Name').getText()).toBe('Sea Breeze');
|
||||
expect(form.getTextField('Length').getText()).toBe('45');
|
||||
expect(form.getTextField('Width').getText()).toBe('14');
|
||||
expect(form.getTextField('Draft').getText()).toBe('6');
|
||||
expect(form.getTextField('Berth Number').getText()).toBe('A12');
|
||||
// PDF metadata (M-1). pdf-lib doesn't expose `getLanguage`, but the
|
||||
// other setters round-trip through standard PDF info-dict getters.
|
||||
expect(out.getTitle()).toBe('EOI – Alice Smith');
|
||||
expect(out.getAuthor()).toBe('Port Nimara');
|
||||
expect(out.getSubject()).toBe('Expression of Interest');
|
||||
});
|
||||
|
||||
expect(form.getCheckBox('Purchase').isChecked()).toBe(true);
|
||||
expect(form.getCheckBox('Lease_10').isChecked()).toBe(false);
|
||||
it('produces a non-empty PDF and a single page with the synthetic source', async () => {
|
||||
const sourcePdf = await buildSyntheticEoiPdf();
|
||||
const filled = await fillEoiFormFields(sourcePdf, makeContext());
|
||||
|
||||
// Round-trip the saved bytes. With the AcroForm flattened, the doc
|
||||
// still loads as a valid PDF and retains the original page count —
|
||||
// the text-field widgets have been baked into the content stream.
|
||||
const out = await PDFDocument.load(filled);
|
||||
expect(out.getPageCount()).toBe(1);
|
||||
expect(filled.byteLength).toBeGreaterThan(500);
|
||||
});
|
||||
|
||||
it('handles null primary email and null address gracefully', async () => {
|
||||
@@ -118,20 +127,11 @@ describe('fillEoiFormFields', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Still flattens cleanly and round-trips as a valid PDF even with
|
||||
// null email/address — the doc doesn't error out.
|
||||
const out = await PDFDocument.load(filled);
|
||||
const form = out.getForm();
|
||||
expect(form.getTextField('Email').getText()).toBe(undefined);
|
||||
expect(form.getTextField('Address').getText()).toBe(undefined);
|
||||
expect(form.getTextField('Name').getText()).toBe('Bob');
|
||||
});
|
||||
|
||||
it('leaves the form interactive (not flattened) so values can be edited', async () => {
|
||||
const sourcePdf = await buildSyntheticEoiPdf();
|
||||
const filled = await fillEoiFormFields(sourcePdf, makeContext());
|
||||
|
||||
const out = await PDFDocument.load(filled);
|
||||
// Field still present and reachable as a TextField → not flattened.
|
||||
expect(() => out.getForm().getTextField('Name')).not.toThrow();
|
||||
expect(out.getForm().getFields()).toEqual([]);
|
||||
expect(out.getTitle()).toBe('EOI – Bob');
|
||||
});
|
||||
|
||||
it('skips fields silently if the source PDF lacks them', async () => {
|
||||
|
||||
Reference in New Issue
Block a user