diff --git a/docs/api.yaml b/docs/api.yaml index 415ebb9e8..eb4cf901b 100644 --- a/docs/api.yaml +++ b/docs/api.yaml @@ -13436,7 +13436,7 @@ components: source: type: object description: The source of the Entity version, such as a Submission or an API request. This property is experimental and may change in the future. - example: {event: {}, submissionCreate: {}, submission: {}} + example: {event: {}, submission: {}} baseDiff: type: array description: List of properties that are different between this version and its base version. Includes the label if it differs between the two versions. diff --git a/lib/data/entity.js b/lib/data/entity.js index 1dc3c2d4a..d23f9eff2 100644 --- a/lib/data/entity.js +++ b/lib/data/entity.js @@ -365,7 +365,7 @@ const getWithConflictDetails = (defs, audits, relevantToConflict) => { const relevantBaseVersions = new Set(); for (const def of defs) { - const { sourceEvent, submissionCreate, submission } = auditMap.get(def.id); + const { source = {} } = auditMap.get(def.id); const v = mergeLeft(def.forApi(), { @@ -373,7 +373,7 @@ const getWithConflictDetails = (defs, audits, relevantToConflict) => { resolved: false, baseDiff: [], serverDiff: [], - source: { event: sourceEvent, submissionCreate, submission }, + source, lastGoodVersion: false }); diff --git a/lib/model/query/audits.js b/lib/model/query/audits.js index 67868ffdb..8b3ee36c7 100644 --- a/lib/model/query/audits.js +++ b/lib/model/query/audits.js @@ -192,21 +192,29 @@ const getByEntityId = (entityId, options) => ({ all }) => { .map(a => a.withAux('actor', audit.aux.triggeringEventActor.orNull())) .map(a => a.forApi()); - const submissionCreate = audit.aux.submissionCreateEvent - .map(a => a.withAux('actor', audit.aux.submissionCreateEventActor.orNull())) - .map(a => a.forApi()); - - const submission = audit.aux.submission - .map(s => s.withAux('submitter', audit.aux.submissionActor.orNull())) - .map(s => s.withAux('currentVersion', audit.aux.currentVersion.map(v => v.withAux('submitter', audit.aux.currentSubmissionActor.orNull())))) - .map(s => s.forApi()) - .map(s => mergeLeft(s, { xmlFormId: audit.aux.form.map(f => f.xmlFormId).orNull() })); - - const details = mergeLeft(audit.details, { - sourceEvent: sourceEvent.orElse(undefined), - submissionCreate: submissionCreate.orElse(undefined), - submission: submission.orElse(undefined) - }); + // If the entity event is based on a submission, get submission details from the submission create event, + // which should always exist, even if the entity has been deleted. + const submission = audit.aux.submissionCreateEvent.map((createEvent) => { + const baseSubmission = { + instanceId: createEvent.details.instanceId, + createdAt: createEvent.loggedAt, + submitter: audit.aux.submissionCreateEventActor.get().forApi() + }; + + // If the submission has not been deleted, return full submission details, not just what + // was in the submission create event details. + const fullSubmission = audit.aux.submission + .map(s => s.withAux('submitter', audit.aux.submissionActor.orNull())) + .map(s => s.withAux('currentVersion', audit.aux.currentVersion.map(v => v.withAux('submitter', audit.aux.currentSubmissionActor.orNull())))) + .map(s => s.forApi()) + .map(s => mergeLeft(s, { xmlFormId: audit.aux.form.map(f => f.xmlFormId).orNull() })); + return mergeLeft(baseSubmission, fullSubmission.orElse(undefined)); + }) + .orElse(undefined); + + const details = mergeLeft(audit.details, sourceEvent + .map(event => ({ source: { event, submission } })) + .orElse(undefined)); return new Audit({ ...audit, details }, { actor: audit.aux.actor }); })); diff --git a/test/integration/api/datasets.js b/test/integration/api/datasets.js index b4a423fb9..67c56eed4 100644 --- a/test/integration/api/datasets.js +++ b/test/integration/api/datasets.js @@ -3825,10 +3825,10 @@ describe('datasets and entities', () => { logs[0].action.should.be.eql('entity.create'); logs[0].actor.displayName.should.be.eql('Alice'); - logs[0].details.submission.should.be.a.Submission(); - logs[0].details.submission.xmlFormId.should.be.eql('simpleEntity'); - logs[0].details.submission.currentVersion.instanceName.should.be.eql('one'); - logs[0].details.submission.currentVersion.submitter.displayName.should.be.eql('Alice'); + logs[0].details.source.submission.should.be.a.Submission(); + logs[0].details.source.submission.xmlFormId.should.be.eql('simpleEntity'); + logs[0].details.source.submission.currentVersion.instanceName.should.be.eql('one'); + logs[0].details.source.submission.currentVersion.submitter.displayName.should.be.eql('Alice'); }); @@ -4159,8 +4159,8 @@ describe('datasets and entities', () => { .then(({ body: logs }) => { const updateDetails = logs.filter(log => log.action === 'entity.update.version').map(log => log.details); updateDetails.length.should.equal(4); - updateDetails.filter(d => d.sourceEvent.action === 'submission.create').length.should.equal(4); - updateDetails.map(d => d.submission.instanceId).should.eql([ + updateDetails.filter(d => d.source.event.action === 'submission.create').length.should.equal(4); + updateDetails.map(d => d.source.submission.instanceId).should.eql([ 'six', 'five', 'four', 'three' ]); }); @@ -4170,9 +4170,9 @@ describe('datasets and entities', () => { .expect(200) .then(({ body: logs }) => { logs[0].action.should.equal('entity.create'); - logs[0].details.submission.xmlFormId.should.equal('simpleEntity'); - logs[0].details.submission.instanceId.should.equal('two'); - logs[0].details.sourceEvent.action.should.equal('submission.create'); + logs[0].details.source.submission.xmlFormId.should.equal('simpleEntity'); + logs[0].details.source.submission.instanceId.should.equal('two'); + logs[0].details.source.event.action.should.equal('submission.create'); }); // only one entity def should have a source with a non-null parent id @@ -4486,7 +4486,7 @@ describe('datasets and entities', () => { // Observe that the entity's audit says it was updated by the new edited submission await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') .then(({ body: logs }) => { - logs[0].details.submission.instanceId.should.equal('one2'); + logs[0].details.source.submission.instanceId.should.equal('one2'); }); // Observe that the submission's audit log makes sense diff --git a/test/integration/api/entities.js b/test/integration/api/entities.js index 8befcc02e..21a27a281 100644 --- a/test/integration/api/entities.js +++ b/test/integration/api/entities.js @@ -841,14 +841,14 @@ describe('Entities API', () => { logs[0].details.entity.uuid.should.be.eql('12345678-1234-4123-8234-123456789abc'); logs[0].actor.displayName.should.be.eql('Bob'); - logs[0].details.submission.should.be.a.Submission(); - logs[0].details.submission.xmlFormId.should.be.eql('updateEntity'); - logs[0].details.submission.currentVersion.instanceName.should.be.eql('one'); - logs[0].details.submission.currentVersion.submitter.displayName.should.be.eql('Bob'); - logs[0].details.sourceEvent.should.be.an.Audit(); - logs[0].details.sourceEvent.actor.displayName.should.be.eql('Bob'); - logs[0].details.sourceEvent.loggedAt.should.be.isoDate(); - logs[0].details.sourceEvent.action.should.be.eql('submission.create'); + logs[0].details.source.submission.should.be.a.Submission(); + logs[0].details.source.submission.xmlFormId.should.be.eql('updateEntity'); + logs[0].details.source.submission.currentVersion.instanceName.should.be.eql('one'); + logs[0].details.source.submission.currentVersion.submitter.displayName.should.be.eql('Bob'); + logs[0].details.source.event.should.be.an.Audit(); + logs[0].details.source.event.actor.displayName.should.be.eql('Bob'); + logs[0].details.source.event.loggedAt.should.be.isoDate(); + logs[0].details.source.event.action.should.be.eql('submission.create'); logs[1].should.be.an.Audit(); logs[1].action.should.be.eql('entity.update.version'); @@ -859,15 +859,15 @@ describe('Entities API', () => { logs[2].action.should.be.eql('entity.create'); logs[2].actor.displayName.should.be.eql('Alice'); - logs[2].details.sourceEvent.should.be.an.Audit(); - logs[2].details.sourceEvent.actor.displayName.should.be.eql('Alice'); - logs[2].details.sourceEvent.loggedAt.should.be.isoDate(); - logs[2].details.sourceEvent.action.should.be.eql('submission.update'); + logs[2].details.source.event.should.be.an.Audit(); + logs[2].details.source.event.actor.displayName.should.be.eql('Alice'); + logs[2].details.source.event.loggedAt.should.be.isoDate(); + logs[2].details.source.event.action.should.be.eql('submission.update'); - logs[2].details.submission.should.be.a.Submission(); - logs[2].details.submission.xmlFormId.should.be.eql('simpleEntity'); - logs[2].details.submission.currentVersion.instanceName.should.be.eql('one'); - logs[2].details.submission.currentVersion.submitter.displayName.should.be.eql('Alice'); + logs[2].details.source.submission.should.be.a.Submission(); + logs[2].details.source.submission.xmlFormId.should.be.eql('simpleEntity'); + logs[2].details.source.submission.currentVersion.instanceName.should.be.eql('one'); + logs[2].details.source.submission.currentVersion.submitter.displayName.should.be.eql('Alice'); }); })); @@ -917,92 +917,198 @@ describe('Entities API', () => { logs[0].action.should.be.eql('entity.create'); logs[0].actor.displayName.should.be.eql('Alice'); - logs[0].details.submission.should.be.a.Submission(); - logs[0].details.submission.xmlFormId.should.be.eql('simpleEntity'); - logs[0].details.submission.currentVersion.instanceName.should.be.eql('new instance name'); + logs[0].details.source.submission.should.be.a.Submission(); + logs[0].details.source.submission.xmlFormId.should.be.eql('simpleEntity'); + logs[0].details.source.submission.currentVersion.instanceName.should.be.eql('new instance name'); }); })); - it('should return instanceId even when submission is deleted', testEntities(async (service, container) => { - const asAlice = await service.login('alice'); + describe('entity source within an audit event', () => { + it('should not include submission or source event when entity created or updated via API', testService(async (service) => { + const asAlice = await service.login('alice'); - await asAlice.delete('/v1/projects/1/forms/simpleEntity') - .expect(200); + await asAlice.post('/v1/projects/1/forms?publish=true') + .send(testData.forms.simpleEntity) + .expect(200); - await container.Forms.purge(true); + await asAlice.post('/v1/projects/1/datasets/people/entities') + .send({ + uuid: '12345678-1234-4123-8234-123456789abc', + label: 'Johnny Doe', + data: { first_name: 'Johnny', age: '22' } + }) + .expect(200); - await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') - .expect(200) - .then(({ body: logs }) => { + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') + .expect(200) + .then(({ body: logs }) => { + logs[0].details.should.not.have.property('source'); + }); - logs[0].should.be.an.Audit(); - logs[0].action.should.be.eql('entity.create'); - logs[0].actor.displayName.should.be.eql('Alice'); + await asAlice.patch('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc?force=true') + .send({ label: 'two' }) + .expect(200); - logs[0].details.sourceEvent.should.be.an.Audit(); - logs[0].details.sourceEvent.actor.displayName.should.be.eql('Alice'); - logs[0].details.sourceEvent.loggedAt.should.be.isoDate(); + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') + .expect(200) + .then(({ body: logs }) => { + logs[0].details.should.not.have.property('source'); + }); + })); - logs[0].details.should.not.have.property('submission'); + it('should return source when entity created via submission approval', testEntities(async (service) => { + const asAlice = await service.login('alice'); - logs[0].details.submissionCreate.details.instanceId.should.be.eql('one'); - logs[0].details.submissionCreate.actor.displayName.should.be.eql('Alice'); - logs[0].details.submissionCreate.loggedAt.should.be.isoDate(); - }); - })); + // testEntities creates an entity on submission approval + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') + .expect(200) + .then(({ body: logs }) => { + logs[0].details.source.submission.should.be.a.Submission(); + logs[0].details.source.submission.instanceId.should.be.eql('one'); + logs[0].details.source.submission.xmlFormId.should.be.eql('simpleEntity'); + logs[0].details.source.submission.currentVersion.instanceName.should.be.eql('one'); - it('should return instanceId even when form is deleted', testEntities(async (service) => { - const asAlice = await service.login('alice'); + logs[0].details.source.event.should.be.an.Audit(); + logs[0].details.source.event.actor.displayName.should.be.eql('Alice'); + logs[0].details.source.event.action.should.be.eql('submission.update'); + }); + })); - await asAlice.delete('/v1/projects/1/forms/simpleEntity') - .expect(200); + it('should return source when entity created via submission creation', testService(async (service, container) => { + const asAlice = await service.login('alice'); - await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') - .expect(200) - .then(({ body: logs }) => { + await asAlice.post('/v1/projects/1/forms?publish=true') + .send(testData.forms.simpleEntity) + .expect(200); - logs[0].should.be.an.Audit(); - logs[0].action.should.be.eql('entity.create'); - logs[0].actor.displayName.should.be.eql('Alice'); + await asAlice.post('/v1/projects/1/forms/simpleEntity/submissions') + .send(testData.instances.simpleEntity.one) + .set('Content-Type', 'application/xml') + .expect(200); - logs[0].details.sourceEvent.should.be.an.Audit(); - logs[0].details.sourceEvent.actor.displayName.should.be.eql('Alice'); - logs[0].details.sourceEvent.loggedAt.should.be.isoDate(); + await exhaust(container); - logs[0].details.should.not.have.property('submission'); + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') + .expect(200) + .then(({ body: logs }) => { + logs[0].details.source.submission.should.be.a.Submission(); + logs[0].details.source.submission.instanceId.should.be.eql('one'); + logs[0].details.source.submission.xmlFormId.should.be.eql('simpleEntity'); + logs[0].details.source.submission.currentVersion.instanceName.should.be.eql('one'); - logs[0].details.submissionCreate.details.instanceId.should.be.eql('one'); - logs[0].details.submissionCreate.actor.displayName.should.be.eql('Alice'); - logs[0].details.submissionCreate.loggedAt.should.be.isoDate(); - }); - })); + logs[0].details.source.event.should.be.an.Audit(); + logs[0].details.source.event.actor.displayName.should.be.eql('Alice'); + logs[0].details.source.event.action.should.be.eql('submission.create'); + }); + })); - // It's not possible to purge audit logs via API. - // However System Administrators can purge/archive audit logs via SQL - // to save disk space and improve performance - it('should return entity audits even when submission and its logs are deleted', testEntities(async (service, container) => { - const asAlice = await service.login('alice'); + it('should return source when entity updated via submission', testEntityUpdates(async (service, container) => { + const asAlice = await service.login('alice'); - await asAlice.delete('/v1/projects/1/forms/simpleEntity') - .expect(200); + // testEntityUpdates does the following: creates dataset, creates update form. test needs to submit update. + await asAlice.post('/v1/projects/1/forms/updateEntity/submissions') + .send(testData.instances.updateEntity.one) + .set('Content-Type', 'application/xml') + .expect(200); - await container.Forms.purge(true); + await exhaust(container); - await container.run(sql`DELETE FROM audits WHERE action like 'submission%'`); + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') + .expect(200) + .then(({ body: logs }) => { + logs[0].details.source.submission.should.be.a.Submission(); + logs[0].details.source.submission.instanceId.should.be.eql('one'); + logs[0].details.source.submission.xmlFormId.should.be.eql('updateEntity'); + logs[0].details.source.submission.currentVersion.instanceName.should.be.eql('one'); - await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') - .expect(200) - .then(({ body: logs }) => { + logs[0].details.source.event.should.be.an.Audit(); + logs[0].details.source.event.actor.displayName.should.be.eql('Alice'); + logs[0].details.source.event.action.should.be.eql('submission.create'); + }); + })); - logs[0].should.be.an.Audit(); - logs[0].action.should.be.eql('entity.create'); - logs[0].actor.displayName.should.be.eql('Alice'); + it('should return instanceId even when submission is deleted', testEntities(async (service, container) => { + const asAlice = await service.login('alice'); - logs[0].details.should.not.have.property('approval'); - logs[0].details.should.not.have.property('submission'); - logs[0].details.should.not.have.property('submissionCreate'); - }); - })); + await asAlice.delete('/v1/projects/1/forms/simpleEntity') + .expect(200); + + await container.Forms.purge(true); + + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') + .expect(200) + .then(({ body: logs }) => { + logs[0].should.be.an.Audit(); + logs[0].action.should.be.eql('entity.create'); + logs[0].actor.displayName.should.be.eql('Alice'); + + logs[0].details.source.event.should.be.an.Audit(); + logs[0].details.source.event.actor.displayName.should.be.eql('Alice'); + logs[0].details.source.event.loggedAt.should.be.isoDate(); + + logs[0].details.source.submission.instanceId.should.be.eql('one'); + logs[0].details.source.submission.submitter.displayName.should.be.eql('Alice'); + logs[0].details.source.submission.createdAt.should.be.isoDate(); + + // submission is only a stub so it shouldn't have currentVersion + logs[0].details.source.submission.should.not.have.property('currentVersion'); + }); + })); + + it('should return instanceId even when form is deleted', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.delete('/v1/projects/1/forms/simpleEntity') + .expect(200); + + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') + .expect(200) + .then(({ body: logs }) => { + logs[0].should.be.an.Audit(); + logs[0].action.should.be.eql('entity.create'); + logs[0].actor.displayName.should.be.eql('Alice'); + + logs[0].details.source.event.should.be.an.Audit(); + logs[0].details.source.event.actor.displayName.should.be.eql('Alice'); + logs[0].details.source.event.loggedAt.should.be.isoDate(); + + logs[0].details.source.submission.instanceId.should.be.eql('one'); + logs[0].details.source.submission.submitter.displayName.should.be.eql('Alice'); + logs[0].details.source.submission.createdAt.should.be.isoDate(); + + // submission is only a stub so it doesn't have things like instanceName or currentVersion + logs[0].details.source.submission.should.not.have.property('instanceName'); + logs[0].details.source.submission.should.not.have.property('currentVersion'); + }); + })); + + // It's not possible to purge audit logs via API. + // However System Administrators can purge/archive audit logs via SQL + // to save disk space and improve performance + it('should return entity audits even when submission and its logs are deleted', testEntities(async (service, container) => { + const asAlice = await service.login('alice'); + + await asAlice.delete('/v1/projects/1/forms/simpleEntity') + .expect(200); + + await container.Forms.purge(true); + + await container.run(sql`DELETE FROM audits WHERE action like 'submission%'`); + + await asAlice.get('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc/audits') + .expect(200) + .then(({ body: logs }) => { + + logs[0].should.be.an.Audit(); + logs[0].action.should.be.eql('entity.create'); + logs[0].actor.displayName.should.be.eql('Alice'); + + logs[0].details.should.not.have.property('approval'); + logs[0].details.should.not.have.property('submission'); + logs[0].details.should.not.have.property('submissionCreate'); + }); + })); + }); it('should return right approval details when we have multiple approvals', testService(async (service, container) => { const asAlice = await service.login('alice'); @@ -1061,15 +1167,15 @@ describe('Entities API', () => { logs[0].action.should.be.eql('entity.create'); logs[0].actor.displayName.should.be.eql('Alice'); - logs[0].details.sourceEvent.should.be.an.Audit(); - logs[0].details.sourceEvent.actor.displayName.should.be.eql('Alice'); - logs[0].details.sourceEvent.loggedAt.should.be.isoDate(); - logs[0].details.sourceEvent.notes.should.be.eql('create entity'); // this confirms that it's the second approval + logs[0].details.source.event.should.be.an.Audit(); + logs[0].details.source.event.actor.displayName.should.be.eql('Alice'); + logs[0].details.source.event.loggedAt.should.be.isoDate(); + logs[0].details.source.event.notes.should.be.eql('create entity'); // this confirms that it's the second approval - logs[0].details.submission.should.be.a.Submission(); - logs[0].details.submission.xmlFormId.should.be.eql('simpleEntity'); - logs[0].details.submission.currentVersion.instanceName.should.be.eql('one'); - logs[0].details.submission.currentVersion.submitter.displayName.should.be.eql('Alice'); + logs[0].details.source.submission.should.be.a.Submission(); + logs[0].details.source.submission.xmlFormId.should.be.eql('simpleEntity'); + logs[0].details.source.submission.currentVersion.instanceName.should.be.eql('one'); + logs[0].details.source.submission.currentVersion.submitter.displayName.should.be.eql('Alice'); }); })); diff --git a/test/integration/other/migrations.js b/test/integration/other/migrations.js index 667870036..57db20dcf 100644 --- a/test/integration/other/migrations.js +++ b/test/integration/other/migrations.js @@ -637,7 +637,7 @@ describe('database migrations from 20230512: adding entity_def_sources table', f audits[0].details.entityDefId.should.equal(newDef.id); })); - it('should migrate the submissionDefId source of an entity to new table with event links', testServiceFullTrx(async (service, container) => { + it.skip('should migrate the submissionDefId source of an entity to new table with event links', testServiceFullTrx(async (service, container) => { await upToMigration('20230512-03-add-entity-source.js', false); // actually this is the previous migration await populateUsers(container); await populateForms(container); diff --git a/test/integration/worker/entity.js b/test/integration/worker/entity.js index e556c86e4..d482f39bb 100644 --- a/test/integration/worker/entity.js +++ b/test/integration/worker/entity.js @@ -954,7 +954,7 @@ describe('worker: entity', () => { .expect(200) .then(({ body: logs }) => { logs[0].action.should.be.eql('entity.update.version'); - logs[0].details.sourceEvent.action.should.be.eql('submission.create'); + logs[0].details.source.event.action.should.be.eql('submission.create'); }); }));