03e6617f38
Stage C of the three-stage backup-hardening plan (Stage A: inline
DB dump + fail-loud landed in 7fdf01a; Stage B: config-driven walker
in 302fc6b). Answers the "what would I lose if I clicked Run Backup
Now right now?" question that Stage B made possible to answer.
Backend:
- new backupCoverageService.js: per-path coverage classification,
drift detection (top-level subdirs not in backup_paths and not
in the backups/tmp allow-list), DB-dump mode + staleness block
- new GET /api/admin/system-health/backup-coverage route, same
auth + settings.view permission as /backup-integrity
- 7 integration scenarios pinning the classifier behaviour
Frontend:
- new BackupCoverageCard with auto-fetch (cheap; no recursion)
- new Coverage tab on BackupManagement next to Integrity
- en + de i18n; other locales fall back to en keys until a native
speaker reviews
Verification:
- 26/26 backup integration tests pass (Stage A 5 + Stage B 7 +
Stage C 7 + adminBackupIntegrity 4 + businessDocs 3)
- frontend build clean
- 4 pre-existing integration failures confirmed unrelated
88 lines
3.0 KiB
JavaScript
88 lines
3.0 KiB
JavaScript
/**
|
|
* 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 { getCoverageReport } = require('../services/backupCoverageService');
|
|
|
|
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 });
|
|
}),
|
|
);
|
|
|
|
/**
|
|
* GET /api/admin/system-health/backup-coverage
|
|
*
|
|
* Stage C of the backup-hardening plan. Returns the data-driven
|
|
* coverage report — what the next "Run Backup Now" will include /
|
|
* skip / silently miss, plus the database-dump status block.
|
|
*
|
|
* Read-only, on-demand. No scope parameter — the report is cheap
|
|
* (only top-level directory listing under STORAGE_PATH, no recursion).
|
|
*
|
|
* See backupCoverageService.js for the full rationale and the
|
|
* coverage-classification rules.
|
|
*/
|
|
router.get(
|
|
'/backup-coverage',
|
|
requirePermission('settings.view'),
|
|
handleAsync(async (req, res) => {
|
|
const report = await getCoverageReport();
|
|
return successResponse(res, { report });
|
|
}),
|
|
);
|
|
|
|
module.exports = router;
|