diff --git a/.claude/security-reports/2026-05-22-idor-crm-admin-endpoints.md b/.claude/security-reports/2026-05-22-idor-crm-admin-endpoints.md new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/.claude/security-reports/2026-05-22-idor-crm-admin-endpoints.md @@ -0,0 +1 @@ + diff --git a/backend/__tests__/integration/backupService.businessDocs.test.js b/backend/__tests__/integration/backupService.businessDocs.test.js new file mode 100644 index 00000000..2b74b161 --- /dev/null +++ b/backend/__tests__/integration/backupService.businessDocs.test.js @@ -0,0 +1,88 @@ +/** + * Regression net for the business-docs coverage gap fixed in this PR. + * + * Prior to the fix, `getFilesToBackupInternal()` enumerated a fixed + * list of storage subdirectories (events/active, events/archived, + * thumbnails, previews, heroes, uploads) and silently omitted the + * entire `business-docs/` tree. That meant every CRM PDF + signature + * drawing — quotes, contracts (system-rendered + wet uploads), + * invoices, Storno, imported historical invoices, and the customer + * signature PNG/JPG drawn on the public signing page — fell outside + * the in-app scheduled backup, leaving every `*_path` column on + * `quotes` / `contracts` / `invoices` as a broken FK after restore. + * + * The fix is a single `scanDirectory(business-docs, ...)` call. This + * suite pins the contract so a future refactor of the walker cannot + * silently drop business-docs again. + */ + +const fs = require('fs'); +const path = require('path'); + +const { bootCrmDb } = require('./helpers/crmDb'); + +describe('backupService — business-docs is in the backup walker', () => { + let cleanup; + let backupService; + let storagePath; + + beforeAll(async () => { + ({ cleanup } = await bootCrmDb()); + storagePath = process.env.STORAGE_PATH; + // Cold-require after bootCrmDb so backupService picks up the same + // db instance + STORAGE_PATH the test harness configured. + backupService = require('../../src/services/backupService'); + }, 120000); + + afterAll(async () => { + if (cleanup) await cleanup(); + }); + + function seed(relPath, content = 'dummy bytes for backup test') { + const abs = path.join(storagePath, relPath); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content); + } + + it('does not error when business-docs is absent', async () => { + // Fresh harness has no business-docs/ tree at all. The walker + // must short-circuit on ENOENT rather than throw — installs that + // never used CRM features have to keep backing up fine. + await expect(backupService.getFilesToBackup(false)).resolves.toEqual(expect.any(Array)); + }); + + it('picks up every CRM-relevant business-docs subdirectory', async () => { + // Seed one file in each of the five subpaths the renderer + import + // routes write to. The signature path is the one most prone to be + // forgotten — it lives one level deeper than the others (per- + // contract subfolder, not per-year). + seed('business-docs/quote/2026/Q-001.pdf'); + seed('business-docs/contract/2026/C-001.pdf'); + seed('business-docs/contract/signatures/42/customer-1700000000000.png'); + seed('business-docs/invoice/2026/INV-001.pdf'); + seed('business-docs/invoice-imports/2026/scan.pdf'); + + const files = await backupService.getFilesToBackup(false); + const rels = files.map((f) => f.relativePath); + + expect(rels).toEqual(expect.arrayContaining([ + 'business-docs/quote/2026/Q-001.pdf', + 'business-docs/contract/2026/C-001.pdf', + 'business-docs/contract/signatures/42/customer-1700000000000.png', + 'business-docs/invoice/2026/INV-001.pdf', + 'business-docs/invoice-imports/2026/scan.pdf', + ])); + }); + + it('walks newly-created business-docs files without needing a restart', async () => { + // The walker reads the filesystem live on every call; this guards + // against a future "cache the scan result at boot" optimisation + // that would miss freshly-written PDFs (which is exactly what + // happens during normal operation — every send writes a new file). + seed('business-docs/invoice/2027/INV-NEW.pdf'); + + const files = await backupService.getFilesToBackup(false); + const rels = files.map((f) => f.relativePath); + expect(rels).toContain('business-docs/invoice/2027/INV-NEW.pdf'); + }); +}); diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js index 033688c4..463a9f33 100644 --- a/backend/src/services/backupService.js +++ b/backend/src/services/backupService.js @@ -372,6 +372,21 @@ async function getFilesToBackupInternal(includeArchived = true) { // by the original backup walk before this addition. await scanDirectory(path.join(storagePath, 'heroes'), files, storagePath); await scanDirectory(path.join(storagePath, 'uploads'), files, storagePath); + // CRM document estate — every PDF and signature artefact the + // service persists for legal-evidence purposes: + // - business-docs/quote//*.pdf + // - business-docs/contract//*.pdf (system-rendered + wet uploads) + // - business-docs/contract/signatures//*.{png,jpg} + // (drawn signatures, forensic-preserved per Date.now() filename) + // - business-docs/invoice//*.pdf (issued invoices + Storno) + // - business-docs/invoice-imports//*.pdf (admin-imported + // historical invoices — irrecoverable if not backed up) + // Without this scan, the audit trail (signed_pdf_sha256, signed_* + // _ip, accepted_at, etc.) survives the restore but the documents + // those values refer to do not, leaving every CRM *_path column a + // broken FK. scanDirectory short-circuits on ENOENT so installs + // that never used CRM features won't error. + await scanDirectory(path.join(storagePath, 'business-docs'), files, storagePath); return files; }