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:
Luca
2026-05-29 13:00:18 +02:00
parent a9280ea9ba
commit 4812fcdec3
5 changed files with 649 additions and 0 deletions
@@ -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();
});
});
+1
View File
@@ -660,6 +660,7 @@ app.use('/api/admin/contracts', require('./src/routes/adminContracts'));
app.use('/api/admin/calendar', require('./src/routes/adminCalendar'));
app.use('/api/admin/deals', require('./src/routes/adminDeals'));
app.use('/api/admin/tax-report', require('./src/routes/adminTaxReport'));
app.use('/api/admin/system-health', require('./src/routes/adminSystemHealth'));
app.use('/api/admin/dev', require('./src/routes/adminDev'));
app.use('/api/public/quotes', require('./src/routes/publicQuotes'));
app.use('/api/public/contracts', require('./src/routes/publicContracts'));
+64
View File
@@ -0,0 +1,64 @@
/**
* Admin → System Health
*
* Endpoint mounted at /api/admin/system-health. The "Backup
* integrity" sub-endpoint is the on-demand verifier for CRM
* document artefacts — 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.
*
* Per the design decisions locked with the maintainer:
* - On-demand only; no scheduler (D1)
* - Not auto-triggered after restore (D2)
* - Wet-upload contracts are hash-verified same as system-rendered (D3)
*
* Read-only. Returns a JSON report — never mutates DB or fs.
*/
const express = require('express');
const { query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { verifyDocumentArtefacts } = require('../services/backupIntegrityService');
const router = express.Router();
router.use(adminAuth);
const VALID_SCOPES = ['quote', 'contract', 'contract-signature', 'invoice'];
router.get(
'/backup-integrity',
requirePermission('settings.view'),
[
// CSV string like `?scope=contract,invoice`. Each member must be
// one of the four known scopes. Empty / omitted means full scan.
query('scope').optional({ values: 'falsy' }).isString().isLength({ max: 128 }),
],
handleAsync(async (req, res) => {
validateRequest(req);
let scope;
if (req.query.scope) {
scope = String(req.query.scope)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
// Defense-in-depth: reject unknown scope tokens so a typo doesn't
// silently scan everything when the caller wanted just one slice.
const unknown = scope.filter((s) => !VALID_SCOPES.includes(s));
if (unknown.length > 0) {
return res.status(400).json({
error: `Unknown scope(s): ${unknown.join(', ')}`,
code: 'BACKUP_INTEGRITY_UNKNOWN_SCOPE',
validScopes: VALID_SCOPES,
});
}
}
const report = await verifyDocumentArtefacts({ scope });
return successResponse(res, { report });
}),
);
module.exports = router;
@@ -0,0 +1,228 @@
/**
* Backup-integrity verifier — walks every CRM document-artefact path
* column and confirms (a) the file exists on disk, (b) when a SHA-256
* is stored, the file's actual bytes hash to the stored value.
*
* **Why this is a separate service**
*
* The audit trail captured at issue / sign time (signed_customer_ip,
* signed_by_customer_at, signed_pdf_sha256, signed_*_signature_path,
* issue_date, etc.) is worth exactly nothing on its own — what makes
* it legally meaningful is being able to produce the document the
* audit trail refers to. A backup that captures the DB but skips
* `storage/business-docs/` (the bug fixed in this same PR) leaves
* every `*_path` column a broken FK and every `*_sha256` column with
* nothing to verify against. This service is the diagnostic for
* exactly that drift — runs on demand, surfaces missing files +
* hash mismatches without making any changes.
*
* **Verification modes**
*
* - existence — file at `*_path` must exist on disk
* - sha256 — file at `*_path` must exist AND its sha256 must
* equal `*_sha256` column (when that column is set)
*
* Per-table coverage (lines reference migrations/core/107_crm_consolidated.js):
*
* quotes.pdf_path line 844 (existence)
* contracts.pdf_path line 1245 (existence + sha256 via contracts.pdf_sha256)
* contracts.signed_pdf_path line 1246 (existence + sha256 via contracts.signed_pdf_sha256)
* contracts.signed_customer_signature_path line 1269 (existence — drawn signatures, no hash column)
* contracts.signed_admin_signature_path line 1273 (existence — admin counter-signature drawing)
* invoices.pdf_path line 1026 (existence)
* invoices.imported_pdf_path line 1020 (existence — admin-uploaded scans)
*
* Wet uploads (`contracts.signed_pdf_is_wet_upload = true`) DO have a
* `signed_pdf_sha256` computed at upload time (contractService.js
* upload route), so they're hash-verified the same as system-rendered
* contracts — no special case here.
*
* **What this service does NOT do**
*
* - Does not write anything (no DB mutations, no fs touches)
* - Does not fail the request when a mismatch is found — the
* report shape carries the data, the caller decides what to do
* - Does not auto-trigger after restore (D2 decision: surface a
* CTA on the restore-completed screen instead)
* - Does not run on a schedule (D1 decision: on-demand v1; revisit
* once we have runtime data on large installs)
*/
const fs = require('fs');
const crypto = require('crypto');
const path = require('path');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
/**
* Every column the verifier walks, declared once so the test suite
* and the service share a single source of truth. Order is the order
* the report lists rows in — table-major, then column-by-column.
*/
const CHECKS = [
{ table: 'quotes', pathColumn: 'pdf_path', shaColumn: null, scope: 'quote' },
{ table: 'contracts', pathColumn: 'pdf_path', shaColumn: 'pdf_sha256', scope: 'contract' },
{ table: 'contracts', pathColumn: 'signed_pdf_path', shaColumn: 'signed_pdf_sha256', scope: 'contract' },
{ table: 'contracts', pathColumn: 'signed_customer_signature_path', shaColumn: null, scope: 'contract-signature' },
{ table: 'contracts', pathColumn: 'signed_admin_signature_path', shaColumn: null, scope: 'contract-signature' },
{ table: 'invoices', pathColumn: 'pdf_path', shaColumn: null, scope: 'invoice' },
{ table: 'invoices', pathColumn: 'imported_pdf_path', shaColumn: null, scope: 'invoice' },
];
/** Stream-hash a file to sha256 hex without buffering the whole thing. */
function hashFile(absPath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(absPath);
stream.on('error', reject);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
});
}
/**
* @param {object} [options]
* @param {string[]} [options.scope] Filter checks by scope tag:
* 'quote' | 'contract' | 'contract-signature' | 'invoice'.
* Defaults to all four (full scan).
* @returns {Promise<{
* scannedAt: string,
* scopes: string[],
* summary: {
* totalRows: number,
* verifiedOk: number,
* missingFiles: number,
* hashMismatches: number,
* existsButNoHash: number,
* },
* missing: Array<{ table, rowId, column, expectedPath }>,
* hashMismatches: Array<{ table, rowId, column, expectedPath, expectedSha, actualSha }>,
* existsButNoHash: Array<{ table, rowId, column, path }>,
* }>}
*
* `existsButNoHash` is the existence-only-verified bucket — the file
* was found but no `*_sha256` column exists for it (quote/invoice PDFs,
* signature PNGs). Surfaced separately so admins can distinguish
* "verified by hash" from "verified by existence only" — the latter
* is weaker evidence in a legal dispute.
*/
async function verifyDocumentArtefacts(options = {}) {
const scopes = Array.isArray(options.scope) && options.scope.length > 0
? options.scope.slice()
: Array.from(new Set(CHECKS.map((c) => c.scope)));
const checksToRun = CHECKS.filter((c) => scopes.includes(c.scope));
const storageRoot = STORAGE_ROOT();
const missing = [];
const hashMismatches = [];
const existsButNoHash = [];
let totalRows = 0;
let verifiedOk = 0;
for (const check of checksToRun) {
// Skip the check cleanly when the column or table doesn't exist
// on this install — keeps the verifier safe to run on partial
// migrations or installs that have features disabled.
if (!(await db.schema.hasTable(check.table))) continue;
if (!(await db.schema.hasColumn(check.table, check.pathColumn))) continue;
const select = ['id', check.pathColumn];
const hasHashColumn = check.shaColumn
&& (await db.schema.hasColumn(check.table, check.shaColumn));
if (hasHashColumn) select.push(check.shaColumn);
const rows = await db(check.table)
.whereNotNull(check.pathColumn)
.select(...select);
for (const row of rows) {
totalRows += 1;
const storedPath = row[check.pathColumn];
// Stored paths can be absolute (older rows) or relative-to-
// storage (newer rows). Normalize: resolve relative paths
// against STORAGE_PATH; absolute paths are used verbatim.
const absPath = path.isAbsolute(storedPath)
? storedPath
: path.join(storageRoot, storedPath);
let exists = false;
try {
exists = fs.existsSync(absPath);
} catch (_) { exists = false; }
if (!exists) {
missing.push({
table: check.table,
rowId: row.id,
column: check.pathColumn,
expectedPath: storedPath,
});
continue;
}
const expectedSha = hasHashColumn ? row[check.shaColumn] : null;
if (!expectedSha) {
// File exists but we have no hash to verify it against.
existsButNoHash.push({
table: check.table,
rowId: row.id,
column: check.pathColumn,
path: storedPath,
});
continue;
}
let actualSha;
try {
actualSha = await hashFile(absPath);
} catch (err) {
logger.warn(`backupIntegrity: failed to hash ${absPath}: ${err.message}`);
missing.push({
table: check.table,
rowId: row.id,
column: check.pathColumn,
expectedPath: storedPath,
});
continue;
}
if (actualSha !== expectedSha) {
hashMismatches.push({
table: check.table,
rowId: row.id,
column: check.pathColumn,
expectedPath: storedPath,
expectedSha,
actualSha,
});
continue;
}
verifiedOk += 1;
}
}
return {
scannedAt: new Date().toISOString(),
scopes,
summary: {
totalRows,
verifiedOk,
missingFiles: missing.length,
hashMismatches: hashMismatches.length,
existsButNoHash: existsButNoHash.length,
},
missing,
hashMismatches,
existsButNoHash,
};
}
module.exports = {
verifyDocumentArtefacts,
// Exported for tests; not part of the route API.
_internal: { CHECKS, hashFile },
};