diff --git a/src/backupintegrity.js b/src/backupintegrity.js index efae99f09..b31adf68e 100644 --- a/src/backupintegrity.js +++ b/src/backupintegrity.js @@ -24,11 +24,13 @@ async function verify(backup, backupSite, progressCallback) { if (backup === null) return [`${backup.id} is missing in database`]; + const stats = { startTime: Date.now(), duration: null }; + const [downloadError, backupInfoBuffer] = await safe(downloadBackupInfo(backupSite, backup)); if (downloadError) { const messages = [`Failed to download ${backup.remotePath}.backupinfo: ${downloadError.message}`]; - await backups.setIntegrityResult(backup, 'failed', { messages }); - return; + stats.duration = Date.now() - stats.startTime; + return { stats, messages }; } const validSignature = crypto.verify(null /* algo */, backupInfoBuffer, backupSite.integrityKeyPair.publicKey, Buffer.from(backup.integrity.signature, 'hex')); @@ -47,7 +49,8 @@ async function verify(backup, backupSite, progressCallback) { debug(`verified: ${JSON.stringify(verifyMessages, null, 4)}`); - return messages; + stats.duration = Date.now() - stats.startTime; + return { stats, messages }; } async function check(backupId, progressCallback) { @@ -57,18 +60,20 @@ async function check(backupId, progressCallback) { const backupSite = await backupSites.get(backup.siteId); if (!backupSite) throw new BoxError(BoxError.BAD_FIELD, 'Backup site not found'); + const aggregatedStats = { startTime: Date.now(), duration: null }; const aggregatedMessages = []; for (const depId of backup.dependsOn) { const depBackup = await backups.get(depId); - const messages = await verify(depBackup, backupSite, progressCallback); + const result = await verify(depBackup, backupSite, progressCallback); // { stats, messages } - await backups.setIntegrityResult(backup, messages.length === 0 ? 'passed' : 'failed', { messages }); - if (messages.length) aggregatedMessages.push(`Integrity check of dependent backup ${depBackup.remotePath} failed`); + await backups.setIntegrityResult(backup, result.messages.length === 0 ? 'passed' : 'failed', result); + if (result.messages.length) aggregatedMessages.push(`Integrity check of dependent backup ${depBackup.remotePath} failed`); } - const messages = await verify(backup, backupSite, progressCallback); - aggregatedMessages.push(...messages); - await backups.setIntegrityResult(backup, aggregatedMessages.length === 0 ? 'passed' : 'failed', { messages: aggregatedMessages }); + const result = await verify(backup, backupSite, progressCallback); + aggregatedStats.duration = Date.now() - aggregatedStats.startTime; + aggregatedMessages.push(...result.messages); + await backups.setIntegrityResult(backup, aggregatedMessages.length === 0 ? 'passed' : 'failed', { stats: aggregatedStats, messages: aggregatedMessages }); } export default { diff --git a/src/progress-stream.js b/src/progress-stream.js index 758891bf1..824782efc 100644 --- a/src/progress-stream.js +++ b/src/progress-stream.js @@ -21,7 +21,7 @@ class ProgressStream extends TransformStream { } stats() { - const duration = Date.now() - this.#startTime; + const duration = Date.now() - this.#startTime; // this is not at _stop time because other streams in pipeline might have to be taken into account return { startTime: this.#startTime, duration, transferred: this.#transferred }; } diff --git a/src/routes/test/backups-test.js b/src/routes/test/backups-test.js index 98ecb1933..84fe57b54 100644 --- a/src/routes/test/backups-test.js +++ b/src/routes/test/backups-test.js @@ -100,7 +100,8 @@ describe('Backups API', function () { expect(response.status).to.equal(200); expect(response.body.lastIntegrityCheckTime).to.be.a('string'); expect(response.body.integrityCheckStatus).to.equal('passed'); - expect(response.body.integrityCheckResult).to.eql({ messages: [] }); + expect(response.body.integrityCheckResult.messages).to.eql([]); + expect(response.body.integrityCheckResult.stats).to.be.an('object'); expect(response.body.integrityCheckTask).to.be(null); }); }); diff --git a/src/test/backupintegrity-test.js b/src/test/backupintegrity-test.js new file mode 100644 index 000000000..5bc767da5 --- /dev/null +++ b/src/test/backupintegrity-test.js @@ -0,0 +1,96 @@ +/* jslint node:true */ + +import backupIntegrity from '../backupintegrity.js'; +import backups from '../backups.js'; +import backupSites from '../backupsites.js'; +import BoxError from '../boxerror.js'; +import child_process from 'node:child_process'; +import common from './common.js'; +import expect from 'expect.js'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import tasks from '../tasks.js'; +import timers from 'timers/promises'; + +/* global it:false */ +/* global describe:false */ +/* global before:false */ +/* global after:false */ + +describe('backupintegrity', function () { + const { setup, cleanup, getDefaultBackupSite, auditSource } = common; + + before(setup); + after(cleanup); + + describe('check', function () { + const backupConfig = { + provider: 'filesystem', + backupDir: path.join(os.tmpdir(), 'backupintegrity-test-filesystem'), + }; + + let defaultBackupSite; + + before(async function () { + fs.rmSync(backupConfig.backupDir, { recursive: true, force: true }); + defaultBackupSite = await getDefaultBackupSite(); + await backupSites.setConfig(defaultBackupSite, backupConfig, auditSource); + }); + + async function createBackup(site) { + const taskId = await backupSites.startBackupTask(site, auditSource); + + while (true) { + await timers.setTimeout(1000); + + const p = await tasks.get(taskId); + + if (p.percent !== 100) continue; + if (p.error) throw new Error(`backup failed: taskId: ${taskId} ${p.error.message}`); + if (!p.result) throw new Error('backup has no result:' + p); + + const result = await backups.listByIdentifierAndStatePaged(backups.BACKUP_IDENTIFIER_BOX, backups.BACKUP_STATE_NORMAL, 1, 1); + + if (result.length !== 1) throw new Error('result is not of length 1'); + + // the task progress and the db entry is set in the worker. wait for 2 seconds for backup lock to get released in parent process + await timers.setTimeout(2000); + + return result[0]; + } + } + + it('throws for missing backup id', async function () { + try { + await backupIntegrity.check('nonexistent-id', () => {}); + expect().fail('expected BoxError'); + } catch (err) { + expect(err).to.be.a(BoxError); + expect(err.reason).to.be(BoxError.BAD_FIELD); + expect(err.message).to.contain('Backup not found'); + } + }); + + it('verifies backup integrity', async function () { + // arch only has maria db which lacks some mysqldump options we need, this is only here to allow running the tests :-/ + if (child_process.execSync('/usr/bin/mysqldump --version').toString().indexOf('MariaDB') !== -1) { + console.log('test skipped because of MariaDB'); + return; + } + + const backup = await createBackup(defaultBackupSite); + const progressCalls = []; + await backupIntegrity.check(backup.id, (p) => { progressCalls.push(p); }); + + const updated = await backups.get(backup.id); + expect(updated.integrityCheckStatus).to.be('passed'); + expect(updated.integrityCheckResult.messages).to.eql([]); + expect(updated.integrityCheckResult.stats).to.be.an('object'); + }); + + it('cleanup', function () { + fs.rmSync(backupConfig.backupDir, { recursive: true, force: true }); + }); + }); +});