diff --git a/lib/bin/create-docker-databases.js b/lib/bin/create-docker-databases.js index b191e8073..76af3b7e5 100644 --- a/lib/bin/create-docker-databases.js +++ b/lib/bin/create-docker-databases.js @@ -19,7 +19,7 @@ const connect = (database) => knex({ connection: { host: 'localhost', user: 'postgres', password: 'odktest', database } }); -program.option('-l', 'Print all db statements to log.'); +program.option('-l, --log', 'Print all db statements to log.'); program.parse(); const { log } = program.opts(); diff --git a/lib/bin/purge-forms.js b/lib/bin/purge-forms.js index 33aa77d76..cc5561d3e 100644 --- a/lib/bin/purge-forms.js +++ b/lib/bin/purge-forms.js @@ -14,12 +14,13 @@ const { run } = require('../task/task'); const { purgeForms } = require('../task/purge'); const { program } = require('commander'); -program.option('-f', 'Force any soft-deleted form to be purged right away.'); -program.option('-i ', 'Purge a specific form based on its id.', parseInt); -program.option('-p ', 'Restrict purging to a specific project.', parseInt); +program.option('-f, --force', 'Force any soft-deleted form to be purged right away.'); +program.option('-i, --formId ', 'Purge a specific form based on its id.', parseInt); +program.option('-p, --projectId ', 'Restrict purging to a specific project.', parseInt); +program.option('-x, --xmlFormId ', 'Restrict purging to specific form based on xmlFormId (must be used with project id).'); program.parse(); const options = program.opts(); -run(purgeForms(options.force, options.formId, options.projectId) +run(purgeForms(options.force, options.formId, options.projectId, options.xmlFormId) .then((count) => `Forms purged: ${count}`)); diff --git a/lib/model/query/forms.js b/lib/model/query/forms.js index 22634ead3..ecc3d753e 100644 --- a/lib/model/query/forms.js +++ b/lib/model/query/forms.js @@ -309,16 +309,19 @@ const DAY_RANGE = config.has('default.taskSchedule.purge') ? config.get('default.taskSchedule.purge') : 30; // Default is 30 days -const _trashedFilter = (force, id, projectId) => { +const _trashedFilter = (force, id, projectId, xmlFormId) => { const idFilter = (id ? sql`and forms.id = ${id}` : sql``); const projectFilter = (projectId ? sql`and forms."projectId" = ${projectId}` : sql``); + const xmlFormIdFilter = ((xmlFormId && projectId) + ? sql`and forms."projectId" = ${projectId} and forms."xmlFormId" = ${xmlFormId}` + : sql``); return (force - ? sql`forms."deletedAt" is not null ${idFilter} ${projectFilter}` - : sql`forms."deletedAt" < current_date - cast(${DAY_RANGE} as int) ${idFilter} ${projectFilter}`); + ? sql`forms."deletedAt" is not null ${idFilter} ${projectFilter} ${xmlFormIdFilter}` + : sql`forms."deletedAt" < current_date - cast(${DAY_RANGE} as int) ${idFilter} ${projectFilter} ${xmlFormIdFilter}`); }; // NOTE: copypasta alert! @@ -333,18 +336,20 @@ const _trashedFilter = (force, id, projectId) => { // 3. Update actees table for the specific form to leave some useful information behind // 4. Delete the forms and their resources from the database // 5. Purge unattached blobs -const purge = (force = false, id = null, projectId = null) => ({ oneFirst, Blobs }) => - oneFirst(sql` +const purge = (force = false, id = null, projectId = null, xmlFormId = null) => ({ oneFirst, Blobs }) => { + if (xmlFormId != null && projectId == null) + throw Problem.internal.unknown({ error: 'Must also specify projectId when using xmlFormId' }); + return oneFirst(sql` with redacted_audits as ( update audits set notes = '' from forms where audits."acteeId" = forms."acteeId" - and ${_trashedFilter(force, id, projectId)} + and ${_trashedFilter(force, id, projectId, xmlFormId)} ), purge_audits as ( insert into audits ("action", "acteeId", "loggedAt", "processed") select 'form.purge', "acteeId", clock_timestamp(), clock_timestamp() from forms - where ${_trashedFilter(force, id, projectId)} + where ${_trashedFilter(force, id, projectId, xmlFormId)} ), update_actees as ( update actees set "purgedAt" = clock_timestamp(), "purgedName" = form_defs."name", @@ -356,15 +361,16 @@ with redacted_audits as ( from forms left outer join form_defs on coalesce(forms."currentDefId", forms."draftDefId") = form_defs.id where actees.id = forms."acteeId" - and ${_trashedFilter(force, id, projectId)} + and ${_trashedFilter(force, id, projectId, xmlFormId)} ), deleted_forms as ( delete from forms - where ${_trashedFilter(force, id, projectId)} + where ${_trashedFilter(force, id, projectId, xmlFormId)} returning * ) select count(*) from deleted_forms`) .then((count) => Blobs.purgeUnattached() .then(() => Promise.resolve(count))); +}; //////////////////////////////////////////////////////////////////////////////// // CLEARING UNNEEDED DRAFTS diff --git a/lib/task/purge.js b/lib/task/purge.js index ddcfb2c65..974b12c1b 100644 --- a/lib/task/purge.js +++ b/lib/task/purge.js @@ -9,7 +9,7 @@ const { task } = require('./task'); -const purgeForms = task.withContainer(({ Forms }) => (force = false, formId = null, projectId = null) => - Forms.purge(force, formId, projectId)); +const purgeForms = task.withContainer(({ Forms }) => (force = false, formId = null, projectId = null, xmlFormId = null) => + Forms.purge(force, formId, projectId, xmlFormId)); module.exports = { purgeForms }; diff --git a/test/integration/task/purge.js b/test/integration/task/purge.js index 01404f61a..11ee39c62 100644 --- a/test/integration/task/purge.js +++ b/test/integration/task/purge.js @@ -1,6 +1,8 @@ const appRoot = require('app-root-path'); -const { testTask } = require('../setup'); +const assert = require('assert'); +const { testTask, testService } = require('../setup'); const { purgeForms } = require(appRoot + '/lib/task/purge'); +const testData = require('../../data/xml'); // The basics of this task are tested here, including returning the count // eslint-disable-next-line no-trailing-spaces @@ -26,14 +28,13 @@ describe('task: purge deleted forms', () => { it('should return count for multiple forms purged', testTask(({ Forms }) => Forms.getByProjectAndXmlFormId(1, 'simple') - .then((form) => Forms.del(form.get()) - .then(() => Forms.getByProjectAndXmlFormId(1, 'withrepeat')) - // eslint-disable-next-line no-shadow - .then((form) => Forms.del(form.get()) - .then(() => purgeForms(true)) - .then((count) => { - count.should.equal(2); - }))))); + .then((form) => Forms.del(form.get())) + .then(() => Forms.getByProjectAndXmlFormId(1, 'withrepeat') + .then((form) => Forms.del(form.get()))) + .then(() => purgeForms(true) + .then((count) => { + count.should.equal(2); + })))); it('should not purge specific recently deleted form', testTask(({ Forms }) => Forms.getByProjectAndXmlFormId(1, 'simple') @@ -123,5 +124,90 @@ describe('task: purge deleted forms', () => { count.should.equal(0); }))))); }); + + describe('with xmlFormId', () => { + it('should thow error if xmlFormId specified without projectId', testTask(async ({ Forms }) => { + const form = await Forms.getByProjectAndXmlFormId(1, 'simple'); + await Forms.del(form.get()); + await assert.throws(() => { purgeForms(true, null, null, 'simple'); }, (err) => { + err.problemCode.should.equal(500.1); + err.problemDetails.error.should.equal('Must also specify projectId when using xmlFormId'); + return true; + }); + })); + + it('should force purge form by project and xmlFormId', testTask(({ Forms }) => + Forms.getByProjectAndXmlFormId(1, 'simple') + .then((form) => Forms.del(form.get()) + .then(() => purgeForms(true, null, 1, 'simple')) + .then((count) => { + count.should.equal(1); + })))); + it('should not purge form by project and xmlFormId if form deleted recently and not forced', testTask(({ Forms }) => + Forms.getByProjectAndXmlFormId(1, 'simple') + .then((form) => Forms.del(form.get()) + .then(() => purgeForms(false, null, 1, 'simple')) + .then((count) => { + count.should.equal(0); + })))); + + it('should purge all versions of deleted form in project', testService(async (service, container) => { + const asAlice = await service.login('alice'); + + await asAlice.delete('/v1/projects/1/forms/simple') + .expect(200); + + // new version (will be v2) + await asAlice.post('/v1/projects/1/forms?ignoreWarnings=true') + .send(testData.forms.simple) + .set('Content-Type', 'application/xml') + .expect(200); + + // publish new version v2 + await asAlice.post('/v1/projects/1/forms/simple/draft/publish?ignoreWarnings=true&version=v2') + .expect(200); + + // delete new version v2 + await asAlice.delete('/v1/projects/1/forms/simple') + .expect(200); + + // new version (will be v3) + await asAlice.post('/v1/projects/1/forms?ignoreWarnings=true') + .send(testData.forms.simple) + .set('Content-Type', 'application/xml') + .expect(200); + + // publish new version v3 but don't delete + await asAlice.post('/v1/projects/1/forms/simple/draft/publish?ignoreWarnings=true&version=v3') + .expect(200); + + const count = await container.Forms.purge(true, null, 1, 'simple'); + count.should.equal(2); + })); + + it('should purged named form only from specified project', testService(async (service, container) => { + const asAlice = await service.login('alice'); + + // delete simple form in project 1 (but don't purge it) + await asAlice.delete('/v1/projects/1/forms/simple') + .expect(200); + + const newProjectId = await asAlice.post('/v1/projects') + .send({ name: 'Project Two' }) + .then(({ body }) => body.id); + + await asAlice.post(`/v1/projects/${newProjectId}/forms?publish=true`) + .send(testData.forms.simple) + .set('Content-Type', 'application/xml') + .expect(200); + + await asAlice.delete(`/v1/projects/${newProjectId}/forms/simple`) + .expect(200); + + const count = await container.Forms.purge(true, null, newProjectId, 'simple'); + count.should.equal(1); + })); + }); + });