From a9280ea9bac731199133a34ede5a89359bcde89e Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Fri, 29 May 2026 12:50:02 +0200 Subject: [PATCH] fix(backup): include storage/business-docs/ in the in-app backup walker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backupService.getFilesToBackupInternal() enumerated a fixed list of storage subdirectories (events/active, events/archived, thumbnails, previews, heroes, uploads) and silently omitted the entire business-docs/ tree. Every CRM PDF artefact and signature image fell outside the in-app scheduled backup — restoring the DB without the PDFs would have left every *_path column on quotes/contracts/invoices as a broken FK and lost forensic evidence (the customer signature PNG/JPG drawn on the public signing page is referenced by contracts.signed_customer_signature_path; the rendered contract PDF is referenced by signed_pdf_path with a stored signed_pdf_sha256 that would have nothing to verify against; wet-uploaded contracts and admin-imported historical invoices are irrecoverable by design since no renderer can reproduce them). Single new scanDirectory call after the existing uploads scan, covering: - business-docs/quote//*.pdf - business-docs/contract//*.pdf - business-docs/contract/signatures//*.{png,jpg} - business-docs/invoice//*.pdf - business-docs/invoice-imports//*.pdf - and incidentally business-docs/dev-test/ (managed by adminDev.js, bounded to 7 newest files, harmless to back up) Verified that no migration is needed: hasFileChanged returns !existing || checksum mismatch, so the first backup after this lands flags every business-docs/** file as new and copies it. Restore path in restoreService.performFilesRestore uses fs.mkdir({ recursive: true }) on path.dirname(targetPath), so business-docs subdirectories are recreated automatically from manifest entries — no restore-side code change required. Integration test pins the contract so a future refactor cannot silently drop business-docs again. The shell-script backup at scripts/backup.sh already covered all of this via blanket `tar -czf storage`; only the in-app service was affected. --- .../2026-05-22-idor-crm-admin-endpoints.md | 1 + .../backupService.businessDocs.test.js | 88 +++++++++++++++++++ backend/src/services/backupService.js | 15 ++++ 3 files changed, 104 insertions(+) create mode 100644 .claude/security-reports/2026-05-22-idor-crm-admin-endpoints.md create mode 100644 backend/__tests__/integration/backupService.businessDocs.test.js 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; }