a2e723413e
* fix(backup): make backup settings actually apply (#871) - Wire the What-to-Backup toggles into the walker: honor backup_include_thumbnails / backup_include_photos (opt-out, default ON) and accept the UI's backup_include_archives spelling for the archived gate (the engine expected _archived, so the Archives checkbox silently never worked). - Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that node-postgres returns as a string, and the S3 path concatenated it onto the byte counter; coerce to Number at the source. - Compute the real next scheduled run (cron-parser) and return it as nextBackup; the UI read a field the API never sent and rendered a hardcoded 'Not scheduled'. A named schedule label now beats the stray default cron the UI always sent, which silently turned weekly schedules into daily 03:00 runs. - Never back up filesystem noise (.nfs* silly-renames, .DS_Store, Thumbs.db) and honor backup_exclude_patterns in the walker (previously rsync-only). - Remove the compression/encryption toggles from the configuration UI: no backend implementation exists, and collecting an encryption passphrase while uploading plaintext is a false promise. Closes #871 * fix(backup): close the review gaps in the settings wiring - The UI's backup_include_archives now beats the migration-seeded backup_include_archived: every install has the singular key seeded true, so the alias-only-when-absent lookup made unchecking Archives a no-op. - rsync destinations now receive the de-selected What-to-Backup paths and the noise filters as anchored --exclude args; previously rsync synced the whole storage root and the walker's selection only shaped the manifest, which then misreported what was actually transferred. - Escape regex metacharacters in the walker's glob matcher: '.nfs*' compiled to /^.nfs.*$/ whose leading dot matched any character, so files like anfs-photo.jpg were silently dropped from backups. - The Backup Coverage report now uses the same gate as the walker (new 'skipped-by-setting' status) instead of re-implementing it without the opt-out toggles and the archives alias. * fix(backup): make the coverage diagnostics agree with the walker - The coverage table shows the alias-aware flag value the gate actually used, instead of the seeded backup_include_archived shadowed by the UI's plural key (true next to a 'Gated off' badge). - skipped-by-setting paths are now counted in the coverage summary (backend, TS contract, summary card, EN/DE locales) so the totals reconcile again when Photos or Thumbnails is unchecked. - The form's thumbnail default now matches the backend's never-saved fallback (include): the checkbox no longer shows 'off' while thumbnails are being backed up, and saving an unrelated setting no longer flips the backup scope. * fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display - Saving a named schedule no longer wipes the stored custom cron: the backend already prefers the label, so the cron field stays inert for named schedules and is preserved for switching back to Custom. A custom schedule now validates the 5-field expression before saving (the backend silently fell back to daily 02:00 on a blank value). - resolveExcludedBackupPaths now also returns rows disabled via include_in_default, so rsync excludes them; the enabled-only loader hid them and rsync transferred their contents anyway. - The coverage table normalizes flag values like the walker does — Boolean('false') displayed true beside a gated-off badge.
380 lines
14 KiB
JavaScript
380 lines
14 KiB
JavaScript
/**
|
|
* Pins the Stage-B refactor that lifted the file-backup walker's
|
|
* subdirectory list out of hard-coded JS into the `backup_paths`
|
|
* table seeded by migration 109.
|
|
*
|
|
* Scenarios:
|
|
* 1. Walker reads canonical seed → all 7 default subdirs walked
|
|
* 2. include_in_default=false on one row → that subdir is skipped
|
|
* 3. New row inserted at runtime → walker picks it up without restart
|
|
* 4. feature_flag gating → row only walked when the named app_settings
|
|
* boolean is truthy (mirrors historical `includeArchived` behavior)
|
|
* 5. Empty table → walker falls back to LEGACY_BACKUP_PATHS (defense
|
|
* in depth — never silently scans nothing)
|
|
*
|
|
* Why not stub `db('backup_paths')`: the whole point of Stage B is
|
|
* that the walker is now data-driven, so the test has to actually
|
|
* mutate the table and observe the walker's output change. Stubs
|
|
* would re-introduce the hard-coding the refactor is meant to remove.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const { bootCrmDb } = require('./helpers/crmDb');
|
|
|
|
jest.setTimeout(120000);
|
|
|
|
describe('backupService — configurable walker (backup_paths)', () => {
|
|
let db;
|
|
let cleanup;
|
|
let storagePath;
|
|
let backupService;
|
|
|
|
beforeAll(async () => {
|
|
({ db, cleanup } = await bootCrmDb());
|
|
storagePath = process.env.STORAGE_PATH;
|
|
backupService = require('../../src/services/backupService');
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
if (cleanup) await cleanup();
|
|
});
|
|
|
|
function seedFile(relPath, content = 'dummy bytes') {
|
|
const abs = path.join(storagePath, relPath);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, content);
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
// Restore canonical seed before every test. Tests mutate this table
|
|
// freely; the next test starts from a known state.
|
|
await db('backup_paths').del();
|
|
const {
|
|
DEFAULT_PATHS,
|
|
} = require('../../migrations/core/109_add_backup_paths');
|
|
await db('backup_paths').insert(DEFAULT_PATHS.map((row) => ({
|
|
...row,
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
})));
|
|
});
|
|
|
|
it('migration 109 seeds the canonical 7 paths', async () => {
|
|
const rows = await db('backup_paths').orderBy('display_order', 'asc').select();
|
|
expect(rows.map((r) => r.path)).toEqual([
|
|
'events/active',
|
|
'events/archived',
|
|
'thumbnails',
|
|
'previews',
|
|
'heroes',
|
|
'uploads',
|
|
'business-docs',
|
|
]);
|
|
// Only events/archived is gated by a feature flag.
|
|
expect(rows.filter((r) => r.feature_flag).map((r) => r.path)).toEqual([
|
|
'events/archived',
|
|
]);
|
|
});
|
|
|
|
it('walks every default subdir when files are present', async () => {
|
|
seedFile('events/active/E1/a.jpg');
|
|
seedFile('thumbnails/E1/a.jpg');
|
|
seedFile('previews/E1/a.jpg');
|
|
seedFile('heroes/E1/hero.jpg');
|
|
seedFile('uploads/intake/x.bin');
|
|
seedFile('business-docs/quote/2026/Q-001.pdf');
|
|
// events/archived is gated — left out of this test; covered below.
|
|
|
|
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
|
const rels = files.map((f) => f.relativePath);
|
|
|
|
expect(rels).toEqual(expect.arrayContaining([
|
|
'events/active/E1/a.jpg',
|
|
'thumbnails/E1/a.jpg',
|
|
'previews/E1/a.jpg',
|
|
'heroes/E1/hero.jpg',
|
|
'uploads/intake/x.bin',
|
|
'business-docs/quote/2026/Q-001.pdf',
|
|
]));
|
|
});
|
|
|
|
it('skips a path when include_in_default is toggled off', async () => {
|
|
seedFile('thumbnails/E1/thumb.jpg');
|
|
seedFile('events/active/E1/photo.jpg');
|
|
|
|
await db('backup_paths').where('path', 'thumbnails').update({
|
|
include_in_default: false,
|
|
});
|
|
|
|
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
|
const rels = files.map((f) => f.relativePath);
|
|
|
|
expect(rels).toContain('events/active/E1/photo.jpg');
|
|
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
|
|
});
|
|
|
|
it('picks up a new path inserted at runtime — no restart needed', async () => {
|
|
// Simulates a future feature shipping its own subdirectory and
|
|
// self-healing a `backup_paths` row at boot.
|
|
await db('backup_paths').insert({
|
|
path: 'plugin-store',
|
|
include_in_default: true,
|
|
feature_flag: null,
|
|
display_order: 200,
|
|
description: 'Hypothetical future feature payload',
|
|
created_at: new Date(),
|
|
updated_at: new Date(),
|
|
});
|
|
seedFile('plugin-store/cache/payload.bin');
|
|
|
|
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
|
const rels = files.map((f) => f.relativePath);
|
|
|
|
expect(rels).toContain('plugin-store/cache/payload.bin');
|
|
});
|
|
|
|
it('respects feature_flag gating (events/archived ⇄ backup_include_archived)', async () => {
|
|
seedFile('events/active/E1/active.jpg');
|
|
seedFile('events/archived/E2/archived.jpg');
|
|
|
|
// backup_include_archived=false → archived/ is skipped.
|
|
const filesOff = await backupService.getFilesToBackup({ backup_include_archived: false });
|
|
const relsOff = filesOff.map((f) => f.relativePath);
|
|
expect(relsOff).toContain('events/active/E1/active.jpg');
|
|
expect(relsOff).not.toContain('events/archived/E2/archived.jpg');
|
|
|
|
// backup_include_archived=true → archived/ is included.
|
|
const filesOn = await backupService.getFilesToBackup({ backup_include_archived: true });
|
|
const relsOn = filesOn.map((f) => f.relativePath);
|
|
expect(relsOn).toContain('events/archived/E2/archived.jpg');
|
|
});
|
|
|
|
it('falls back to LEGACY_BACKUP_PATHS when the table is empty', async () => {
|
|
// Defense in depth: even if seed-and-self-heal both failed, the
|
|
// walker must still cover the historical set so "Run Backup Now"
|
|
// cannot silently degrade to no-op.
|
|
await db('backup_paths').del();
|
|
seedFile('events/active/E1/photo.jpg');
|
|
seedFile('business-docs/quote/2026/Q-002.pdf');
|
|
|
|
const files = await backupService.getFilesToBackup({ backup_include_archived: true });
|
|
const rels = files.map((f) => f.relativePath);
|
|
|
|
expect(rels).toContain('events/active/E1/photo.jpg');
|
|
expect(rels).toContain('business-docs/quote/2026/Q-002.pdf');
|
|
});
|
|
|
|
it('legacy boolean call signature still works (backward compat)', async () => {
|
|
// Existing call sites (and the businessDocs regression test) pass
|
|
// a boolean for `includeArchived`. Refactor must not break them.
|
|
seedFile('events/archived/E3/legacy.jpg');
|
|
|
|
const filesOff = await backupService.getFilesToBackup(false);
|
|
expect(filesOff.map((f) => f.relativePath)).not.toContain('events/archived/E3/legacy.jpg');
|
|
|
|
const filesOn = await backupService.getFilesToBackup(true);
|
|
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
|
|
});
|
|
|
|
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
|
|
describe('UI opt-out toggles (issue #871)', () => {
|
|
it('unchecking Thumbnails excludes thumbnails/', async () => {
|
|
seedFile('thumbnails/E1/thumb.jpg');
|
|
seedFile('events/active/E1/photo.jpg');
|
|
|
|
const files = await backupService.getFilesToBackup({
|
|
backup_include_thumbnails: false,
|
|
});
|
|
const rels = files.map((f) => f.relativePath);
|
|
|
|
expect(rels).toContain('events/active/E1/photo.jpg');
|
|
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
|
|
});
|
|
|
|
it('unchecking Photos excludes events/active', async () => {
|
|
seedFile('thumbnails/E1/thumb.jpg');
|
|
seedFile('events/active/E1/photo.jpg');
|
|
|
|
const files = await backupService.getFilesToBackup({
|
|
backup_include_photos: false,
|
|
});
|
|
const rels = files.map((f) => f.relativePath);
|
|
|
|
expect(rels).toContain('thumbnails/E1/thumb.jpg');
|
|
expect(rels).not.toContain('events/active/E1/photo.jpg');
|
|
});
|
|
|
|
it('defaults to including everything when the keys were never saved', async () => {
|
|
seedFile('thumbnails/E1/thumb.jpg');
|
|
seedFile('events/active/E1/photo.jpg');
|
|
|
|
const files = await backupService.getFilesToBackup({});
|
|
const rels = files.map((f) => f.relativePath);
|
|
|
|
expect(rels).toContain('thumbnails/E1/thumb.jpg');
|
|
expect(rels).toContain('events/active/E1/photo.jpg');
|
|
});
|
|
|
|
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
|
|
seedFile('events/archived/E4/archived.jpg');
|
|
|
|
const files = await backupService.getFilesToBackup({
|
|
backup_include_archives: true,
|
|
});
|
|
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
|
|
});
|
|
|
|
it('the UI plural key beats the migration-seeded singular key', async () => {
|
|
// Migration seeds backup_include_archived=true on every install; the
|
|
// form only ever writes the plural key, so unchecking Archives must
|
|
// win over the stale seeded value.
|
|
seedFile('events/archived/E5/archived.jpg');
|
|
|
|
const files = await backupService.getFilesToBackup({
|
|
backup_include_archived: true, // seeded default
|
|
backup_include_archives: false, // what the admin actually chose
|
|
});
|
|
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
|
|
});
|
|
|
|
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
|
|
const excluded = await backupService.resolveExcludedBackupPaths({
|
|
backup_include_thumbnails: false,
|
|
backup_include_archives: false,
|
|
});
|
|
expect(excluded.map((r) => r.path)).toEqual(
|
|
expect.arrayContaining(['thumbnails', 'events/archived'])
|
|
);
|
|
|
|
const args = backupService.buildRsyncArgs(
|
|
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
|
|
excluded.map((r) => `/${r.path}/`)
|
|
);
|
|
const excludes = args
|
|
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
|
|
.filter(Boolean);
|
|
expect(excludes).toEqual(expect.arrayContaining([
|
|
'.nfs*',
|
|
'/thumbnails/',
|
|
'/events/archived/',
|
|
]));
|
|
});
|
|
|
|
it('rows toggled off via include_in_default also become rsync excludes', async () => {
|
|
// The enabled-only loader hides these rows from the walker, but rsync
|
|
// syncs the whole storage root, so they must still appear as excludes.
|
|
await db('backup_paths').where('path', 'previews').update({
|
|
include_in_default: false,
|
|
});
|
|
|
|
const excluded = await backupService.resolveExcludedBackupPaths({});
|
|
expect(excluded.map((r) => r.path)).toContain('previews');
|
|
});
|
|
});
|
|
|
|
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
|
|
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
|
|
seedFile('thumbnails/E1/.nfs000000000000006600000008');
|
|
seedFile('events/active/E1/.DS_Store');
|
|
seedFile('events/active/E1/photo.jpg');
|
|
|
|
const files = await backupService.getFilesToBackup({});
|
|
const rels = files.map((f) => f.relativePath);
|
|
|
|
expect(rels).toContain('events/active/E1/photo.jpg');
|
|
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
|
|
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
|
|
});
|
|
|
|
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
|
|
seedFile('events/active/E1/photo.jpg');
|
|
seedFile('events/active/E1/scratch.tmp');
|
|
|
|
const files = await backupService.getFilesToBackup({
|
|
backup_exclude_patterns: ['*.tmp'],
|
|
});
|
|
const rels = files.map((f) => f.relativePath);
|
|
|
|
expect(rels).toContain('events/active/E1/photo.jpg');
|
|
expect(rels).not.toContain('events/active/E1/scratch.tmp');
|
|
});
|
|
|
|
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
|
|
seedFile('events/active/E1/anfs-photo.jpg');
|
|
seedFile('events/active/E1/notes-tmp');
|
|
|
|
const files = await backupService.getFilesToBackup({
|
|
backup_exclude_patterns: ['*.tmp'],
|
|
});
|
|
const rels = files.map((f) => f.relativePath);
|
|
|
|
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
|
|
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
|
|
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
|
|
expect(rels).toContain('events/active/E1/notes-tmp');
|
|
});
|
|
|
|
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
|
|
// "next backup" was a hardcoded "tomorrow 02:00".
|
|
describe('schedule resolution + next run (issue #871)', () => {
|
|
it('a named label beats the stray default cron the UI used to send', () => {
|
|
expect(backupService.resolveScheduleCron({
|
|
backup_schedule: 'weekly',
|
|
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
|
|
})).toBe('0 3 * * 0');
|
|
});
|
|
|
|
it('custom schedules use the cron field', () => {
|
|
expect(backupService.resolveScheduleCron({
|
|
backup_schedule: 'custom',
|
|
backup_schedule_cron: '15 5 * * 2',
|
|
})).toBe('15 5 * * 2');
|
|
});
|
|
|
|
it('falls back to the default daily cron', () => {
|
|
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
|
|
});
|
|
|
|
it('getNextScheduledRun is null when backups are disabled', () => {
|
|
expect(backupService.getNextScheduledRun(null)).toBeNull();
|
|
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
|
|
});
|
|
|
|
it('getNextScheduledRun returns the real next weekly fire time', () => {
|
|
const iso = backupService.getNextScheduledRun({
|
|
backup_enabled: true,
|
|
backup_schedule: 'weekly',
|
|
backup_schedule_cron: '0 3 * * *',
|
|
});
|
|
const next = new Date(iso);
|
|
expect(Number.isNaN(next.getTime())).toBe(false);
|
|
expect(next.getTime()).toBeGreaterThan(Date.now());
|
|
expect(next.getDay()).toBe(0); // Sunday
|
|
expect(next.getHours()).toBe(3); // 03:00
|
|
});
|
|
});
|
|
|
|
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
|
|
// column, node-postgres returns int8 as a string, and the S3 path did
|
|
// `backedUpSize += size` — string concatenation.
|
|
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
|
|
await db('database_backup_runs').del();
|
|
await db('database_backup_runs').insert({
|
|
backup_type: 'full',
|
|
status: 'completed',
|
|
file_path: '/backups/db/dump.sql.gz',
|
|
// Simulate the PG int8-as-string driver behaviour (sqlite stores
|
|
// whatever it is handed, so the string round-trips).
|
|
file_size_bytes: '421988',
|
|
started_at: new Date().toISOString(),
|
|
completed_at: new Date().toISOString(),
|
|
});
|
|
|
|
const info = await backupService.getDatabaseBackupInfo();
|
|
expect(typeof info.size).toBe('number');
|
|
expect(info.size).toBe(421988);
|
|
});
|
|
});
|