diff --git a/backend/__tests__/integration/backup-s3.test.js b/backend/__tests__/integration/backup-s3.test.js index 723fa7c1..d8f26d5a 100644 --- a/backend/__tests__/integration/backup-s3.test.js +++ b/backend/__tests__/integration/backup-s3.test.js @@ -176,8 +176,9 @@ describe('S3 Backup Integration Tests', () => { .first(); expect(backupRun.status).toBe('completed'); - expect(backupRun.files_backed_up).toBeGreaterThan(0); - expect(backupRun.total_size_bytes).toBeGreaterThan(0); + // pg driver returns bigint columns as strings; coerce for the size assertion. + expect(Number(backupRun.files_backed_up)).toBeGreaterThan(0); + expect(Number(backupRun.total_size_bytes)).toBeGreaterThan(0); // Verify files in S3 const s3Objects = await listS3Objects(); diff --git a/backend/src/services/backupManifest.js b/backend/src/services/backupManifest.js index 0a082f3b..d69fb548 100644 --- a/backend/src/services/backupManifest.js +++ b/backend/src/services/backupManifest.js @@ -181,8 +181,13 @@ class BackupManifestGenerator { const content = await fs.readFile(filePath, 'utf8'); let manifest; - // Detect format and parse - if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) { + // Detect format from BOTH the extension and the content. Earlier code + // trusted the extension alone, which broke when callers stored a YAML + // manifest under a .json temp name (getBackupManifest does this when + // downloading the s3:// path to a tmp file). + const looksLikeJson = content.trimStart().startsWith('{') + || content.trimStart().startsWith('['); + if (filePath.endsWith('.yaml') || filePath.endsWith('.yml') || !looksLikeJson) { manifest = yaml.load(content); } else { manifest = JSON.parse(content); diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js index 69eba927..29c6c372 100644 --- a/backend/src/services/backupService.js +++ b/backend/src/services/backupService.js @@ -936,17 +936,41 @@ async function startBackupService() { backupJob = null; } + // Two settings cooperate here: + // - backup_schedule — UI label like "daily" / "weekly" / "custom" + // - backup_schedule_cron — actual cron expression + // The frontend writes both (BackupConfiguration.jsx). Older startup code + // here read backup_schedule and crashed when it found a label instead of + // a cron expression. Resolution order: explicit cron field, then map known + // labels, then fall back to default. + const NAMED_SCHEDULES = { + hourly: '0 * * * *', + daily: '0 2 * * *', + weekly: '0 3 * * 0', // Sunday 03:00 + monthly: '0 4 1 * *', + }; + const isCronExpression = (s) => typeof s === 'string' && /^\s*\S+(\s+\S+){4}\s*$/.test(s); + const readSetting = (key) => { + if (config && Object.prototype.hasOwnProperty.call(config, key)) { + return String(config[key] ?? '').trim(); + } + if (config?.__raw && Object.prototype.hasOwnProperty.call(config.__raw, key)) { + return String(parseSettingValue(config.__raw[key]) ?? '').trim(); + } + return ''; + }; + let schedule = '0 2 * * *'; - if (Object.prototype.hasOwnProperty.call(config, 'backup_schedule')) { - const candidate = String(config.backup_schedule ?? '').trim(); - if (candidate.length) { - schedule = candidate; - } - } else if (config.__raw && Object.prototype.hasOwnProperty.call(config.__raw, 'backup_schedule')) { - const candidate = String(parseSettingValue(config.__raw.backup_schedule) ?? '').trim(); - if (candidate.length) { - schedule = candidate; - } + const cronCandidate = readSetting('backup_schedule_cron'); + const labelCandidate = readSetting('backup_schedule'); + if (cronCandidate && isCronExpression(cronCandidate)) { + schedule = cronCandidate; + } else if (labelCandidate && NAMED_SCHEDULES[labelCandidate.toLowerCase()]) { + schedule = NAMED_SCHEDULES[labelCandidate.toLowerCase()]; + } else if (labelCandidate && isCronExpression(labelCandidate)) { + // Back-compat: a deployment that wrote a cron expression directly into + // backup_schedule (no _cron field) still works. + schedule = labelCandidate; } backupJob = cron.schedule(schedule, async () => {