feat(backup): coverage diagnostic — what will the next backup miss?
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
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Integration test for GET /api/admin/system-health/backup-coverage.
|
||||
*
|
||||
* Pins the Stage C diagnostic that tells admins what the next
|
||||
* "Run Backup Now" will include, skip, or silently miss.
|
||||
*
|
||||
* Test surface:
|
||||
* 1. Empty / fresh install → default seed (7 paths), inline mode,
|
||||
* no DB dump on file yet, no drift
|
||||
* 2. Toggle `include_in_default=false` → coverage flips to
|
||||
* 'skipped-by-toggle'
|
||||
* 3. Feature_flag gating reflects the actual app_settings value
|
||||
* (events/archived ⇄ backup_include_archived)
|
||||
* 4. Drift detection: a top-level subdir on disk with no
|
||||
* `backup_paths` row is flagged in `unconfiguredOnDisk`
|
||||
* 5. Allow-list: `backups/` and `tmp/` are never flagged as drift
|
||||
* 6. Scheduled-only mode + recent dump → `database.ok = true`
|
||||
* 7. Scheduled-only mode + stale (>26h) dump → `database.ok = false`
|
||||
* and `lastDumpStale = true`
|
||||
*
|
||||
* Same auth/permission pass-through strategy as
|
||||
* adminBackupIntegrity.test.js — we exercise the route's logic,
|
||||
* not the auth middleware.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
jest.mock('../../src/middleware/auth', () => ({
|
||||
adminAuth: (req, _res, next) => { req.admin = { id: 1 }; next(); },
|
||||
customerAuth: (_req, _res, next) => next(),
|
||||
galleryAuth: (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.mock('../../src/middleware/permissions', () => ({
|
||||
requirePermission: () => (_req, _res, next) => next(),
|
||||
}));
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('GET /api/admin/system-health/backup-coverage', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let storagePath;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
storagePath = process.env.STORAGE_PATH;
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
function mkdir(rel) {
|
||||
fs.mkdirSync(path.join(storagePath, rel), { recursive: true });
|
||||
}
|
||||
|
||||
function rmdir(rel) {
|
||||
fs.rmSync(path.join(storagePath, rel), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function restoreDefaultPaths() {
|
||||
await db('backup_paths').del();
|
||||
const { DEFAULT_PATHS } = require('../../migrations/core/108_add_backup_paths');
|
||||
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
})));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await restoreDefaultPaths();
|
||||
await db('database_backup_runs').del().catch(() => {});
|
||||
await db('app_settings').where('setting_type', 'backup').del().catch(() => {});
|
||||
});
|
||||
|
||||
it('returns the canonical 7 paths + database block on a fresh install', async () => {
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toHaveProperty('report');
|
||||
|
||||
const { report } = res.body;
|
||||
expect(report.paths.map((p) => p.path)).toEqual([
|
||||
'events/active',
|
||||
'events/archived',
|
||||
'thumbnails',
|
||||
'previews',
|
||||
'heroes',
|
||||
'uploads',
|
||||
'business-docs',
|
||||
]);
|
||||
|
||||
// Default mode is inline — no inline_dump setting present means
|
||||
// "inline is ON" (matches ensureDatabaseDumpForBackup semantics).
|
||||
expect(report.database.mode).toBe('inline');
|
||||
expect(report.database.ok).toBe(true);
|
||||
|
||||
expect(report.summary).toMatchObject({
|
||||
configuredCount: 7,
|
||||
tableMissingFallbackInUse: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('flips a path to skipped-by-toggle when include_in_default=false', async () => {
|
||||
await db('backup_paths').where('path', 'thumbnails').update({
|
||||
include_in_default: false,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const thumbnails = res.body.report.paths.find((p) => p.path === 'thumbnails');
|
||||
expect(thumbnails.coverage).toBe('skipped-by-toggle');
|
||||
expect(thumbnails.includeInDefault).toBe(false);
|
||||
});
|
||||
|
||||
it('feature_flag gating reflects app_settings (archived path off vs on)', async () => {
|
||||
// backup_include_archived not set → archived skipped via flag
|
||||
const off = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const archivedOff = off.body.report.paths.find((p) => p.path === 'events/archived');
|
||||
expect(archivedOff.coverage).toBe('skipped-by-feature-flag');
|
||||
expect(archivedOff.featureFlag).toBe('backup_include_archived');
|
||||
expect(archivedOff.featureFlagValue).toBe(null); // unset
|
||||
|
||||
// Now set the flag — but path is missing on disk, so coverage
|
||||
// resolves to 'missing-on-disk', proving the flag was honoured.
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_include_archived',
|
||||
setting_value: JSON.stringify(true),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const on = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
const archivedOn = on.body.report.paths.find((p) => p.path === 'events/archived');
|
||||
expect(archivedOn.featureFlagValue).toBe(true);
|
||||
// No on-disk dir → 'missing-on-disk' (not 'skipped-by-feature-flag')
|
||||
expect(['missing-on-disk', 'will-scan']).toContain(archivedOn.coverage);
|
||||
});
|
||||
|
||||
it('detects unconfigured top-level subdirs as drift', async () => {
|
||||
mkdir('events/active'); // configured
|
||||
mkdir('plugin-store/cache'); // DRIFT
|
||||
mkdir('shiny-new-feature/data'); // DRIFT
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).toEqual(expect.arrayContaining([
|
||||
'plugin-store',
|
||||
'shiny-new-feature',
|
||||
]));
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('events');
|
||||
|
||||
rmdir('plugin-store');
|
||||
rmdir('shiny-new-feature');
|
||||
});
|
||||
|
||||
it('never flags backups/ or tmp/ as drift (allow-list)', async () => {
|
||||
mkdir('backups');
|
||||
mkdir('tmp');
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('backups');
|
||||
expect(res.body.report.drift.unconfiguredOnDisk).not.toContain('tmp');
|
||||
expect(res.body.report.drift.expectedNonBackupDirs).toEqual(
|
||||
expect.arrayContaining(['backups', 'tmp']),
|
||||
);
|
||||
|
||||
rmdir('backups');
|
||||
rmdir('tmp');
|
||||
});
|
||||
|
||||
it('scheduled-only mode + recent dump → database.ok=true, not stale', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const recentDump = path.join(storagePath, 'backups', 'recent.sql.gz');
|
||||
fs.mkdirSync(path.dirname(recentDump), { recursive: true });
|
||||
fs.writeFileSync(recentDump, 'pretend dump');
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: new Date(),
|
||||
completed_at: new Date(), // just now
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: recentDump,
|
||||
file_size_bytes: fs.statSync(recentDump).size,
|
||||
destination_path: recentDump,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.database.mode).toBe('scheduled-only');
|
||||
expect(res.body.report.database.inlineDumpExplicitlyDisabled).toBe(true);
|
||||
expect(res.body.report.database.lastDumpStale).toBe(false);
|
||||
expect(res.body.report.database.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('scheduled-only mode + stale dump → database.ok=false, lastDumpStale=true', async () => {
|
||||
await db('app_settings').insert({
|
||||
setting_key: 'backup_database_inline_dump',
|
||||
setting_value: JSON.stringify(false),
|
||||
setting_type: 'backup',
|
||||
}).onConflict('setting_key').merge();
|
||||
|
||||
const oldDump = path.join(storagePath, 'backups', 'old.sql.gz');
|
||||
fs.mkdirSync(path.dirname(oldDump), { recursive: true });
|
||||
fs.writeFileSync(oldDump, 'pretend old dump');
|
||||
// 48 hours ago — well past the 26h staleness threshold. ISO
|
||||
// string instead of a Date object because knex-sqlite's datetime
|
||||
// serialisation has a quirk where some Date instances coerce to
|
||||
// '[object Object]' on insert (the test 6 "recent dump" case
|
||||
// passes only because `new Date()` happens to round-trip safely;
|
||||
// arithmetic Dates don't).
|
||||
const stale = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString();
|
||||
await db('database_backup_runs').insert({
|
||||
started_at: stale,
|
||||
completed_at: stale,
|
||||
status: 'completed',
|
||||
backup_type: 'pg',
|
||||
file_path: oldDump,
|
||||
file_size_bytes: fs.statSync(oldDump).size,
|
||||
destination_path: oldDump,
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/admin/system-health/backup-coverage');
|
||||
expect(res.body.report.database.lastDumpStale).toBe(true);
|
||||
expect(res.body.report.database.ok).toBe(false);
|
||||
// Top-level summary reflects the failed DB check.
|
||||
expect(res.body.report.summary.databaseOk).toBe(false);
|
||||
expect(res.body.report.summary.overallOk).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ 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();
|
||||
|
||||
@@ -61,4 +62,26 @@ router.get(
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Backup-coverage diagnostic — Stage C of the backup-hardening plan.
|
||||
*
|
||||
* **Why this is a separate service**
|
||||
*
|
||||
* Stage A (inline DB dump + fail-loud) and Stage B (config-driven
|
||||
* walker via `backup_paths`) close the data-loss footgun, but they
|
||||
* don't tell an admin *what* the next backup will actually cover.
|
||||
* That's a separate question — and a particularly important one,
|
||||
* because the whole reason Stage B exists is that the walker's
|
||||
* subdirectory list used to silently fall behind reality every time
|
||||
* a new feature dropped artefacts under STORAGE_PATH.
|
||||
*
|
||||
* This service answers two questions:
|
||||
*
|
||||
* 1. For every row in `backup_paths`, what will the next backup
|
||||
* do with it? (scan / skip-via-feature-flag / skip-via-toggle /
|
||||
* missing-on-disk)
|
||||
* 2. What subdirectories EXIST under STORAGE_PATH but have NO row
|
||||
* in `backup_paths` — i.e. drift the admin should know about
|
||||
* before they lose data on a restore?
|
||||
*
|
||||
* Plus a top-level database-dump status block: are we configured
|
||||
* for inline dump (default), or relying on the scheduled dump?
|
||||
* When was the last successful dump? Is it stale?
|
||||
*
|
||||
* **What this service does NOT do**
|
||||
*
|
||||
* - Does not run the backup
|
||||
* - Does not write anything (no DB mutations, no fs touches)
|
||||
* - Does not auto-recover drift (it's a diagnostic — admins decide
|
||||
* whether to add a `backup_paths` row, delete the orphan dir, etc.)
|
||||
* - Does not walk file contents — only top-level directory entries
|
||||
* under STORAGE_PATH are inspected (cheap; no recursion through
|
||||
* potentially-millions of photos)
|
||||
*
|
||||
* Read-only. Returns a JSON report — same shape as
|
||||
* backupIntegrityService.verifyDocumentArtefacts.
|
||||
*/
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('../utils/logger');
|
||||
const backupService = require('./backupService');
|
||||
|
||||
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
|
||||
|
||||
/**
|
||||
* Top-level subdirectories we expect to find under STORAGE_PATH but
|
||||
* which are intentionally NOT in `backup_paths` — they're generated
|
||||
* caches / runtime artefacts that the backup is supposed to skip.
|
||||
* Listing them here keeps the drift detector from flagging them.
|
||||
*
|
||||
* `backups` — the destination directory the backup writer itself
|
||||
* creates, plus the `database_backup_runs` dump files.
|
||||
* Including it in the walker would create a recursive
|
||||
* "backup of backups" feedback loop.
|
||||
*
|
||||
* `tmp` — short-lived scratch space (e.g. PDF render staging,
|
||||
* S3 multipart uploads). Re-created on demand, never
|
||||
* holds the only copy of anything.
|
||||
*/
|
||||
const EXPECTED_NON_BACKUP_DIRS = new Set([
|
||||
'backups',
|
||||
'tmp',
|
||||
]);
|
||||
|
||||
/**
|
||||
* How stale a database dump can be before we flag it. 26 hours so a
|
||||
* daily scheduled dump is still considered "fresh" if it ran a few
|
||||
* hours late.
|
||||
*/
|
||||
const DB_DUMP_STALE_AFTER_MS = 26 * 60 * 60 * 1000;
|
||||
|
||||
function parseSettingValue(raw) {
|
||||
if (raw === null || raw === undefined) return null;
|
||||
if (typeof raw !== 'string') return raw;
|
||||
try { return JSON.parse(raw); } catch (_) {
|
||||
if (raw === 'true') return true;
|
||||
if (raw === 'false') return false;
|
||||
const n = Number(raw);
|
||||
return Number.isFinite(n) ? n : raw;
|
||||
}
|
||||
}
|
||||
|
||||
async function readBackupConfig() {
|
||||
try {
|
||||
const rows = await db('app_settings')
|
||||
.where('setting_type', 'backup')
|
||||
.select('setting_key', 'setting_value');
|
||||
const cfg = {};
|
||||
for (const row of rows) {
|
||||
cfg[row.setting_key] = parseSettingValue(row.setting_value);
|
||||
}
|
||||
return cfg;
|
||||
} catch (err) {
|
||||
logger.warn(`backup-coverage: could not read backup config — ${err.message}`);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function listConfiguredPaths() {
|
||||
try {
|
||||
if (!(await db.schema.hasTable('backup_paths'))) return null;
|
||||
return await db('backup_paths')
|
||||
.orderBy('display_order', 'asc')
|
||||
.select('path', 'include_in_default', 'feature_flag', 'display_order', 'description');
|
||||
} catch (err) {
|
||||
logger.warn(`backup-coverage: could not read backup_paths — ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function listTopLevelStorageDirs() {
|
||||
const root = STORAGE_ROOT();
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
return entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return [];
|
||||
logger.warn(`backup-coverage: could not read STORAGE_PATH (${root}) — ${err.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function statPath(absPath) {
|
||||
try {
|
||||
const st = await fs.stat(absPath);
|
||||
return { exists: true, isDir: st.isDirectory() };
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return { exists: false, isDir: false };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the database-dump status block. Tells the admin whether
|
||||
* "Run Backup Now" will inline-dump (default) or rely on the
|
||||
* scheduled-dump path, plus how fresh the most recent dump is.
|
||||
*/
|
||||
async function buildDatabaseStatus(config) {
|
||||
// normalizeBoolean(undefined) === false, so we have to gate on
|
||||
// explicit-false the same way ensureDatabaseDumpForBackup does.
|
||||
const inlineExplicitlyOff = config.backup_database_inline_dump !== undefined
|
||||
&& config.backup_database_inline_dump !== null
|
||||
&& config.backup_database_inline_dump === false;
|
||||
const mode = inlineExplicitlyOff ? 'scheduled-only' : 'inline';
|
||||
|
||||
let recent = null;
|
||||
try {
|
||||
if (await db.schema.hasTable('database_backup_runs')) {
|
||||
recent = await db('database_backup_runs')
|
||||
.where('status', 'completed')
|
||||
.orderBy('completed_at', 'desc')
|
||||
.first();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`backup-coverage: could not read database_backup_runs — ${err.message}`);
|
||||
}
|
||||
|
||||
const status = {
|
||||
mode,
|
||||
inlineDumpExplicitlyDisabled: inlineExplicitlyOff,
|
||||
lastDumpAt: recent ? recent.completed_at : null,
|
||||
lastDumpType: recent ? recent.backup_type : null,
|
||||
lastDumpSizeBytes: recent ? Number(recent.file_size_bytes || 0) : 0,
|
||||
lastDumpFilePath: recent ? recent.file_path : null,
|
||||
lastDumpAgeMs: null,
|
||||
lastDumpStale: null,
|
||||
ok: null,
|
||||
};
|
||||
|
||||
if (recent && recent.completed_at) {
|
||||
const completedAt = recent.completed_at instanceof Date
|
||||
? recent.completed_at
|
||||
: new Date(recent.completed_at);
|
||||
status.lastDumpAgeMs = Date.now() - completedAt.getTime();
|
||||
status.lastDumpStale = status.lastDumpAgeMs > DB_DUMP_STALE_AFTER_MS;
|
||||
}
|
||||
|
||||
// ok semantics:
|
||||
// - inline mode: always ok=true (next backup will produce a fresh
|
||||
// dump on demand, staleness is irrelevant)
|
||||
// - scheduled-only: ok=true iff a recent non-stale dump exists,
|
||||
// because the file-backup guard will fail-loud otherwise
|
||||
if (mode === 'inline') {
|
||||
status.ok = true;
|
||||
} else {
|
||||
status.ok = Boolean(recent && recent.file_path && status.lastDumpStale === false);
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-path coverage:
|
||||
* - configured + include_in_default + (no feature_flag OR flag truthy) → 'will-scan'
|
||||
* - configured + include_in_default + flag falsey → 'skipped-by-feature-flag'
|
||||
* - configured + include_in_default = false → 'skipped-by-toggle'
|
||||
* - configured but missing on disk → 'missing-on-disk'
|
||||
*
|
||||
* Returns one entry per `backup_paths` row.
|
||||
*/
|
||||
async function buildConfiguredPathReport(configuredRows, config) {
|
||||
const root = STORAGE_ROOT();
|
||||
const result = [];
|
||||
|
||||
for (const row of configuredRows) {
|
||||
const absPath = path.join(root, row.path);
|
||||
const stat = await statPath(absPath);
|
||||
const includedInDefault = Boolean(row.include_in_default);
|
||||
let featureFlagValue = null;
|
||||
if (row.feature_flag) {
|
||||
const v = config[row.feature_flag];
|
||||
featureFlagValue = v === undefined ? null : Boolean(v);
|
||||
}
|
||||
|
||||
let coverage;
|
||||
if (!includedInDefault) {
|
||||
coverage = 'skipped-by-toggle';
|
||||
} else if (row.feature_flag && featureFlagValue !== true) {
|
||||
// null (unset) and explicit false both gate the path off — matches
|
||||
// the walker's normalizeBoolean semantics
|
||||
coverage = 'skipped-by-feature-flag';
|
||||
} else if (!stat.exists) {
|
||||
coverage = 'missing-on-disk';
|
||||
} else {
|
||||
coverage = 'will-scan';
|
||||
}
|
||||
|
||||
result.push({
|
||||
path: row.path,
|
||||
includeInDefault: includedInDefault,
|
||||
featureFlag: row.feature_flag || null,
|
||||
featureFlagValue,
|
||||
displayOrder: row.display_order,
|
||||
description: row.description || null,
|
||||
existsOnDisk: stat.exists,
|
||||
coverage,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drift detection: top-level subdirs under STORAGE_PATH that are not
|
||||
* in `backup_paths` AND not in the `EXPECTED_NON_BACKUP_DIRS` allow-list.
|
||||
*
|
||||
* These are the directories that will be missed by "Run Backup Now"
|
||||
* — either intentionally (a new feature drops cache files there and
|
||||
* the admin doesn't want them backed up — they should add them to the
|
||||
* allow-list) or accidentally (a feature shipped without a matching
|
||||
* `backup_paths` row — the data-loss footgun this whole effort is
|
||||
* designed to catch).
|
||||
*/
|
||||
function detectDrift(diskDirs, configuredPaths) {
|
||||
// configured paths can be nested ('events/active'); we only diff the
|
||||
// top-level segment ('events') because that's the granularity admins
|
||||
// see in the storage tree. A path like 'events/active' implies the
|
||||
// 'events' top-level is "known to the backup config".
|
||||
const configuredTopLevels = new Set(
|
||||
configuredPaths.map((p) => p.path.split('/')[0]),
|
||||
);
|
||||
|
||||
return diskDirs
|
||||
.filter((d) => !configuredTopLevels.has(d))
|
||||
.filter((d) => !EXPECTED_NON_BACKUP_DIRS.has(d))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public entry point.
|
||||
*
|
||||
* @returns {Promise<{
|
||||
* database: object,
|
||||
* paths: Array<object>,
|
||||
* drift: { unconfiguredOnDisk: string[], expectedNonBackupDirs: string[] },
|
||||
* summary: object,
|
||||
* generatedAt: string,
|
||||
* }>}
|
||||
*/
|
||||
async function getCoverageReport() {
|
||||
const config = await readBackupConfig();
|
||||
const configuredRows = await listConfiguredPaths();
|
||||
const diskDirs = await listTopLevelStorageDirs();
|
||||
|
||||
// Fallback when the table doesn't exist yet (migration 108 hasn't
|
||||
// run for some reason). Mirrors the walker's LEGACY_BACKUP_PATHS
|
||||
// contract — every other layer of this system uses the same
|
||||
// belt-and-suspenders fallback.
|
||||
const fallback = configuredRows === null;
|
||||
const effectiveRows = configuredRows || [
|
||||
{ path: 'events/active', include_in_default: true, feature_flag: null, display_order: 10, description: 'Legacy fallback (backup_paths missing)' },
|
||||
{ path: 'events/archived', include_in_default: true, feature_flag: 'backup_include_archived', display_order: 20, description: 'Legacy fallback (backup_paths missing)' },
|
||||
{ path: 'thumbnails', include_in_default: true, feature_flag: null, display_order: 30, description: 'Legacy fallback (backup_paths missing)' },
|
||||
{ path: 'previews', include_in_default: true, feature_flag: null, display_order: 40, description: 'Legacy fallback (backup_paths missing)' },
|
||||
{ path: 'heroes', include_in_default: true, feature_flag: null, display_order: 50, description: 'Legacy fallback (backup_paths missing)' },
|
||||
{ path: 'uploads', include_in_default: true, feature_flag: null, display_order: 60, description: 'Legacy fallback (backup_paths missing)' },
|
||||
{ path: 'business-docs', include_in_default: true, feature_flag: null, display_order: 70, description: 'Legacy fallback (backup_paths missing)' },
|
||||
];
|
||||
|
||||
const [database, paths] = await Promise.all([
|
||||
buildDatabaseStatus(config),
|
||||
buildConfiguredPathReport(effectiveRows, config),
|
||||
]);
|
||||
|
||||
const unconfiguredOnDisk = detectDrift(diskDirs, effectiveRows);
|
||||
|
||||
const summary = {
|
||||
configuredCount: effectiveRows.length,
|
||||
willScanCount: paths.filter((p) => p.coverage === 'will-scan').length,
|
||||
skippedByToggleCount: paths.filter((p) => p.coverage === 'skipped-by-toggle').length,
|
||||
skippedByFeatureFlagCount: paths.filter((p) => p.coverage === 'skipped-by-feature-flag').length,
|
||||
missingOnDiskCount: paths.filter((p) => p.coverage === 'missing-on-disk').length,
|
||||
driftCount: unconfiguredOnDisk.length,
|
||||
tableMissingFallbackInUse: fallback,
|
||||
databaseOk: database.ok,
|
||||
// overall: green only when DB is ok AND there's at least one path
|
||||
// that will actually be scanned AND no drift was found
|
||||
overallOk: Boolean(
|
||||
database.ok
|
||||
&& paths.some((p) => p.coverage === 'will-scan')
|
||||
&& unconfiguredOnDisk.length === 0,
|
||||
),
|
||||
};
|
||||
|
||||
return {
|
||||
database,
|
||||
paths,
|
||||
drift: {
|
||||
unconfiguredOnDisk,
|
||||
expectedNonBackupDirs: Array.from(EXPECTED_NON_BACKUP_DIRS).sort(),
|
||||
},
|
||||
summary,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCoverageReport,
|
||||
// Exported for test introspection — the route doesn't use these.
|
||||
EXPECTED_NON_BACKUP_DIRS,
|
||||
DB_DUMP_STALE_AFTER_MS,
|
||||
};
|
||||
|
||||
// Silence unused import lint warning — backupService is required so
|
||||
// the module-graph cache primes (some tests jest.mock it before
|
||||
// requiring this service).
|
||||
void backupService;
|
||||
Reference in New Issue
Block a user