diff --git a/api/src/controllers/bulk-operations.js b/api/src/controllers/bulk-operations.js new file mode 100644 index 00000000000..915f55f770c --- /dev/null +++ b/api/src/controllers/bulk-operations.js @@ -0,0 +1,80 @@ +const service = require('../services/bulk-operations'); +const serverUtils = require('../server-utils'); +const auth = require('../auth'); + +module.exports = { + v1: { + /** + * @openapi + * /api/v1/bulk-operations/{id}: + * get: + * summary: Get the status of a bulk operation + * operationId: v1BulkOperationIdGet + * description: > + * Returns the log document for a bulk operation, including the per-action status and the + * count of changes applied so far. Used to poll the progress of an operation that was + * started through one of the bulk endpoints. The bulk operation can be considered finished + * when all of its actions have a status of "completed" or "failed". + * tags: [Bulk] + * x-since: 5.3.0 + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the bulk operation, as returned when it was started. + * responses: + * '200': + * description: The bulk operation log + * content: + * application/json: + * schema: + * type: object + * properties: + * _id: + * type: string + * description: The bulk operation id. + * start_date: + * type: string + * format: date-time + * description: When the operation was started. + * actions: + * type: object + * description: Per-action status, keyed by action id. + * additionalProperties: + * type: object + * properties: + * status: + * type: string + * enum: [queued, completed, failed] + * action: + * type: string + * enum: [archive, set-contact, delete-user] + * updated_date: + * type: string + * format: date-time + * total_changes_count: + * type: integer + * failed_operations: + * type: array + * description: The operations that failed, present only when status is failed. + * items: + * type: object + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ + get: serverUtils.doOrError(async (req, res) => { + await auth.assertPermissions(req, { isOnline: true }); + const log = await service.getLog(req.params.id); + if (!log) { + return serverUtils.error({ status: 404, message: 'Bulk operation not found' }, req, res); + } + res.json(log); + }) + } +}; diff --git a/api/src/controllers/person.js b/api/src/controllers/person.js index f9468ab39e5..6765a9044cb 100644 --- a/api/src/controllers/person.js +++ b/api/src/controllers/person.js @@ -2,6 +2,7 @@ const { Person, Qualifier } = require('@medic/cht-datasource'); const ctx = require('../services/data-context'); const serverUtils = require('../server-utils'); const auth = require('../auth'); +const deleteContactService = require('../services/delete-contact'); const getPerson = ctx.bind(Person.v1.get); const getPersonWithLineage = ctx.bind(Person.v1.getWithLineage); @@ -208,5 +209,48 @@ module.exports = { const updatedPersonDoc = await updatePerson(updatePersonInput); return res.json(updatedPersonDoc); }), + + /** + * @openapi + * /api/v1/person/{id}: + * delete: + * summary: Delete a person + * operationId: v1PersonIdDelete + * description: > + * Queues an asynchronous bulk operation that removes the person and the reports they are the + * subject of, clears any dangling primary-contact references, and (with delete_users=true) + * removes linked user accounts. Returns a summary of the changes and the bulk operation id + * to poll. + * tags: [Person] + * x-since: 5.3.0 + * x-permissions: + * hasAll: [can_delete_contact_hierarchy, can_delete_users] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the person to delete + * - $ref: '#/components/parameters/deleteUsers' + * - $ref: '#/components/parameters/dryRun' + * responses: + * '202': + * $ref: '#/components/responses/BulkOperationQueued' + * '200': + * $ref: '#/components/responses/BulkOperationDryRun' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ + delete: deleteContactService.handleDelete({ + get: (uuid) => getPerson(Qualifier.byUuid(uuid)), + type: 'Person', + }), }, }; diff --git a/api/src/controllers/place.js b/api/src/controllers/place.js index 02dfc657f9d..6b3cb9e49ee 100644 --- a/api/src/controllers/place.js +++ b/api/src/controllers/place.js @@ -2,6 +2,7 @@ const { Place, Qualifier } = require('@medic/cht-datasource'); const ctx = require('../services/data-context'); const serverUtils = require('../server-utils'); const auth = require('../auth'); +const deleteContactService = require('../services/delete-contact'); const getPlace = ctx.bind(Place.v1.get); const getPlaceWithLineage = ctx.bind(Place.v1.getWithLineage); @@ -214,6 +215,49 @@ module.exports = { }; const updatedPlaceDoc = await update(updatePlaceInput); return res.json(updatedPlaceDoc); + }), + + /** + * @openapi + * /api/v1/place/{id}: + * delete: + * summary: Delete a place and its hierarchy + * operationId: v1PlaceIdDelete + * description: > + * Queues an asynchronous bulk operation that removes the place, every descendant contact, + * and the reports they are the subject of, clears any dangling primary-contact references, + * and (with delete_users=true) removes linked user accounts. Returns a summary of the + * changes and the bulk operation id to poll. + * tags: [Place] + * x-since: 5.3.0 + * x-permissions: + * hasAll: [can_delete_contact_hierarchy, can_delete_users] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the place to delete + * - $ref: '#/components/parameters/deleteUsers' + * - $ref: '#/components/parameters/dryRun' + * responses: + * '202': + * $ref: '#/components/responses/BulkOperationQueued' + * '200': + * $ref: '#/components/responses/BulkOperationDryRun' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ + delete: deleteContactService.handleDelete({ + get: (uuid) => getPlace(Qualifier.byUuid(uuid)), + type: 'Place', }) } }; diff --git a/api/src/routing.js b/api/src/routing.js index 396711829b7..4f7d897576c 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -46,6 +46,7 @@ const { people, places } = require('@medic/contacts')(config, db, dataContext); const upgrade = require('./controllers/upgrade'); const settings = require('./controllers/settings'); const bulkDocs = require('./controllers/bulk-docs'); +const bulkOperations = require('./controllers/bulk-operations'); const monitoring = require('./controllers/monitoring'); const africasTalking = require('./controllers/africas-talking'); const rapidPro = require('./controllers/rapidpro'); @@ -477,6 +478,8 @@ app.get('/api/v1/monitoring', deprecation.deprecate('/api/v2/monitoring'), monit app.get('/api/v2/monitoring', monitoring.getV2); app.get('/api/v1/impact', impact.v1.get); +app.get('/api/v1/bulk-operations/:id', bulkOperations.v1.get); + app.post('/api/v1/upgrade', jsonParser, upgrade.upgrade); app.post('/api/v1/upgrade/stage', jsonParser, upgrade.stage); app.post('/api/v1/upgrade/complete', jsonParser, upgrade.complete); @@ -660,6 +663,7 @@ app.get('/api/v1/place', place.v1.getAll); app.get('/api/v1/place/:uuid', place.v1.get); app.postJson('/api/v1/place', place.v1.create); app.putJson('/api/v1/place/:uuid', place.v1.update); +app.delete('/api/v1/place/:uuid', place.v1.delete); /** * @openapi @@ -735,6 +739,7 @@ app.get('/api/v1/person', person.v1.getAll); app.get('/api/v1/person/:uuid', person.v1.get); app.postJson('/api/v1/person', person.v1.create); app.putJson('/api/v1/person/:uuid', person.v1.update); +app.delete('/api/v1/person/:uuid', person.v1.delete); app.get('/api/v1/contact', contact.v1.getAll); app.get('/api/v1/contact/uuid', contact.v1.getUuids); diff --git a/api/src/services/bulk-operations.js b/api/src/services/bulk-operations.js new file mode 100644 index 00000000000..c866add7812 --- /dev/null +++ b/api/src/services/bulk-operations.js @@ -0,0 +1,96 @@ +const { v7: uuid } = require('uuid'); +const db = require('../db'); +const { BULK_OPERATIONS, PREFIXES } = require('@medic/constants'); + +const { OPERATIONS_ATTACHMENT, STATUSES } = BULK_OPERATIONS; +const { BULK_OPERATION_LOG: LOG_ID_PREFIX, BULK_OPERATION_ACTION: ACTION_ID_PREFIX } = PREFIXES; +const OPERATIONS_CONTENT_TYPE = 'application/json'; + +const getOperationUuid = (operationId) => operationId.slice(LOG_ID_PREFIX.length); +const generateOperationId = () => `${LOG_ID_PREFIX}${uuid()}`; +const generateActionId = (operationId) => `${ACTION_ID_PREFIX}${getOperationUuid(operationId)}:${uuid()}`; + +// Params live in an attachment so advancing the cursor as Sentinel batches does not rewrite them. +const encodeOperations = (operations) => ({ + content_type: OPERATIONS_CONTENT_TYPE, + data: Buffer.from(JSON.stringify(operations)).toString('base64'), +}); + +const buildActionDoc = (operationId, action, operations) => ({ + _id: generateActionId(operationId), + bulk_operation_id: operationId, + action, + cursor: 0, + total: operations.length, + _attachments: { + [OPERATIONS_ATTACHMENT]: encodeOperations(operations), + }, +}); + +const buildLogAction = (action, totalChangesCount, date) => ({ + status: STATUSES.QUEUED, + action, + updated_date: date, + total_changes_count: totalChangesCount, +}); + +const buildBulkOperation = (actionOperations) => { + const date = new Date(); + const operationId = generateOperationId(); + const actions = actionOperations.map(({ action, operations }) => buildActionDoc(operationId, action, operations)); + + const logActions = {}; + actions.forEach((actionDoc) => { + logActions[actionDoc._id] = buildLogAction(actionDoc.action, actionDoc.total, date); + }); + + const log = { + _id: operationId, + start_date: date, + actions: logActions, + }; + + return { log, actions }; +}; + +// Guards against returning the other kinds of log doc that share the medic-logs database. +const getLog = async (id) => { + if (!id?.startsWith(LOG_ID_PREFIX)) { + return null; + } + + try { + const log = await db.medicLogs.get(id); + delete log._rev; + return log; + } catch (err) { + if (err.status === 404) { + return null; + } + throw err; + } +}; + +/** + * Queues a bulk operation: writes the log document to medic-logs (the status record the polling + * endpoint reads) and one action document per action type to medic-sentinel (what the Sentinel + * listener processes). The log is written first so it exists before the listener picks up an action. + * Action groups with no operations are skipped. + * @param {Object[]} actionOperations - one group per action type + * @param {string} actionOperations[].action - the action type (`archive`, `set-contact`, `delete-user`) + * @param {Object[]} actionOperations[].operations - the per-item params for that action + * @returns {Promise} the bulk operation id + */ +const queue = async (actionOperations) => { + const nonEmpty = actionOperations.filter(({ operations }) => operations.length); + const { log, actions } = buildBulkOperation(nonEmpty); + + await db.medicLogs.put(log); + // saveDocs checks each result; bulkDocs alone does not reject when an individual doc fails. + return db.saveDocs(db.sentinel, actions).then(() => log._id); +}; + +module.exports = { + getLog, + queue, +}; diff --git a/api/src/services/delete-contact.js b/api/src/services/delete-contact.js new file mode 100644 index 00000000000..3dec2d6b05b --- /dev/null +++ b/api/src/services/delete-contact.js @@ -0,0 +1,135 @@ +const db = require('../db'); +const auth = require('../auth'); +const serverUtils = require('../server-utils'); +const bulkOperations = require('./bulk-operations'); +const { NotFoundError, BadRequestError } = require('../errors'); +const { BULK_OPERATIONS } = require('@medic/constants'); + +const { ACTIONS } = BULK_OPERATIONS; + +// contacts_by_depth returns one row per contact in the subtree, each carrying its uuid and shortcode. +const getSubtree = (id) => db.medic.query('medic/contacts_by_depth', { key: [id] }); + +const getSubjectKeys = (rows) => { + const keys = []; + rows.forEach(row => { + keys.push(row.id); + if (row.value?.shortcode) { + keys.push(row.value.shortcode); + } + }); + return keys; +}; + +// Match reports by uuid and shortcode, so a report recording only the shortcode is not missed. +const getReportIds = async (subjectKeys) => { + const result = await db.medic.query('medic-client/reports_by_subject', { keys: subjectKeys }); + return [ ...new Set(result.rows.map(row => row.id)) ]; +}; + +// Surviving places whose primary contact is being deleted; the current id guards a since-changed ref. +const getPrimaryContactClears = async (contactIds) => { + const result = await db.medic.query('medic/contacts_by_primary_contact', { keys: contactIds }); + // Seeded with the deleted ids so those rows are skipped; grows as surviving places are collected. + const seen = new Set(contactIds); + const operations = []; + result.rows.forEach(row => { + if (seen.has(row.id)) { + return; + } + seen.add(row.id); + operations.push({ id: row.id, current_contact_id: row.key }); + }); + return operations; +}; + +// A place is a user's `facility_id` and a person can be their `contact_id`; either breaks the user. +const getLinkedUserIds = async (contactIds) => { + const result = await db.users.query('users/users_by_field', { + keys: contactIds.flatMap(id => [ [ 'facility_id', id ], [ 'contact_id', id ] ]), + }); + return [ ...new Set(result.rows.map(row => row.id)) ]; +}; + +/** + * Gathers everything a contact-hierarchy delete touches and queues it as a bulk operation. + * @param {string} id - the target contact id + * @param {Object} options + * @param {boolean} options.deleteUsers - also remove users linked to the deleted contacts + * @param {boolean} options.dryRun - return the summary without queuing anything + * @returns {Promise} the summary of changes, plus the bulk operation id when the operation + * was queued (omitted for a dry run) + * @throws {Error} a 400 when linked users would be left behind and `deleteUsers` was not requested + */ +const deleteContactHierarchy = async (id, { deleteUsers, dryRun } = {}) => { + const subtree = await getSubtree(id); + const contactIds = subtree.rows.map(row => row.id); + + const [ reportIds, setContactOperations, userIds ] = await Promise.all([ + getReportIds(getSubjectKeys(subtree.rows)), + getPrimaryContactClears(contactIds), + getLinkedUserIds(contactIds), + ]); + + if (userIds.length && !deleteUsers) { + throw new BadRequestError( + `${userIds.length} user(s) are linked to contacts in this hierarchy. ` + + `Set delete_users=true (requires can_delete_users) to remove them.` + ); + } + + const userOperations = userIds.map(userId => ({ id: userId })); + const summary = { + archive: { contacts: contactIds.length, reports: reportIds.length }, + 'set-contact': setContactOperations.length, + 'delete-user': userOperations.length, + }; + + if (dryRun) { + return { summary }; + } + + // Archive last, so contacts are removed only after the references to them are cleared. + const bulkOperationId = await bulkOperations.queue([ + { action: ACTIONS.SET_CONTACT, operations: setContactOperations }, + { action: ACTIONS.DELETE_USER, operations: userOperations }, + { action: ACTIONS.ARCHIVE, operations: [ ...reportIds, ...contactIds ].map(docId => ({ id: docId })) }, + ]); + + return { summary, id: bulkOperationId }; +}; + +/** + * Builds the DELETE express handler for a contact type. The person and place endpoints delete a + * hierarchy the same way, so they share this handler; each passes the pieces that make its endpoint + * type-specific. `get` fetches the target as its own type and returns null for the wrong type, so a + * place id cannot be deleted through the person endpoint or vice versa, and `type` names it for the + * not-found message. The handler reads the `delete_users`/`dry_run` query params, asserts the + * required permissions, hands the type-agnostic work off to `deleteContactHierarchy`, and responds + * with the summary (202 when queued, 200 for a dry run). + * @param {Object} options + * @param {Function} options.get - fetches the target contact by uuid, or null when it is not this type + * @param {string} options.type - the contact type name, used in the not-found message + * @returns {Function} the express request handler + */ +const handleDelete = ({ get, type }) => serverUtils.doOrError(async (req, res) => { + const deleteUsers = req.query.delete_users === 'true'; + const dryRun = req.query.dry_run === 'true'; + const permissions = deleteUsers + ? ['can_delete_contact_hierarchy', 'can_delete_users'] + : ['can_delete_contact_hierarchy']; + await auth.assertPermissions(req, { isOnline: true, hasAll: permissions }); + + const { uuid } = req.params; + const contact = await get(uuid); + if (!contact) { + return serverUtils.error(new NotFoundError(`${type} not found`), req, res); + } + + const result = await deleteContactHierarchy(uuid, { deleteUsers, dryRun }); + return res.status(dryRun ? 200 : 202).json(result); +}); + +module.exports = { + handleDelete, +}; diff --git a/api/tests/mocha/controllers/bulk-operations.spec.js b/api/tests/mocha/controllers/bulk-operations.spec.js new file mode 100644 index 00000000000..be3d0bd50cf --- /dev/null +++ b/api/tests/mocha/controllers/bulk-operations.spec.js @@ -0,0 +1,68 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const serverUtils = require('../../../src/server-utils'); +const controller = require('../../../src/controllers/bulk-operations'); +const service = require('../../../src/services/bulk-operations'); +const auth = require('../../../src/auth'); +const { PermissionError } = require('../../../src/errors'); + +describe('Bulk operations controller', () => { + let req; + let res; + + beforeEach(() => { + sinon.stub(serverUtils, 'error'); + req = { params: { id: 'bulk-operation:abc' } }; + res = { json: sinon.stub() }; + }); + + afterEach(() => sinon.restore()); + + describe('v1 get', () => { + it('returns the bulk operation log for an authorised online user', async () => { + const log = { _id: 'bulk-operation:abc', start_date: 'date', actions: {} }; + sinon.stub(auth, 'assertPermissions').resolves(); + sinon.stub(service, 'getLog').resolves(log); + + await controller.v1.get(req, res); + + expect(auth.assertPermissions.calledOnceWithExactly(req, { isOnline: true })).to.equal(true); + expect(service.getLog.calledOnceWithExactly('bulk-operation:abc')).to.equal(true); + expect(res.json.calledOnceWithExactly(log)).to.equal(true); + expect(serverUtils.error.called).to.equal(false); + }); + + it('returns a 404 when the operation is not found', async () => { + sinon.stub(auth, 'assertPermissions').resolves(); + sinon.stub(service, 'getLog').resolves(null); + + await controller.v1.get(req, res); + + expect(res.json.called).to.equal(false); + expect(serverUtils.error.calledOnce).to.equal(true); + expect(serverUtils.error.args[0][0]).to.deep.equal({ status: 404, message: 'Bulk operation not found' }); + }); + + it('does not reach the service when the user is not permitted', async () => { + sinon.stub(auth, 'assertPermissions').rejects(new PermissionError('Insufficient privileges')); + sinon.stub(service, 'getLog').resolves({}); + + await controller.v1.get(req, res); + + expect(service.getLog.called).to.equal(false); + expect(res.json.called).to.equal(false); + expect(serverUtils.error.calledOnce).to.equal(true); + }); + + it('handles a service rejection gracefully', async () => { + sinon.stub(auth, 'assertPermissions').resolves(); + sinon.stub(service, 'getLog').rejects(new Error('db down')); + + await controller.v1.get(req, res); + + expect(res.json.called).to.equal(false); + expect(serverUtils.error.calledOnce).to.equal(true); + }); + }); +}); diff --git a/api/tests/mocha/controllers/person.spec.js b/api/tests/mocha/controllers/person.spec.js index b94ad7817dd..644e1ea4b6f 100644 --- a/api/tests/mocha/controllers/person.spec.js +++ b/api/tests/mocha/controllers/person.spec.js @@ -4,6 +4,7 @@ const { Person, Qualifier } = require('@medic/cht-datasource'); const auth = require('../../../src/auth'); const dataContext = require('../../../src/services/data-context'); const serverUtils = require('../../../src/server-utils'); +const { NotFoundError } = require('../../../src/errors'); describe('Person Controller', () => { const sandbox = sinon.createSandbox(); @@ -204,5 +205,23 @@ describe('Person Controller', () => { expect(res.json.calledOnceWithExactly(updatePersonDoc)).to.be.true; }); }); + + describe('delete', () => { + // the delete logic itself is covered in the delete-contact service spec + it('responds 404 for an id that is not a person', async () => { + req = { params: { uuid: 'place-1' }, query: {} }; + personGet.resolves(null); + + await controller.v1.delete(req, res); + + expect(personGet.calledOnceWithExactly(Qualifier.byUuid('place-1'))).to.be.true; + expect(serverUtilsError.calledOnce).to.be.true; + const err = serverUtilsError.args[0][0]; + expect(err).to.be.an.instanceOf(NotFoundError); + expect(err.message).to.equal('Person not found'); + expect(serverUtilsError.args[0][1]).to.equal(req); + expect(serverUtilsError.args[0][2]).to.equal(res); + }); + }); }); }); diff --git a/api/tests/mocha/controllers/place.spec.js b/api/tests/mocha/controllers/place.spec.js index e29776761d8..b85de99b278 100644 --- a/api/tests/mocha/controllers/place.spec.js +++ b/api/tests/mocha/controllers/place.spec.js @@ -4,6 +4,7 @@ const { Place, Qualifier} = require('@medic/cht-datasource'); const auth = require('../../../src/auth'); const dataContext = require('../../../src/services/data-context'); const serverUtils = require('../../../src/server-utils'); +const { NotFoundError } = require('../../../src/errors'); describe('Place Controller', () => { const sandbox = sinon.createSandbox(); @@ -212,5 +213,23 @@ describe('Place Controller', () => { expect(res.json.calledOnceWithExactly(updatePlaceDoc)).to.be.true; }); }); + + describe('delete', () => { + // the delete logic itself is covered in the delete-contact service spec + it('responds 404 for an id that is not a place', async () => { + req = { params: { uuid: 'person-1' }, query: {} }; + placeGet.resolves(null); + + await controller.v1.delete(req, res); + + expect(placeGet.calledOnceWithExactly(Qualifier.byUuid('person-1'))).to.be.true; + expect(serverUtilsError.calledOnce).to.be.true; + const err = serverUtilsError.args[0][0]; + expect(err).to.be.an.instanceOf(NotFoundError); + expect(err.message).to.equal('Place not found'); + expect(serverUtilsError.args[0][1]).to.equal(req); + expect(serverUtilsError.args[0][2]).to.equal(res); + }); + }); }); }); diff --git a/api/tests/mocha/services/bulk-operations.spec.js b/api/tests/mocha/services/bulk-operations.spec.js new file mode 100644 index 00000000000..3c5a49e1f1d --- /dev/null +++ b/api/tests/mocha/services/bulk-operations.spec.js @@ -0,0 +1,145 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const db = require('../../../src/db'); +const service = require('../../../src/services/bulk-operations'); + +describe('Bulk operations service', () => { + afterEach(() => sinon.restore()); + + describe('getLog', () => { + it('returns the log document without the couch _rev', async () => { + const doc = { + _id: 'bulk-operation:abc', + _rev: '1-xyz', + start_date: 'date', + actions: { 'bulk-operation-action:abc:1': { status: 'queued' } }, + }; + sinon.stub(db.medicLogs, 'get').resolves(doc); + + const log = await service.getLog('bulk-operation:abc'); + + expect(db.medicLogs.get.calledOnceWithExactly('bulk-operation:abc')).to.equal(true); + expect(log).to.deep.equal({ + _id: 'bulk-operation:abc', + start_date: 'date', + actions: { 'bulk-operation-action:abc:1': { status: 'queued' } }, + }); + expect(log._rev).to.be.undefined; + }); + + it('returns null when the operation does not exist', async () => { + sinon.stub(db.medicLogs, 'get').rejects({ status: 404 }); + + const log = await service.getLog('bulk-operation:missing'); + + expect(log).to.be.null; + }); + + it('does not query the database for an id that is not a bulk operation', async () => { + const get = sinon.stub(db.medicLogs, 'get'); + + const results = await Promise.all([ + service.getLog(undefined), + service.getLog(''), + service.getLog('upgrade_log:something'), + service.getLog('some-other-doc'), + ]); + + expect(results).to.deep.equal([null, null, null, null]); + expect(get.called).to.equal(false); + }); + + it('rethrows errors that are not a 404', async () => { + sinon.stub(db.medicLogs, 'get').rejects({ status: 500 }); + + try { + await service.getLog('bulk-operation:boom'); + expect.fail('should have thrown'); + } catch (err) { + expect(err.status).to.equal(500); + } + }); + }); + + describe('queue', () => { + const actionOperations = [ + { action: 'archive', operations: [{ id: 'person' }, { id: 'report' }] }, + { action: 'set-contact', operations: [{ id: 'place', current_contact_id: 'person' }] }, + { action: 'delete-user', operations: [{ id: 'org.couchdb.user:chw' }] }, + ]; + + it('writes the log to medic-logs and the actions to medic-sentinel, and returns the operation id', async () => { + const put = sinon.stub(db.medicLogs, 'put').resolves(); + const saveDocs = sinon.stub(db, 'saveDocs').resolves(); + + const operationId = await service.queue(actionOperations); + + expect(operationId.startsWith('bulk-operation:')).to.equal(true); + + expect(put.calledOnce).to.equal(true); + const log = put.args[0][0]; + expect(log._id).to.equal(operationId); + expect(Object.keys(log.actions)).to.have.length(3); + + expect(saveDocs.calledOnce).to.equal(true); + expect(saveDocs.args[0][0]).to.equal(db.sentinel); + const actions = saveDocs.args[0][1]; + expect(actions).to.have.length(3); + expect(actions.map(action => action.action)).to.deep.equal(['archive', 'set-contact', 'delete-user']); + expect(actions[0].bulk_operation_id).to.equal(operationId); + + // the log must exist before the listener can pick up an action + expect(put.calledBefore(saveDocs)).to.equal(true); + }); + + it('stores each action\'s params in a base64 json attachment and records the log action detail', async () => { + const put = sinon.stub(db.medicLogs, 'put').resolves(); + const saveDocs = sinon.stub(db, 'saveDocs').resolves(); + + await service.queue(actionOperations); + + const log = put.args[0][0]; + const actions = saveDocs.args[0][1]; + + // per-item params live in a base64 json attachment + const attachment = actions[0]._attachments.operations; + expect(attachment.content_type).to.equal('application/json'); + expect(JSON.parse(Buffer.from(attachment.data, 'base64').toString())) + .to.deep.equal(actionOperations[0].operations); + expect(actions[0].cursor).to.equal(0); + expect(actions[0].total).to.equal(2); + + // the log action entry cross-links to the action doc and starts queued + const logAction = log.actions[actions[0]._id]; + expect(logAction.status).to.equal('queued'); + expect(logAction.action).to.equal('archive'); + expect(logAction.total_changes_count).to.equal(2); + expect(logAction.updated_date).to.equal(log.start_date); + }); + + it('generates a distinct operation id on each call', async () => { + sinon.stub(db.medicLogs, 'put').resolves(); + sinon.stub(db, 'saveDocs').resolves(); + + const [ first, second ] = await Promise.all([ service.queue(actionOperations), service.queue(actionOperations) ]); + + expect(first).to.not.equal(second); + }); + + it('skips action groups that have no operations', async () => { + sinon.stub(db.medicLogs, 'put').resolves(); + const saveDocs = sinon.stub(db, 'saveDocs').resolves(); + const groups = [ + { action: 'archive', operations: [{ id: 'person' }] }, + { action: 'set-contact', operations: [] }, + { action: 'delete-user', operations: [{ id: 'org.couchdb.user:chw' }] }, + ]; + + await service.queue(groups); + + const actions = saveDocs.args[0][1]; + expect(actions.map(action => action.action)).to.deep.equal(['archive', 'delete-user']); + }); + }); +}); diff --git a/api/tests/mocha/services/delete-contact.spec.js b/api/tests/mocha/services/delete-contact.spec.js new file mode 100644 index 00000000000..2485604a4b8 --- /dev/null +++ b/api/tests/mocha/services/delete-contact.spec.js @@ -0,0 +1,170 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const db = require('../../../src/db'); +const auth = require('../../../src/auth'); +const serverUtils = require('../../../src/server-utils'); +const bulkOperations = require('../../../src/services/bulk-operations'); +const { NotFoundError, BadRequestError } = require('../../../src/errors'); +const service = require('../../../src/services/delete-contact'); + +describe('Delete contact service', () => { + let res; + + beforeEach(() => { + sinon.stub(auth, 'assertPermissions').resolves(); + sinon.stub(serverUtils, 'error'); + res = { status: sinon.stub().returnsThis(), json: sinon.stub() }; + }); + + afterEach(() => sinon.restore()); + + // The delete logic is only reachable through the shared handler, so it is tested through it: each + // test builds the handler with a `get` (the type-specific fetch the controllers pass in) and stubs + // the db layer the gather runs against. + const handlerFor = (get) => service.handleDelete({ get, type: 'Person' }); + + describe('handleDelete', () => { + it('gathers the hierarchy, queues the actions in order, and responds 202 with the summary', async () => { + const get = sinon.stub().resolves({ _id: 'target' }); + sinon.stub(db.medic, 'query').callsFake((view) => { + if (view === 'medic/contacts_by_depth') { + return Promise.resolve({ rows: [ + { id: 'target', value: { shortcode: 'PID-1' } }, + { id: 'child', value: { shortcode: null } }, + ] }); + } + if (view === 'medic-client/reports_by_subject') { + return Promise.resolve({ rows: [ { id: 'report-1' }, { id: 'report-1' }, { id: 'report-2' } ] }); + } + if (view === 'medic/contacts_by_primary_contact') { + return Promise.resolve({ rows: [ + { id: 'parent-place', key: 'target' }, // parent whose primary is the target -> clear + { id: 'child', key: 'target' }, // child is itself being deleted -> skip + ] }); + } + return Promise.resolve({ rows: [] }); + }); + sinon.stub(db.users, 'query').resolves({ rows: [ { id: 'org.couchdb.user:chw' } ] }); + const queue = sinon.stub(bulkOperations, 'queue').resolves('bulk-operation:xyz'); + + const req = { params: { uuid: 'target' }, query: { delete_users: 'true' } }; + await handlerFor(get)(req, res); + + expect(auth.assertPermissions.calledOnceWithExactly( + req, + { isOnline: true, hasAll: [ 'can_delete_contact_hierarchy', 'can_delete_users' ] } + )).to.be.true; + + // reports matched by uuid + shortcode + const reportsCall = db.medic.query.getCalls().find(c => c.args[0] === 'medic-client/reports_by_subject'); + expect(reportsCall.args[1].keys).to.deep.equal([ 'target', 'PID-1', 'child' ]); + + // users looked up by both facility_id and contact_id + expect(db.users.query.args[0][1].keys).to.deep.equal([ + [ 'facility_id', 'target' ], [ 'contact_id', 'target' ], + [ 'facility_id', 'child' ], [ 'contact_id', 'child' ], + ]); + + // queued in order: set-contact, delete-user, then archive (reports before their subject contacts) + const actions = queue.args[0][0]; + expect(actions.map(a => a.action)).to.deep.equal([ 'set-contact', 'delete-user', 'archive' ]); + const byAction = Object.fromEntries(actions.map(a => [ a.action, a.operations ])); + expect(byAction['set-contact']).to.deep.equal([ { id: 'parent-place', current_contact_id: 'target' } ]); + expect(byAction['delete-user']).to.deep.equal([ { id: 'org.couchdb.user:chw' } ]); + expect(byAction.archive.map(o => o.id)).to.deep.equal([ 'report-1', 'report-2', 'target', 'child' ]); + + expect(res.status.calledOnceWithExactly(202)).to.be.true; + expect(res.json.calledOnceWithExactly({ + summary: { archive: { contacts: 2, reports: 2 }, 'set-contact': 1, 'delete-user': 1 }, + id: 'bulk-operation:xyz', + })).to.be.true; + }); + + it('asserts only can_delete_contact_hierarchy when delete_users is not set', async () => { + const get = sinon.stub().resolves({ _id: 'place' }); + sinon.stub(db.medic, 'query').callsFake((view) => { + if (view === 'medic/contacts_by_depth') { + return Promise.resolve({ rows: [ { id: 'place', value: {} } ] }); + } + return Promise.resolve({ rows: [] }); + }); + sinon.stub(db.users, 'query').resolves({ rows: [] }); + const queue = sinon.stub(bulkOperations, 'queue').resolves('bulk-operation:1'); + + const req = { params: { uuid: 'place' }, query: {} }; + await handlerFor(get)(req, res); + + expect(auth.assertPermissions.calledOnceWithExactly( + req, + { isOnline: true, hasAll: [ 'can_delete_contact_hierarchy' ] } + )).to.be.true; + const byAction = Object.fromEntries(queue.args[0][0].map(a => [ a.action, a.operations ])); + expect(byAction['delete-user']).to.deep.equal([]); + expect(res.status.calledOnceWithExactly(202)).to.be.true; + }); + + it('responds 200 with the summary and queues nothing for a dry run', async () => { + const get = sinon.stub().resolves({ _id: 'place' }); + sinon.stub(db.medic, 'query').callsFake((view) => { + if (view === 'medic/contacts_by_depth') { + return Promise.resolve({ rows: [ { id: 'place', value: {} } ] }); + } + if (view === 'medic-client/reports_by_subject') { + return Promise.resolve({ rows: [ { id: 'r1' } ] }); + } + return Promise.resolve({ rows: [] }); + }); + sinon.stub(db.users, 'query').resolves({ rows: [] }); + const queue = sinon.stub(bulkOperations, 'queue').resolves('x'); + + const req = { params: { uuid: 'place' }, query: { dry_run: 'true' } }; + await handlerFor(get)(req, res); + + expect(queue.called).to.equal(false); + expect(res.status.calledOnceWithExactly(200)).to.be.true; + expect(res.json.calledOnceWithExactly({ + summary: { archive: { contacts: 1, reports: 1 }, 'set-contact': 0, 'delete-user': 0 }, + })).to.be.true; + }); + + it('responds 404 and does not gather when the target is not the expected type', async () => { + const get = sinon.stub().resolves(null); + const query = sinon.stub(db.medic, 'query'); + sinon.stub(bulkOperations, 'queue'); + + const req = { params: { uuid: 'wrong' }, query: {} }; + await handlerFor(get)(req, res); + + expect(serverUtils.error.calledOnce).to.be.true; + const err = serverUtils.error.args[0][0]; + expect(err).to.be.an.instanceOf(NotFoundError); + expect(err.status).to.equal(404); + expect(err.message).to.equal('Person not found'); + expect(serverUtils.error.args[0][1]).to.equal(req); + expect(serverUtils.error.args[0][2]).to.equal(res); + expect(query.called).to.equal(false); + }); + + it('responds 400 and queues nothing when linked users exist and delete_users is not set', async () => { + const get = sinon.stub().resolves({ _id: 'place' }); + sinon.stub(db.medic, 'query').callsFake((view) => { + if (view === 'medic/contacts_by_depth') { + return Promise.resolve({ rows: [ { id: 'place', value: {} } ] }); + } + return Promise.resolve({ rows: [] }); + }); + sinon.stub(db.users, 'query').resolves({ rows: [ { id: 'org.couchdb.user:chw' } ] }); + const queue = sinon.stub(bulkOperations, 'queue'); + + const req = { params: { uuid: 'place' }, query: {} }; + await handlerFor(get)(req, res); + + expect(serverUtils.error.calledOnce).to.be.true; + const err = serverUtils.error.args[0][0]; + expect(err).to.be.an.instanceOf(BadRequestError); + expect(err.message).to.contain('user(s) are linked to contacts'); + expect(queue.called).to.equal(false); + }); + }); +}); diff --git a/config/default/app_settings.json b/config/default/app_settings.json index 1d91b088dbb..db2b1987ec9 100644 --- a/config/default/app_settings.json +++ b/config/default/app_settings.json @@ -122,6 +122,7 @@ "can_create_users": [ "program_officer" ], + "can_delete_contact_hierarchy": [], "can_delete_contacts": [ "program_officer", "chw_supervisor", diff --git a/scripts/build/generate-openapi.js b/scripts/build/generate-openapi.js index 87bada620ba..5fe9d120fed 100644 --- a/scripts/build/generate-openapi.js +++ b/scripts/build/generate-openapi.js @@ -62,6 +62,28 @@ const SWAGGER_OPTIONS = { ok: { const: true }, }, }, + BulkOperationSummary: { + type: 'object', + description: 'A count of the changes an operation will make, grouped by action.', + properties: { + archive: { + type: 'object', + description: 'Documents to remove: the contacts in the hierarchy and their reports.', + properties: { + contacts: { type: 'integer' }, + reports: { type: 'integer' }, + }, + }, + 'set-contact': { + type: 'integer', + description: 'Primary-contact references on surviving places that will be cleared.', + }, + 'delete-user': { + type: 'integer', + description: 'Linked user accounts that will be removed.', + }, + }, + }, }, parameters: { cursor: { @@ -89,13 +111,56 @@ const SWAGGER_OPTIONS = { name: 'with_lineage', schema: { 'enum': ['true', 'false'], default: 'false' }, description: 'Include the full parent lineage.' + }, + deleteUsers: { + in: 'query', + name: 'delete_users', + schema: { type: 'boolean', default: false }, + description: + 'Also delete user accounts linked to the removed contacts. Requires the can_delete_users ' + + 'permission. When not set, the request is rejected with 400 if any contacts to delete have linked users.', + }, + dryRun: { + in: 'query', + name: 'dry_run', + schema: { type: 'boolean', default: false }, + description: + 'Return the summary of what would be changed by executing this operation. Nothing is ' + + 'applied when dry_run is set.', } }, responses: { NotFound: { description: 'Entity not found' }, BadRequest: { description: 'Invalid input (missing required fields, invalid types, etc.)' }, Unauthorized: { description: 'Not authenticated' }, - Forbidden: { description: 'Insufficient permissions' } + Forbidden: { description: 'Insufficient permissions' }, + BulkOperationQueued: { + description: 'The bulk operation was queued', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + summary: { $ref: '#/components/schemas/BulkOperationSummary' }, + id: { type: 'string', description: 'The bulk operation id to poll.' } + } + } + } + } + }, + BulkOperationDryRun: { + description: 'The dry-run summary (nothing queued)', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + summary: { $ref: '#/components/schemas/BulkOperationSummary' } + } + } + } + } + } } }, }, diff --git a/sentinel/server.js b/sentinel/server.js index 74bf0ee206f..fe04f72889e 100755 --- a/sentinel/server.js +++ b/sentinel/server.js @@ -47,6 +47,9 @@ logger.info('Running server checks...'); const schedule = require('./src/schedule'); schedule.init(); + const bulkOperations = require('./src/lib/bulk-operations'); + await bulkOperations.listen(); + logger.info('startup complete.'); const processHooks = require('./src/process-hooks'); diff --git a/sentinel/src/db.js b/sentinel/src/db.js index 420ceb6cd58..3b162483c60 100644 --- a/sentinel/src/db.js +++ b/sentinel/src/db.js @@ -40,6 +40,12 @@ if (UNIT_TEST_ENV) { changes: stubMe('changes'), }; + module.exports.medicLogs = { + allDocs: stubMe('allDocs'), + get: stubMe('get'), + put: stubMe('put'), + }; + module.exports.users = { allDocs: stubMe('allDocs'), bulkDocs: stubMe('bulkDocs'), @@ -87,6 +93,7 @@ if (UNIT_TEST_ENV) { module.exports.medic = new PouchDB(couchUrl, { fetch: fetchFn }); module.exports.sentinel = new PouchDB(`${couchUrl}-sentinel`, { fetch: fetchFn }); + module.exports.medicLogs = new PouchDB(`${couchUrl}-logs`, { fetch: fetchFn }); module.exports.archive = new PouchDB(`${couchUrl}-archive`, { fetch: fetchFn }); module.exports.allDbs = () => request.get({ url: `${environment.serverUrl}/_all_dbs`, json: true }); module.exports.get = db => new PouchDB(`${environment.serverUrl}/${db}`); diff --git a/sentinel/src/lib/archiving.js b/sentinel/src/lib/archiving.js index 7b4613cfca9..3c532d59f67 100644 --- a/sentinel/src/lib/archiving.js +++ b/sentinel/src/lib/archiving.js @@ -30,6 +30,8 @@ const readIds = async (job) => { const canArchive = (doc) => { const archivableDocTypes = [ 'contact', + 'person', + ...Object.values(constants.CONTACT_TYPES), constants.DOC_TYPES.DATA_RECORD, 'task', 'target', @@ -173,4 +175,5 @@ const archive = async ({ duration } = {}) => { module.exports = { archive, + archiveBatch, }; diff --git a/sentinel/src/lib/bulk-operations/archive.js b/sentinel/src/lib/bulk-operations/archive.js new file mode 100644 index 00000000000..3df64f15fe0 --- /dev/null +++ b/sentinel/src/lib/bulk-operations/archive.js @@ -0,0 +1,31 @@ +const logger = require('@medic/logger'); +const archiving = require('../archiving'); + +// Archive the batch directly: copy the docs to the archive database and purge them from medic. +// The whole batch fails if archiving throws. +const archive = async (batch, actionId) => { + const failed = []; + const ids = []; + batch.forEach(op => { + if (op.id) { + ids.push(op.id); + } else { + logger.error(`bulk-operations: archive skipped an operation with no id (action ${actionId})`); + failed.push(op); + } + }); + + if (!ids.length) { + return failed; + } + + try { + await archiving.archiveBatch(ids); + } catch (err) { + logger.error(`bulk-operations: archive failed (action ${actionId}): %o`, err); + return batch; + } + return failed; +}; + +module.exports = { archive }; diff --git a/sentinel/src/lib/bulk-operations/delete-user.js b/sentinel/src/lib/bulk-operations/delete-user.js new file mode 100644 index 00000000000..e28876799cc --- /dev/null +++ b/sentinel/src/lib/bulk-operations/delete-user.js @@ -0,0 +1,28 @@ +const logger = require('@medic/logger'); +const db = require('../../db'); +const config = require('../../config'); +const dataContext = require('../../data-context'); +const { PREFIXES } = require('@medic/constants'); + +const userManagement = require('@medic/user-management')(config, db, dataContext); + +// Remove each linked user via the existing user-delete path; a failure fails only that op. +const deleteUser = async (batch, actionId) => { + const failed = []; + for (const op of batch) { + if (!op.id) { + logger.error(`bulk-operations: delete-user skipped an operation with no id (action ${actionId})`); + failed.push(op); + continue; + } + try { + await userManagement.users.deleteUser(op.id.replace(PREFIXES.COUCH_USER, '')); + } catch (err) { + logger.error(`bulk-operations: delete-user failed for ${op.id} (action ${actionId}): %o`, err); + failed.push(op); + } + } + return failed; +}; + +module.exports = { deleteUser }; diff --git a/sentinel/src/lib/bulk-operations/index.js b/sentinel/src/lib/bulk-operations/index.js new file mode 100644 index 00000000000..9b22e80ce86 --- /dev/null +++ b/sentinel/src/lib/bulk-operations/index.js @@ -0,0 +1,168 @@ +const async = require('async'); +const logger = require('@medic/logger'); +const db = require('../../db'); +const { BULK_OPERATIONS, PREFIXES } = require('@medic/constants'); +const { setContact } = require('./set-contact'); +const { deleteUser } = require('./delete-user'); +const { archive } = require('./archive'); + +const { ACTIONS, STATUSES, OPERATIONS_ATTACHMENT } = BULK_OPERATIONS; +const { BULK_OPERATION_ACTION: ACTION_ID_PREFIX } = PREFIXES; + +const BATCH_SIZE = 100; +const RETRY_TIMEOUT = 60000; + +const HANDLERS = { + [ACTIONS.SET_CONTACT]: setContact, + [ACTIONS.DELETE_USER]: deleteUser, + [ACTIONS.ARCHIVE]: archive, +}; + +const readOperations = async (actionId) => { + const buffer = await db.sentinel.getAttachment(actionId, OPERATIONS_ATTACHMENT); + return JSON.parse(buffer.toString()); +}; + +// Base the cursor update on the latest doc (not our in-memory copy) and hand it back to the caller. +const saveProgress = async (action, processedCount, failed) => { + const updated = await db.sentinel.get(action._id); + updated.cursor = (updated.cursor || 0) + processedCount; + if (failed.length) { + updated.failed_operations = [ ...(updated.failed_operations || []), ...failed ]; + } + await db.sentinel.put(updated); + return updated; +}; + +// Never throws: by the time we record the result there is nothing more to do about a failure. +const recordResultOnLog = async (action, status) => { + try { + const log = await db.medicLogs.get(action.bulk_operation_id); + log.actions = log.actions || {}; + log.actions[action._id] = { + status, + action: action.action, + updated_date: new Date(), + total_changes_count: action.total, + failed_operations: action.failed_operations, + }; + await db.medicLogs.put(log); + } catch (err) { + logger.error(`bulk-operations: error updating log ${action.bulk_operation_id}: %o`, err); + } +}; + +const deleteAction = async (action) => { + const latest = await db.sentinel.get(action._id); + await db.sentinel.put({ ...latest, _deleted: true }); +}; + +// null when the action doc is gone (already processed and removed, e.g. queued twice at startup). +const getAction = async (actionId) => { + try { + return await db.sentinel.get(actionId); + } catch (err) { + if (err.status === 404) { + return null; + } + throw err; + } +}; + +const runOperations = async (action, handler, actionId) => { + const operations = await readOperations(actionId); + while (action.cursor < operations.length) { + const batch = operations.slice(action.cursor, action.cursor + BATCH_SIZE); + let failed; + try { + failed = await handler(batch, actionId); + } catch (err) { + // Unexpected handler error: treat the whole batch as failed so the rest still runs. + logger.error(`bulk-operations: error handling action ${actionId}: %o`, err); + failed = batch; + } finally { + action = await saveProgress(action, batch.length, failed); + } + } + return action; +}; + +// Always record the result and delete the action, so a failed action is not re-queued on the next +// Sentinel start (via loadInitialQueue). +const processAction = async (actionId) => { + const action = await getAction(actionId); + if (!action) { + return; + } + + const handler = HANDLERS[action.action]; + let completedAction; + try { + if (!handler) { + throw new Error(`bulk-operations: no handler for action "${action.action}"`); + } + completedAction = action.cursor < action.total + ? await runOperations(action, handler, actionId) + : action; + } finally { + const status = !completedAction || completedAction.failed_operations?.length + ? STATUSES.FAILED + : STATUSES.COMPLETED; + completedAction = completedAction || action; + await recordResultOnLog(completedAction, status); + await deleteAction(completedAction); + logger.info(`bulk-operations: completed action ${actionId}`); + } +}; + +// inProgress stops our own cursor writes (which show up on the feed) from re-queueing an in-flight +// action. +const inProgress = new Set(); + +const queue = async.queue((actionId, callback) => { + processAction(actionId) + .catch(err => logger.error(`bulk-operations: error processing action ${actionId}: %o`, err)) + .then(() => callback()); +}); + +const enqueue = (actionId) => { + if (inProgress.has(actionId)) { + return; + } + inProgress.add(actionId); + queue.push(actionId, () => inProgress.delete(actionId)); +}; + +const loadInitialQueue = async () => { + const result = await db.sentinel.allDocs({ + startkey: ACTION_ID_PREFIX, + endkey: `${ACTION_ID_PREFIX}\ufff0`, + }); + result.rows.forEach(row => enqueue(row.id)); +}; + +const registerFeed = () => { + db.sentinel + .changes({ live: true, since: 'now' }) + .on('change', (change) => { + if (!change.deleted && change.id.startsWith(ACTION_ID_PREFIX)) { + enqueue(change.id); + } + }) + .on('error', (err) => { + logger.error('bulk-operations: changes feed error: %o', err); + setTimeout(registerFeed, RETRY_TIMEOUT); + }); +}; + +const listen = async () => { + // Register the feed before loading the queue so an action written in between is not missed. + registerFeed(); + await loadInitialQueue(); + logger.info('bulk-operations: listening for queued actions on medic-sentinel'); +}; + +module.exports = { + processAction, + listen, +}; diff --git a/sentinel/src/lib/bulk-operations/set-contact.js b/sentinel/src/lib/bulk-operations/set-contact.js new file mode 100644 index 00000000000..4eff17d3a7f --- /dev/null +++ b/sentinel/src/lib/bulk-operations/set-contact.js @@ -0,0 +1,55 @@ +const logger = require('@medic/logger'); +const db = require('../../db'); + +// Point a place's contact at a new value (or clear it), only when the doc still holds the contact we +// recorded, so a concurrent edit is not clobbered. A missing id/doc or a changed contact is failed. +const setContact = async (batch, actionId) => { + const withId = batch.filter(op => op.id); + const result = withId.length + ? await db.medic.allDocs({ keys: withId.map(op => op.id), include_docs: true }) + : { rows: [] }; + const docsById = {}; + result.rows.forEach(row => { + if (row.doc) { + docsById[row.doc._id] = row.doc; + } + }); + + const failed = []; + const toUpdate = []; + batch.forEach(op => { + if (!op.id) { + logger.error(`bulk-operations: set-contact skipped an operation with no id (action ${actionId})`); + failed.push(op); + return; + } + const doc = docsById[op.id]; + if (!doc) { + logger.error(`bulk-operations: set-contact failed for ${op.id}: doc missing (action ${actionId})`); + failed.push(op); + return; + } + const currentContactId = doc.contact?._id || doc.contact; + if (currentContactId !== op.current_contact_id) { + logger.error(`bulk-operations: set-contact failed for ${op.id}: contact changed (action ${actionId})`); + failed.push(op); + return; + } + doc.contact = op.contact; + toUpdate.push(doc); + }); + + if (toUpdate.length) { + // bulkDocs does not reject when an individual doc fails, so check each result. + const results = await db.medic.bulkDocs(toUpdate); + results.forEach((res, i) => { + if (res.error) { + logger.error(`bulk-operations: set-contact failed for ${toUpdate[i]._id}: %o (action ${actionId})`, res); + failed.push(batch.find(op => op.id === toUpdate[i]._id)); + } + }); + } + return failed; +}; + +module.exports = { setContact }; diff --git a/sentinel/tests/unit/lib/archiving.spec.js b/sentinel/tests/unit/lib/archiving.spec.js index ec5b687d4f6..c0e354ca872 100644 --- a/sentinel/tests/unit/lib/archiving.spec.js +++ b/sentinel/tests/unit/lib/archiving.spec.js @@ -432,9 +432,13 @@ describe('Sentinel archiving lib', () => { }); describe('canArchive', () => { - it('accepts the four archivable types', () => { + it('accepts contacts (modern and legacy types), reports, tasks and targets', () => { const canArchive = lib.__get__('canArchive'); chai.expect(canArchive({ type: 'contact' })).to.equal(true); + chai.expect(canArchive({ type: 'person' })).to.equal(true); + chai.expect(canArchive({ type: 'clinic' })).to.equal(true); + chai.expect(canArchive({ type: 'health_center' })).to.equal(true); + chai.expect(canArchive({ type: 'district_hospital' })).to.equal(true); chai.expect(canArchive({ type: 'data_record' })).to.equal(true); chai.expect(canArchive({ type: 'task' })).to.equal(true); chai.expect(canArchive({ type: 'target' })).to.equal(true); @@ -442,9 +446,8 @@ describe('Sentinel archiving lib', () => { it('rejects other types and missing docs', () => { const canArchive = lib.__get__('canArchive'); - chai.expect(canArchive({ type: 'person' })).to.equal(false); - chai.expect(canArchive({ type: 'clinic' })).to.equal(false); chai.expect(canArchive({ type: 'feedback' })).to.equal(false); + chai.expect(canArchive({ type: 'usersmeta' })).to.equal(false); chai.expect(canArchive({})).to.equal(false); chai.expect(canArchive(null)).to.equal(false); chai.expect(canArchive(undefined)).to.equal(false); @@ -482,7 +485,7 @@ describe('Sentinel archiving lib', () => { rows: [ { doc: { _id: 'c1', _rev: '1-a', type: 'contact', name: 'C' } }, { doc: { _id: 'r1', _rev: '1-b', type: 'data_record', form: 'visit' } }, - { doc: { _id: 'p1', _rev: '1-c', type: 'person' } }, // not archivable + { doc: { _id: 'x1', _rev: '1-c', type: 'feedback' } }, // not archivable { doc: null }, // missing ], }); @@ -497,11 +500,11 @@ describe('Sentinel archiving lib', () => { const audit = lib.__get__('audit'); sinon.stub(audit, 'recordArchiving').resolves(); - await archiveBatch([' c1 ', 'r1', 'p1', 'missing']); + await archiveBatch([' c1 ', 'r1', 'x1', 'missing']); chai.expect(db.medic.allDocs.args[0]).to.deep.equal([{ attachments: true, - keys: ['c1', 'r1', 'p1', 'missing'], + keys: ['c1', 'r1', 'x1', 'missing'], include_docs: true, conflicts: true, }]); diff --git a/sentinel/tests/unit/lib/bulk-operations/archive.spec.js b/sentinel/tests/unit/lib/bulk-operations/archive.spec.js new file mode 100644 index 00000000000..73cbb860567 --- /dev/null +++ b/sentinel/tests/unit/lib/bulk-operations/archive.spec.js @@ -0,0 +1,48 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const archiving = require('../../../../src/lib/archiving'); +const { archive } = require('../../../../src/lib/bulk-operations/archive'); + +describe('bulk-operations archive handler', () => { + let archiveBatch; + + beforeEach(() => { + archiveBatch = sinon.stub(archiving, 'archiveBatch'); + }); + + afterEach(() => sinon.restore()); + + it('archives the batch ids and returns no failures', async () => { + archiveBatch.resolves(); + + const failed = await archive([ { id: 'a' }, { id: 'b' } ], 'action-1'); + + expect(failed).to.deep.equal([]); + expect(archiveBatch.calledOnceWithExactly([ 'a', 'b' ])).to.equal(true); + }); + + it('fails an operation with no id and archives only the rest', async () => { + archiveBatch.resolves(); + + const failed = await archive([ { id: 'a' }, {} ], 'action-1'); + + expect(failed).to.have.length(1); + expect(archiveBatch.calledOnceWithExactly([ 'a' ])).to.equal(true); + }); + + it('does not archive when the batch has no ids', async () => { + const failed = await archive([ {} ], 'action-1'); + + expect(failed).to.have.length(1); + expect(archiveBatch.called).to.equal(false); + }); + + it('fails the whole batch when archiving throws', async () => { + archiveBatch.rejects(new Error('boom')); + + const failed = await archive([ { id: 'a' }, { id: 'b' } ], 'action-1'); + + expect(failed.map(op => op.id)).to.deep.equal([ 'a', 'b' ]); + }); +}); diff --git a/sentinel/tests/unit/lib/bulk-operations/delete-user.spec.js b/sentinel/tests/unit/lib/bulk-operations/delete-user.spec.js new file mode 100644 index 00000000000..f1cd4e510b1 --- /dev/null +++ b/sentinel/tests/unit/lib/bulk-operations/delete-user.spec.js @@ -0,0 +1,50 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const config = require('../../../../src/config'); +const db = require('../../../../src/db'); +const dataContext = require('../../../../src/data-context'); +const { users } = require('@medic/user-management')(config, db, dataContext); +const { deleteUser } = require('../../../../src/lib/bulk-operations/delete-user'); + +describe('bulk-operations delete-user handler', () => { + let deleteUserStub; + + beforeEach(() => { + deleteUserStub = sinon.stub(users, 'deleteUser'); + }); + + afterEach(() => sinon.restore()); + + it('deletes each user via the user-delete path, stripping the couch prefix', async () => { + deleteUserStub.resolves(); + + const failed = await deleteUser([ { id: 'org.couchdb.user:alice' }, { id: 'org.couchdb.user:bob' } ], 'action-1'); + + expect(failed).to.deep.equal([]); + expect(deleteUserStub.args.map(a => a[0])).to.deep.equal([ 'alice', 'bob' ]); + }); + + it('records a user that fails to delete as failed and keeps deleting the rest', async () => { + deleteUserStub.withArgs('alice').resolves(); + deleteUserStub.withArgs('bob').rejects(new Error('boom')); + deleteUserStub.withArgs('carol').resolves(); + + const failed = await deleteUser( + [ { id: 'org.couchdb.user:alice' }, { id: 'org.couchdb.user:bob' }, { id: 'org.couchdb.user:carol' } ], + 'action-1' + ); + + expect(failed.map(op => op.id)).to.deep.equal([ 'org.couchdb.user:bob' ]); + expect(deleteUserStub.args.map(a => a[0])).to.deep.equal([ 'alice', 'bob', 'carol' ]); + }); + + it('fails an operation with no id without calling the delete path', async () => { + deleteUserStub.resolves(); + + const failed = await deleteUser([ {} ], 'action-1'); + + expect(failed).to.have.length(1); + expect(deleteUserStub.called).to.equal(false); + }); +}); diff --git a/sentinel/tests/unit/lib/bulk-operations/index.spec.js b/sentinel/tests/unit/lib/bulk-operations/index.spec.js new file mode 100644 index 00000000000..194c654880c --- /dev/null +++ b/sentinel/tests/unit/lib/bulk-operations/index.spec.js @@ -0,0 +1,171 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const rewire = require('rewire'); + +const db = require('../../../../src/db'); + +const expect = chai.expect; + +describe('bulk-operations sentinel processor', () => { + let service; + + beforeEach(() => { + service = rewire('../../../../src/lib/bulk-operations'); + }); + + afterEach(() => sinon.restore()); + + describe('processAction', () => { + const actionId = 'bulk-operation-action:op:1'; + const buildAction = (overrides = {}) => ({ + _id: actionId, + bulk_operation_id: 'bulk-operation:op', + action: 'set-contact', + cursor: 0, + total: 2, + ...overrides, + }); + + const stubDb = (action, operations) => { + sinon.stub(db.sentinel, 'get').resolves(action); + const put = sinon.stub(db.sentinel, 'put').resolves(); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from(JSON.stringify(operations))); + const log = { _id: action.bulk_operation_id, actions: {} }; + sinon.stub(db.medicLogs, 'get').resolves(log); + sinon.stub(db.medicLogs, 'put').resolves(); + return { put, log }; + }; + + it('fetches the action, runs the handler in batches, records the log, and deletes the action', () => { + const action = buildAction(); + const { put, log } = stubDb(action, [ { id: 'a' }, { id: 'b' } ]); + const handler = sinon.stub().resolves([]); + service.__set__('HANDLERS', { 'set-contact': handler }); + + return service.processAction(actionId).then(() => { + expect(db.sentinel.get.firstCall.args[0]).to.equal(actionId); + expect(handler.calledOnce).to.equal(true); + expect(handler.args[0][0].map(op => op.id)).to.deep.equal([ 'a', 'b' ]); + expect(handler.args[0][1]).to.equal(actionId); // the action id is passed for logging + + const entry = log.actions[actionId]; + expect(entry.status).to.equal('completed'); + expect(entry.total_changes_count).to.equal(2); + expect(entry.failed_operations).to.be.undefined; + + expect(put.args.some(callArgs => callArgs[0]._deleted === true)).to.equal(true); + }); + }); + + it('records failed operations on the log', () => { + const action = buildAction(); + const { log } = stubDb(action, [ { id: 'a' }, { id: 'b' } ]); + service.__set__('HANDLERS', { 'set-contact': sinon.stub().resolves([ { id: 'b' } ]) }); + + return service.processAction(actionId).then(() => { + const entry = log.actions[actionId]; + expect(entry.status).to.equal('failed'); + expect(entry.total_changes_count).to.equal(2); + expect(entry.failed_operations.map(op => op.id)).to.deep.equal([ 'b' ]); + }); + }); + + it('records the action failed and still deletes it when there is no handler', () => { + const { put, log } = stubDb(buildAction({ action: 'archive' }), []); + service.__set__('HANDLERS', {}); + + return service + .processAction(actionId) + .then(() => chai.expect.fail('should have thrown')) + .catch(err => { + expect(err.message).to.contain('no handler'); + expect(log.actions[actionId].status).to.equal('failed'); + expect(put.args.some(callArgs => callArgs[0]._deleted === true)).to.equal(true); + }); + }); + + it('treats an unexpected handler error as a failed batch and still records and deletes', () => { + const { put, log } = stubDb(buildAction(), [ { id: 'a' }, { id: 'b' } ]); + service.__set__('HANDLERS', { 'set-contact': sinon.stub().rejects(new Error('boom')) }); + + return service.processAction(actionId).then(() => { + const entry = log.actions[actionId]; + expect(entry.status).to.equal('failed'); + expect(entry.failed_operations.map(op => op.id)).to.deep.equal([ 'a', 'b' ]); + expect(put.args.some(callArgs => callArgs[0]._deleted === true)).to.equal(true); + }); + }); + + it('is a no-op when the action doc is already gone', () => { + sinon.stub(db.sentinel, 'get').rejects({ status: 404 }); + const medicGet = sinon.stub(db.medicLogs, 'get'); + + return service.processAction(actionId).then(() => { + expect(medicGet.called).to.equal(false); + }); + }); + + it('skips recording when the log doc is missing, without crashing', () => { + const action = buildAction(); + sinon.stub(db.sentinel, 'get').resolves(action); + const put = sinon.stub(db.sentinel, 'put').resolves(); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from(JSON.stringify([ { id: 'a' }, { id: 'b' } ]))); + sinon.stub(db.medicLogs, 'get').rejects({ status: 404 }); + const logPut = sinon.stub(db.medicLogs, 'put').resolves(); + service.__set__('HANDLERS', { 'set-contact': sinon.stub().resolves([]) }); + + return service.processAction(actionId).then(() => { + expect(logPut.called).to.equal(false); + // the action is still removed + expect(put.args.some(callArgs => callArgs[0]._deleted === true)).to.equal(true); + }); + }); + + it('resolves safely when there are no operations to process', () => { + const action = buildAction({ total: 0 }); + const { put } = stubDb(action, []); + service.__set__('HANDLERS', { 'set-contact': sinon.stub().resolves([]) }); + + return service.processAction(actionId).then(() => { + expect(put.args.some(callArgs => callArgs[0]._deleted === true)).to.equal(true); + }); + }); + }); + + describe('listen', () => { + it('registers the feed before loading the queue, enqueues ids, dedupes, and ignores irrelevant changes', () => { + const processStub = sinon.stub(); + processStub.onFirstCall().rejects(new Error('processing error')); // exercises the queue error handler + processStub.resolves(); + service.__set__('processAction', processStub); + + let onChange; + const changes = sinon.stub(db.sentinel, 'changes').returns({ + on(event, cb) { + if (event === 'change') { + onChange = cb; + } + return this; + }, + }); + const allDocs = sinon.stub(db.sentinel, 'allDocs').resolves({ rows: [ { id: 'bulk-operation-action:op:1' } ] }); + + return service + .listen() + .then(() => { + expect(changes.calledBefore(allDocs)).to.equal(true); // feed registered before the initial queue + onChange({ id: 'bulk-operation-action:op:2' }); + onChange({ id: 'bulk-operation-action:op:2' }); // already queued -> deduped + onChange({ id: 'bulk-operation-action:op:3', deleted: true }); // ignored: deleted + onChange({ id: 'not-an-action' }); // ignored: wrong prefix + return new Promise(resolve => setTimeout(resolve, 20)); + }) + .then(() => { + expect(processStub.args.map(a => a[0])).to.deep.equal([ + 'bulk-operation-action:op:1', + 'bulk-operation-action:op:2', + ]); + }); + }); + }); +}); diff --git a/sentinel/tests/unit/lib/bulk-operations/set-contact.spec.js b/sentinel/tests/unit/lib/bulk-operations/set-contact.spec.js new file mode 100644 index 00000000000..1846f8896f9 --- /dev/null +++ b/sentinel/tests/unit/lib/bulk-operations/set-contact.spec.js @@ -0,0 +1,77 @@ +const sinon = require('sinon'); +const { expect } = require('chai'); + +const db = require('../../../../src/db'); +const { setContact } = require('../../../../src/lib/bulk-operations/set-contact'); + +describe('bulk-operations set-contact handler', () => { + afterEach(() => sinon.restore()); + + it('applies matching operations and returns no failures', async () => { + const batch = [ + { id: 'place-1', contact: { _id: 'new-1' }, current_contact_id: 'old-1' }, + { id: 'place-2', current_contact_id: 'old-2' }, // no `contact` means clear it + ]; + sinon.stub(db.medic, 'allDocs').resolves({ rows: [ + { doc: { _id: 'place-1', contact: { _id: 'old-1' } } }, + { doc: { _id: 'place-2', contact: 'old-2' } }, + ] }); + const bulkDocs = sinon.stub(db.medic, 'bulkDocs').resolves([ { ok: true }, { ok: true } ]); + + const failed = await setContact(batch, 'action-1'); + + expect(failed).to.deep.equal([]); + const updated = bulkDocs.args[0][0]; + expect(updated.find(d => d._id === 'place-1').contact).to.deep.equal({ _id: 'new-1' }); + expect(updated.find(d => d._id === 'place-2').contact).to.be.undefined; + }); + + it('fails an operation whose write is rejected by couch', async () => { + sinon.stub(db.medic, 'allDocs').resolves({ rows: [ + { doc: { _id: 'place-1', contact: { _id: 'old-1' } } }, + ] }); + sinon.stub(db.medic, 'bulkDocs').resolves([ { id: 'place-1', error: 'conflict' } ]); + + const failed = await setContact( + [ { id: 'place-1', contact: { _id: 'new-1' }, current_contact_id: 'old-1' } ], + 'action-1' + ); + + expect(failed.map(op => op.id)).to.deep.equal([ 'place-1' ]); + }); + + it('fails an operation whose doc is missing', async () => { + sinon.stub(db.medic, 'allDocs').resolves({ rows: [ { key: 'gone', error: 'not_found' } ] }); + const bulkDocs = sinon.stub(db.medic, 'bulkDocs').resolves([]); + + const failed = await setContact([ { id: 'gone', current_contact_id: 'old' } ], 'action-1'); + + expect(failed.map(op => op.id)).to.deep.equal([ 'gone' ]); + expect(bulkDocs.called).to.equal(false); + }); + + it('fails an operation whose contact has changed since it was queued', async () => { + sinon.stub(db.medic, 'allDocs').resolves({ rows: [ + { doc: { _id: 'place-1', contact: { _id: 'changed-since' } } }, + ] }); + const bulkDocs = sinon.stub(db.medic, 'bulkDocs').resolves([]); + + const failed = await setContact( + [ { id: 'place-1', contact: { _id: 'new' }, current_contact_id: 'old-1' } ], + 'action-1' + ); + + expect(failed.map(op => op.id)).to.deep.equal([ 'place-1' ]); + expect(bulkDocs.called).to.equal(false); + }); + + it('fails an operation with no id without querying', async () => { + const allDocs = sinon.stub(db.medic, 'allDocs').resolves({ rows: [] }); + sinon.stub(db.medic, 'bulkDocs').resolves([]); + + const failed = await setContact([ { current_contact_id: 'x' } ], 'action-1'); + + expect(failed).to.have.length(1); + expect(allDocs.called).to.equal(false); + }); +}); diff --git a/shared-libs/constants/src/index.js b/shared-libs/constants/src/index.js index 7af3a111f20..914d7a2c168 100644 --- a/shared-libs/constants/src/index.js +++ b/shared-libs/constants/src/index.js @@ -68,6 +68,23 @@ const PREFIXES = { UI_EXTENSION: `${DOC_TYPES.UI_EXTENSION}:`, FORM: 'form:', ARCHIVE_JOB: 'archive:', + BULK_OPERATION_LOG: 'bulk-operation:', + BULK_OPERATION_ACTION: 'bulk-operation-action:', +}; + +// Bulk operation framework (delete, move, merge) shared between the api and sentinel. +const BULK_OPERATIONS = { + OPERATIONS_ATTACHMENT: 'operations', + ACTIONS: { + ARCHIVE: 'archive', + SET_CONTACT: 'set-contact', + DELETE_USER: 'delete-user', + }, + STATUSES: { + QUEUED: 'queued', + COMPLETED: 'completed', + FAILED: 'failed', + }, }; module.exports = { @@ -80,4 +97,5 @@ module.exports = { CONTACT_TYPES, STANDARD_HTTP_HEADERS, PREFIXES, + BULK_OPERATIONS, }; diff --git a/tests/integration/api/controllers/bulk-operations.spec.js b/tests/integration/api/controllers/bulk-operations.spec.js new file mode 100644 index 00000000000..ae5690c0a2f --- /dev/null +++ b/tests/integration/api/controllers/bulk-operations.spec.js @@ -0,0 +1,97 @@ +const utils = require('@utils'); +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); +const userFactory = require('@factories/cht/users/users'); +const reportFactory = require('@factories/cht/reports/generic-report'); +const { CONTACT_TYPES } = require('@medic/constants'); +const { expect } = require('chai'); + +describe('Bulk operations API', () => { + const contact0 = utils.deepFreeze(personFactory.build({ name: 'contact0', role: 'chw' })); + const placeMap = utils.deepFreeze(placeFactory.generateHierarchy()); + const place0 = utils.deepFreeze({ ...placeMap.get(CONTACT_TYPES.CLINIC), contact: { _id: contact0._id } }); + const place1 = utils.deepFreeze(placeMap.get(CONTACT_TYPES.HEALTH_CENTER)); + const place2 = utils.deepFreeze(placeMap.get('district_hospital')); + + const offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-bulk', + place: place0._id, + contact: { + _id: 'fixture:user:offline-bulk', + name: 'Offline User', + }, + roles: ['chw'] + })); + + const allDocItems = [contact0, place0, place1, place2]; + + const pollBulkOperation = async (id, tries = 30) => { + for (let i = 0; i < tries; i++) { + const log = await utils.request({ path: `/api/v1/bulk-operations/${id}` }); + const actions = Object.values(log.actions || {}); + if (actions.length && actions.every(action => action.status !== 'queued')) { + return log; + } + await utils.delayPromise(1000); + } + throw new Error(`bulk operation ${id} did not complete`); + }; + + before(async () => { + await utils.saveDocs(allDocItems); + await utils.createUsers([offlineUser]); + }); + + after(async () => { + await utils.revertDb([], true); + await utils.deleteUsers([offlineUser]); + }); + + describe('GET /api/v1/bulk-operations/:id', () => { + const endpoint = '/api/v1/bulk-operations'; + + it('throws 404 when no operation matches the id', async () => { + await expect(utils.request({ path: `${endpoint}/not-a-real-id` })) + .to.be.rejectedWith('404 - {"code":404,"error":"Bulk operation not found"}'); + }); + + it('throws 403 for an offline user', async () => { + const opts = { + path: `${endpoint}/whatever`, + auth: { username: offlineUser.username, password: offlineUser.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + + it('reports the operation as completed once a delete is processed', async () => { + const person = personFactory.build({ parent: { _id: place0._id, parent: place0.parent } }); + const report = reportFactory.report().build({ form: 'test-report' }, { patient: person }); + await utils.saveDocs([person, report]); + + const { id } = await utils.request({ path: `/api/v1/person/${person._id}`, method: 'DELETE' }); + expect(id).to.be.a('string'); + + const log = await pollBulkOperation(id); + expect(log._id).to.equal(id); + const actions = Object.values(log.actions); + expect(actions).to.have.lengthOf(1); + expect(actions[0]).to.include({ action: 'archive', status: 'completed' }); + }); + }); + + describe('DELETE end to end', () => { + it('archives and purges the deleted contact and its reports', async function () { + this.timeout(60000); + const person = personFactory.build({ parent: { _id: place0._id, parent: place0.parent } }); + const report = reportFactory.report().build({ form: 'test-report' }, { patient: person }); + await utils.saveDocs([person, report]); + + const { id } = await utils.request({ path: `/api/v1/person/${person._id}`, method: 'DELETE' }); + await pollBulkOperation(id); + + const remaining = await utils.db.allDocs({ keys: [person._id, report._id] }); + const stillPresent = remaining.rows.filter(row => !row.error); + expect(stillPresent).to.have.lengthOf(0); + }); + }); +}); diff --git a/tests/integration/api/controllers/person.spec.js b/tests/integration/api/controllers/person.spec.js index 5eea2894d1a..e2c2210855d 100644 --- a/tests/integration/api/controllers/person.spec.js +++ b/tests/integration/api/controllers/person.spec.js @@ -2,6 +2,7 @@ const utils = require('@utils'); const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); const userFactory = require('@factories/cht/users/users'); +const reportFactory = require('@factories/cht/reports/generic-report'); const { USER_ROLES, CONTACT_TYPES } = require('@medic/constants'); const { expect } = require('chai'); @@ -492,4 +493,48 @@ describe('Person API', () => { }); }); }); + + describe('DELETE /api/v1/person/:uuid', () => { + const endpoint = '/api/v1/person'; + + it('returns a dry-run summary and deletes nothing', async () => { + const person = personFactory.build({ parent: { _id: place0._id, parent: place0.parent } }); + const reports = [ + reportFactory.report().build({ form: 'test-report' }, { patient: person }), + reportFactory.report().build({ form: 'test-report' }, { patient: person }), + ]; + await utils.saveDocs([person, ...reports]); + + const response = await utils.request({ + path: `${endpoint}/${person._id}`, + method: 'DELETE', + qs: { dry_run: true }, + }); + + expect(response).to.deep.equal({ + summary: { archive: { contacts: 1, reports: 2 }, 'set-contact': 0, 'delete-user': 0 }, + }); + const stillThere = await utils.getDoc(person._id); + expect(stillThere._id).to.equal(person._id); + }); + + it('throws 404 when the id is a place, not a person', async () => { + await expect(utils.request({ path: `${endpoint}/${place0._id}`, method: 'DELETE' })) + .to.be.rejectedWith('404 - {"code":404,"error":"Person not found"}'); + }); + + [ + ['does not have can_delete_contact_hierarchy permission', userNoPerms], + ['is not an online user', offlineUser] + ].forEach(([description, user]) => { + it(`throws 403 when user ${description}`, async () => { + const opts = { + path: `${endpoint}/${patient._id}`, + method: 'DELETE', + auth: { username: user.username, password: user.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + }); + }); }); diff --git a/tests/integration/api/controllers/place.spec.js b/tests/integration/api/controllers/place.spec.js index f58fe1f9d1b..3494e49da8e 100644 --- a/tests/integration/api/controllers/place.spec.js +++ b/tests/integration/api/controllers/place.spec.js @@ -2,6 +2,7 @@ const utils = require('@utils'); const placeFactory = require('@factories/cht/contacts/place'); const personFactory = require('@factories/cht/contacts/person'); const userFactory = require('@factories/cht/users/users'); +const reportFactory = require('@factories/cht/reports/generic-report'); const { USER_ROLES, CONTACT_TYPES } = require('@medic/constants'); const { expect } = require('chai'); @@ -591,4 +592,76 @@ describe('Place API', () => { }); }); }); + + describe('DELETE /api/v1/place/:uuid', () => { + const endpoint = '/api/v1/place'; + + it('returns a dry-run summary of the subtree and deletes nothing', async () => { + const hc = placeFactory.place().build({ name: 'del-hc', type: CONTACT_TYPES.HEALTH_CENTER, contact: {} }); + const clinic = placeFactory.place().build({ + name: 'del-clinic', + type: CONTACT_TYPES.CLINIC, + contact: {}, + parent: { _id: hc._id }, + }); + const person = personFactory.build({ parent: { _id: clinic._id, parent: { _id: hc._id } } }); + const report = reportFactory.report().build({ form: 'test-report' }, { patient: person }); + await utils.saveDocs([hc, clinic, person, report]); + + const response = await utils.request({ + path: `${endpoint}/${hc._id}`, + method: 'DELETE', + qs: { dry_run: true }, + }); + + expect(response).to.deep.equal({ + summary: { archive: { contacts: 3, reports: 1 }, 'set-contact': 0, 'delete-user': 0 }, + }); + const stillThere = await utils.getDoc(hc._id); + expect(stillThere._id).to.equal(hc._id); + }); + + it('throws 400 when a linked user would be left behind and delete_users is not set', async () => { + const clinic = placeFactory.place().build({ + name: 'del-clinic-user', + type: CONTACT_TYPES.CLINIC, + contact: {}, + parent: { _id: place1._id, parent: { _id: place2._id } }, + }); + await utils.saveDocs([clinic]); + const linkedUser = userFactory.build({ + username: 'del-linked-user', + place: clinic._id, + contact: { _id: 'fixture:user:del-linked-user', name: 'Linked User' }, + roles: ['chw'], + }); + await utils.createUsers([linkedUser]); + + try { + await expect(utils.request({ path: `${endpoint}/${clinic._id}`, method: 'DELETE' })) + .to.be.rejectedWith(/400 - .*user\(s\) are linked to contacts/); + } finally { + await utils.deleteUsers([linkedUser]); + } + }); + + it('throws 404 when the id is a person, not a place', async () => { + await expect(utils.request({ path: `${endpoint}/${contact0._id}`, method: 'DELETE' })) + .to.be.rejectedWith('404 - {"code":404,"error":"Place not found"}'); + }); + + [ + ['does not have can_delete_contact_hierarchy permission', userNoPerms], + ['is not an online user', offlineUser] + ].forEach(([description, user]) => { + it(`throws 403 when user ${description}`, async () => { + const opts = { + path: `${endpoint}/${place0._id}`, + method: 'DELETE', + auth: { username: user.username, password: user.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + }); + }); });