Bundles the prior session's 50-task fix sweep (Documenso v2 + EOI/signing-
progress redesign + env-to-admin migration + dev-mode banner) with the
2026-05-18 audit fix wave (3 CRITICAL, 14 HIGH, 28 MEDIUM, 6 LOW).
CRITICAL (3):
- C-01 interest-berths INNER JOIN -> LEFT JOIN so hard-deleted berths
no longer silently drop interest links
- C-02 /setup added to PUBLIC_PATHS; fresh-deploy bootstrap loop fixed
- C-03 generic PATCH /interests/[id] no longer accepts pipelineStage —
callers must go through /stage with the override-guard chain
HIGH (14/15):
- H-01 explicit ON DELETE on previously-implicit NO ACTION FKs across
interests/documents/reservations/reminders/invoices (migration 0070)
- H-02 login page reads ?redirect= param with same-origin guard
- H-03 CRM invite token moves to URL fragment so it never lands in
nginx access logs / Referer headers
- H-04 Retry-After header on sign-in-by-identifier 429 (RFC 6585 §4)
- H-05 toggleAccount writes an audit row
- H-06 upsertSetting masks any value whose key ends with _encrypted
- H-07 archiveClient cascade fires per-interest audit rows
- H-08 createSalesTransporter applies SMTP_TIMEOUTS
- H-09 AppShell stable children — viewport flip across breakpoint no
longer destroys in-progress form drafts
- H-10 portal documents page swaps Unicode glyph status icons for
Lucide CheckCircle2/XCircle/Circle + aria-labels
- H-12 list components swap alert(...) for toast.warning(...)
- H-13 5 icon-only buttons gain aria-label
- H-14 parseBody treats empty bodies as {}
- H-15 admin layout renders a 403 panel instead of silent bounce
- H-11 not applicable — mobile-search-overlay IS a mobile bottom-sheet
MEDIUM (28+):
- M-MT01-05 defense-in-depth port_id/parent-id filters on UPDATE/DELETE
WHEREs across custom-fields, notes (all 6 entity types x update +
delete), client-contacts, yacht ownerClient lookup, webhook reads
- M-D01 documents-hub realtime event-name typo (file:created -> uploaded)
- M-EM01 portal-auth emails thread through portId
- M-EM02 sendEmail accepts cc/bcc params
- M-EM04 notification_digest catalog key
- M-IN01 portal presigned download URLs use 4h TTL
- M-IN02 OpenAI client lazy-instantiated
- M-IN04 stale pdfme refs updated to pdf-lib AcroForm
- M-IN05 umami.testConnection returns tagged union
- M-L01 reservations tenure_type unified with berths
- M-L02 report-generators canonicalize stage values
- M-AU01 audit log placeholder copy fixed
- M-AU04 outcome_set / outcome_cleared distinct audit verbs
- M-NEW-2 activity feed entity name+type separator
- M-R01 portal allowlist narrowed + portal_session backstop in proxy
- M-SC02 companies archived partial index
- M-SC04 audit_logs.searchText documented as DB-managed
- M-S01 storage_s3_access_key_encrypted admin field
- M-U01 audit log empty state uses <EmptyState>
- M-U09 invoice delete dialog -> <AlertDialog>
- M-U10 toast.success on ClientForm + InterestForm create/edit
- M-U11 settings-form-card logo preview alt text
- M-U14 mobile topbar title on clients/yachts/interests/berths
- M-U15 Invoices in mobile More-sheet
LOW (6/8):
- L-AU01 severity defaults for security-relevant verbs
- L-AU02 +13 missing actions in admin audit filter
- L-AU03 +7 missing entity types in admin audit filter
- L-AU04 dead listAuditLogs stubbed
- L-D02 CLAUDE.md Owner-wins chain tightened
Bonus — Document detail polish (#67 partial, 3/6 deliverables):
- state-aware action button per signer
- watcher Add UI with display-name resolution
- cleanSignerName cleanup
Prior session work bundled in:
- Documenso v2 webhook + envelope-ID normalization + sequential signing
- SigningProgress UI redesign (avatars, per-signer state, timestamps)
- env->admin settings registry + RegistryDrivenForm + encrypted creds
- Embedded-signing card + Test connection + setup help
- Dev-mode EMAIL_REDIRECT_TO banner
- Pipeline rules admin page
- Sales email config card
- Audit log details Sheet
- EOI tab: Finalising badge, absolute timestamps, sequential indicator
- Notes pipeline_stage_at_creation (migration 0069)
- Documenso numeric ID dual-key webhook (migration 0068)
- Dimensions criterion copy (migration 0067)
Tests: 1374/1374 vitest pass. tsc clean. lint clean.
See docs/AUDIT-FIX-WAVE-2026-05-18.md for the full progress report and
the user-input items still pending.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
6.5 KiB
TypeScript
191 lines
6.5 KiB
TypeScript
import { promises as fs } from 'node:fs';
|
||
import path from 'node:path';
|
||
import os from 'node:os';
|
||
|
||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||
import { PDFDocument } from 'pdf-lib';
|
||
|
||
import { fillEoiFormFields, loadEoiTemplatePdf } from '@/lib/pdf/fill-eoi-form';
|
||
import type { EoiContext } from '@/lib/services/eoi-context';
|
||
|
||
// ─── Test PDF builder (synthetic source PDF with the same field names) ───────
|
||
|
||
async function buildSyntheticEoiPdf(): Promise<Uint8Array> {
|
||
const doc = await PDFDocument.create();
|
||
const page = doc.addPage([600, 800]);
|
||
const form = doc.getForm();
|
||
|
||
const textFieldNames = [
|
||
'Name',
|
||
'Email',
|
||
'Address',
|
||
'Yacht Name',
|
||
'Length',
|
||
'Width',
|
||
'Draft',
|
||
'Berth Number',
|
||
];
|
||
textFieldNames.forEach((name, i) => {
|
||
const f = form.createTextField(name);
|
||
f.addToPage(page, { x: 50, y: 700 - i * 40, width: 300, height: 24 });
|
||
});
|
||
|
||
for (const name of ['Lease_10', 'Purchase']) {
|
||
const cb = form.createCheckBox(name);
|
||
cb.addToPage(page, { x: 400, y: 700 - (name === 'Purchase' ? 0 : 40), width: 12, height: 12 });
|
||
}
|
||
|
||
return doc.save();
|
||
}
|
||
|
||
function makeContext(overrides: Partial<EoiContext> = {}): EoiContext {
|
||
return {
|
||
client: {
|
||
id: 'client-test-1',
|
||
fullName: 'Alice Smith',
|
||
nationality: 'US',
|
||
primaryEmail: 'alice@example.com',
|
||
primaryPhone: '+1-555-0100',
|
||
address: {
|
||
street: '123 Main St',
|
||
city: 'Austin',
|
||
subdivision: 'TX',
|
||
postalCode: '78701',
|
||
country: 'United States',
|
||
countryIso: 'US',
|
||
},
|
||
},
|
||
yacht: {
|
||
id: 'yacht-test-1',
|
||
name: 'Sea Breeze',
|
||
lengthFt: '45',
|
||
widthFt: '14',
|
||
draftFt: '6',
|
||
lengthM: null,
|
||
widthM: null,
|
||
draftM: null,
|
||
lengthUnit: 'ft' as const,
|
||
widthUnit: 'ft' as const,
|
||
draftUnit: 'ft' as const,
|
||
hullNumber: 'HN-1',
|
||
flag: 'US',
|
||
yearBuilt: 2020,
|
||
},
|
||
company: null,
|
||
owner: { type: 'client', name: 'Alice Smith' },
|
||
berth: {
|
||
mooringNumber: 'A12',
|
||
area: 'North',
|
||
lengthFt: '50',
|
||
price: '1000',
|
||
priceCurrency: 'USD',
|
||
tenureType: 'permanent',
|
||
},
|
||
eoiBerthRange: '',
|
||
interest: { stage: 'open', leadCategory: null, dateFirstContact: null, notes: null },
|
||
port: { name: 'Port Nimara', defaultCurrency: 'USD' },
|
||
date: { today: '2026-04-26', year: '2026' },
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
// ─── fillEoiFormFields ────────────────────────────────────────────────────────
|
||
|
||
describe('fillEoiFormFields', () => {
|
||
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);
|
||
// 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([]);
|
||
|
||
// 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');
|
||
});
|
||
|
||
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 () => {
|
||
const sourcePdf = await buildSyntheticEoiPdf();
|
||
const filled = await fillEoiFormFields(
|
||
sourcePdf,
|
||
makeContext({
|
||
client: {
|
||
id: 'client-test-2',
|
||
fullName: 'Bob',
|
||
nationality: null,
|
||
primaryEmail: null,
|
||
primaryPhone: null,
|
||
address: null,
|
||
},
|
||
}),
|
||
);
|
||
|
||
// 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);
|
||
expect(out.getForm().getFields()).toEqual([]);
|
||
expect(out.getTitle()).toBe('EOI – Bob');
|
||
});
|
||
|
||
it('skips fields silently if the source PDF lacks them', async () => {
|
||
// Build a PDF with only a subset of fields and ensure no error.
|
||
const doc = await PDFDocument.create();
|
||
const page = doc.addPage([600, 800]);
|
||
const form = doc.getForm();
|
||
const f = form.createTextField('Name');
|
||
f.addToPage(page, { x: 50, y: 700, width: 300, height: 24 });
|
||
const sparse = await doc.save();
|
||
|
||
await expect(fillEoiFormFields(sparse, makeContext())).resolves.toBeInstanceOf(Uint8Array);
|
||
});
|
||
});
|
||
|
||
// ─── loadEoiTemplatePdf ───────────────────────────────────────────────────────
|
||
|
||
describe('loadEoiTemplatePdf', () => {
|
||
let tmpFile: string;
|
||
const originalEnv = process.env.EOI_TEMPLATE_PDF_PATH;
|
||
|
||
beforeAll(async () => {
|
||
const sourcePdf = await buildSyntheticEoiPdf();
|
||
tmpFile = path.join(os.tmpdir(), `eoi-template-${Date.now()}.pdf`);
|
||
await fs.writeFile(tmpFile, sourcePdf);
|
||
});
|
||
|
||
afterAll(async () => {
|
||
if (originalEnv === undefined) delete process.env.EOI_TEMPLATE_PDF_PATH;
|
||
else process.env.EOI_TEMPLATE_PDF_PATH = originalEnv;
|
||
await fs.unlink(tmpFile).catch(() => undefined);
|
||
});
|
||
|
||
it('reads the PDF from EOI_TEMPLATE_PDF_PATH override', async () => {
|
||
process.env.EOI_TEMPLATE_PDF_PATH = tmpFile;
|
||
const bytes = await loadEoiTemplatePdf();
|
||
expect(bytes.byteLength).toBeGreaterThan(100);
|
||
// Round-trip: should re-load as a valid PDF.
|
||
await expect(PDFDocument.load(bytes)).resolves.toBeInstanceOf(PDFDocument);
|
||
});
|
||
|
||
it('throws a clear error with instructions when the file is missing', async () => {
|
||
process.env.EOI_TEMPLATE_PDF_PATH = '/nope/does-not-exist.pdf';
|
||
await expect(loadEoiTemplatePdf()).rejects.toThrow(/EOI source PDF not found/);
|
||
});
|
||
});
|