diff --git a/migrations/20250724102340-backupTargets-create-table.js b/migrations/20250724102340-backupTargets-create-table.js index 8dffb95af..c99c20bcb 100644 --- a/migrations/20250724102340-backupTargets-create-table.js +++ b/migrations/20250724102340-backupTargets-create-table.js @@ -23,7 +23,7 @@ exports.up = async function (db) { const results = await db.runSql('SELECT name, value FROM settings WHERE name=? OR name=? OR name=?', [ 'backup_storage', 'backup_limits', 'backup_policy' ]); - const label = '', main = true; + const label = 'Default', main = true; let config = null, limits = null, encryption = null, format = null, provider = null; let retention = { keepWithinSecs: 2 * 24 * 60 * 60 }; let schedule = '00 00 23 * * *'; diff --git a/src/backuptargets.js b/src/backuptargets.js index 07b0c1255..1f3dee084 100644 --- a/src/backuptargets.js +++ b/src/backuptargets.js @@ -96,11 +96,13 @@ function removePrivateFields(target) { assert.strictEqual(typeof target, 'object'); if (target.encryption) { + target.encryptedFilenames = target.encryption.encryptedFilenames; delete target.encryption; target.password = constants.SECRET_PLACEHOLDER; } - delete target.rootPath; - return storage.api(target.provider).removePrivateFields(target.config); + delete target.config.rootPath; + target.config = storage.api(target.provider).removePrivateFields(target.config); + return target; } function validateFormat(format) { @@ -152,7 +154,7 @@ async function list(page, perPage) { assert(typeof page === 'number' && page > 0); assert(typeof perPage === 'number' && perPage > 0); - const results = await database.query(`SELECT ${BACKUP_TARGET_FIELDS} FROM backupTargets ORDER BY creationTime DESC LIMIT ?,?`, [ (page-1)*perPage, perPage ]); + const results = await database.query(`SELECT ${BACKUP_TARGET_FIELDS} FROM backupTargets ORDER BY main DESC LIMIT ?,?`, [ (page-1)*perPage, perPage ]); results.forEach(function (result) { postProcess(result); }); @@ -218,7 +220,6 @@ async function setRetention(target, retention) { async function setPrimary(target) { assert.strictEqual(typeof target, 'object'); - assert.strictEqual(typeof retention, 'object'); const queries = [ { query: 'SELECT 1 FROM backupTargets WHERE id=? FOR UPDATE', args: [ target.id ] }, // ensure this exists! @@ -235,11 +236,11 @@ async function del(target, auditSource) { assert.strictEqual(typeof target, 'object'); assert(auditSource && typeof auditSource === 'object'); - if (target.main) throw new BoxError(BoxError.CONFLICT, 'Cannot delete the primary backup target'); + if (target.primary) throw new BoxError(BoxError.CONFLICT, 'Cannot delete the primary backup target'); const queries = [ { query: 'DELETE FROM backups WHERE targetId = ?', args: [ target.id ] }, - { query: 'DELETE FROM backupTargets WHERE id=? AND main=?', args: [ target.id, false ] }, + { query: 'DELETE FROM backupTargets WHERE id=? AND main=?', args: [ target.id, false ] }, // cannot delete primary ]; const [error, result] = await safe(database.transaction(queries)); @@ -252,7 +253,7 @@ async function del(target, auditSource) { await cron.handleBackupScheduleChanged(target); debug('del: clearing backup cache'); - cleanupCacheFilesSync(); + cleanupCacheFilesSync(target); } async function startBackupTask(target, auditSource) { diff --git a/src/routes/backuptargets.js b/src/routes/backuptargets.js index 1dcdd2ff0..5d0bf6bbe 100644 --- a/src/routes/backuptargets.js +++ b/src/routes/backuptargets.js @@ -15,7 +15,7 @@ exports = module.exports = { setRetention, setPrimary, - create, + createBackup, cleanup, remount, getMountStatus, @@ -73,7 +73,7 @@ async function list(req, res, next) { async function add(req, res, next) { assert.strictEqual(typeof req.body, 'object'); - const { label, format, config } = req.body; + const { label, format, provider, config } = req.body; if (typeof format !== 'string') return next(new HttpError(400, 'format must be a string')); if (typeof label !== 'string') return next(new HttpError(400, 'label must be a string')); @@ -109,46 +109,12 @@ async function del(req, res, next) { next(new HttpSuccess(204)); } -async function create(req, res, next) { - assert.strictEqual(typeof req.resources.backupTarget, 'object'); - - const [error, taskId] = await safe(backupTargets.startBackupTask(req.resources.backupTarget, AuditSource.fromRequest(req))); - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(202, { taskId })); -} - -async function cleanup(req, res, next) { - assert.strictEqual(typeof req.resources.backupTarget, 'object'); - - const [error, taskId] = await safe(backupTargets.startCleanupTask(req.resources.backupTarget, AuditSource.fromRequest(req))); - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(202, { taskId })); -} - -async function remount(req, res, next) { - assert.strictEqual(typeof req.resources.backupTarget, 'object'); - - const [error] = await safe(backupTargets.remount(req.resources.backupTarget)); - if (error) return next(BoxError.toHttpError(error)); - - next(new HttpSuccess(202, {})); -} - -async function getMountStatus(req, res, next) { - assert.strictEqual(typeof req.resources.backupTarget, 'object'); - - - const [error, mountStatus] = await safe(backupTargets.getMountStatus(req.resources.backupTarget)); - if (error) return next(BoxError.toHttpError(error)); - next(new HttpSuccess(200, mountStatus)); -} - async function setLimits(req, res, next) { assert.strictEqual(typeof req.body, 'object'); - const limits = req.body; + const { limits } = req.body; + + if (!limits || typeof limits !== 'object') return next(new HttpError(400, 'limits is required')); if ('syncConcurrency' in limits) { if (typeof limits.syncConcurrency !== 'number') return next(new HttpError(400, 'syncConcurrency must be a positive integer')); @@ -173,7 +139,7 @@ async function setLimits(req, res, next) { if ('memoryLimit' in limits && typeof limits.memoryLimit !== 'number') return next(new HttpError(400, 'memoryLimit must be a positive integer')); - const [error] = await safe(backupTargets.setLimits(req.resources.backupTarget, req.body)); + const [error] = await safe(backupTargets.setLimits(req.resources.backupTarget, limits)); if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(200, {})); @@ -182,13 +148,12 @@ async function setLimits(req, res, next) { async function setConfig(req, res, next) { assert.strictEqual(typeof req.body, 'object'); - // provider specific options are validated by provider backends - if (typeof req.body.provider !== 'string') return next(new HttpError(400, 'provider is required')); + if (!req.body.config || typeof req.body.config !== 'object') return next(new HttpError(400, 'config is required')); // testing the backup using put/del takes a bit of time at times req.clearTimeout(); - const [error] = await safe(backupTargets.setConfig(req.resources.backupTarget, req.body)); + const [error] = await safe(backupTargets.setConfig(req.resources.backupTarget, req.body.config)); if (error) return next(BoxError.toHttpError(error)); next(new HttpSuccess(200, {})); @@ -224,3 +189,38 @@ async function setPrimary(req, res, next) { next(new HttpSuccess(200, {})); } + +async function createBackup(req, res, next) { + assert.strictEqual(typeof req.resources.backupTarget, 'object'); + + const [error, taskId] = await safe(backupTargets.startBackupTask(req.resources.backupTarget, AuditSource.fromRequest(req))); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(202, { taskId })); +} + +async function cleanup(req, res, next) { + assert.strictEqual(typeof req.resources.backupTarget, 'object'); + + const [error, taskId] = await safe(backupTargets.startCleanupTask(req.resources.backupTarget, AuditSource.fromRequest(req))); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(202, { taskId })); +} + +async function remount(req, res, next) { + assert.strictEqual(typeof req.resources.backupTarget, 'object'); + + const [error] = await safe(backupTargets.remount(req.resources.backupTarget)); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(202, {})); +} + +async function getMountStatus(req, res, next) { + assert.strictEqual(typeof req.resources.backupTarget, 'object'); + + const [error, mountStatus] = await safe(backupTargets.getMountStatus(req.resources.backupTarget)); + if (error) return next(BoxError.toHttpError(error)); + next(new HttpSuccess(200, mountStatus)); +} diff --git a/src/routes/test/backups-test.js b/src/routes/test/backups-test.js index 64681d74f..6e4ac5892 100644 --- a/src/routes/test/backups-test.js +++ b/src/routes/test/backups-test.js @@ -1,325 +1,65 @@ -/* global it:false */ -/* global describe:false */ -/* global before:false */ -/* global after:false */ +/* global it, describe, before, after */ 'use strict'; -const backupTargets = require('../../backuptargets.js'), - common = require('./common.js'), +const common = require('./common.js'), expect = require('expect.js'), superagent = require('@cloudron/superagent'); -const BACKUP_FOLDER = '/tmp/backup_test'; - describe('Backups API', function () { - const { setup, cleanup, waitForTask, serverUrl, owner } = common; + const { setup, cleanup, waitForTask, serverUrl, owner, admin, getDefaultBackupTarget } = common; before(setup); after(cleanup); - describe('backup_policy', function () { - const defaultPolicy = { - retention: { keepWithinSecs: 2 * 24 * 60 * 60 }, // 2 days - schedule: '00 00 23 * * *' // every day at 11pm - }; + let someBackup; - it('cannot set backup_policy without schedule', async function () { - const tmp = Object.assign({} , defaultPolicy); - delete tmp.schedule; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/policy`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_policy with invalid schedule', async function () { - const tmp = Object.assign({} , defaultPolicy); - tmp.schedule = 'not a pattern'; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/policy`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_policy without retention', async function () { - const tmp = Object.assign({} , defaultPolicy); - delete tmp.retention; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/policy`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_policy with invalid retention', async function () { - const tmp = Object.assign({} , defaultPolicy); - tmp.retention = 'not an object'; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/policy`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_policy with empty retention', async function () { - const tmp = Object.assign({} , defaultPolicy); - tmp.retention = {}; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/policy`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_policy with retention missing properties', async function () { - const tmp = Object.assign({} , defaultPolicy); - tmp.retention = { foo: 'bar' }; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/policy`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_policy with retention with invalid keepWithinSecs', async function () { - const tmp = Object.assign({} , defaultPolicy); - tmp.retention = { keepWithinSecs: 'not a number' }; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/policy`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); + // create some backup first + before(async function () { + const target = await getDefaultBackupTarget(); + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${target.id}/create_backup`) + .query({ access_token: admin.token }); + expect(response.status).to.equal(202); + expect(response.body.taskId).to.be.a('string'); + await waitForTask(response.body.taskId); }); - describe('backup_config', function () { - // keep in sync with defaults in settings.js - const defaultConfig = { - provider: 'filesystem', - backupFolder: '/var/backups', - format: 'tgz', - encryption: null, - }; - - it('can get backup_config (default)', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/backup_targets`) - .query({ access_token: owner.token }); - - expect(response.status).to.equal(200); - expect(response.body.config).to.eql(defaultConfig); - expect(response.body.config).to.eql(defaultConfig); - }); - - it('cannot set backup_config without provider', async function () { - const tmp = JSON.parse(JSON.stringify(defaultConfig)); - delete tmp.provider; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/config`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_config with invalid provider', async function () { - const tmp = JSON.parse(JSON.stringify(defaultConfig)); - tmp.provider = 'invalid provider'; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/config`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_config without format', async function () { - const tmp = JSON.parse(JSON.stringify(defaultConfig)); - delete tmp.format; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/config`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_config with invalid format', async function () { - const tmp = JSON.parse(JSON.stringify(defaultConfig)); - tmp.format = 'invalid format'; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/config`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_config with invalid password', async function () { - const tmp = JSON.parse(JSON.stringify(defaultConfig)); - tmp.password = 1234; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/config`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_config with invalid syncConcurrency', async function () { - const tmp = JSON.parse(JSON.stringify(defaultConfig)); - tmp.limits = { syncConcurrency: 'not a number' }; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/config`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_config with invalid syncConcurrency', async function () { - const tmp = JSON.parse(JSON.stringify(defaultConfig)); - tmp.limits = { syncConcurrency: 0 }; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/config`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('cannot set backup_config with invalid acceptSelfSignedCerts', async function () { - const tmp = JSON.parse(JSON.stringify(defaultConfig)); - tmp.acceptSelfSignedCerts = 'not a boolean'; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/config`) - .query({ access_token: owner.token }) - .send(tmp) - .ok(() => true); - - expect(response.status).to.equal(400); - }); - - it('can set backup_config', async function () { - const tmp = JSON.parse(JSON.stringify(defaultConfig)); - tmp.format = 'rsync'; - tmp.backupFolder = BACKUP_FOLDER; - tmp.limits = { copyConcurrency: 34 }; - - const response = await superagent.post(`${serverUrl}/api/v1/backups/config/storage`) - .query({ access_token: owner.token }) - .send(tmp); - - expect(response.status).to.equal(200); - }); - - it('can get backup_config', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/backups/config`) - .query({ access_token: owner.token }); - - expect(response.status).to.equal(200); - expect(response.body.format).to.equal('rsync'); - expect(response.body.backupFolder).to.equal(BACKUP_FOLDER); - }); + it('can list', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/backups`) + .query({ access_token: admin.token }); + expect(response.status).to.equal(200); + expect(response.body.backups.length).to.be(1); + someBackup = response.body.backups[0]; }); - describe('create', function () { - before(async function () { - await backupTargets.setStorage({ - provider: 'filesystem', - backupFolder: '/tmp/backups', - format: 'tgz', - encryption: null, - }); - }); - - it('fails due to mising token', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/backups/create`) - .ok(() => true); - expect(response.status).to.equal(401); - }); - - it('fails due to wrong token', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/backups/create`) - .query({ access_token: 'randomtoken' }) - .ok(() => true); - expect(response.status).to.equal(401); - }); - - it('succeeds', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/backups/create`) - .query({ access_token: owner.token }); - expect(response.status).to.equal(202); - expect(response.body.taskId).to.be.a('string'); - await waitForTask(response.body.taskId); - }); + it('cannot get random id', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/backups/bad_id`) + .query({ access_token: owner.token }) + .ok(() => true); + expect(response.status).to.equal(400); }); - describe('list', function () { - it('succeeds', async function () { - const response = await superagent.get(`${serverUrl}/api/v1/backups`) - .query({ access_token: owner.token }); - expect(response.status).to.equal(200); - expect(response.body.backupTargets.length).to.be(1); - }); + it('can get valid id', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/backups/${someBackup.id}`) + .query({ access_token: owner.token }) + .ok(() => true); + expect(response.status).to.equal(200); + expect(response.body.preserveSecs).to.be(0); + expect(response.body.label).to.be(''); }); - describe('update', function () { - let someBackup; + it('cannot update invalid preserve secs', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backups/${someBackup.id}`) + .query({ access_token: owner.token }) + .send({ preserveSecs: 'not-a-number', label: 'some string' }) + .ok(() => true); + expect(response.status).to.equal(400); + }); - before(async function () { - const response = await superagent.get(`${serverUrl}/api/v1/backups`) - .query({ access_token: owner.token }); - expect(response.status).to.equal(200); - expect(response.body.backupTargets.length).to.be(1); - someBackup = response.body.backups[0]; - }); - - it('fails for bad param', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/backups/bad_id`) - .query({ access_token: owner.token }) - .send({ preserveSecs: 'not-a-number', label: 'some string' }) - .ok(() => true); - expect(response.status).to.equal(400); - }); - - it('fails for unknown backup', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/backups/bad_id`) - .query({ access_token: owner.token }) - .send({ preserveSecs: 30, label: 'NewOrleans' }) - .ok(() => true); - - expect(response.status).to.equal(404); - }); - - it('succeeds', async function () { - const response = await superagent.post(`${serverUrl}/api/v1/backups/${someBackup.id}`) - .query({ access_token: owner.token }) - .send({ preserveSecs: 30, label: 'NewOrleans' }); - expect(response.status).to.equal(200); - }); + it('can update', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backups/${someBackup.id}`) + .query({ access_token: owner.token }) + .send({ preserveSecs: 30, label: 'NewOrleans' }); + expect(response.status).to.equal(200); }); }); diff --git a/src/routes/test/backuptargets-test.js b/src/routes/test/backuptargets-test.js new file mode 100644 index 000000000..2f7538e80 --- /dev/null +++ b/src/routes/test/backuptargets-test.js @@ -0,0 +1,321 @@ +/* global it, describe, before, after */ + +'use strict'; + +const backupTargets = require('../../backuptargets.js'), + common = require('./common.js'), + expect = require('expect.js'), + superagent = require('@cloudron/superagent'); + +describe('Backups API', function () { + const { setup, cleanup, waitForTask, serverUrl, owner, admin, getDefaultBackupTarget } = common; + + before(setup); + after(cleanup); + + const newTarget = { + provider: 'filesystem', + label: 'NewTarget', + config: { backupFolder: '/tmp/boxtest-newtarget' }, + format: 'tgz', + retention: { keepWithinSecs: 60 * 60 }, + schedule: '00 01 * * * *' + }; + + const encryptedTarget = { + provider: 'filesystem', + label: 'EncryptedTarget', + config: { backupFolder: '/tmp/boxtest-enctarget' }, + format: 'rsync', + retention: { keepMonthly: 60 }, + schedule: '* 1 * * * *', + }; + + describe('add', function () { + it('fails as admin', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets`) + .query({ access_token: admin.token }) + .send(newTarget) + .ok(() => true); + + expect(response.status).to.equal(403); + }); + + it('succeeds as owner', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets`) + .query({ access_token: owner.token }) + .send(newTarget) + .ok(() => true); + + expect(response.status).to.equal(200); + newTarget.id = response.body.id; + }); + + it('succeeds with password', async function () { + const tmp = Object.assign({}, encryptedTarget, { encryptionPassword: 'deutsch-a1', encryptedFilenames: true }); + + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets`) + .query({ access_token: owner.token }) + .send(tmp) + .ok(() => true); + + expect(response.status).to.equal(200); + encryptedTarget.id = response.body.id; + }); + }); + + describe('list', function () { + it('succeeds as admin', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/backup_targets`) + .query({ access_token: admin.token }); + expect(response.status).to.equal(200); + expect(response.body.backupTargets.length).to.be(3); + const tmp = response.body.backupTargets.find(t => t.id === newTarget.id); + expect(tmp.provider).to.be(newTarget.provider); + expect(tmp.config).to.be.ok(); + expect(tmp.format).to.be(newTarget.format); + expect(tmp.label).to.be(newTarget.label); + expect(tmp.primary).to.be(false); + }); + }); + + describe('get', function () { + it('succeeds as admin', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/backup_targets/${newTarget.id}`) + .query({ access_token: admin.token }); + expect(response.status).to.equal(200); + expect(response.body.provider).to.be(newTarget.provider); + expect(response.body.config).to.be.ok(); + expect(response.body.format).to.be(newTarget.format); + expect(response.body.label).to.be(newTarget.label); + expect(response.body.primary).to.be(false); + }); + + it('succeeds as admin (encrypted)', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/backup_targets/${encryptedTarget.id}`) + .query({ access_token: admin.token }); + expect(response.status).to.equal(200); + expect(response.body.provider).to.be(encryptedTarget.provider); + expect(response.body.config).to.be.ok(); + expect(response.body.format).to.be(encryptedTarget.format); + expect(response.body.label).to.be(encryptedTarget.label); + expect(response.body.primary).to.be(false); + console.log(response.body); + expect(response.body.password).to.be.ok(); + expect(response.body.encryptedFilenames).to.be(true); + }); + }); + + describe('schedule', function () { + it('cannot set without schedule', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/schedule`) + .query({ access_token: owner.token }) + .send({}) + .ok(() => true); + + expect(response.status).to.equal(400); + }); + + it('cannot set invalid schedule', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/schedule`) + .query({ access_token: owner.token }) + .send({ schedule: 'whatever' }) + .ok(() => true); + + expect(response.status).to.equal(400); + }); + + it('can set "never" schedule', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/schedule`) + .query({ access_token: owner.token }) + .send({ schedule: 'never' }) + .ok(() => true); + + expect(response.status).to.equal(200); + + const result = await backupTargets.get(newTarget.id); + expect(result.schedule).to.be('never'); + }); + + it('can set valid schedule', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/schedule`) + .query({ access_token: owner.token }) + .send({ schedule: '00 00 3 * * *' }) + .ok(() => true); + + expect(response.status).to.equal(200); + const result = await backupTargets.get(newTarget.id); + expect(result.schedule).to.be('00 00 3 * * *'); + }); + }); + + describe('retention', function () { + it('cannot set without retention', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/retention`) + .query({ access_token: owner.token }) + .send({}) + .ok(() => true); + + expect(response.status).to.equal(400); + }); + + it('cannot set invalid retention', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/retention`) + .query({ access_token: owner.token }) + .send({ retention: 'whatever' }) + .ok(() => true); + + expect(response.status).to.equal(400); + }); + + it('cannot set backup_policy with retention with invalid keepWithinSecs', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/retention`) + .query({ access_token: owner.token }) + .send({ retention: { keepWithinSecs: 'not a number' } }) + .ok(() => true); + + expect(response.status).to.equal(400); + }); + + it('can set valid retention', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/retention`) + .query({ access_token: owner.token }) + .send({ retention: { keepWithinSecs: 2 * 24 * 60 * 60 } }) + .ok(() => true); + + expect(response.status).to.equal(200); + const result = await backupTargets.get(newTarget.id); + expect(result.retention).to.eql({ keepWithinSecs: 2 * 24 * 60 * 60 }); + }); + }); + + describe('limits', function () { + it('cannot set invalid limits', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/limits`) + .query({ access_token: owner.token }) + .send({ limits: 2 }) + .ok(() => true); + + expect(response.status).to.equal(400); + }); + + it('can set valid limits', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/limits`) + .query({ access_token: owner.token }) + .send({ limits: { syncConcurrency: 34 } }) + .ok(() => true); + + expect(response.status).to.equal(200); + const result = await backupTargets.get(newTarget.id); + expect(result.limits).to.eql({ syncConcurrency: 34 }); + }); + }); + + describe('primary', function () { + it('cannot set invalid id', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}xx/configure/primary`) + .query({ access_token: owner.token }) + .send({}) + .ok(() => true); + + expect(response.status).to.equal(404); + }); + + it('can set valid primary', async function () { + const oldDefault = await getDefaultBackupTarget(); + + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/primary`) + .query({ access_token: owner.token }) + .send({}) + .ok(() => true); + + expect(response.status).to.equal(200); + const result = await backupTargets.get(newTarget.id); + expect(result.primary).to.be(true); + + const result2 = await backupTargets.get(oldDefault.id); + expect(result2.primary).to.be(false); + }); + }); + + // at this point , newTarget is the primary and the default one is not + describe('del', function () { + it('cannot delete invalid id', async function () { + const response = await superagent.del(`${serverUrl}/api/v1/backup_targets/${newTarget.id}xx`) + .query({ access_token: owner.token }) + .ok(() => true); + + expect(response.status).to.equal(404); + }); + + it('cannot delete primary', async function () { + const response = await superagent.del(`${serverUrl}/api/v1/backup_targets/${newTarget.id}`) + .query({ access_token: owner.token }) + .ok(() => true); + + expect(response.status).to.equal(409); + }); + + it('can delete non-primary', async function () { + const result2 = (await backupTargets.list(1, 10)).pop(); + const response = await superagent.del(`${serverUrl}/api/v1/backup_targets/${result2.id}`) + .query({ access_token: owner.token }) + .ok(() => true); + + expect(response.status).to.equal(204); + }); + }); + + describe('config', function () { + const someConfig = { + backupFolder: '/tmp/boxtest-someconfig', + }; + + it('cannot set invalid config', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/config`) + .query({ access_token: owner.token }) + .send({ config: 32 }) + .ok(() => true); + + expect(response.status).to.equal(400); + }); + + it('can set valid config', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/configure/config`) + .query({ access_token: owner.token }) + .send({ config: someConfig }) + .ok(() => true); + + expect(response.status).to.equal(200); + const result = await backupTargets.get(newTarget.id); + expect(result.config.backupFolder).to.be(someConfig.backupFolder); + }); + }); + + describe('mounting', function () { + it('mount status', async function () { + const response = await superagent.get(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/mount_status`) + .query({ access_token: owner.token }); + expect(response.status).to.equal(200); + expect(response.body.state).to.be('active'); + expect(response.body.message).to.be('Mounted'); + }); + + it('remount', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/remount`) + .query({ access_token: owner.token }) + .send({}); + expect(response.status).to.equal(202); + }); + }); + + describe('create', function () { + it('succeeds', async function () { + const response = await superagent.post(`${serverUrl}/api/v1/backup_targets/${newTarget.id}/create_backup`) + .query({ access_token: admin.token }); + expect(response.status).to.equal(202); + expect(response.body.taskId).to.be.a('string'); + await waitForTask(response.body.taskId); + }); + }); +}); diff --git a/src/routes/test/common.js b/src/routes/test/common.js index 8eb55419c..6e0cba172 100644 --- a/src/routes/test/common.js +++ b/src/routes/test/common.js @@ -118,14 +118,15 @@ async function setupServer() { await database._clear(); await appstore._setApiServerOrigin(exports.mockApiServerOrigin); // duplicated here since we clear the database - await backupTargets.add({ + const id = await backupTargets.add({ provider: 'filesystem', - label: '', + label: 'Default', config: { backupFolder: '/tmp/boxtest' }, format: 'tgz', retention: { keepWithinSecs: 2 * 24 * 60 * 60 }, schedule: '00 00 23 * * *' }); + await backupTargets.setPrimary({ id }); await oidcServer.stop(); await server.start(); debug('Set up server complete'); @@ -161,7 +162,7 @@ async function setup() { // create an admin response = await superagent.post(`${serverUrl}/api/v1/users`) .query({ access_token: owner.token }) - .send({ username: admin.username, email: admin.email, password: admin.password }); + .send({ username: admin.username, email: admin.email, password: admin.password, role: 'admin' }); expect(response.status).to.equal(201); admin.id = response.body.id; // HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...) diff --git a/src/server.js b/src/server.js index 19d7222bf..4de3b3d4d 100644 --- a/src/server.js +++ b/src/server.js @@ -158,10 +158,10 @@ async function initializeExpressSync() { // backup target (destination) routes router.get ('/api/v1/backup_targets/', token, authorizeAdmin, routes.backupTargets.list); router.get ('/api/v1/backup_targets/:id', token, authorizeAdmin, routes.backupTargets.load, routes.backupTargets.get); - router.post('/api/v1/backup_targets/:id', token, authorizeOwner, routes.backupTargets.add); + router.post('/api/v1/backup_targets', json, token, authorizeOwner, routes.backupTargets.add); router.del ('/api/v1/backup_targets/:id', token, authorizeOwner, routes.backupTargets.load, routes.backupTargets.del); router.get ('/api/v1/backup_targets/:id/mount_status', token, authorizeAdmin, routes.backupTargets.load, routes.backupTargets.getMountStatus); - router.post('/api/v1/backup_targets/:id/create', token, authorizeAdmin, routes.backupTargets.load, routes.backupTargets.create); + router.post('/api/v1/backup_targets/:id/create_backup', token, authorizeAdmin, routes.backupTargets.load, routes.backupTargets.createBackup); router.post('/api/v1/backup_targets/:id/cleanup', json, token, authorizeAdmin, routes.backupTargets.load, routes.backupTargets.cleanup); router.post('/api/v1/backup_targets/:id/remount', json, token, authorizeAdmin, routes.backupTargets.load, routes.backupTargets.remount); router.post('/api/v1/backup_targets/:id/configure/config', json, token, authorizeOwner, routes.backupTargets.load, routes.backupTargets.setConfig); diff --git a/src/test/common.js b/src/test/common.js index 73edaf285..a88b5ea7d 100644 --- a/src/test/common.js +++ b/src/test/common.js @@ -222,14 +222,15 @@ async function databaseSetup() { await dashboard._setLocation(constants.DASHBOARD_SUBDOMAIN, exports.dashboardDomain); // duplicated here since we clear the database - await backupTargets.add({ + const id = await backupTargets.add({ provider: 'filesystem', - label: '', + label: 'Default', config: { backupFolder: '/tmp/boxtest' }, format: 'tgz', retention: { keepWithinSecs: 2 * 24 * 60 * 60 }, schedule: '00 00 23 * * *' }); + await backupTargets.setPrimary({ id }); } async function domainSetup() {