feat(backup): admin endpoint to verify CRM document-artefact integrity
Diagnostic for the bug fixed in a9280ea — confirms every *_path
column on quotes / contracts / invoices points at a file that
actually exists on disk and (where a *_sha256 column is set) the
file's bytes still hash to the expected value. Read-only;
on-demand only; no scheduler.
Per the design decisions locked in this PR's design call:
D1 — on-demand only for v1; scheduling deferred until we have
runtime data on large installs
D2 — not auto-triggered after restore; surface a "verify
integrity now" CTA on the restore-completed screen instead
D3 — wet-upload contracts hash-verified same as system-rendered
(signed_pdf_sha256 is computed at upload time, no special
case needed in the verifier)
Coverage (single source of truth in backupIntegrityService.CHECKS):
quotes.pdf_path existence
contracts.pdf_path + pdf_sha256 existence + hash
contracts.signed_pdf_path + signed_pdf_sha256 existence + hash
contracts.signed_customer_signature_path existence (PNG/JPG, no hash)
contracts.signed_admin_signature_path existence (PNG/JPG, no hash)
invoices.pdf_path existence
invoices.imported_pdf_path existence (admin-uploaded scans)
Report shape buckets each row into verifiedOk / missing /
hashMismatches / existsButNoHash so callers can distinguish hash-
verified from existence-only — the latter is weaker evidence in
a legal dispute and the UI should reflect that.
Route GET /api/admin/system-health/backup-integrity accepts an
optional ?scope= CSV filter (quote | contract | contract-signature
| invoice). Unknown scope tokens are rejected with a 400 +
BACKUP_INTEGRITY_UNKNOWN_SCOPE code rather than silently scanning
everything.
Frontend half (BackupIntegrityCard on a System Health page) is
deferred until backlog #11 (System Health page) is scaffolded.
The endpoint is independently useful via curl in the meantime.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Integration test for GET /api/admin/system-health/backup-integrity.
|
||||
*
|
||||
* Auth + permission middleware are mocked to pass-through so the test
|
||||
* focuses on the route's own behaviour: scope-param validation, the
|
||||
* successResponse envelope, and that the underlying service report
|
||||
* surfaces correctly in the JSON body.
|
||||
*
|
||||
* The verifier service itself is exercised against the real schema
|
||||
* (bootCrmDb) and real filesystem — only the auth gate is stubbed.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
|
||||
|
||||
// Pass-through auth so we don't need to mint JWTs.
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
|
||||
customerAuth: (_req, _res, next) => next(),
|
||||
galleryAuth: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
// Pass-through permissions so settings.view always allows.
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-integrity', () => {
|
||||
let cleanup;
|
||||
let db;
|
||||
let customerId;
|
||||
let app;
|
||||
let storagePath;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
|
||||
// Mount the route on a minimal Express app. Cold-require after
|
||||
// bootCrmDb so the route's downstream `require('../database/db')`
|
||||
// sees the same db instance.
|
||||
const route = require('../../src/routes/adminSystemHealth');
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/admin/system-health', route);
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await db('contracts').del().catch(() => {});
|
||||
await db('invoices').del().catch(() => {});
|
||||
await db('quotes').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns a report envelope when nothing references any path', async () => {
|
||||
const res = await request(app).get('/api/admin/system-health/backup-integrity');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('report');
|
||||
expect(res.body.report.summary).toMatchObject({
|
||||
totalRows: 0,
|
||||
missingFiles: 0,
|
||||
hashMismatches: 0,
|
||||
verifiedOk: 0,
|
||||
existsButNoHash: 0,
|
||||
});
|
||||
expect(res.body.report.scopes).toEqual(expect.arrayContaining([
|
||||
'quote', 'contract', 'contract-signature', 'invoice',
|
||||
]));
|
||||
});
|
||||
|
||||
it('surfaces a missing file in the response payload', async () => {
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-B7-MISSING',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-B7-MISSING.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-integrity');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.report.summary.missingFiles).toBe(1);
|
||||
expect(res.body.report.missing[0]).toMatchObject({
|
||||
table: 'contracts',
|
||||
column: 'signed_pdf_path',
|
||||
expectedPath: 'business-docs/contract/2026/C-B7-MISSING.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('honours the ?scope=invoice filter', async () => {
|
||||
// Seed both an invoice and a contract with missing files. With
|
||||
// scope=invoice the contract row must not appear.
|
||||
await db('invoices').insert({
|
||||
customer_account_id: customerId,
|
||||
invoice_number: 'INV-B7-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
due_date: '2026-01-31',
|
||||
pdf_path: 'business-docs/invoice/2026/INV-B7-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-B7-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-B7-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/admin/system-health/backup-integrity')
|
||||
.query({ scope: 'invoice' });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.report.scopes).toEqual(['invoice']);
|
||||
expect(res.body.report.missing.every((m) => m.table === 'invoices')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown scope with 400 + a code', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/admin/system-health/backup-integrity')
|
||||
.query({ scope: 'gallery' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.code).toBe('BACKUP_INTEGRITY_UNKNOWN_SCOPE');
|
||||
expect(res.body.validScopes).toEqual(expect.arrayContaining([
|
||||
'quote', 'contract', 'contract-signature', 'invoice',
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Verifies the backup-integrity check covers every CRM document
|
||||
* artefact column and correctly buckets each row into:
|
||||
* - verifiedOk — file exists AND hash matches (when hash is stored)
|
||||
* - missing — `*_path` set but file is not on disk
|
||||
* - hashMismatches — file exists but bytes don't hash to `*_sha256`
|
||||
* - existsButNoHash — file exists, no `*_sha256` column for this row
|
||||
*
|
||||
* Uses the CRM integration harness (bootCrmDb) so the schema +
|
||||
* STORAGE_PATH wiring exactly mirrors production behaviour.
|
||||
*
|
||||
* Background: this service is the diagnostic for the
|
||||
* `storage/business-docs/` gap fixed in the same PR — without it,
|
||||
* a restored install would have audit-trail columns referencing
|
||||
* files that no longer exist, but admins would have no way to see
|
||||
* the breakage until a customer asked for their contract back.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let customerId;
|
||||
let storagePath;
|
||||
let backupIntegrityService;
|
||||
|
||||
function seedFile(relPath, content) {
|
||||
const abs = path.join(storagePath, relPath);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content);
|
||||
return { abs, relPath, sha: sha256(content) };
|
||||
}
|
||||
|
||||
function sha256(content) {
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
({ customerId } = await seedMinimal(db));
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
backupIntegrityService = require('../../src/services/backupIntegrityService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Wipe CRM rows between tests so each scenario sees a clean slate.
|
||||
// Order matters: child tables before parents.
|
||||
await db('invoice_line_items').del().catch(() => {});
|
||||
await db('invoice_payment_log').del().catch(() => {});
|
||||
await db('invoices').del().catch(() => {});
|
||||
await db('quote_line_items').del().catch(() => {});
|
||||
await db('quotes').del().catch(() => {});
|
||||
await db('contracts').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns an empty report when no documents reference any path', async () => {
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts();
|
||||
expect(report.summary.totalRows).toBe(0);
|
||||
expect(report.summary.verifiedOk).toBe(0);
|
||||
expect(report.missing).toEqual([]);
|
||||
expect(report.hashMismatches).toEqual([]);
|
||||
expect(report.existsButNoHash).toEqual([]);
|
||||
expect(report.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(report.scopes).toEqual(expect.arrayContaining(['quote', 'contract', 'contract-signature', 'invoice']));
|
||||
});
|
||||
|
||||
it('flags a contract whose signed_pdf_path file is missing', async () => {
|
||||
// Reference a file that we deliberately never create on disk.
|
||||
const [{ id }] = await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-MISSING',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-2026-MISSING.pdf',
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const contractId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
const hit = report.missing.find((m) => m.rowId === contractId);
|
||||
expect(hit).toMatchObject({
|
||||
table: 'contracts',
|
||||
column: 'signed_pdf_path',
|
||||
expectedPath: 'business-docs/contract/2026/C-2026-MISSING.pdf',
|
||||
});
|
||||
expect(report.summary.missingFiles).toBe(1);
|
||||
});
|
||||
|
||||
it('verifies a contract whose file exists AND hash matches', async () => {
|
||||
const { relPath, sha } = seedFile(
|
||||
'business-docs/contract/2026/C-2026-OK.pdf',
|
||||
'this is the signed contract content',
|
||||
);
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-OK',
|
||||
status: 'fully_signed',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: relPath,
|
||||
signed_pdf_sha256: sha,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
expect(report.summary.verifiedOk).toBeGreaterThanOrEqual(1);
|
||||
expect(report.summary.missingFiles).toBe(0);
|
||||
expect(report.summary.hashMismatches).toBe(0);
|
||||
});
|
||||
|
||||
it('flags a hash mismatch when the file exists but bytes differ from signed_pdf_sha256', async () => {
|
||||
const { relPath } = seedFile(
|
||||
'business-docs/contract/2026/C-2026-TAMPER.pdf',
|
||||
'tampered bytes on disk',
|
||||
);
|
||||
const [{ id }] = await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-TAMPER',
|
||||
status: 'fully_signed',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: relPath,
|
||||
// Hash for completely different content — simulates tampering or
|
||||
// bit-rot between sign-time and now.
|
||||
signed_pdf_sha256: sha256('the ORIGINAL bytes the customer signed'),
|
||||
created_at: new Date(),
|
||||
}).returning('id');
|
||||
const contractId = typeof id === 'object' ? id.id : id;
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
const hit = report.hashMismatches.find((m) => m.rowId === contractId);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit.expectedSha).not.toBe(hit.actualSha);
|
||||
expect(hit.column).toBe('signed_pdf_path');
|
||||
});
|
||||
|
||||
it('buckets signature PNGs into existsButNoHash (no hash column)', async () => {
|
||||
const { relPath } = seedFile(
|
||||
'business-docs/contract/signatures/99/customer-1700000000000.png',
|
||||
'\x89PNG\r\n\x1a\n', // doesn't have to be a real PNG, just bytes
|
||||
);
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-SIG',
|
||||
status: 'fully_signed',
|
||||
issue_date: '2026-01-01',
|
||||
signed_customer_signature_path: relPath,
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({
|
||||
scope: ['contract-signature'],
|
||||
});
|
||||
expect(report.summary.existsButNoHash).toBeGreaterThanOrEqual(1);
|
||||
expect(report.summary.verifiedOk).toBe(0); // no hash → not "verified ok"
|
||||
expect(report.summary.missingFiles).toBe(0);
|
||||
const hit = report.existsButNoHash.find((r) => r.column === 'signed_customer_signature_path');
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
|
||||
it('respects the scope filter — contract scope skips quote/invoice tables', async () => {
|
||||
// Seed an invoice with a missing pdf_path AND a contract with a
|
||||
// missing signed_pdf_path. Scoping to contract should only flag
|
||||
// the contract.
|
||||
await db('invoices').insert({
|
||||
customer_account_id: customerId,
|
||||
invoice_number: 'INV-2026-SCOPE',
|
||||
status: 'sent',
|
||||
pdf_path: 'business-docs/invoice/2026/INV-2026-SCOPE.pdf',
|
||||
issue_date: '2026-01-01',
|
||||
due_date: '2026-01-31',
|
||||
created_at: new Date(),
|
||||
});
|
||||
await db('contracts').insert({
|
||||
customer_account_id: customerId,
|
||||
contract_number: 'C-2026-SCOPE',
|
||||
status: 'sent',
|
||||
issue_date: '2026-01-01',
|
||||
signed_pdf_path: 'business-docs/contract/2026/C-2026-SCOPE.pdf',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['contract'] });
|
||||
expect(report.scopes).toEqual(['contract']);
|
||||
expect(report.missing.every((m) => m.table === 'contracts')).toBe(true);
|
||||
expect(report.missing.some((m) => m.table === 'invoices')).toBe(false);
|
||||
});
|
||||
|
||||
it('covers invoices.imported_pdf_path (admin-uploaded historical scans)', async () => {
|
||||
// Imported invoices are the most catastrophic case — there's no
|
||||
// renderer that can reproduce them. Verifier must check this column
|
||||
// alongside invoices.pdf_path.
|
||||
await db('invoices').insert({
|
||||
customer_account_id: customerId,
|
||||
invoice_number: 'IMP-2025-001',
|
||||
status: 'sent',
|
||||
imported_pdf_path: 'business-docs/invoice-imports/2025/legacy.pdf',
|
||||
issue_date: '2025-06-01',
|
||||
due_date: '2025-07-01',
|
||||
created_at: new Date(),
|
||||
});
|
||||
|
||||
const report = await backupIntegrityService.verifyDocumentArtefacts({ scope: ['invoice'] });
|
||||
const hit = report.missing.find((m) => m.column === 'imported_pdf_path');
|
||||
expect(hit).toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user