Files
dockhand-upstream/tests/backup-helpers.test.ts

823 lines
33 KiB
TypeScript

/**
* 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('<webhook>');
});
});
// ============================================================================
// 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();
});
});