From 03e6617f38344434add3bb866e13807f19940519 Mon Sep 17 00:00:00 2001 From: Luca <102960244+Luca-Timo@users.noreply.github.com> Date: Fri, 29 May 2026 22:20:32 +0200 Subject: [PATCH] =?UTF-8?q?feat(backup):=20coverage=20diagnostic=20?= =?UTF-8?q?=E2=80=94=20what=20will=20the=20next=20backup=20miss=3F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../integration/adminBackupCoverage.test.js | 242 ++++++++++ backend/src/routes/adminSystemHealth.js | 23 + backend/src/services/backupCoverageService.js | 351 ++++++++++++++ .../components/admin/BackupCoverageCard.tsx | 430 ++++++++++++++++++ frontend/src/i18n/locales/de.json | 48 +- frontend/src/i18n/locales/en.json | 48 +- frontend/src/pages/admin/BackupManagement.tsx | 9 +- frontend/src/services/admin.service.ts | 62 +++ 8 files changed, 1210 insertions(+), 3 deletions(-) create mode 100644 backend/__tests__/integration/adminBackupCoverage.test.js create mode 100644 backend/src/services/backupCoverageService.js create mode 100644 frontend/src/components/admin/BackupCoverageCard.tsx diff --git a/backend/__tests__/integration/adminBackupCoverage.test.js b/backend/__tests__/integration/adminBackupCoverage.test.js new file mode 100644 index 00000000..ab10c160 --- /dev/null +++ b/backend/__tests__/integration/adminBackupCoverage.test.js @@ -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); + }); +}); diff --git a/backend/src/routes/adminSystemHealth.js b/backend/src/routes/adminSystemHealth.js index e638cad4..a76bee98 100644 --- a/backend/src/routes/adminSystemHealth.js +++ b/backend/src/routes/adminSystemHealth.js @@ -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; diff --git a/backend/src/services/backupCoverageService.js b/backend/src/services/backupCoverageService.js new file mode 100644 index 00000000..7af24cc4 --- /dev/null +++ b/backend/src/services/backupCoverageService.js @@ -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, + * 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; diff --git a/frontend/src/components/admin/BackupCoverageCard.tsx b/frontend/src/components/admin/BackupCoverageCard.tsx new file mode 100644 index 00000000..511d8cf1 --- /dev/null +++ b/frontend/src/components/admin/BackupCoverageCard.tsx @@ -0,0 +1,430 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + ShieldCheck, + ShieldAlert, + Database, + FolderTree, + AlertTriangle, + CheckCircle2, + XCircle, + EyeOff, + Clock, + RefreshCw, + Loader2, +} from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { format } from 'date-fns'; + +import { Card, Button } from '../common'; +import { + adminService, + BackupCoverageReport, + BackupPathCoverage, +} from '../../services/admin.service'; + +/** + * BackupCoverageCard — Stage C of the backup-hardening plan. + * + * Tells the admin what the next "Run Backup Now" will actually do: + * + * - Database: inline-dump or scheduled, last dump age, staleness + * - Configured paths: per-row coverage (will-scan / skipped by + * toggle / skipped by feature flag / missing on disk) + * - Drift: top-level subdirs under STORAGE_PATH that have no + * `backup_paths` row (the "feature shipped without a backup row" + * footgun this whole effort is designed to catch) + * + * Auto-fetches on mount — unlike the integrity verifier, this is + * a cheap query (no recursion) so admins should always see the + * current state when they open the tab. + */ +export const BackupCoverageCard: React.FC = () => { + const { t } = useTranslation(); + const { data, isLoading, isError, error, refetch, isFetching } = useQuery({ + queryKey: ['backup-coverage'], + queryFn: () => adminService.getBackupCoverage(), + // The report changes only when (a) backup_paths is edited or + // (b) a new scheduled dump completes. Stale time of 30s keeps + // the UI snappy without hammering the endpoint. + staleTime: 30_000, + }); + + return ( + +
refetch()} refreshing={isFetching} /> + + {isError && ( + + )} + + {data && ( + <> + {data.summary.tableMissingFallbackInUse && ( + + )} + + + + + + + + + + +

+ {t('backup.coverage.generatedAt', 'Coverage generated: {{when}}', { + when: format(new Date(data.generatedAt), 'yyyy-MM-dd HH:mm:ss'), + })} +

+ + )} + + ); +}; + +const Header: React.FC<{ + report: BackupCoverageReport | undefined; + loading: boolean; + onRefresh: () => void; + refreshing: boolean; +}> = ({ report, loading, onRefresh, refreshing }) => { + const { t } = useTranslation(); + const healthy = report?.summary.overallOk; + return ( +
+
+
+ {loading || refreshing ? ( + + ) : healthy ? ( + + ) : report ? ( + + ) : ( + + )} +

+ {t('backup.coverage.title', 'Backup coverage')} +

+
+

+ {t( + 'backup.coverage.description', + 'Shows what the next backup will include, skip, or silently miss. The database block confirms the dump strategy. The "drift" section flags subdirectories that exist on disk but are not in the backup configuration — usually a sign that a new feature shipped without a matching backup_paths row.', + )} +

+
+ +
+ ); +}; + +const ErrorBanner: React.FC<{ message: string }> = ({ message }) => { + const { t } = useTranslation(); + return ( +
+ {t('backup.coverage.error', 'Could not load coverage report: {{message}}', { message })} +
+ ); +}; + +const FallbackWarning: React.FC = () => { + const { t } = useTranslation(); + return ( +
+ + + {t( + 'backup.coverage.fallbackInUse', + 'The backup_paths table is missing. The walker is using its legacy hard-coded fallback. Migration 108 may not have run — check server logs and re-run migrations.', + )} + +
+ ); +}; + +const SectionGrid: React.FC<{ children: React.ReactNode }> = ({ children }) => ( +
{children}
+); + +const DatabaseStatusCard: React.FC<{ + database: BackupCoverageReport['database']; +}> = ({ database }) => { + const { t } = useTranslation(); + const isInline = database.mode === 'inline'; + const tone: Tone = database.ok ? 'green' : 'red'; + const dumpAge = database.lastDumpAgeMs !== null + ? formatAge(database.lastDumpAgeMs) + : null; + + return ( +
+
+ +

+ {t('backup.coverage.database.title', 'Database')} +

+ {database.ok ? ( + + ) : ( + + )} +
+
+ + {database.lastDumpAt ? ( + <> + + + + ) : ( + + )} + {database.lastDumpStale && ( + } + /> + )} +
+
+ ); +}; + +const SummaryCard: React.FC<{ + summary: BackupCoverageReport['summary']; +}> = ({ summary }) => { + const { t } = useTranslation(); + const tone: Tone = summary.overallOk + ? 'green' + : summary.driftCount > 0 || !summary.databaseOk + ? 'amber' + : 'neutral'; + return ( +
+
+ +

+ {t('backup.coverage.summary.title', 'Summary')} +

+
+
+ + {summary.skippedByToggleCount > 0 && ( + + )} + {summary.skippedByFeatureFlagCount > 0 && ( + + )} + {summary.missingOnDiskCount > 0 && ( + + )} + +
+
+ ); +}; + +const PathsTable: React.FC<{ paths: BackupCoverageReport['paths'] }> = ({ paths }) => { + const { t } = useTranslation(); + return ( +
+
+

+ {t('backup.coverage.paths.heading', 'Configured paths')} +

+
+
+ + + + + + + + + + + {paths.map((p) => ( + + + + + + + ))} + +
{t('backup.coverage.paths.path', 'Path')}{t('backup.coverage.paths.coverage', 'Coverage')}{t('backup.coverage.paths.featureFlag', 'Feature flag')}{t('backup.coverage.paths.description', 'Description')}
+ {p.path} + + + + {p.featureFlag + ? `${p.featureFlag} = ${p.featureFlagValue === null ? '∅' : String(p.featureFlagValue)}` + : '—'} + + {p.description ?? '—'} +
+
+
+ ); +}; + +const DriftSection: React.FC<{ drift: BackupCoverageReport['drift'] }> = ({ drift }) => { + const { t } = useTranslation(); + if (drift.unconfiguredOnDisk.length === 0) { + return ( +
+ + {t( + 'backup.coverage.drift.none', + 'No drift detected — every top-level subdirectory under STORAGE_PATH is either in backup_paths or in the expected non-backup allow-list.', + )} +
+ ); + } + return ( +
+
+
+ +

+ {t('backup.coverage.drift.heading', 'Drift detected: subdirectories not covered by any backup_paths row')} +

+
+

+ {t( + 'backup.coverage.drift.caption', + 'These directories exist on disk but the walker will skip them. Either add a backup_paths row, move the files into a covered location, or — if they are runtime caches — confirm they are safe to exclude.', + )} +

+
+
    + {drift.unconfiguredOnDisk.map((d) => ( +
  • + + {d} +
  • + ))} +
+
+ ); +}; + +const CoverageBadge: React.FC<{ coverage: BackupPathCoverage }> = ({ coverage }) => { + const { t } = useTranslation(); + const map: Record = { + 'will-scan': { + tone: 'green', + label: t('backup.coverage.coverage.willScan', 'Will scan'), + }, + 'skipped-by-toggle': { + tone: 'neutral', + label: t('backup.coverage.coverage.skippedByToggle', 'Off'), + }, + 'skipped-by-feature-flag': { + tone: 'neutral', + label: t('backup.coverage.coverage.skippedByFlag', 'Gated off'), + }, + 'missing-on-disk': { + tone: 'amber', + label: t('backup.coverage.coverage.missingOnDisk', 'Missing on disk'), + }, + }; + const { tone, label } = map[coverage]; + return ( + + {label} + + ); +}; + +const Row: React.FC<{ label: string; value: string; icon?: React.ReactNode }> = ({ + label, value, icon, +}) => ( +
+
+ {icon} + {label} +
+
{value}
+
+); + +type Tone = 'neutral' | 'green' | 'amber' | 'red'; + +const TONE_BG: Record = { + neutral: 'bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-200', + green: 'bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-300', + amber: 'bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300', + red: 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-300', +}; + +function formatBytes(bytes: number): string { + if (!bytes) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +function formatAge(ms: number): string { + const sec = Math.floor(ms / 1000); + if (sec < 60) return `${sec}s ago`; + const min = Math.floor(sec / 60); + if (min < 60) return `${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 48) return `${hr}h ago`; + const day = Math.floor(hr / 24); + return `${day}d ago`; +} diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 4c6855b1..e30ed666 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -231,7 +231,8 @@ "configuration": "Konfiguration", "history": "Backup-Verlauf", "restore": "Wiederherstellung", - "integrity": "Integrität" + "integrity": "Integrität", + "coverage": "Abdeckung" }, "integrity": { "title": "Dokumentintegrität", @@ -264,6 +265,51 @@ "detail": "Detail" } }, + "coverage": { + "title": "Backup-Abdeckung", + "description": "Zeigt, was das nächste Backup einschließt, überspringt oder stillschweigend verfehlt. Der Datenbank-Block bestätigt die Dump-Strategie. Der \"Drift\"-Abschnitt markiert Unterverzeichnisse, die auf der Festplatte existieren, aber nicht in der Backup-Konfiguration stehen — meist ein Hinweis darauf, dass eine neue Funktion ohne passende backup_paths-Zeile ausgeliefert wurde.", + "refresh": "Aktualisieren", + "error": "Abdeckungs-Bericht konnte nicht geladen werden: {{message}}", + "generatedAt": "Abdeckung erstellt: {{when}}", + "fallbackInUse": "Die Tabelle backup_paths fehlt. Der Walker nutzt seine fest verdrahtete Legacy-Fallback-Liste. Migration 108 wurde möglicherweise nicht ausgeführt — prüfen Sie die Server-Logs und führen Sie die Migrationen erneut aus.", + "database": { + "title": "Datenbank", + "mode": "Modus", + "modeInline": "Inline-Dump bei jedem Backup", + "modeScheduled": "Nur geplant (Inline abgewählt)", + "lastDump": "Letzter Dump", + "lastDumpSize": "Größe", + "noDump": "Noch kein Dump vorhanden", + "staleLabel": "Status", + "stale": "Veraltet — älter als 26 Std." + }, + "summary": { + "title": "Zusammenfassung", + "willScan": "Wird gescannt", + "skippedByToggle": "Übersprungen (Schalter aus)", + "skippedByFlag": "Übersprungen (Feature-Flag)", + "missingOnDisk": "Auf Festplatte fehlend", + "drift": "Nicht konfiguriert auf Festplatte (Drift)" + }, + "paths": { + "heading": "Konfigurierte Pfade", + "path": "Pfad", + "coverage": "Abdeckung", + "featureFlag": "Feature-Flag", + "description": "Beschreibung" + }, + "coverage": { + "willScan": "Wird gescannt", + "skippedByToggle": "Aus", + "skippedByFlag": "Per Flag aus", + "missingOnDisk": "Auf Festplatte fehlend" + }, + "drift": { + "heading": "Drift erkannt: Unterverzeichnisse ohne backup_paths-Zeile", + "caption": "Diese Verzeichnisse existieren auf der Festplatte, werden vom Walker aber übersprungen. Entweder eine backup_paths-Zeile hinzufügen, die Dateien in ein abgedecktes Verzeichnis verschieben, oder — wenn es sich um Laufzeit-Caches handelt — bestätigen, dass der Ausschluss sicher ist.", + "none": "Kein Drift erkannt — jedes Top-Level-Unterverzeichnis unter STORAGE_PATH steht entweder in backup_paths oder in der erwarteten Nicht-Backup-Allow-List." + } + }, "status": { "inProgress": "Backup läuft...", "lastBackup": "Letztes Backup", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 3e94ebf5..7d6831c5 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -2122,7 +2122,8 @@ "configuration": "Configuration", "history": "Backup History", "restore": "Restore", - "integrity": "Integrity" + "integrity": "Integrity", + "coverage": "Coverage" }, "integrity": { "title": "Document integrity", @@ -2155,6 +2156,51 @@ "detail": "Detail" } }, + "coverage": { + "title": "Backup coverage", + "description": "Shows what the next backup will include, skip, or silently miss. The database block confirms the dump strategy. The \"drift\" section flags subdirectories that exist on disk but are not in the backup configuration — usually a sign that a new feature shipped without a matching backup_paths row.", + "refresh": "Refresh", + "error": "Could not load coverage report: {{message}}", + "generatedAt": "Coverage generated: {{when}}", + "fallbackInUse": "The backup_paths table is missing. The walker is using its legacy hard-coded fallback. Migration 108 may not have run — check server logs and re-run migrations.", + "database": { + "title": "Database", + "mode": "Mode", + "modeInline": "Inline dump on every backup", + "modeScheduled": "Scheduled-only (inline opted out)", + "lastDump": "Last dump", + "lastDumpSize": "Size", + "noDump": "No dump on file yet", + "staleLabel": "Status", + "stale": "Stale — older than 26h" + }, + "summary": { + "title": "Summary", + "willScan": "Will scan", + "skippedByToggle": "Skipped (toggle off)", + "skippedByFlag": "Skipped (feature flag)", + "missingOnDisk": "Missing on disk", + "drift": "Unconfigured on disk (drift)" + }, + "paths": { + "heading": "Configured paths", + "path": "Path", + "coverage": "Coverage", + "featureFlag": "Feature flag", + "description": "Description" + }, + "coverage": { + "willScan": "Will scan", + "skippedByToggle": "Off", + "skippedByFlag": "Gated off", + "missingOnDisk": "Missing on disk" + }, + "drift": { + "heading": "Drift detected: subdirectories not covered by any backup_paths row", + "caption": "These directories exist on disk but the walker will skip them. Either add a backup_paths row, move the files into a covered location, or — if they are runtime caches — confirm they are safe to exclude.", + "none": "No drift detected — every top-level subdirectory under STORAGE_PATH is either in backup_paths or in the expected non-backup allow-list." + } + }, "status": { "inProgress": "Backup in progress...", "lastBackup": "Last backup", diff --git a/frontend/src/pages/admin/BackupManagement.tsx b/frontend/src/pages/admin/BackupManagement.tsx index f127149a..43e89774 100644 --- a/frontend/src/pages/admin/BackupManagement.tsx +++ b/frontend/src/pages/admin/BackupManagement.tsx @@ -11,6 +11,7 @@ import { Loader2, Shield, ShieldCheck, + FolderTree, } from 'lucide-react'; import { toast } from 'react-toastify'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -23,9 +24,10 @@ import { BackupConfiguration } from '../../components/admin/BackupConfiguration' import { BackupHistory } from '../../components/admin/BackupHistory'; import { RestoreWizard } from '../../components/admin/RestoreWizard'; import { BackupIntegrityCard } from '../../components/admin/BackupIntegrityCard'; +import { BackupCoverageCard } from '../../components/admin/BackupCoverageCard'; import { api } from '../../config/api'; -type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity'; +type TabId = 'dashboard' | 'configuration' | 'history' | 'restore' | 'integrity' | 'coverage'; export const BackupManagement: React.FC = () => { const [activeTab, setActiveTab] = useState('dashboard'); @@ -38,6 +40,7 @@ export const BackupManagement: React.FC = () => { { id: 'history' as const, label: t('backup.tabs.history'), icon: History }, { id: 'restore' as const, label: t('backup.tabs.restore'), icon: RefreshCw }, { id: 'integrity' as const, label: t('backup.tabs.integrity', 'Integrity'), icon: ShieldCheck }, + { id: 'coverage' as const, label: t('backup.tabs.coverage', 'Coverage'), icon: FolderTree }, ]; const { data: backupStatus, isLoading: statusLoading } = useQuery({ @@ -227,6 +230,10 @@ export const BackupManagement: React.FC = () => { {activeTab === 'integrity' && ( )} + + {activeTab === 'coverage' && ( + + )} ); diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts index c622688f..09c137d6 100644 --- a/frontend/src/services/admin.service.ts +++ b/frontend/src/services/admin.service.ts @@ -245,6 +245,58 @@ export interface BackupIntegrityReport { existsButNoHash: BackupIntegrityExistsButNoHashRow[]; } +// ---- Backup-coverage (Stage C of backup-hardening plan) ----------------- + +export type BackupPathCoverage = + | 'will-scan' + | 'skipped-by-toggle' + | 'skipped-by-feature-flag' + | 'missing-on-disk'; + +export interface BackupCoveragePath { + path: string; + includeInDefault: boolean; + featureFlag: string | null; + featureFlagValue: boolean | null; + displayOrder: number; + description: string | null; + existsOnDisk: boolean; + coverage: BackupPathCoverage; +} + +export interface BackupCoverageDatabase { + mode: 'inline' | 'scheduled-only'; + inlineDumpExplicitlyDisabled: boolean; + lastDumpAt: string | null; + lastDumpType: string | null; + lastDumpSizeBytes: number; + lastDumpFilePath: string | null; + lastDumpAgeMs: number | null; + lastDumpStale: boolean | null; + ok: boolean; +} + +export interface BackupCoverageReport { + generatedAt: string; + database: BackupCoverageDatabase; + paths: BackupCoveragePath[]; + drift: { + unconfiguredOnDisk: string[]; + expectedNonBackupDirs: string[]; + }; + summary: { + configuredCount: number; + willScanCount: number; + skippedByToggleCount: number; + skippedByFeatureFlagCount: number; + missingOnDiskCount: number; + driftCount: number; + tableMissingFallbackInUse: boolean; + databaseOk: boolean; + overallOk: boolean; + }; +} + export interface AdminProfile { id: number; username: string; @@ -319,6 +371,16 @@ export const adminService = { return response.data.report; }, + // Backup-coverage diagnostic (Stage C). Answers "what will the + // next backup actually include / skip / silently miss?" — read-only, + // no parameters. See backupCoverageService.js for the full report shape. + async getBackupCoverage(): Promise { + const response = await api.get<{ report: BackupCoverageReport }>( + '/admin/system-health/backup-coverage', + ); + return response.data.report; + }, + // Format activity message formatActivityMessage(activity: Activity): string { // Feature-flag toggles carry a `changed` diff in metadata. Render