/** * Unit tests for pure helper functions in src/lib/server/backups/helpers.ts and * src/lib/server/host-path.ts. * * Pure means: no DB, no network, no spawn. Just (input) → (output) functions. * Goal here is to make every helper that touches user-controllable data have a * fast, deterministic, runs-everywhere safety net so we can refactor without fear. */ import { describe, expect, test } from 'bun:test'; import { parseRetentionJson, parseOptionsJson, parsePoliciesJson, isValidSnapshotId, isRepoNotInitializedError, wouldDeleteAllSnapshots, retentionToStore, resolveRetentionForUpdate, validateFlags, validatePolicySchedules, validateRetention, isAllowedRepository, sanitizeWebhookUrlForLog, classifyBackupError, validateRepositoryForSave, parseSelectedVolumes, parseBackupFlags, serializeBackupFlags, validateAndSerializeFlags, sanitizeResticFlags, sanitizeRestoreFlags, buildJobOptions } from '../src/lib/server/backups/helpers'; import { isLocalRepo } from '../src/lib/server/backups/models'; import { findRelativeBindSources, extractUidFromSocketPath } from '../src/lib/server/host-path'; // ============================================================================ // Split backup/restore flags: JSON in the flags column, legacy string compat, and // per-scope allowlists (restore permits --exclude-xattr, backup does NOT). // ============================================================================ describe('parseBackupFlags (string-vs-JSON boundary, backward compat)', () => { test('new JSON shape parses into {backup, restore}', () => { expect(parseBackupFlags('{"backup":"--verbose","restore":"--exclude-xattr security.selinux"}')) .toEqual({ backup: '--verbose', restore: '--exclude-xattr security.selinux' }); }); test('LEGACY bare string = backup flags, empty restore (no migration, no data loss)', () => { expect(parseBackupFlags('--limit-upload 5120')).toEqual({ backup: '--limit-upload 5120', restore: '' }); }); test('null / empty -> both empty', () => { expect(parseBackupFlags(null)).toEqual({ backup: '', restore: '' }); expect(parseBackupFlags('')).toEqual({ backup: '', restore: '' }); expect(parseBackupFlags(' ')).toEqual({ backup: '', restore: '' }); }); test('malformed JSON object -> treated as legacy backup string', () => { expect(parseBackupFlags('{not json')).toEqual({ backup: '{not json', restore: '' }); }); test('JSON with missing fields coerces the missing one to empty', () => { expect(parseBackupFlags('{"backup":"--verbose"}')).toEqual({ backup: '--verbose', restore: '' }); }); }); describe('serializeBackupFlags (round-trips, null when empty)', () => { test('both empty -> null (column stays NULL, not {})', () => { expect(serializeBackupFlags({ backup: '', restore: '' })).toBeNull(); expect(serializeBackupFlags({ backup: ' ', restore: '' })).toBeNull(); }); test('round-trips through parse', () => { const s = serializeBackupFlags({ backup: '--verbose', restore: '--exclude-xattr security.selinux' }); expect(parseBackupFlags(s)).toEqual({ backup: '--verbose', restore: '--exclude-xattr security.selinux' }); }); }); describe('per-scope allowlists — restore-only flags never reach backup', () => { test('--exclude-xattr is allowed for RESTORE', () => { expect(sanitizeRestoreFlags('--exclude-xattr security.selinux')).toEqual(['--exclude-xattr', 'security.selinux']); }); test('--exclude-xattr is REJECTED for backup (would break restic backup)', () => { expect(() => sanitizeResticFlags('--exclude-xattr security.selinux')).toThrow(/Disallowed/); }); test('common flags pass on both', () => { expect(sanitizeResticFlags('--compression max')).toEqual(['--compression', 'max']); expect(sanitizeRestoreFlags('--compression max')).toEqual(['--compression', 'max']); }); test('a dangerous flag is rejected on both', () => { expect(() => sanitizeResticFlags('--option x')).toThrow(/Disallowed/); expect(() => sanitizeRestoreFlags('--password-command evil')).toThrow(/Disallowed/); }); }); describe('validateAndSerializeFlags (API save path)', () => { test('valid backup + restore -> JSON', () => { const s = validateAndSerializeFlags('--verbose', '--exclude-xattr security.selinux'); expect(parseBackupFlags(s)).toEqual({ backup: '--verbose', restore: '--exclude-xattr security.selinux' }); }); test('both empty -> null', () => { expect(validateAndSerializeFlags('', '')).toBeNull(); }); test('THROWS when a restore-only flag is put in the BACKUP field', () => { expect(() => validateAndSerializeFlags('--exclude-xattr security.selinux', '')).toThrow(/Disallowed/); }); test('THROWS on a disallowed restore flag', () => { expect(() => validateAndSerializeFlags('', '--option evil')).toThrow(/Disallowed/); }); }); // ============================================================================ // parseSelectedVolumes — SHAPE guard, not just JSON (B2) // ============================================================================ describe('parseSelectedVolumes', () => { test('a valid string[] round-trips', () => { expect(parseSelectedVolumes('["data","config"]')).toEqual(['data', 'config']); }); test('null / empty -> null (all volumes)', () => { expect(parseSelectedVolumes(null)).toBeNull(); expect(parseSelectedVolumes(undefined)).toBeNull(); expect(parseSelectedVolumes('')).toBeNull(); }); test('a NON-array (corrupt/legacy string) -> null, NOT an iterable-of-chars', () => { // The bug: a bare string flowed into new Set(...) as chars, matching no volume key // and silently filtering EVERY volume out (metadata-only snapshot). Must be null. expect(parseSelectedVolumes('"data"')).toBeNull(); expect(parseSelectedVolumes('42')).toBeNull(); expect(parseSelectedVolumes('{"a":1}')).toBeNull(); }); test('invalid JSON -> null', () => { expect(parseSelectedVolumes('not json')).toBeNull(); }); test('drops non-string array members', () => { expect(parseSelectedVolumes('["data",5,null,"config"]')).toEqual(['data', 'config']); }); }); // ============================================================================ // isLocalRepo — the SINGLE local-repo predicate (B4) // ============================================================================ describe('isLocalRepo', () => { test('an absolute path is local', () => { expect(isLocalRepo('/mnt/nas/backups')).toBe(true); }); test('a ./ relative path is NOT local (matches the save gate isAllowedRepository)', () => { expect(isLocalRepo('./data/repo')).toBe(false); expect(isAllowedRepository('./data/repo')).toBe(false); // the two agree }); test('scheme URLs are not local', () => { expect(isLocalRepo('s3:bucket')).toBe(false); expect(isLocalRepo('rest:http://host:8000/')).toBe(false); }); }); // ============================================================================ // parseRetentionJson — small but trusted by retention scheduling // ============================================================================ describe('parseRetentionJson', () => { test('null → empty object (no retention configured = no policy applied)', () => { expect(parseRetentionJson(null)).toEqual({}); }); test('undefined → empty object', () => { expect(parseRetentionJson(undefined)).toEqual({}); }); test('empty string → empty object', () => { expect(parseRetentionJson('')).toEqual({}); }); test('valid JSON with keepLast roundtrips', () => { expect(parseRetentionJson('{"keepLast":3}')).toEqual({ keepLast: 3 }); }); test('valid JSON with full policy roundtrips', () => { const full = { keepLast: 5, keepDaily: 7, keepWeekly: 4, keepMonthly: 12, keepYearly: 3 }; expect(parseRetentionJson(JSON.stringify(full))).toEqual(full); }); test('malformed JSON → empty object (NOT thrown — caller must not crash on bad DB data)', () => { // This is the contract: a corrupted retention field in the DB must not // kill the backup. The whole retention step is skipped instead. expect(parseRetentionJson('{not json')).toEqual({}); expect(parseRetentionJson('[1,2,3]')).toEqual([1, 2, 3] as any); }); test('JSON null → null (caller must treat as no retention; parser does not coerce)', () => { // Documenting current behaviour: the function only catches parse errors, // it does not normalize falsy parse results. Callers using `retention.keepLast` // will get `undefined` on `null` and skip retention, which is the intended outcome. expect(parseRetentionJson('null')).toBe(null as any); }); test('JSON with extra fields preserved (forward-compat for future keep-* options)', () => { expect(parseRetentionJson('{"keepLast":3,"unknownField":"x"}')).toEqual({ keepLast: 3, unknownField: 'x' }); }); }); // ============================================================================ // parseOptionsJson — semantically identical but separate function // ============================================================================ describe('parseOptionsJson', () => { test('null → empty object', () => { expect(parseOptionsJson(null)).toEqual({}); }); test('undefined → empty object', () => { expect(parseOptionsJson(undefined)).toEqual({}); }); test('compression option roundtrips', () => { expect(parseOptionsJson('{"compression":"max"}')).toEqual({ compression: 'max' }); }); test('exclude patterns roundtrip as a single string (not parsed further)', () => { // The string-splitting on comma happens at backup time, not here. expect(parseOptionsJson('{"excludePatterns":"*.tmp,*.log"}')).toEqual({ excludePatterns: '*.tmp,*.log' }); }); test('malformed JSON → empty object', () => { expect(parseOptionsJson('garbage')).toEqual({}); }); test('options with webhooks survive (these are URLs, must not be mangled)', () => { const opts = { webhookSuccess: 'https://hooks.example.com/x?token=abc&y=1', webhookFailure: 'https://hooks.example.com/x?token=def' }; expect(parseOptionsJson(JSON.stringify(opts))).toEqual(opts); }); }); // ============================================================================ // isValidSnapshotId — guards every restore endpoint from arbitrary input // ============================================================================ describe('isValidSnapshotId', () => { test('accepts restic short ID (8 hex chars)', () => { expect(isValidSnapshotId('deadbeef')).toBe(true); }); test('accepts restic full ID (64 hex chars)', () => { expect(isValidSnapshotId('a'.repeat(64))).toBe(true); expect(isValidSnapshotId('0123456789abcdef'.repeat(4))).toBe(true); }); test('rejects too-short ID (<8 chars)', () => { expect(isValidSnapshotId('abcdef')).toBe(false); expect(isValidSnapshotId('')).toBe(false); }); test('rejects too-long ID (>64 chars)', () => { expect(isValidSnapshotId('a'.repeat(65))).toBe(false); }); test('rejects non-hex characters (path traversal / injection bait)', () => { expect(isValidSnapshotId('deadbeef../etc')).toBe(false); expect(isValidSnapshotId('../../passwd')).toBe(false); expect(isValidSnapshotId('deadbeef;rm -rf')).toBe(false); expect(isValidSnapshotId('DEADBEEF')).toBe(false); // uppercase not accepted }); test('rejects whitespace, newlines', () => { expect(isValidSnapshotId(' deadbeef ')).toBe(false); expect(isValidSnapshotId('deadbeef\n')).toBe(false); expect(isValidSnapshotId('dead beef')).toBe(false); }); }); // ============================================================================ // isRepoNotInitializedError — exit-code detection from restic // ============================================================================ describe('isRepoNotInitializedError', () => { test('detects restic exit code 10 (repo not initialized)', () => { expect(isRepoNotInitializedError({ exitCode: 10 })).toBe(true); }); test('rejects other exit codes', () => { expect(isRepoNotInitializedError({ exitCode: 0 })).toBe(false); expect(isRepoNotInitializedError({ exitCode: 1 })).toBe(false); expect(isRepoNotInitializedError({ exitCode: 11 })).toBe(false); }); test('safe on undefined / null / non-objects', () => { expect(isRepoNotInitializedError(undefined)).toBe(false); expect(isRepoNotInitializedError(null)).toBe(false); expect(isRepoNotInitializedError('error string')).toBe(false); expect(isRepoNotInitializedError(42)).toBe(false); }); test('safe on object without exitCode', () => { expect(isRepoNotInitializedError({})).toBe(false); expect(isRepoNotInitializedError({ message: 'something' })).toBe(false); }); }); // ============================================================================ // findRelativeBindSources — gates cross-env deploys // ============================================================================ describe('findRelativeBindSources', () => { test('empty compose → empty array', () => { expect(findRelativeBindSources('')).toEqual([]); }); test('compose with no volumes section → empty', () => { const c = `services: web: image: nginx `; expect(findRelativeBindSources(c)).toEqual([]); }); test('compose with only named volumes → empty (named volumes are portable)', () => { const c = `services: db: image: postgres volumes: - data:/var/lib/postgresql/data - logs:/var/log `; expect(findRelativeBindSources(c)).toEqual([]); }); test('compose with absolute bind path → empty (absolute paths are portable, even if wrong)', () => { const c = `services: web: image: nginx volumes: - /etc/nginx/conf.d:/etc/nginx/conf.d:ro `; expect(findRelativeBindSources(c)).toEqual([]); }); test('compose with ./ bind path → detected', () => { const c = `services: web: image: nginx volumes: - ./html:/usr/share/nginx/html `; expect(findRelativeBindSources(c)).toEqual(['./html']); }); test('compose with ../ bind path → detected (parent dir reference)', () => { const c = `services: web: image: nginx volumes: - ../shared:/data `; expect(findRelativeBindSources(c)).toEqual(['../shared']); }); test('multiple relative binds → all detected, in declaration order', () => { const c = `services: web: image: nginx volumes: - ./html:/web - ./conf:/etc/nginx/conf.d db: image: postgres volumes: - ./pgdata:/var/lib/postgresql/data `; expect(findRelativeBindSources(c)).toEqual(['./html', './conf', './pgdata']); }); test('quoted bind path (single quotes) → detected', () => { const c = `services: web: volumes: - './html':/web `; expect(findRelativeBindSources(c)).toEqual(['./html']); }); test('quoted bind path (double quotes) → detected', () => { const c = `services: web: volumes: - "./html":/web `; expect(findRelativeBindSources(c)).toEqual(['./html']); }); test('mixed named + relative bind → only relative reported', () => { const c = `services: app: volumes: - mydata:/data - ./config:/etc/app - shared:/shared `; expect(findRelativeBindSources(c)).toEqual(['./config']); }); test('bind with :ro flag → still detected (flag does not change source-path classification)', () => { const c = `services: web: volumes: - ./html:/web:ro `; expect(findRelativeBindSources(c)).toEqual(['./html']); }); test('compose with comments containing ./ → not falsely detected', () => { // The regex anchors at start-of-line, so comments would only false-match // if they were also list items, which is unusual. Validate the // current behaviour anyway. const c = `services: # don't use ./html here web: image: nginx `; expect(findRelativeBindSources(c)).toEqual([]); }); test('long-form bind syntax (type: bind) is NOT currently detected', () => { // KNOWN LIMITATION: the regex only matches short-form binds. // Long-form (volumes: - type: bind, source: ./html, target: /web) // slips through. Document the gap here so we remember; fix later. const c = `services: web: volumes: - type: bind source: ./html target: /web `; expect(findRelativeBindSources(c)).toEqual([]); }); }); // ============================================================================ // extractUidFromSocketPath — pull the UID out of a rootless docker socket path // so scanner helpers bind the right host socket (host-path.ts, was untested). // ============================================================================ describe('extractUidFromSocketPath', () => { test('extracts the UID from a rootless socket path', () => { expect(extractUidFromSocketPath('/run/user/1000/docker.sock')).toBe('1000'); expect(extractUidFromSocketPath('/var/run/user/1000/docker.sock')).toBe('1000'); expect(extractUidFromSocketPath('/run/user/0/docker.sock')).toBe('0'); }); test('returns null for the standard (root) socket path', () => { expect(extractUidFromSocketPath('/var/run/docker.sock')).toBeNull(); }); test('returns null when the path is not a docker.sock or the uid is non-numeric', () => { expect(extractUidFromSocketPath('/run/user/1000/podman.sock')).toBeNull(); expect(extractUidFromSocketPath('/run/user/abc/docker.sock')).toBeNull(); }); test('is anchored on docker.sock — a suffixed path does not match', () => { expect(extractUidFromSocketPath('/run/user/1000/docker.sock.bak')).toBeNull(); expect(extractUidFromSocketPath('')).toBeNull(); }); }); // ============================================================================ // wouldDeleteAllSnapshots — the prune safety guard's brain // // Restic's `forget --dry-run --json` output is what we feed this. Real samples // captured from restic 0.17 (the format hasn't changed in years but if it does // these tests will fail loudly). The point of the guard is to refuse pruning // when the policy would empty the repo for a target — typo'd retention // (keepLast: 0) should not delete every snapshot the user has. // ============================================================================ describe('wouldDeleteAllSnapshots', () => { test('empty stdout (restic not run yet / repo empty) → not a wipe', () => { const r = wouldDeleteAllSnapshots(''); expect(r.wouldWipe).toBe(false); expect(r.keep).toBe(0); expect(r.remove).toBe(0); expect(r.total).toBe(0); }); test('whitespace-only stdout → not a wipe (treat as empty)', () => { expect(wouldDeleteAllSnapshots(' \n\t ').wouldWipe).toBe(false); }); test('unparseable stdout → not a wipe (refuse to guess — let the run fail naturally)', () => { expect(wouldDeleteAllSnapshots('not json at all').wouldWipe).toBe(false); expect(wouldDeleteAllSnapshots('{partial').wouldWipe).toBe(false); }); test('JSON that is not an array → not a wipe (defensive)', () => { expect(wouldDeleteAllSnapshots('{"keep":[],"remove":[]}').wouldWipe).toBe(false); expect(wouldDeleteAllSnapshots('null').wouldWipe).toBe(false); expect(wouldDeleteAllSnapshots('42').wouldWipe).toBe(false); }); test('empty array (repo has no matching snapshots) → not a wipe (nothing to lose)', () => { const r = wouldDeleteAllSnapshots('[]'); expect(r.wouldWipe).toBe(false); expect(r.total).toBe(0); }); test('keep N, remove 0 → not a wipe (the happy path)', () => { const sample = JSON.stringify([{ keep: [{ id: 'a1' }, { id: 'a2' }, { id: 'a3' }], remove: [], reasons: [] }]); const r = wouldDeleteAllSnapshots(sample); expect(r.wouldWipe).toBe(false); expect(r.keep).toBe(3); expect(r.remove).toBe(0); expect(r.total).toBe(3); }); test('keep some, remove some → not a wipe (normal retention pass)', () => { const sample = JSON.stringify([{ keep: [{ id: 'a1' }, { id: 'a2' }], remove: [{ id: 'b1' }, { id: 'b2' }, { id: 'b3' }], reasons: [] }]); const r = wouldDeleteAllSnapshots(sample); expect(r.wouldWipe).toBe(false); expect(r.keep).toBe(2); expect(r.remove).toBe(3); expect(r.total).toBe(5); }); test('keep 0, remove N → WIPE (the case we exist to catch)', () => { const sample = JSON.stringify([{ keep: [], remove: [{ id: 'a1' }, { id: 'a2' }, { id: 'a3' }], reasons: [] }]); const r = wouldDeleteAllSnapshots(sample); expect(r.wouldWipe).toBe(true); expect(r.keep).toBe(0); expect(r.remove).toBe(3); expect(r.total).toBe(3); }); test('multiple groups — aggregate across all groups', () => { // Although Dockhand forces --group-by '' so there's normally one group, // the parser must still tolerate multi-group output for robustness in // case a shared repo has snapshots from another tool. const sample = JSON.stringify([ { keep: [{ id: 'a1' }], remove: [] }, { keep: [], remove: [{ id: 'b1' }] } ]); const r = wouldDeleteAllSnapshots(sample); // Not a wipe — at least one snapshot is kept across all groups expect(r.wouldWipe).toBe(false); expect(r.keep).toBe(1); expect(r.remove).toBe(1); }); test('multiple groups all wiping → wipe', () => { const sample = JSON.stringify([ { keep: [], remove: [{ id: 'a1' }] }, { keep: [], remove: [{ id: 'b1' }] } ]); const r = wouldDeleteAllSnapshots(sample); expect(r.wouldWipe).toBe(true); expect(r.keep).toBe(0); expect(r.remove).toBe(2); }); test('group with no keep/remove arrays (malformed restic output) → no crash, count 0', () => { const sample = JSON.stringify([{ reasons: [], somethingElse: true }]); expect(() => wouldDeleteAllSnapshots(sample)).not.toThrow(); const r = wouldDeleteAllSnapshots(sample); expect(r.total).toBe(0); }); }); // ============================================================================ // validateRetention: bounds-check each retention count (integer 0..10000). // ============================================================================ describe('validateRetention', () => { test('a valid retention object passes', () => { expect(validateRetention({ keepLast: 7, keepDaily: 30 })).toEqual({ ok: true }); }); test('a negative count is rejected with a field-named reason', () => { const r = validateRetention({ keepLast: -1 }); expect(r.ok).toBe(false); if (!r.ok) expect(r.reason).toMatch(/keepLast/); }); test('a non-integer / NaN count is rejected', () => { expect(validateRetention({ keepLast: 'x' }).ok).toBe(false); }); }); // ============================================================================ // isAllowedRepository / validateRepositoryForSave: repo-scheme allowlist + SSRF // guard on URL-form backends. Security-relevant (a destination is user input). // ============================================================================ describe('isAllowedRepository', () => { test('accepts an absolute local path and supported schemes', () => { expect(isAllowedRepository('/data/repo')).toBe(true); expect(isAllowedRepository('s3:http://minio/bucket')).toBe(true); }); test('rejects an unknown scheme and empty/nullish input', () => { expect(isAllowedRepository('ftp://x')).toBe(false); expect(isAllowedRepository('')).toBe(false); expect(isAllowedRepository(null)).toBe(false); expect(isAllowedRepository(undefined)).toBe(false); }); }); describe('validateRepositoryForSave', () => { test('a local path and a LAN rest repo are accepted (null = no error)', () => { expect(validateRepositoryForSave('/data/repo')).toBeNull(); }); test('an unsupported scheme returns an error string', () => { expect(validateRepositoryForSave('ftp://x')).toMatch(/Invalid repository/); }); test('a URL-form repo pointing at the cloud-metadata IP is rejected (SSRF guard)', () => { expect(validateRepositoryForSave('rest:http://169.254.169.254/x')).toMatch(/not allowed/); }); }); // ============================================================================ // sanitizeWebhookUrlForLog: redact a webhook URL for logs without leaking the // secret path. Fail-closed to a placeholder on an unparseable URL (#6). // ============================================================================ describe('sanitizeWebhookUrlForLog', () => { test('keeps origin + first path segment, redacts the rest', () => { expect(sanitizeWebhookUrlForLog('https://hooks.slack.com/services/T/B/XXX')) .toBe('https://hooks.slack.com/services/***'); }); test('a URL with no path is returned as its origin', () => { expect(sanitizeWebhookUrlForLog('https://example.com')).toBe('https://example.com'); }); test('an unparseable URL fails closed to a placeholder (never leaks the raw value)', () => { expect(sanitizeWebhookUrlForLog('not a url')).toBe(''); }); }); // ============================================================================ // classifyBackupError: map a restic failure to a stable code vocabulary; must // never throw and defaults to UNKNOWN. // ============================================================================ describe('classifyBackupError', () => { test('maps a known signature (already locked → REPO_LOCKED)', () => { expect(classifyBackupError(new Error('repository is already locked'))).toBe('REPO_LOCKED'); }); test('defaults to UNKNOWN for an unrecognised message', () => { expect(classifyBackupError(new Error('something weird'))).toBe('UNKNOWN'); }); test('is safe on null / non-Error input', () => { expect(classifyBackupError(null)).toBe('UNKNOWN'); expect(classifyBackupError(undefined)).toBe('UNKNOWN'); expect(classifyBackupError('a string')).toBe('UNKNOWN'); }); }); // ============================================================================ // parsePoliciesJson: tolerant parse of a destination's policies JSON — returns // {} on malformed input (logged) rather than throwing. // ============================================================================ describe('parsePoliciesJson', () => { test('parses valid JSON', () => { expect(parsePoliciesJson('{"pruneSchedule":"0 3 * * *"}')).toEqual({ pruneSchedule: '0 3 * * *' }); }); test('returns {} on malformed JSON (never throws)', () => { expect(parsePoliciesJson('{bad')).toEqual({}); }); test('returns {} on null/undefined', () => { expect(parsePoliciesJson(null)).toEqual({}); expect(parsePoliciesJson(undefined)).toEqual({}); }); }); // ============================================================================ // retentionToStore: turn a retention object + schedule into the stored JSON, // or null. Feeds what actually gets persisted for a config's retention policy. // ============================================================================ describe('retentionToStore', () => { test('a retention object with any truthy value is stored verbatim', () => { expect(retentionToStore({ keepLast: 7 }, null)).toBe(JSON.stringify({ keepLast: 7 })); }); test('a retention object with only zero/empty values is treated as no policy', () => { expect(retentionToStore({ keepLast: 0, keepDaily: 0 }, null)).toBeNull(); }); test('no retention but a non-empty schedule falls back to the default scheduled retention', () => { expect(retentionToStore(null, '0 3 * * *')).toBe(JSON.stringify({ keepDaily: 7, keepWeekly: 4, keepMonthly: 6 })); }); test('no retention and no (or whitespace-only) schedule stores null', () => { expect(retentionToStore(null, null)).toBeNull(); expect(retentionToStore(null, ' ')).toBeNull(); }); }); // ============================================================================ // resolveRetentionForUpdate: partial-update guard. A minimal PUT that omits // retention (e.g. pause/resume, which only sends `enabled`) must NOT re-apply // the scheduled default and wipe the stored policy (#1462). // ============================================================================ describe('resolveRetentionForUpdate', () => { const SCHED = '0 4 * * *'; test('retention omitted and schedule unchanged -> undefined (leave stored value as-is)', () => { // The pause/resume case: body has no retention, schedule not in the body. expect(resolveRetentionForUpdate(undefined, undefined, SCHED)).toBeUndefined(); }); test('retention omitted but the SAME schedule resent -> still leaves it alone', () => { expect(resolveRetentionForUpdate(undefined, SCHED, SCHED)).toBeUndefined(); }); test('an explicit retention is stored verbatim', () => { expect(resolveRetentionForUpdate({ keepYearly: 3 }, undefined, SCHED)).toBe( JSON.stringify({ keepYearly: 3 }) ); }); test('adding a schedule with no retention applies the scheduled default', () => { // none -> cron transition: the default-injection the helper exists for. expect(resolveRetentionForUpdate(undefined, SCHED, null)).toBe( JSON.stringify({ keepDaily: 7, keepWeekly: 4, keepMonthly: 6 }) ); }); test('clearing to all-zero on a SCHEDULED config falls back to the default (no unbounded growth)', () => { // retention is provided (so not skipped), but empty; with a schedule present the // scheduled default applies rather than storing "no policy". expect(resolveRetentionForUpdate({ keepLast: 0 }, undefined, SCHED)).toBe( JSON.stringify({ keepDaily: 7, keepWeekly: 4, keepMonthly: 6 }) ); }); test('clearing to all-zero on an UNSCHEDULED config stores null', () => { expect(resolveRetentionForUpdate({ keepLast: 0 }, undefined, null)).toBeNull(); }); }); // ============================================================================ // validateFlags: save-time wrapper over sanitizeResticFlags — surfaces the // throw as an error string (or null when the flags are acceptable / absent). // ============================================================================ describe('validateFlags', () => { test('null / undefined flags are acceptable (no error)', () => { expect(validateFlags(null)).toBeNull(); expect(validateFlags(undefined)).toBeNull(); }); test('an allowlisted flag is acceptable', () => { expect(validateFlags('--limit-upload=100')).toBeNull(); }); test('a disallowed flag surfaces the sanitizer error as a string', () => { expect(validateFlags('--password-command=x')).toMatch(/Disallowed/); }); }); // ============================================================================ // validatePolicySchedules: validate prune/check/verify cron schedules in a // destination's policies payload (string or already-parsed object). // ============================================================================ describe('validatePolicySchedules', () => { test('null policies are acceptable', () => { expect(validatePolicySchedules(null)).toBeNull(); }); test('unparseable JSON is rejected (fail-closed)', () => { expect(validatePolicySchedules('{bad')).toBe('Invalid policies JSON'); }); test('a valid cron in any schedule field passes', () => { expect(validatePolicySchedules(JSON.stringify({ pruneSchedule: '0 3 * * *' }))).toBeNull(); }); test('an invalid cron is reported with its field name', () => { expect(validatePolicySchedules(JSON.stringify({ checkSchedule: 'nonsense' }))).toMatch(/checkSchedule/); }); test('an empty / whitespace-only schedule is skipped (not validated)', () => { expect(validatePolicySchedules(JSON.stringify({ pruneSchedule: ' ' }))).toBeNull(); }); test('accepts an already-parsed object, not just a JSON string', () => { expect(validatePolicySchedules({ verifySchedule: '0 0 1 * *' })).toBeNull(); }); }); // ============================================================================ // assertStackBackupable: external-stack guard (not in this module — but the // test belongs next to the helpers so it's easy to find. The guard itself // is in backup.ts because it touches stacks.ts.) // ============================================================================ // ============================================================================ // buildJobOptions: maps a config's stored options -> the job.options a run uses. // Regression guard: excludedStackFiles MUST survive this mapping, or a SAVED backup // (scheduled or "run now") silently ignores the user's stack-file deselections while // an in-request run honors them. This is exactly the field that was being dropped. // ============================================================================ describe('buildJobOptions', () => { test('forwards excludedStackFiles (the field that was being dropped)', () => { const out = buildJobOptions({ excludedStackFiles: ['secrets.txt', 'notes.md'] }); expect(out.excludedStackFiles).toEqual(['secrets.txt', 'notes.md']); }); test('excludedStackFiles is undefined when absent or not an array', () => { expect(buildJobOptions({}).excludedStackFiles).toBeUndefined(); expect(buildJobOptions({ excludedStackFiles: 'nope' }).excludedStackFiles).toBeUndefined(); expect(buildJobOptions({ excludedStackFiles: null }).excludedStackFiles).toBeUndefined(); }); test('splits excludePatterns CSV, trims, drops empties', () => { expect(buildJobOptions({ excludePatterns: 'a, b ,,c' }).excludePatterns).toEqual(['a', 'b', 'c']); expect(buildJobOptions({}).excludePatterns).toBeUndefined(); }); test('carries the plain scalar options through', () => { const out = buildJobOptions({ excludeCaches: true, compression: 'max', limitUpload: 100, limitDownload: 200, webhookSuccess: 'https://ok', webhookFailure: 'https://fail' }); expect(out.excludeCaches).toBe(true); expect(out.compression).toBe('max'); expect(out.limitUpload).toBe(100); expect(out.limitDownload).toBe(200); expect(out.webhookSuccess).toBe('https://ok'); expect(out.webhookFailure).toBe('https://fail'); }); test('non-string webhook values are dropped (not forwarded as junk)', () => { const out = buildJobOptions({ webhookSuccess: 123, webhookFailure: {} }); expect(out.webhookSuccess).toBeUndefined(); expect(out.webhookFailure).toBeUndefined(); }); });