4812fcdec3
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.
141 lines
4.7 KiB
JavaScript
141 lines
4.7 KiB
JavaScript
/**
|
|
* 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',
|
|
]));
|
|
});
|
|
});
|