diff --git a/lib/resources/datasets.js b/lib/resources/datasets.js index e107e2b6b..f81102a41 100644 --- a/lib/resources/datasets.js +++ b/lib/resources/datasets.js @@ -8,7 +8,7 @@ // except according to the terms contained in the LICENSE file. const sanitize = require('sanitize-filename'); -const { getOrNotFound } = require('../util/promise'); +const { getOrNotFound, reject } = require('../util/promise'); const { streamEntityCsv } = require('../data/entity'); const { validateDatasetName, validatePropertyName } = require('../data/dataset'); const { contentDisposition, success, withEtag } = require('../util/http'); @@ -16,6 +16,7 @@ const { md5sum } = require('../util/crypto'); const { Dataset } = require('../model/frames'); const Problem = require('../util/problem'); const { QueryOptions } = require('../util/db'); +const { entityList } = require('../formats/openrosa'); module.exports = (service, endpoint) => { service.get('/projects/:id/datasets', endpoint(({ Projects, Datasets }, { auth, params, queryOptions }) => @@ -102,4 +103,20 @@ module.exports = (service, endpoint) => { return withEtag(serverEtag, csv); })); + + service.get('/projects/:projectId/datasets/:name/integrity', endpoint.openRosa(async ({ Datasets, Entities }, { params, auth, queryOptions }) => { + const dataset = await Datasets.get(params.projectId, params.name, true).then(getOrNotFound); + + // Anyone with the verb `entity.list` or anyone with read access on a Form + // that consumes this dataset can call this endpoint. + const canAccessEntityList = await auth.can('entity.list', dataset); + if (!canAccessEntityList) { + await Datasets.canReadForOpenRosa(auth, params.name, params.projectId) + .then(canAccess => canAccess || reject(Problem.user.insufficientRights())); + } + + const entities = await Entities.getEntitiesState(dataset.id, queryOptions.allowArgs('id')); + + return entityList({ entities }); + })); }; diff --git a/lib/resources/entities.js b/lib/resources/entities.js index 1b82b29f5..fc78abf0b 100644 --- a/lib/resources/entities.js +++ b/lib/resources/entities.js @@ -13,7 +13,6 @@ const { Entity } = require('../model/frames'); const Problem = require('../util/problem'); const { diffEntityData, extractBulkSource, getWithConflictDetails } = require('../data/entity'); const { QueryOptions } = require('../util/db'); -const { entityList } = require('../formats/openrosa'); module.exports = (service, endpoint) => { @@ -26,22 +25,6 @@ module.exports = (service, endpoint) => { return Entities.getAll(dataset.id, queryOptions); })); - service.get('/projects/:projectId/datasets/:name/integrity', endpoint.openRosa(async ({ Datasets, Entities }, { params, auth, queryOptions }) => { - const dataset = await Datasets.get(params.projectId, params.name, true).then(getOrNotFound); - - // Anyone with the verb `entity.list` or anyone with read access on a Form - // that consumes this dataset can call this endpoint. - const canAccessEntityList = await auth.can('entity.list', dataset); - if (!canAccessEntityList) { - await Datasets.canReadForOpenRosa(auth, params.name, params.projectId) - .then(canAccess => canAccess || reject(Problem.user.insufficientRights())); - } - - const entities = await Entities.getEntitiesState(dataset.id, queryOptions.allowArgs('id')); - - return entityList({ entities }); - })); - service.get('/projects/:projectId/datasets/:name/entities/:uuid', endpoint(async ({ Datasets, Entities }, { params, auth, queryOptions }) => { const dataset = await Datasets.get(params.projectId, params.name, true).then(getOrNotFound); diff --git a/test/integration/api/datasets.js b/test/integration/api/datasets.js index 36be9d2f0..a07acf491 100644 --- a/test/integration/api/datasets.js +++ b/test/integration/api/datasets.js @@ -10,10 +10,34 @@ const { sql } = require('slonik'); const { QueryOptions } = require('../../../lib/util/db'); const { createConflict } = require('../fixtures/scenarios'); const { omit } = require('ramda'); +const xml2js = require('xml2js'); const { exhaust } = require(appRoot + '/lib/worker/worker'); const Option = require(appRoot + '/lib/util/option'); +const testEntities = (test) => testService(async (service, container) => { + const asAlice = await service.login('alice'); + + await asAlice.post(`/v1/projects/1/datasets`) + .send({ name: 'people' }); + + const uuids = [ + '12345678-1234-4123-8234-123456789aaa', + '12345678-1234-4123-8234-123456789abc' + ]; + + uuids.forEach(async _uuid => { + await asAlice.post('/v1/projects/1/datasets/people/entities') + .send({ + uuid: _uuid, + label: 'John Doe' + }) + .expect(200); + }); + + await test(service, container); +}); + describe('datasets and entities', () => { describe('creating datasets and properties via the API', () => { @@ -239,7 +263,7 @@ describe('datasets and entities', () => { const withOutTs = result.replace(isoRegex, ''); withOutTs.should.be.eql( '__id,label,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' + - '12345678-1234-4123-8234-123456789aaa,Willow,,5,Alice,0,,1\n' + '12345678-1234-4123-8234-123456789aaa,Willow,,5,Alice,0,,1\n' ); })); @@ -356,7 +380,7 @@ describe('datasets and entities', () => { const withOutTs = result.replace(isoRegex, ''); withOutTs.should.be.eql( '__id,label,height,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' + - '12345678-1234-4123-8234-123456789aaa,redwood,120,,5,Alice,0,,1\n' + '12345678-1234-4123-8234-123456789aaa,redwood,120,,5,Alice,0,,1\n' ); })); @@ -487,7 +511,7 @@ describe('datasets and entities', () => { logs[0].actorId.should.equal(5); logs[0].actee.should.be.a.Dataset(); logs[0].actee.name.should.equal('trees'); - logs[0].details.properties.should.eql([ 'circumference' ]); + logs[0].details.properties.should.eql(['circumference']); }); })); @@ -926,8 +950,8 @@ describe('datasets and entities', () => { const withOutTs = result.replace(isoRegex, ''); withOutTs.should.be.eql( '__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' + - '12345678-1234-4123-8234-123456789aaa,Jane (30),Jane,30,,5,Alice,0,,1\n' + - '12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88,,5,Alice,0,,1\n' + '12345678-1234-4123-8234-123456789aaa,Jane (30),Jane,30,,5,Alice,0,,1\n' + + '12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88,,5,Alice,0,,1\n' ); })); @@ -958,7 +982,7 @@ describe('datasets and entities', () => { const withOutTs = result.replace(isoRegex, ''); withOutTs.should.be.eql( '__id,label,first_name,the.age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' + - '12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88,,5,Alice,0,,1\n' + '12345678-1234-4123-8234-123456789abc,Alice (88),Alice,88,,5,Alice,0,,1\n' ); })); @@ -1014,8 +1038,8 @@ describe('datasets and entities', () => { const withOutTs = text.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/g, ''); withOutTs.should.be.eql( '__id,label,f_q1,e_q2,a_q3,c_q4,b_q1,d_q2,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' + - '12345678-1234-4123-8234-123456789ccc,one,w,x,y,z,,,,5,Alice,0,,1\n'+ - '12345678-1234-4123-8234-123456789bbb,two,,,c,d,a,b,,5,Alice,0,,1\n'+ + '12345678-1234-4123-8234-123456789ccc,one,w,x,y,z,,,,5,Alice,0,,1\n' + + '12345678-1234-4123-8234-123456789bbb,two,,,c,d,a,b,,5,Alice,0,,1\n' + '12345678-1234-4123-8234-123456789aaa,one,,,y,z,w,x,,5,Alice,0,,1\n' ); })); @@ -1088,7 +1112,7 @@ describe('datasets and entities', () => { const withOutTs = result.replace(isoRegex, ''); withOutTs.should.be.eql( '__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' + - '12345678-1234-4123-8234-111111111aaa,Robert Doe (expired),Robert,,,5,Alice,1,,2\n' + '12345678-1234-4123-8234-111111111aaa,Robert Doe (expired),Robert,,,5,Alice,1,,2\n' ); })); @@ -1133,7 +1157,7 @@ describe('datasets and entities', () => { const withOutTs = result.replace(isoRegex, ''); withOutTs.should.be.eql( '__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' + - '12345678-1234-4123-8234-123456789abc,Alicia (85),Alicia,85,,5,Alice,1,,2\n' + '12345678-1234-4123-8234-123456789abc,Alicia (85),Alicia,85,,5,Alice,1,,2\n' ); })); @@ -1170,7 +1194,7 @@ describe('datasets and entities', () => { const withOutTs = result.replace(isoRegex, ''); withOutTs.should.be.eql( '__id,label,first_name,age,__createdAt,__creatorId,__creatorName,__updates,__updatedAt,__version\n' + - '12345678-1234-4123-8234-123456789abc,Alicia (85),Alicia,85,,5,Alice,2,,3\n' + '12345678-1234-4123-8234-123456789abc,Alicia (85),Alicia,85,,5,Alice,2,,3\n' ); })); @@ -2836,7 +2860,7 @@ describe('datasets and entities', () => { .expect(200) .then(({ text }) => { text.should.equal('name,label,__version,first_name,the.age\n' + - '12345678-1234-4123-8234-123456789abc,Alice (88),1,Alice,88\n'); + '12345678-1234-4123-8234-123456789abc,Alice (88),1,Alice,88\n'); }); })); @@ -4570,7 +4594,7 @@ describe('datasets and entities', () => { .expect(200) .then(({ body }) => { body.name.should.be.eql('people'); - body.properties.map(p => p.name).should.eql([ 'first_name', 'age' ]); + body.properties.map(p => p.name).should.eql(['first_name', 'age']); }); await asAlice.get('/v1/audits?action=dataset.create') @@ -5963,4 +5987,166 @@ describe('datasets and entities', () => { })); }); }); + + // OpenRosa endpoint + describe('GET /datasets/:name/integrity', () => { + it('should return notfound if the dataset does not exist', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.get('/v1/projects/1/datasets/nonexistent/integrity') + .set('X-OpenRosa-Version', '1.0') + .expect(404); + })); + + it('should reject if the user cannot read', testEntities(async (service) => { + const asChelsea = await service.login('chelsea'); + + await asChelsea.get('/v1/projects/1/datasets/people/integrity') + .set('X-OpenRosa-Version', '1.0') + .expect(403); + })); + + it('should happily return given no entities', testService(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.post('/v1/projects/1/forms?publish=true') + .send(testData.forms.simpleEntity) + .expect(200); + + await asAlice.get('/v1/projects/1/datasets/people/integrity') + .set('X-OpenRosa-Version', '1.0') + .expect(200) + .then(async ({ text }) => { + const result = await xml2js.parseStringPromise(text, { explicitArray: false }); + result.data.entities.should.not.have.property('entity'); + }); + })); + + it('should return data for app-user with access to consuming Form', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.post('/v1/projects/1/forms?publish=true') + .send(testData.forms.withAttachments.replace(/goodone/g, 'people')) + .set('Content-Type', 'application/xml') + .expect(200); + + const appUser = await asAlice.post('/v1/projects/1/app-users') + .send({ displayName: 'test' }) + .then(({ body }) => body); + + await asAlice.post(`/v1/projects/1/forms/withAttachments/assignments/app-user/${appUser.id}`); + + await service.get(`/v1/key/${appUser.token}/projects/1/datasets/people/integrity`) + .set('X-OpenRosa-Version', '1.0') + .expect(200) + .then(async ({ text }) => { + const result = await xml2js.parseStringPromise(text, { explicitArray: false }); + result.data.entities.entity.length.should.be.eql(2); + }); + })); + + it('should reject for app-user if consuming Form is closed', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.post('/v1/projects/1/forms?publish=true') + .send(testData.forms.withAttachments.replace(/goodone/g, 'people')) + .set('Content-Type', 'application/xml') + .expect(200); + + const appUser = await asAlice.post('/v1/projects/1/app-users') + .send({ displayName: 'test' }) + .then(({ body }) => body); + + await asAlice.post(`/v1/projects/1/forms/withAttachments/assignments/app-user/${appUser.id}`); + + await asAlice.patch('/v1/projects/1/forms/withAttachments') + .send({ state: 'closed' }) + .expect(200); + + await service.get(`/v1/key/${appUser.token}/projects/1/datasets/people/integrity`) + .set('X-OpenRosa-Version', '1.0') + .expect(403); + })); + + it('should return with correct deleted value', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.delete('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') + .expect(200); + + await asAlice.get(`/v1/projects/1/datasets/people/integrity`) + .set('X-OpenRosa-Version', '1.0') + .expect(200) + .then(async ({ text }) => { + const result = await xml2js.parseStringPromise(text, { explicitArray: false }); + result.data.entities.entity.length.should.be.eql(2); + const [first, second] = result.data.entities.entity; + first.$.id.should.be.eql('12345678-1234-4123-8234-123456789aaa'); + first.deleted.should.be.eql('false'); + second.$.id.should.be.eql('12345678-1234-4123-8234-123456789abc'); + second.deleted.should.be.eql('true'); + }); + })); + + it('should return purged entities as well', testEntities(async (service, { Entities }) => { + const asAlice = await service.login('alice'); + + await asAlice.delete('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') + .expect(200); + + await Entities.purge(true); + + await asAlice.get(`/v1/projects/1/datasets/people/integrity`) + .set('X-OpenRosa-Version', '1.0') + .expect(200) + .then(async ({ text }) => { + const result = await xml2js.parseStringPromise(text, { explicitArray: false }); + result.data.entities.entity.length.should.be.eql(2); + const [first, second] = result.data.entities.entity; + first.$.id.should.be.eql('12345678-1234-4123-8234-123456789aaa'); + first.deleted.should.be.eql('false'); + second.$.id.should.be.eql('12345678-1234-4123-8234-123456789abc'); + second.deleted.should.be.eql('true'); + }); + })); + + it('should return only queried entities', testEntities(async (service) => { + const asAlice = await service.login('alice'); + + await asAlice.delete('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') + .expect(200); + + await asAlice.get(`/v1/projects/1/datasets/people/integrity?id=12345678-1234-4123-8234-123456789abc`) + .set('X-OpenRosa-Version', '1.0') + .expect(200) + .then(async ({ text }) => { + const result = await xml2js.parseStringPromise(text, { explicitArray: false }); + const { entity } = result.data.entities; + entity.$.id.should.be.eql('12345678-1234-4123-8234-123456789abc'); + entity.deleted.should.be.eql('true'); + }); + })); + + it('should return only queried purged entities', testEntities(async (service, { Entities }) => { + const asAlice = await service.login('alice'); + + await asAlice.delete('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') + .expect(200); + + await asAlice.delete('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789aaa') + .expect(200); + + await Entities.purge(true); + + await asAlice.get(`/v1/projects/1/datasets/people/integrity?id=12345678-1234-4123-8234-123456789abc`) + .set('X-OpenRosa-Version', '1.0') + .expect(200) + .then(async ({ text }) => { + const result = await xml2js.parseStringPromise(text, { explicitArray: false }); + const { entity } = result.data.entities; + entity.$.id.should.be.eql('12345678-1234-4123-8234-123456789abc'); + entity.deleted.should.be.eql('true'); + }); + })); + }); }); diff --git a/test/integration/api/entities.js b/test/integration/api/entities.js index 15ebee504..a52006c5c 100644 --- a/test/integration/api/entities.js +++ b/test/integration/api/entities.js @@ -2,7 +2,6 @@ const appRoot = require('app-root-path'); const { testService, testServiceFullTrx } = require('../setup'); const testData = require('../../data/xml'); const { sql } = require('slonik'); -const xml2js = require('xml2js'); const should = require('should'); const { QueryOptions, queryFuncs } = require('../../../lib/util/db'); const { getById, createVersion } = require('../../../lib/model/query/entities'); @@ -241,168 +240,6 @@ describe('Entities API', () => { })); }); - // OpenRosa endpoint - describe('GET /datasets/:name/integrity', () => { - it('should return notfound if the dataset does not exist', testEntities(async (service) => { - const asAlice = await service.login('alice'); - - await asAlice.get('/v1/projects/1/datasets/nonexistent/integrity') - .set('X-OpenRosa-Version', '1.0') - .expect(404); - })); - - it('should reject if the user cannot read', testEntities(async (service) => { - const asChelsea = await service.login('chelsea'); - - await asChelsea.get('/v1/projects/1/datasets/people/integrity') - .set('X-OpenRosa-Version', '1.0') - .expect(403); - })); - - it('should happily return given no entities', testService(async (service) => { - const asAlice = await service.login('alice'); - - await asAlice.post('/v1/projects/1/forms?publish=true') - .send(testData.forms.simpleEntity) - .expect(200); - - await asAlice.get('/v1/projects/1/datasets/people/integrity') - .set('X-OpenRosa-Version', '1.0') - .expect(200) - .then(async ({ text }) => { - const result = await xml2js.parseStringPromise(text, { explicitArray: false }); - result.data.entities.should.not.have.property('entity'); - }); - })); - - it('should return data for app-user with access to consuming Form', testEntities(async (service) => { - const asAlice = await service.login('alice'); - - await asAlice.post('/v1/projects/1/forms?publish=true') - .send(testData.forms.withAttachments.replace(/goodone/g, 'people')) - .set('Content-Type', 'application/xml') - .expect(200); - - const appUser = await asAlice.post('/v1/projects/1/app-users') - .send({ displayName: 'test' }) - .then(({ body }) => body); - - await asAlice.post(`/v1/projects/1/forms/withAttachments/assignments/app-user/${appUser.id}`); - - await service.get(`/v1/key/${appUser.token}/projects/1/datasets/people/integrity`) - .set('X-OpenRosa-Version', '1.0') - .expect(200) - .then(async ({ text }) => { - const result = await xml2js.parseStringPromise(text, { explicitArray: false }); - result.data.entities.entity.length.should.be.eql(2); - }); - })); - - it('should reject for app-user if consuming Form is closed', testEntities(async (service) => { - const asAlice = await service.login('alice'); - - await asAlice.post('/v1/projects/1/forms?publish=true') - .send(testData.forms.withAttachments.replace(/goodone/g, 'people')) - .set('Content-Type', 'application/xml') - .expect(200); - - const appUser = await asAlice.post('/v1/projects/1/app-users') - .send({ displayName: 'test' }) - .then(({ body }) => body); - - await asAlice.post(`/v1/projects/1/forms/withAttachments/assignments/app-user/${appUser.id}`); - - await asAlice.patch('/v1/projects/1/forms/withAttachments') - .send({ state: 'closed' }) - .expect(200); - - await service.get(`/v1/key/${appUser.token}/projects/1/datasets/people/integrity`) - .set('X-OpenRosa-Version', '1.0') - .expect(403); - })); - - it('should return with correct deleted value', testEntities(async (service) => { - const asAlice = await service.login('alice'); - - await asAlice.delete('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') - .expect(200); - - await asAlice.get(`/v1/projects/1/datasets/people/integrity`) - .set('X-OpenRosa-Version', '1.0') - .expect(200) - .then(async ({ text }) => { - const result = await xml2js.parseStringPromise(text, { explicitArray: false }); - result.data.entities.entity.length.should.be.eql(2); - const [first, second] = result.data.entities.entity; - first.$.id.should.be.eql('12345678-1234-4123-8234-123456789aaa'); - first.deleted.should.be.eql('false'); - second.$.id.should.be.eql('12345678-1234-4123-8234-123456789abc'); - second.deleted.should.be.eql('true'); - }); - })); - - it('should return purged entities as well', testEntities(async (service, { Entities }) => { - const asAlice = await service.login('alice'); - - await asAlice.delete('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') - .expect(200); - - await Entities.purge(true); - - await asAlice.get(`/v1/projects/1/datasets/people/integrity`) - .set('X-OpenRosa-Version', '1.0') - .expect(200) - .then(async ({ text }) => { - const result = await xml2js.parseStringPromise(text, { explicitArray: false }); - result.data.entities.entity.length.should.be.eql(2); - const [first, second] = result.data.entities.entity; - first.$.id.should.be.eql('12345678-1234-4123-8234-123456789aaa'); - first.deleted.should.be.eql('false'); - second.$.id.should.be.eql('12345678-1234-4123-8234-123456789abc'); - second.deleted.should.be.eql('true'); - }); - })); - - it('should return only queried entities', testEntities(async (service) => { - const asAlice = await service.login('alice'); - - await asAlice.delete('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') - .expect(200); - - await asAlice.get(`/v1/projects/1/datasets/people/integrity?id=12345678-1234-4123-8234-123456789abc`) - .set('X-OpenRosa-Version', '1.0') - .expect(200) - .then(async ({ text }) => { - const result = await xml2js.parseStringPromise(text, { explicitArray: false }); - const { entity } = result.data.entities; - entity.$.id.should.be.eql('12345678-1234-4123-8234-123456789abc'); - entity.deleted.should.be.eql('true'); - }); - })); - - it('should return only queried purged entities', testEntities(async (service, { Entities }) => { - const asAlice = await service.login('alice'); - - await asAlice.delete('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789abc') - .expect(200); - - await asAlice.delete('/v1/projects/1/datasets/people/entities/12345678-1234-4123-8234-123456789aaa') - .expect(200); - - await Entities.purge(true); - - await asAlice.get(`/v1/projects/1/datasets/people/integrity?id=12345678-1234-4123-8234-123456789abc`) - .set('X-OpenRosa-Version', '1.0') - .expect(200) - .then(async ({ text }) => { - const result = await xml2js.parseStringPromise(text, { explicitArray: false }); - const { entity } = result.data.entities; - entity.$.id.should.be.eql('12345678-1234-4123-8234-123456789abc'); - entity.deleted.should.be.eql('true'); - }); - })); - }); - describe('GET /datasets/:name/entities/:uuid', () => { it('should return notfound if the dataset does not exist', testEntities(async (service) => {