diff --git a/api/src/controllers/archive.js b/api/src/controllers/archive.js new file mode 100644 index 00000000000..04c9328795b --- /dev/null +++ b/api/src/controllers/archive.js @@ -0,0 +1,101 @@ +const readline = require('node:readline'); +const { v7: uuid } = require('uuid'); +const logger = require('@medic/logger'); +const archivingUtils = require('@medic/archiving-utils'); +const constants = require('@medic/constants'); + +const db = require('../db'); +const auth = require('../auth'); +const serverUtils = require('../server-utils'); +const errors = require('../errors'); + +const MAX_IDS_PER_JOB = 100 * 1000; +const EXPECTED_CONTENT_TYPE = 'text/csv'; +const parseCell = (line) => line.trim().replace(/^"(.*)"$/, '$1'); + +const checkAdmin = async (req) => { + const userCtx = await auth.getUserCtx(req); + if (!auth.isDbAdmin(userCtx)) { + throw new errors.AuthenticationError('User is not an admin'); + } +}; + +const checkContentType = (req) => { + if (!req.is(EXPECTED_CONTENT_TYPE)) { + throw new errors.ContentTypeError(`Content-Type must be ${EXPECTED_CONTENT_TYPE}`); + } +}; + +const buildJobId = () => `${constants.PREFIXES.ARCHIVE_JOB}${uuid()}`; + +const persistJob = async (jobs, ids) => { + if (!ids.length) { + return; + } + + const doc = { + _id: buildJobId(), + type: constants.PREFIXES.ARCHIVE_JOB, + date: Date.now(), + total: ids.length, + cursor: 0, + _attachments: { + [archivingUtils.ATTACHMENT_NAME]: { + content_type: archivingUtils.ATTACHMENT_TYPE, + data: archivingUtils.encodeIds(ids), + }, + }, + }; + + await db.sentinel.put(doc); + jobs.push({ id: doc._id, count: doc.total }); +}; + +const flushIfFull = async (jobs, buffer) => { + if (buffer.length < MAX_IDS_PER_JOB) { + return buffer; + } + await persistJob(jobs, buffer); + return []; +}; + +const processPayload = async (req) => { + const jobs = []; + let buffer = []; + const rl = readline.createInterface({ input: req, crlfDelay: Infinity }); + + for await (const line of rl) { + const id = parseCell(line); + if (!id) { + continue; + } + buffer.push(id); + buffer = await flushIfFull(jobs, buffer); + } + + await persistJob(jobs, buffer); + + if (!jobs.length) { + throw new errors.BadRequestError('No valid doc IDs found in request body'); + } + return jobs; +}; + +module.exports = { + create: async (req, res) => { + try { + await checkAdmin(req); + checkContentType(req); + } catch (err) { + return serverUtils.error(err, req, res); + } + + try { + const jobs = await processPayload(req); + res.status(201).json({ jobs }); + } catch (err) { + logger.error('Failed to create archive jobs: %o', err); + return serverUtils.error(err, req, res); + } + }, +}; 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/db.js b/api/src/db.js index 9ea6254dd05..2d1f1bcbdfd 100644 --- a/api/src/db.js +++ b/api/src/db.js @@ -23,6 +23,7 @@ if (UNIT_TEST_ENV) { 'builds', 'vault', 'cache', + 'archive', ]; const DB_FUNCTIONS_TO_STUB = [ 'allDocs', @@ -103,6 +104,7 @@ if (UNIT_TEST_ENV) { module.exports.vault = new PouchDB(`${environment.couchUrl}-vault`, { fetch: fetchFn }); module.exports.createVault = () => module.exports.vault.info(); module.exports.users = new PouchDB(getDbUrl('_users'), { fetch: fetchFn }); + module.exports.archive = new PouchDB(`${environment.couchUrl}-archive`, { fetch: fetchFn }); module.exports.builds = new PouchDB(environment.buildsUrl); // Get the DB with the given name diff --git a/api/src/errors.js b/api/src/errors.js index 9d94cb0e1c0..90b02e0d0ae 100644 --- a/api/src/errors.js +++ b/api/src/errors.js @@ -27,9 +27,26 @@ class AuthenticationError extends Error { } } +class ContentTypeError extends Error { + constructor(message, ...args) { + super(message, ...args); + this.code = 415; + } +} + +class BadRequestError extends Error { + constructor(message, ...args) { + super(message, ...args); + this.code = 400; + } +} + + module.exports = { PublicError, NotFoundError, PermissionError, AuthenticationError, + ContentTypeError, + BadRequestError, }; diff --git a/api/src/routing.js b/api/src/routing.js index 1a8a69e4c7c..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'); @@ -53,6 +54,7 @@ const infodoc = require('./controllers/infodoc'); const impact = require('./controllers/impact'); const targetController = require('./controllers/target'); const credentials = require('./controllers/credentials'); +const archive = require('./controllers/archive'); const authorization = require('./middleware/authorization'); const deprecation = require('./middleware/deprecation'); const hydration = require('./controllers/hydration'); @@ -476,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); @@ -659,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 @@ -734,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); @@ -796,6 +802,13 @@ app.put( credentials.put ); +app.post( + '/api/v1/archive', + authorization.handleAuthErrors, + authorization.offlineUserFirewall, + archive.create +); + app.get('/api/v1/users-doc-count', replicationLimitLogController.get); app.get('/api/v1/replication-failure-logs', replicationFailureLogController.get); app.get('/api/v1/replication-health/failed', replicationHealthController.failed); 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/config-watcher.js b/api/src/services/config-watcher.js index ec9d6f40647..1010253aca3 100644 --- a/api/src/services/config-watcher.js +++ b/api/src/services/config-watcher.js @@ -152,6 +152,7 @@ const load = () => { const listen = () => { dbWatcher.listen(); dbWatcher.medic(change => { + logger.debug('Medic change: %s %s', change.id, change.changes?.[0]?.rev); if (tombstoneUtils.isTombstoneId(change.id)) { return Promise.resolve(); } 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/src/services/replication/replication.js b/api/src/services/replication/replication.js index e0a29d5141a..c9bd2109eea 100644 --- a/api/src/services/replication/replication.js +++ b/api/src/services/replication/replication.js @@ -40,6 +40,11 @@ const getDocIdsRevPairs = async (docIds) => { .map(row => ({ id: row.id, rev: row.value.rev })); }; +const getArchivedDocs = async (docIds) => { + const result = await db.archive.allDocs({ keys: docIds }); + return result.rows.filter(row => !row.error).map(row => row.id); +}; + const getDocIdsToDelete = async (userCtx, docIds) => { if (!docIds.length) { return []; @@ -53,6 +58,9 @@ const getDocIdsToDelete = async (userCtx, docIds) => { const toPurge = await purgedDocs.getPurgedIds(userCtx, docIds, false); toDelete.push(...toPurge); + const toArchive = await getArchivedDocs(docIds); + toDelete.push(...toArchive); + return toDelete; }; diff --git a/api/src/services/settings.js b/api/src/services/settings.js index b1b99e28a87..3970c35a889 100644 --- a/api/src/services/settings.js +++ b/api/src/services/settings.js @@ -107,7 +107,10 @@ module.exports = { if (JSON.stringify(doc.settings) !== original) { info('Updating settings with new defaults'); - return db.medic.put(doc).then(() => true); + return db.medic.put(doc).then((res) => { + info(`settings rev ${res?.rev}`); + return true; + }); } info('Not updating settings - the existing settings are already up to date'); diff --git a/api/tests/mocha/controllers/archive.spec.js b/api/tests/mocha/controllers/archive.spec.js new file mode 100644 index 00000000000..10c0c28a696 --- /dev/null +++ b/api/tests/mocha/controllers/archive.spec.js @@ -0,0 +1,211 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const rewire = require('rewire'); +const { Readable } = require('stream'); + +const db = require('../../../src/db'); +const auth = require('../../../src/auth'); +const serverUtils = require('../../../src/server-utils'); +const errors = require('../../../src/errors'); + +const newReq = (lines = [], { contentType = 'text/csv' } = {}) => { + const body = lines.length ? lines.join('\n') + '\n' : ''; + const stream = Readable.from([body]); + stream.params = {}; + stream.headers = { 'content-type': contentType }; + stream.is = type => { + const main = (contentType || '').split(';')[0].trim().toLowerCase(); + return main === type ? type : false; + }; + return stream; +}; + +const newRes = () => ({ + json: sinon.stub(), + status: sinon.stub().returnsThis(), +}); + +describe('Archive controller', () => { + let controller; + + beforeEach(() => { + controller = rewire('../../../src/controllers/archive'); + }); + + afterEach(() => sinon.restore()); + + describe('create', () => { + it('responds with an AuthenticationError when caller is not a db admin', async () => { + sinon.stub(auth, 'getUserCtx').resolves({ roles: [] }); + sinon.stub(auth, 'isDbAdmin').returns(false); + sinon.stub(serverUtils, 'error').returns(); + sinon.stub(db.sentinel, 'put'); + + const req = newReq(['a', 'b']); + const res = newRes(); + await controller.create(req, res); + + chai.expect(serverUtils.error.callCount).to.equal(1); + const err = serverUtils.error.args[0][0]; + chai.expect(err).to.be.an.instanceOf(errors.AuthenticationError); + chai.expect(err.code).to.equal(401); + chai.expect(err.message).to.equal('User is not an admin'); + chai.expect(db.sentinel.put.callCount).to.equal(0); + }); + + it('writes one job containing every doc id from the body', async () => { + sinon.stub(auth, 'getUserCtx').resolves({}); + sinon.stub(auth, 'isDbAdmin').returns(true); + sinon.stub(db.sentinel, 'put').resolves({ id: 'x', rev: '1-a' }); + + const req = newReq(['doc-1', 'doc-2', 'doc-3']); + const res = newRes(); + await controller.create(req, res); + + chai.expect(db.sentinel.put.callCount).to.equal(1); + const doc = db.sentinel.put.args[0][0]; + chai.expect(doc).to.include({ type: 'archive:', total: 3, cursor: 0 }); + chai.expect(doc).to.not.have.property('status'); + chai.expect(doc._id).to.match(/^archive:/); + chai.expect(doc._attachments.ids.content_type).to.equal('text/plain'); + chai.expect(doc._attachments.ids.data.toString('utf8')).to.equal('doc-1\ndoc-2\ndoc-3'); + chai.expect(res.status.calledWith(201)).to.equal(true); + chai.expect(res.json.callCount).to.equal(1); + chai.expect(res.json.args[0][0].jobs).to.have.length(1); + chai.expect(res.json.args[0][0].jobs[0]).to.deep.equal({ id: doc._id, count: 3 }); + }); + + it('strips surrounding quotes and skips blank lines', async () => { + sinon.stub(auth, 'getUserCtx').resolves({}); + sinon.stub(auth, 'isDbAdmin').returns(true); + sinon.stub(db.sentinel, 'put').resolves({ id: 'x', rev: '1-a' }); + + const req = newReq(['', '"doc-1"', ' doc-2 ', '']); + const res = newRes(); + await controller.create(req, res); + + const doc = db.sentinel.put.args[0][0]; + chai.expect(doc.total).to.equal(2); + chai.expect(doc._attachments.ids.data.toString('utf8')).to.equal('doc-1\ndoc-2'); + }); + + it('splits ids into multiple job docs at MAX_IDS_PER_JOB', async () => { + sinon.stub(auth, 'getUserCtx').resolves({}); + sinon.stub(auth, 'isDbAdmin').returns(true); + sinon.stub(db.sentinel, 'put').resolves({ id: 'x', rev: '1-a' }); + controller.__set__('MAX_IDS_PER_JOB', 3); + + const req = newReq(['a', 'b', 'c', 'd', 'e', 'f', 'g']); + const res = newRes(); + await controller.create(req, res); + + chai.expect(db.sentinel.put.callCount).to.equal(3); + chai.expect(db.sentinel.put.args[0][0].total).to.equal(3); + chai.expect(db.sentinel.put.args[1][0].total).to.equal(3); + chai.expect(db.sentinel.put.args[2][0].total).to.equal(1); + chai.expect(db.sentinel.put.args[0][0]._attachments.ids.data.toString('utf8')).to.equal('a\nb\nc'); + chai.expect(db.sentinel.put.args[2][0]._attachments.ids.data.toString('utf8')).to.equal('g'); + chai.expect(res.json.args[0][0].jobs.map(j => j.count)).to.deep.equal([3, 3, 1]); + }); + + it('rejects requests with a non-text/csv content-type with 415', async () => { + sinon.stub(auth, 'getUserCtx').resolves({}); + sinon.stub(auth, 'isDbAdmin').returns(true); + sinon.stub(db.sentinel, 'put'); + sinon.stub(serverUtils, 'error').returns(); + + const req = newReq(['doc-1'], { contentType: 'application/json' }); + const res = newRes(); + await controller.create(req, res); + + chai.expect(db.sentinel.put.callCount).to.equal(0); + chai.expect(serverUtils.error.callCount).to.equal(1); + const err = serverUtils.error.args[0][0]; + chai.expect(err).to.be.an.instanceOf(errors.ContentTypeError); + chai.expect(err.code).to.equal(415); + chai.expect(err.message).to.equal('Content-Type must be text/csv'); + }); + + it('accepts text/csv with charset parameters', async () => { + sinon.stub(auth, 'getUserCtx').resolves({}); + sinon.stub(auth, 'isDbAdmin').returns(true); + sinon.stub(db.sentinel, 'put').resolves({ id: 'x', rev: '1-a' }); + + const req = newReq(['doc-1'], { contentType: 'text/csv; charset=utf-8' }); + const res = newRes(); + await controller.create(req, res); + + chai.expect(db.sentinel.put.callCount).to.equal(1); + chai.expect(res.status.calledWith(201)).to.equal(true); + }); + + it('rejects an empty body with 400', async () => { + sinon.stub(auth, 'getUserCtx').resolves({}); + sinon.stub(auth, 'isDbAdmin').returns(true); + sinon.stub(db.sentinel, 'put'); + sinon.stub(serverUtils, 'error').returns(); + + const req = newReq([]); + const res = newRes(); + await controller.create(req, res); + + chai.expect(db.sentinel.put.callCount).to.equal(0); + chai.expect(res.json.callCount).to.equal(0); + chai.expect(serverUtils.error.callCount).to.equal(1); + const err = serverUtils.error.args[0][0]; + chai.expect(err).to.be.an.instanceOf(errors.BadRequestError); + chai.expect(err.code).to.equal(400); + chai.expect(err.message).to.equal('No valid doc IDs found in request body'); + }); + + it('rejects a body of only blank lines with 400', async () => { + sinon.stub(auth, 'getUserCtx').resolves({}); + sinon.stub(auth, 'isDbAdmin').returns(true); + sinon.stub(db.sentinel, 'put'); + sinon.stub(serverUtils, 'error').returns(); + + const req = newReq(['', ' ', '']); + const res = newRes(); + await controller.create(req, res); + + chai.expect(db.sentinel.put.callCount).to.equal(0); + chai.expect(serverUtils.error.callCount).to.equal(1); + chai.expect(serverUtils.error.args[0][0].code).to.equal(400); + }); + + it('surfaces errors emitted by the request stream', async () => { + sinon.stub(auth, 'getUserCtx').resolves({}); + sinon.stub(auth, 'isDbAdmin').returns(true); + sinon.stub(db.sentinel, 'put'); + sinon.stub(serverUtils, 'error').returns(); + + const aborted = async function* () { + yield 'doc-1\n'; + throw new Error('client aborted'); + }; + const req = Readable.from(aborted()); + req.headers = { 'content-type': 'text/csv' }; + req.is = () => 'text/csv'; + const res = newRes(); + await controller.create(req, res); + + chai.expect(serverUtils.error.callCount).to.equal(1); + chai.expect(serverUtils.error.args[0][0].message).to.equal('client aborted'); + }); + + it('surfaces db errors via serverUtils.error', async () => { + sinon.stub(auth, 'getUserCtx').resolves({}); + sinon.stub(auth, 'isDbAdmin').returns(true); + sinon.stub(db.sentinel, 'put').rejects({ status: 500, message: 'boom' }); + sinon.stub(serverUtils, 'error').returns(); + + const req = newReq(['a']); + const res = newRes(); + await controller.create(req, res); + + chai.expect(serverUtils.error.callCount).to.equal(1); + chai.expect(serverUtils.error.args[0][0]).to.deep.include({ status: 500 }); + }); + }); + +}); 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/api/tests/mocha/services/replication/replication.spec.js b/api/tests/mocha/services/replication/replication.spec.js index 94b376f10f0..6da732a5ae2 100644 --- a/api/tests/mocha/services/replication/replication.spec.js +++ b/api/tests/mocha/services/replication/replication.spec.js @@ -276,12 +276,20 @@ describe('Initial Replication service', () => { }); sinon.stub(purgedDocs, 'getPurgedIds').resolves([]); + sinon.stub(db.archive, 'allDocs').resolves({ + rows: [ + { key: 'doc1', error: 'not_found' }, + { key: 'doc2', error: 'not_found' }, + { key: 'doc3', error: 'not_found' }, + ] + }); const result = await replication.getDocIdsToDelete(userCtx, ['doc1', 'doc2', 'doc3']); expect(result).to.have.members(['doc2', 'doc3']); expect(db.medic.allDocs.args).to.deep.equal([[{ keys: ['doc1', 'doc2', 'doc3'] }]]); expect(purgedDocs.getPurgedIds.args).to.deep.equal([[userCtx, ['doc1', 'doc2', 'doc3'], false]]); + expect(db.archive.allDocs.args).to.deep.equal([[{ keys: ['doc1', 'doc2', 'doc3'] }]]); }); it('should return purged docs', async () => { @@ -294,12 +302,20 @@ describe('Initial Replication service', () => { }); sinon.stub(purgedDocs, 'getPurgedIds').resolves(['doc1', 'doc2']); + sinon.stub(db.archive, 'allDocs').resolves({ + rows: [ + { key: 'doc1', error: 'not_found' }, + { key: 'doc2', error: 'not_found' }, + { key: 'doc3', error: 'not_found' }, + ] + }); const result = await replication.getDocIdsToDelete(userCtx, ['doc1', 'doc2', 'doc3']); expect(result).to.have.members(['doc1', 'doc2']); expect(db.medic.allDocs.args).to.deep.equal([[{ keys: ['doc1', 'doc2', 'doc3'] }]]); expect(purgedDocs.getPurgedIds.args).to.deep.equal([[userCtx, ['doc1', 'doc2', 'doc3'], false]]); + expect(db.archive.allDocs.args).to.deep.equal([[{ keys: ['doc1', 'doc2', 'doc3'] }]]); }); it('should return deleted and purged docs', async () => { @@ -314,12 +330,68 @@ describe('Initial Replication service', () => { }); sinon.stub(purgedDocs, 'getPurgedIds').resolves(['doc1', 'doc5']); + sinon.stub(db.archive, 'allDocs').resolves({ + rows: [ + { key: 'doc1', error: 'not_found' }, + { key: 'doc2', error: 'not_found' }, + { key: 'doc3', error: 'not_found' }, + { key: 'doc4', error: 'not_found' }, + { key: 'doc5', error: 'not_found' }, + ] + }); const result = await replication.getDocIdsToDelete(userCtx, ['doc1', 'doc2', 'doc3', 'doc4', 'doc5']); expect(result).to.have.members(['doc1', 'doc2', 'doc4', 'doc5']); expect(db.medic.allDocs.args).to.deep.equal([[{ keys: ['doc1', 'doc2', 'doc3', 'doc4', 'doc5'] }]]); expect(purgedDocs.getPurgedIds.args).to.deep.equal([[userCtx, ['doc1', 'doc2', 'doc3', 'doc4', 'doc5'], false]]); + expect(db.archive.allDocs.args).to.deep.equal([[{ keys: ['doc1', 'doc2', 'doc3', 'doc4', 'doc5'] }]]); + }); + + it('should return archived docs', async () => { + sinon.stub(db.medic, 'allDocs').resolves({ + rows: [ + { key: 'doc1', id: 'doc1', value: { rev: 1 } }, + { key: 'doc2', id: 'doc2', value: { rev: 1 } }, + { key: 'doc3', id: 'doc3', value: { rev: 1 } }, + ] + }); + + sinon.stub(purgedDocs, 'getPurgedIds').resolves([]); + sinon.stub(db.archive, 'allDocs').resolves({ + rows: [ + { key: 'doc1', id: 'doc1', value: { rev: '1-a' } }, + { key: 'doc2', error: 'not_found' }, + { key: 'doc3', id: 'doc3', value: { rev: '1-a' } }, + ] + }); + + const result = await replication.getDocIdsToDelete(userCtx, ['doc1', 'doc2', 'doc3']); + expect(result).to.have.members(['doc1', 'doc3']); + }); + + it('should combine deleted, purged and archived docs without duplicates', async () => { + sinon.stub(db.medic, 'allDocs').resolves({ + rows: [ + { key: 'doc1', id: 'doc1', value: { rev: 1 } }, + { key: 'doc2', error: 'deleted' }, + { key: 'doc3', id: 'doc3', value: { rev: 1 } }, + { key: 'doc4', id: 'doc4', value: { rev: 1 } }, + ] + }); + sinon.stub(purgedDocs, 'getPurgedIds').resolves(['doc3']); + sinon.stub(db.archive, 'allDocs').resolves({ + rows: [ + { key: 'doc1', id: 'doc1', value: { rev: '1-a' } }, + { key: 'doc2', error: 'not_found' }, + { key: 'doc3', error: 'not_found' }, + { key: 'doc4', error: 'not_found' }, + ] + }); + + const result = await replication.getDocIdsToDelete(userCtx, ['doc1', 'doc2', 'doc3', 'doc4']); + // doc2 (deleted), doc3 (purged), doc1 (archived) — doc4 stays. + expect(result).to.have.members(['doc1', 'doc2', 'doc3']); }); it('should throw error on db errors', async () => { @@ -333,5 +405,13 @@ describe('Initial Replication service', () => { await expect(replication.getDocIdsToDelete(userCtx, [1])).to.be.rejectedWith(Error, 'boom'); }); + + it('should throw error on archive db errors', async () => { + sinon.stub(db.medic, 'allDocs').resolves({ rows: [] }); + sinon.stub(purgedDocs, 'getPurgedIds').resolves([]); + sinon.stub(db.archive, 'allDocs').rejects(new Error('archive down')); + + await expect(replication.getDocIdsToDelete(userCtx, [1])).to.be.rejectedWith(Error, 'archive down'); + }); }); }); 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/couchdb/Dockerfile b/couchdb/Dockerfile index ff928960dda..d9afbbeaaee 100644 --- a/couchdb/Dockerfile +++ b/couchdb/Dockerfile @@ -1,4 +1,4 @@ -FROM couchdb:3.5.2 as base_couchdb_build +FROM couchdb:3.5.2 AS base_couchdb_build COPY --chown=couchdb:couchdb 10-docker-default.ini /opt/couchdb/etc/default.d/ COPY --chown=couchdb:couchdb vm.args /opt/couchdb/etc/ diff --git a/package-lock.json b/package-lock.json index 0ceae7bab9f..70770ce3f0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "./shared-libs/*" ], "dependencies": { + "@medic/archiving-utils": "^1.0.0", "async": "^3.2.6", "bikram-sambat": "^1.8.1", "body-parser": "^1.20.6", @@ -6362,6 +6363,10 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@medic/archiving-utils": { + "resolved": "shared-libs/archiving-utils", + "link": true + }, "node_modules/@medic/audit": { "resolved": "shared-libs/audit", "link": true @@ -45569,6 +45574,11 @@ "url": "https://github.com/sponsors/wooorm" } }, + "shared-libs/archiving-utils": { + "name": "@medic/archiving-utils", + "version": "1.0.0", + "license": "Apache-2.0" + }, "shared-libs/audit": { "name": "@medic/audit", "version": "1.0.0", 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 bf873e61cad..3b162483c60 100644 --- a/sentinel/src/db.js +++ b/sentinel/src/db.js @@ -25,6 +25,7 @@ if (UNIT_TEST_ENV) { post: stubMe('post'), query: stubMe('query'), get: stubMe('get'), + getAttachment: stubMe('getAttachment'), changes: stubMe('changes'), }; @@ -35,9 +36,16 @@ if (UNIT_TEST_ENV) { post: stubMe('post'), query: stubMe('query'), get: stubMe('get'), + getAttachment: stubMe('getAttachment'), changes: stubMe('changes'), }; + module.exports.medicLogs = { + allDocs: stubMe('allDocs'), + get: stubMe('get'), + put: stubMe('put'), + }; + module.exports.users = { allDocs: stubMe('allDocs'), bulkDocs: stubMe('bulkDocs'), @@ -45,15 +53,23 @@ if (UNIT_TEST_ENV) { post: stubMe('post'), query: stubMe('query'), get: stubMe('get'), + getAttachment: stubMe('getAttachment'), changes: stubMe('changes'), }; + module.exports.archive = { + allDocs: stubMe('allDocs'), + bulkDocs: stubMe('bulkDocs'), + get: stubMe('get'), + put: stubMe('put'), + }; module.exports.allDbs = stubMe('allDbs'); module.exports.get = stubMe('get'); module.exports.close = stubMe('close'); module.exports.medicDbName = stubMe('medicDbName'); module.exports.queryMedic = stubMe('queryMedic'); + module.exports.purge = stubMe('purge'); } else { const service = 'sentinel'; environment.setService(service); @@ -76,7 +92,9 @@ if (UNIT_TEST_ENV) { }; module.exports.medic = new PouchDB(couchUrl, { fetch: fetchFn }); - module.exports.sentinel = new PouchDB(`${couchUrl}-sentinel`, { 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}`); module.exports.close = db => { @@ -91,6 +109,7 @@ if (UNIT_TEST_ENV) { } }; module.exports.users = new PouchDB(`${environment.serverUrl}/_users`, { fetch: fetchFn }); + module.exports.queryMedic = (viewPath, queryParams, body) => { const [ddoc, view] = viewPath.split('/'); const url = ddoc === 'allDocs' ? `${couchUrl}/_all_docs` : `${couchUrl}/_design/${ddoc}/_view/${view}`; @@ -102,4 +121,13 @@ if (UNIT_TEST_ENV) { body, }); }; + + module.exports.purge = (db, docs) => { + const purgePayload = Object.fromEntries(docs.map(doc => [ doc._id, [ doc._rev, ...doc._conflicts || [] ] ])); + + return request.post({ + url: `${db.name}/_purge`, + body: purgePayload, + }); + }; } diff --git a/sentinel/src/lib/archiving.js b/sentinel/src/lib/archiving.js new file mode 100644 index 00000000000..5eaecda317e --- /dev/null +++ b/sentinel/src/lib/archiving.js @@ -0,0 +1,187 @@ +const logger = require('@medic/logger'); +const db = require('../db'); +const request = require('@medic/couch-request'); +const constants = require('@medic/constants'); +const environment = require('@medic/environment'); +const audit = require('@medic/audit'); +const archivingUtils = require('@medic/archiving-utils'); + +const BATCH_SIZE = 1000; +const MAX_JOB_ATTEMPTS = 20; +const FAILED_STATUS = 'failed'; + +let currentlyArchiving = false; + +const fetchNextJob = async (startkey = constants.PREFIXES.ARCHIVE_JOB) => { + const result = await db.sentinel.allDocs({ + startkey, + endkey: `${constants.PREFIXES.ARCHIVE_JOB}\ufff0`, + include_docs: true, + limit: 1, + }); + return result.rows[0]?.doc; +}; + +const readIds = async (job) => { + const buffer = await db.sentinel.getAttachment(job._id, archivingUtils.ATTACHMENT_NAME); + return archivingUtils.decodeIds(buffer); +}; + +const canArchive = (doc) => { + const archivableDocTypes = [ + 'contact', + 'person', + ...Object.values(constants.CONTACT_TYPES), + constants.DOC_TYPES.DATA_RECORD, + 'task', + 'target', + ]; + return archivableDocTypes.includes(doc?.type); +}; + +const archiveBatch = async (batch) => { + const ids = batch.map(i => i.toString().trim()).filter(Boolean); + if (!ids.length) { + return; + } + const date = Date.now(); + + const medicDocs = await db.medic.allDocs({ attachments: true, keys: ids, include_docs: true, conflicts: true }); + + const rejected = []; + const docsToArchive = medicDocs.rows + .filter(row => { + if (canArchive(row.doc)) { + return true; + } + rejected.push(row.key); + return false; + }) + .map(row => ({ ...row.doc, archive_date: date })); + + await db.archive.bulkDocs(docsToArchive, { new_edits: false }); + await persistAudit(docsToArchive, date); + await purgeInfoDocs(docsToArchive); + await db.purge(db.medic, docsToArchive); + return rejected; +}; + +const purgeInfoDocs = async (docsToArchive) => { + const infoDocIds = docsToArchive.map(doc => `${doc._id}-info`); + const infoDocs = await db.sentinel.allDocs({ keys: infoDocIds, include_docs: true, conflicts: true }); + await db.purge(db.sentinel, infoDocs.rows.map(row => row.doc).filter(Boolean)); +}; + +const persistAudit = async (docsToArchive, date) => { + const ids = docsToArchive.map(doc => doc._id); + await audit.recordArchiving(ids, date); +}; + +const indexViews = async () => { + await Promise.all([ + db.medic.query('medic/contacts_by_depth', { limit: 1 }), + db.medic.query('medic-client/contacts_by_last_visited', { limit: 1 }), + request.get({ + url: `${environment.couchUrl}/_design/medic/_nouveau/docs_by_replication_key`, + qs: { limit: 1, q: '*:*' } + }) + ]); +}; + +const MAX_ERRORS_KEPT = 5; + +const saveJob = async (job, batchSize) => { + const latest = await db.sentinel.get(job._id); + job._rev = latest._rev; + job.cursor += batchSize; + job.history = job.history || []; + job.history.push({ date: Date.now(), cursor: job.cursor }); + if (job.cursor >= job.total) { + job._deleted = true; + } + await db.sentinel.put(job); +}; + + +const recordError = async (job, err) => { + try { + const latest = await db.sentinel.get(job._id); + job._rev = latest._rev; + job.error_count = (job.error_count || 0) + 1; + job.errors = job.errors || []; + job.errors.push({ date: Date.now(), message: err?.message || err?.stack || err }); + if (job.errors.length > MAX_ERRORS_KEPT) { + job.errors = job.errors.slice(-MAX_ERRORS_KEPT); + } + if (job.error_count >= MAX_JOB_ATTEMPTS) { + job.status = FAILED_STATUS; + logger.error(`Archiving: job ${job._id} failed ${job.error_count} times, quarantining it`); + } + await db.sentinel.put(job); + } catch (writeErr) { + logger.error(`Archiving: could not record error on job ${job._id}: %o`, writeErr); + } +}; + +const processJob = async (job, deadline) => { + logger.info(`Archiving: processing job ${job._id} (${job.cursor}/${job.total})`); + + try { + const ids = await readIds(job); + let batches = 0; + + do { + const batch = ids.slice(job.cursor, job.cursor + BATCH_SIZE); + await archiveBatch(batch); + await saveJob(job, batch.length); + if (++batches % 10 === 0) { + await indexViews(); + } + } while (job.cursor < job.total && Date.now() < deadline); + } catch (err) { + await recordError(job, err); + throw err; + } +}; + +const processQueue = async (deadline) => { + let startkey = constants.PREFIXES.ARCHIVE_JOB; + do { + const job = await fetchNextJob(startkey); + if (!job) { + break; + } + startkey = `${job._id}￰`; + if (job.status === FAILED_STATUS) { + continue; + } + try { + await processJob(job, deadline); + } catch (err) { + logger.error(`Archiving: job ${job._id} failed, skipping to the next job: %o`, err); + } + } while (Date.now() < deadline); +}; + +const archive = async ({ duration } = {}) => { + if (currentlyArchiving) { + return; + } + logger.info('Running archiving'); + currentlyArchiving = true; + const deadline = duration ? Date.now() + duration : Infinity; + + try { + await processQueue(deadline); + } catch (err) { + logger.error('Error while running archive: %o', err); + } finally { + logger.info('Finished archiving'); + currentlyArchiving = false; + } +}; + +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..8d1ca8faf4b --- /dev/null +++ b/sentinel/src/lib/bulk-operations/archive.js @@ -0,0 +1,32 @@ +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 { + const rejected = await archiving.archiveBatch(ids); + rejected.forEach(id => failed.push({ id })); + } 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..e3872c7d297 --- /dev/null +++ b/sentinel/src/lib/bulk-operations/index.js @@ -0,0 +1,167 @@ +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 = { + 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/src/schedule/archiving.js b/sentinel/src/schedule/archiving.js new file mode 100644 index 00000000000..ed70faa3e31 --- /dev/null +++ b/sentinel/src/schedule/archiving.js @@ -0,0 +1,47 @@ +const config = require('../config'); +const later = require('later'); +const moment = require('moment'); +const archiveLib = require('../lib/archiving'); +const scheduling = require('../lib/scheduling'); + +// set later to use local time +later.date.localTime(); +let archiveTimeout; + +const DURATION_PATTERN = /^(\d+)\s+(\w+)$/; + +// Parses a " " string (e.g. "4 hours", "30 minutes") into milliseconds. +// Returns null if the input is missing, malformed, or resolves to a non-positive duration. +const parseDuration = (text) => { + if (typeof text !== 'string') { + return null; + } + const match = DURATION_PATTERN.exec(text.trim()); + if (!match) { + return null; + } + const ms = moment.duration(Number.parseInt(match[1], 10), match[2]).asMilliseconds(); + return ms > 0 ? ms : null; +}; + +module.exports = { + execute: () => { + const archiveConfig = config.get('archive'); + const schedule = scheduling.getSchedule(archiveConfig); + + if (!schedule) { + return Promise.resolve(); + } + + const duration = parseDuration(archiveConfig?.duration); + + if (archiveTimeout) { + clearTimeout(archiveTimeout); + } + archiveTimeout = setTimeout( + () => archiveLib.archive({ duration }), + scheduling.nextScheduleMillis(schedule) + ); + return Promise.resolve(); + }, +}; diff --git a/sentinel/src/schedule/index.js b/sentinel/src/schedule/index.js index c717949bd32..1e2721723cd 100644 --- a/sentinel/src/schedule/index.js +++ b/sentinel/src/schedule/index.js @@ -42,6 +42,7 @@ const tasks = { replications: require('./replications'), outbound: require('./outbound'), purging: require('./purging'), + archiving: require('./archiving'), transitionsDisabledReminder: require('./transitions-disabled-reminder'), backgroundCleanup: require('./background-cleanup') }; diff --git a/sentinel/tests/unit/db.spec.js b/sentinel/tests/unit/db.spec.js index 0492126ff7d..10aa7ddd526 100644 --- a/sentinel/tests/unit/db.spec.js +++ b/sentinel/tests/unit/db.spec.js @@ -158,4 +158,48 @@ describe('db', () => { }); }); }); + + describe('purge', () => { + it('posts a {id: [rev, ...conflicts]} map to /_purge', async () => { + const fakeDb = { name: 'http://admin:pass@localhost:5984/medic' }; + sinon.stub(request, 'post').resolves({ purged: {} }); + + const docs = [ + { _id: 'doc-a', _rev: '1-a' }, + { _id: 'doc-b', _rev: '2-b', _conflicts: ['2-bb', '2-bbb'] }, + ]; + await db.purge(fakeDb, docs); + + expect(request.post.callCount).to.equal(1); + expect(request.post.args[0]).to.deep.equal([{ + url: 'http://admin:pass@localhost:5984/medic/_purge', + body: { + 'doc-a': ['1-a'], + 'doc-b': ['2-b', '2-bb', '2-bbb'], + }, + }]); + }); + + it('omits conflicts when the doc has none', async () => { + sinon.stub(request, 'post').resolves(); + await db.purge({ name: 'host/db' }, [{ _id: 'x', _rev: '3-x' }]); + expect(request.post.args[0][0].body).to.deep.equal({ x: ['3-x'] }); + }); + + it('handles an empty docs array', async () => { + sinon.stub(request, 'post').resolves(); + await db.purge({ name: 'host/db' }, []); + expect(request.post.args[0][0].body).to.deep.equal({}); + }); + + it('throws request.post errors', async () => { + sinon.stub(request, 'post').rejects(new Error('purge failed')); + try { + await db.purge({ name: 'host/db' }, [{ _id: 'x', _rev: '1-x' }]); + expect.fail('expected to reject'); + } catch (err) { + expect(err.message).to.equal('purge failed'); + } + }); + }); }); diff --git a/sentinel/tests/unit/lib/archiving.spec.js b/sentinel/tests/unit/lib/archiving.spec.js new file mode 100644 index 00000000000..549cec55176 --- /dev/null +++ b/sentinel/tests/unit/lib/archiving.spec.js @@ -0,0 +1,707 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const rewire = require('rewire'); + +const db = require('../../../src/db'); + +const job = (props = {}) => Object.assign({ + _id: 'archive:2026-05-18T00:00:00.000Z:uuid', + _rev: '1-a', + total: 0, + cursor: 0, +}, props); + +// allDocs / put / get fakes that walk a queue: allDocs returns the first non-deleted +// doc as rows[0], get returns the live doc (404 if deleted), put with _deleted flips +// the queue flag so subsequent allDocs / get behave like the doc is gone. +const stubQueue = (jobs) => { + const queue = jobs.map(j => ({ ...j })); + const putSnapshots = []; + let revCounter = 0; + + sinon.stub(db.sentinel, 'allDocs').callsFake((opts = {}) => { + const startkey = opts.startkey || ''; + const next = queue.find(j => !j._deleted && j._id >= startkey); + return Promise.resolve({ rows: next ? [{ doc: next }] : [] }); + }); + + sinon.stub(db.sentinel, 'get').callsFake(id => { + const target = queue.find(j => j._id === id); + if (!target || target._deleted) { + return Promise.reject(Object.assign(new Error('not_found'), { status: 404 })); + } + return Promise.resolve({ ...target }); + }); + + sinon.stub(db.sentinel, 'put').callsFake(doc => { + putSnapshots.push({ ...doc }); + const target = queue.find(j => j._id === doc._id); + const newRev = `${++revCounter}-x`; + if (doc._deleted) { + if (target) { + target._deleted = true; + } + } else if (target) { + Object.assign(target, doc, { _rev: newRev }); + } + return Promise.resolve({ id: doc._id, rev: newRev }); + }); + + return { queue, putSnapshots }; +}; + +describe('Sentinel archiving lib', () => { + let lib; + let clock; + + beforeEach(() => { + // Fake timers before rewire(): rewire binds `Date` at load time and useFakeTimers swaps the + // global Date object, so rewiring first would leave the module on the real Date. + clock = sinon.useFakeTimers({ toFake: ['Date'] }); + lib = rewire('../../../src/lib/archiving'); + // Disable archiveBatch and indexViews by default — the queue stubs don't model the + // medic / archive dbs or _purge, and most tests just want to observe the loop. + lib.__set__('archiveBatch', sinon.stub().resolves()); + lib.__set__('indexViews', sinon.stub().resolves()); + }); + + afterEach(() => { + sinon.restore(); + clock.restore(); + }); + + it('scans the archive: prefix range with limit:1 to find the next job', async () => { + sinon.stub(db.sentinel, 'allDocs').resolves({ rows: [] }); + + await lib.archive(); + + chai.expect(db.sentinel.allDocs.callCount).to.equal(1); + chai.expect(db.sentinel.allDocs.args[0][0]).to.deep.equal({ + startkey: 'archive:', + endkey: 'archive:\ufff0', + include_docs: true, + limit: 1, + }); + }); + + it('processes a job in batches and deletes the doc when cursor reaches total', async () => { + const pending = job({ _id: 'archive:1', total: 5 }); + const { queue, putSnapshots } = stubQueue([pending]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('a\nb\nc\nd\ne', 'utf8')); + lib.__set__('BATCH_SIZE', 2); + + const archiveBatch = sinon.stub().resolves(); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + chai.expect(archiveBatch.callCount).to.equal(3); + chai.expect(archiveBatch.args[0][0]).to.deep.equal(['a', 'b']); + chai.expect(archiveBatch.args[1][0]).to.deep.equal(['c', 'd']); + chai.expect(archiveBatch.args[2][0]).to.deep.equal(['e']); + + // Three saveJob calls: two intermediate puts (cursor 2, cursor 4), then one delete put. + chai.expect(putSnapshots).to.have.lengthOf(3); + chai.expect(putSnapshots[0]).to.include({ cursor: 2 }); + chai.expect(putSnapshots[0]._deleted).to.not.equal(true); + chai.expect(putSnapshots[1]).to.include({ cursor: 4 }); + chai.expect(putSnapshots[1]._deleted).to.not.equal(true); + chai.expect(putSnapshots[2]).to.include({ _id: 'archive:1', _deleted: true }); + chai.expect(queue[0]._deleted).to.equal(true); + }); + + it('appends a {date, cursor} entry to history on every saveJob, including across cycles', async () => { + const pending = job({ _id: 'archive:1', total: 4 }); + const { queue, putSnapshots } = stubQueue([pending]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('a\nb\nc\nd', 'utf8')); + lib.__set__('BATCH_SIZE', 2); + + clock.setSystemTime(1000); + const archiveBatch = sinon.stub().callsFake(() => { + clock.tick(10); + }); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + // Two saveJob calls: cursor 2 (mid-job), cursor 4 (last batch, also deletes). + // After both saves, the job's history holds one entry per saveJob. + chai.expect(putSnapshots).to.have.lengthOf(2); + chai.expect(queue[0].history).to.deep.equal([ + { date: 1010, cursor: 2 }, + { date: 1020, cursor: 4 }, + ]); + }); + + it('preserves prior-cycle history when resuming a job that was already partially saved', async () => { + const earlierHistory = [{ date: 500, cursor: 1 }]; + const resuming = job({ _id: 'archive:1', total: 3, cursor: 1, history: [...earlierHistory] }); + const { queue, putSnapshots } = stubQueue([resuming]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('a\nb\nc', 'utf8')); + lib.__set__('BATCH_SIZE', 10); + + clock.setSystemTime(2000); + const archiveBatch = sinon.stub().callsFake(() => { + clock.tick(5); + }); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + chai.expect(putSnapshots[0].history).to.deep.equal([ + ...earlierHistory, + { date: 2005, cursor: 3 }, + ]); + chai.expect(queue[0].history).to.deep.equal([ + ...earlierHistory, + { date: 2005, cursor: 3 }, + ]); + }); + + it('resumes a job from its existing cursor', async () => { + const resuming = job({ _id: 'archive:1', total: 4, cursor: 2 }); + stubQueue([resuming]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('a\nb\nc\nd', 'utf8')); + lib.__set__('BATCH_SIZE', 10); + + const archiveBatch = sinon.stub().resolves(); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + chai.expect(archiveBatch.callCount).to.equal(1); + chai.expect(archiveBatch.args[0][0]).to.deep.equal(['c', 'd']); + }); + + it('isolates a failing job and still processes the jobs behind it', async () => { + const failing = job({ _id: 'archive:1', total: 1 }); + const next = job({ _id: 'archive:2', total: 1 }); + const { queue } = stubQueue([failing, next]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('x', 'utf8')); + + // First job throws, second succeeds. + const archiveBatch = sinon.stub(); + archiveBatch.onCall(0).rejects(new Error('boom')); + archiveBatch.onCall(1).resolves(); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + chai.expect(archiveBatch.callCount).to.equal(2); + // Failing job is recorded but NOT deleted (retried on the next scheduled run). + chai.expect(queue[0]._deleted).to.not.equal(true); + chai.expect(queue[0].error_count).to.equal(1); + // The job behind it is no longer blocked — it ran to completion. + chai.expect(queue[1]._deleted).to.equal(true); + }); + + it('quarantines a job once it has failed MAX_JOB_ATTEMPTS times', async () => { + lib.__set__('MAX_JOB_ATTEMPTS', 3); + const failing = job({ _id: 'archive:1', total: 1, error_count: 2 }); + const { queue } = stubQueue([failing]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('x', 'utf8')); + + const archiveBatch = sinon.stub().rejects(new Error('boom')); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + // 3rd failure trips the threshold: status flips to 'failed', but the doc is kept for inspection. + chai.expect(queue[0].error_count).to.equal(3); + chai.expect(queue[0].status).to.equal('failed'); + chai.expect(queue[0]._deleted).to.not.equal(true); + }); + + it('skips a quarantined job and processes the next healthy one', async () => { + const quarantined = job({ _id: 'archive:1', total: 1, status: 'failed', error_count: 20 }); + const healthy = job({ _id: 'archive:2', total: 1 }); + const { queue } = stubQueue([quarantined, healthy]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('x', 'utf8')); + + const archiveBatch = sinon.stub().resolves(); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + // Quarantined job is never read or processed... + chai.expect(queue[0]._deleted).to.not.equal(true); + chai.expect(queue[0].status).to.equal('failed'); + // ...and the healthy job behind it still completes. + chai.expect(archiveBatch.callCount).to.equal(1); + chai.expect(queue[1]._deleted).to.equal(true); + }); + + it('records the error on the job doc when archiveBatch throws', async () => { + const failing = job({ _id: 'archive:1', total: 1 }); + const { queue } = stubQueue([failing]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('x', 'utf8')); + clock.setSystemTime(5000); + + const archiveBatch = sinon.stub().rejects(new Error('disk full')); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + chai.expect(queue[0].error_count).to.equal(1); + chai.expect(queue[0].errors).to.deep.equal([{ date: 5000, message: 'disk full' }]); + chai.expect(queue[0]._deleted).to.not.equal(true); + chai.expect(queue[0].cursor).to.equal(0); + }); + + it('caps the errors array at MAX_ERRORS_KEPT while error_count counts every failure', async () => { + const failing = job({ + _id: 'archive:1', + total: 1, + error_count: 4, + errors: [ + { date: 1, message: 'old-1' }, + { date: 2, message: 'old-2' }, + { date: 3, message: 'old-3' }, + { date: 4, message: 'old-4' }, + ], + }); + const { queue } = stubQueue([failing]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('x', 'utf8')); + clock.setSystemTime(100); + + const archiveBatch = sinon.stub().rejects(new Error('boom-5')); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + // Each lib.archive() call is one failed batch → one new error entry, error_count bumped. + await lib.archive(); + await lib.archive(); + + chai.expect(queue[0].error_count).to.equal(7); // 4 seeded + 3 new + chai.expect(queue[0].errors).to.have.lengthOf(5); + // Oldest two seeded entries fell off; latest three are the new failures. + chai.expect(queue[0].errors.map(e => e.message)).to.deep.equal([ + 'old-3', + 'old-4', + 'boom-5', + 'boom-5', + 'boom-5', + ]); + }); + + it('falls back to the stack when an error has no message', async () => { + const failing = job({ _id: 'archive:1', total: 1 }); + const { queue } = stubQueue([failing]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('x', 'utf8')); + clock.setSystemTime(9000); + + const stackOnly = { stack: 'Error\n at somewhere' }; + const archiveBatch = sinon.stub().rejects(stackOnly); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + chai.expect(queue[0].error_count).to.equal(1); + chai.expect(queue[0].errors[0].message).to.equal('Error\n at somewhere'); + }); + + it('falls back to the raw err value when it has neither message nor stack', async () => { + const failing = job({ _id: 'archive:1', total: 1 }); + const { queue } = stubQueue([failing]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('x', 'utf8')); + clock.setSystemTime(9999); + + // A non-Error rejection value with no `message` and no `stack`. sinon.rejects + // forwards non-Error/non-string values as-is, so recordError sees the bare object + // and falls through to the final `|| err` branch. + const rawErr = { code: 42 }; + const archiveBatch = sinon.stub().rejects(rawErr); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + chai.expect(queue[0].error_count).to.equal(1); + chai.expect(queue[0].errors[0].message).to.equal(rawErr); + }); + + it('is a no-op while another archive run is in flight', async () => { + let release; + sinon.stub(db.sentinel, 'allDocs').returns(new Promise(resolve => { + release = () => resolve({ rows: [] }); + })); + + const first = lib.archive(); + const second = lib.archive(); + + release(); + await Promise.all([first, second]); + + chai.expect(db.sentinel.allDocs.callCount).to.equal(1); + }); + + it('finishes the current batch and exits when the deadline expires mid-job', async () => { + const pending = job({ _id: 'archive:1', total: 10 }); + const { queue, putSnapshots } = stubQueue([pending]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('a\nb\nc\nd\ne\nf\ng\nh\ni\nj', 'utf8')); + lib.__set__('BATCH_SIZE', 3); + + clock.setSystemTime(1000); + const archiveBatch = sinon.stub().callsFake(() => { + clock.tick(100); + }); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive({ duration: 250 }); + + // duration=250 from clock=1000 → deadline=1250. After 3 batches clock=1300, loop exits. + chai.expect(archiveBatch.callCount).to.equal(3); + chai.expect(queue[0]).to.include({ cursor: 9 }); + chai.expect(queue[0]._deleted).to.not.equal(true); + chai.expect(putSnapshots.some(d => d._deleted)).to.equal(false); + }); + + it('does not start the next job after the deadline expires', async () => { + const job1 = job({ _id: 'archive:1', total: 1 }); + const job2 = job({ _id: 'archive:2', total: 1 }); + const { queue } = stubQueue([job1, job2]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('x', 'utf8')); + + clock.setSystemTime(1000); + const archiveBatch = sinon.stub().callsFake(() => { + clock.tick(500); + }); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive({ duration: 300 }); + + chai.expect(archiveBatch.callCount).to.equal(1); + chai.expect(queue[0]._deleted).to.equal(true); + chai.expect(queue[1]._deleted).to.not.equal(true); + }); + + it('runs all queued jobs to completion when no duration is provided', async () => { + const job1 = job({ _id: 'archive:1', total: 1 }); + const job2 = job({ _id: 'archive:2', total: 1 }); + const { queue } = stubQueue([job1, job2]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('x', 'utf8')); + const archiveBatch = sinon.stub().resolves(); + lib.__set__('archiveBatch', archiveBatch); + + await lib.archive(); + + chai.expect(archiveBatch.callCount).to.equal(2); + chai.expect(queue[0]._deleted).to.equal(true); + chai.expect(queue[1]._deleted).to.equal(true); + }); + + it('calls indexViews every 10 batches', async () => { + const pending = job({ _id: 'archive:1', total: 25 }); + stubQueue([pending]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('x\n'.repeat(25).slice(0, -1), 'utf8')); + lib.__set__('BATCH_SIZE', 1); + + const indexViews = sinon.stub().resolves(); + lib.__set__('indexViews', indexViews); + + await lib.archive(); + + chai.expect(indexViews.callCount).to.equal(2); + }); + + it('refetches the doc before each put so a stale _rev does not crash the run', async () => { + const pending = job({ _id: 'archive:1', total: 2, _rev: '1-stale' }); + const { putSnapshots, queue } = stubQueue([pending]); + sinon.stub(db.sentinel, 'getAttachment').resolves(Buffer.from('a\nb', 'utf8')); + lib.__set__('BATCH_SIZE', 1); + + // Simulate an external edit between fetchNextJob and the first put: bump the queue + // doc's _rev to something fresher than what archive() saw. + queue[0]._rev = '7-external'; + + await lib.archive(); + + chai.expect(queue[0]._deleted).to.equal(true); + // The first put after the external bump must carry the fresh '7-external' rev. + chai.expect(putSnapshots[0]._rev).to.equal('7-external'); + }); + + it('releases the in-flight guard even when allDocs throws', async () => { + sinon.stub(db.sentinel, 'allDocs').rejects(new Error('couch down')); + + await lib.archive(); + chai.expect(lib.__get__('currentlyArchiving')).to.equal(false); + + db.sentinel.allDocs.resolves({ rows: [] }); + await lib.archive(); + chai.expect(db.sentinel.allDocs.callCount).to.equal(2); + }); + + describe('canArchive', () => { + 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); + }); + + it('rejects other types and missing docs', () => { + const canArchive = lib.__get__('canArchive'); + 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); + }); + }); + + describe('archiveBatch', () => { + // The outer beforeEach stubs `archiveBatch` on `lib` so the loop tests can observe + // its inputs cheaply. These tests want the real implementation, so they rewire a + // fresh module instance whose `archiveBatch` is still the original code. + let freshLib; + beforeEach(() => { + freshLib = rewire('../../../src/lib/archiving'); + }); + + it('does nothing when the batch has no usable ids', async () => { + const archiveBatch = freshLib.__get__('archiveBatch'); + sinon.stub(db.medic, 'allDocs'); + sinon.stub(db.archive, 'bulkDocs'); + sinon.stub(db, 'purge'); + + await archiveBatch([]); + await archiveBatch(['', ' ']); + + chai.expect(db.medic.allDocs.callCount).to.equal(0); + chai.expect(db.archive.bulkDocs.callCount).to.equal(0); + chai.expect(db.purge.callCount).to.equal(0); + }); + + it('archives only docs whose type passes canArchive, purges them, and audits', async () => { + const archiveBatch = freshLib.__get__('archiveBatch'); + clock.setSystemTime(424242); + + sinon.stub(db.medic, 'allDocs').resolves({ + rows: [ + { doc: { _id: 'c1', _rev: '1-a', type: 'contact', name: 'C' } }, + { doc: { _id: 'r1', _rev: '1-b', type: 'data_record', form: 'visit' } }, + { doc: { _id: 'x1', _rev: '1-c', type: 'feedback' } }, // not archivable + { doc: null }, // missing + ], + }); + sinon.stub(db.archive, 'bulkDocs').resolves(); + sinon.stub(db, 'purge').resolves(); + sinon.stub(db.sentinel, 'allDocs').resolves({ + rows: [ + { doc: { _id: 'c1-info', _rev: '1-x' } }, + { doc: { _id: 'r1-info', _rev: '1-y' } }, + ], + }); + const audit = lib.__get__('audit'); + sinon.stub(audit, 'recordArchiving').resolves(); + + await archiveBatch([' c1 ', 'r1', 'x1', 'missing']); + + chai.expect(db.medic.allDocs.args[0]).to.deep.equal([{ + attachments: true, + keys: ['c1', 'r1', 'x1', 'missing'], + include_docs: true, + conflicts: true, + }]); + chai.expect(db.archive.bulkDocs.args[0][0]).to.deep.equal([ + { _id: 'c1', _rev: '1-a', type: 'contact', name: 'C', archive_date: 424242 }, + { _id: 'r1', _rev: '1-b', type: 'data_record', form: 'visit', archive_date: 424242 }, + ]); + chai.expect(db.archive.bulkDocs.args[0][1]).to.deep.equal({ new_edits: false }); + + chai.expect(db.purge.callCount).to.equal(2); + chai.expect(db.sentinel.allDocs.args[0]).to.deep.equal([{ + keys: ['c1-info', 'r1-info'], + include_docs: true, + conflicts: true, + }]); + chai.expect(db.purge.args[0][1].map(d => d._id)).to.deep.equal(['c1-info', 'r1-info']); + chai.expect(db.purge.args[1][1].map(d => d._id)).to.deep.equal(['c1', 'r1']); + + chai.expect(audit.recordArchiving.args[0]).to.deep.equal([['c1', 'r1'], 424242]); + }); + + it('returns the ids it could not archive when the batch mixes valid and invalid docs', async () => { + const archiveBatch = freshLib.__get__('archiveBatch'); + clock.setSystemTime(777); + + sinon.stub(db.medic, 'allDocs').resolves({ + rows: [ + { key: 'c1', doc: { _id: 'c1', _rev: '1-a', type: 'contact' } }, // archivable + { key: 'x1', doc: { _id: 'x1', _rev: '1-b', type: 'feedback' } }, // wrong type + { key: 'missing', doc: null }, // not in the db + ], + }); + sinon.stub(db.archive, 'bulkDocs').resolves(); + sinon.stub(db, 'purge').resolves(); + sinon.stub(db.sentinel, 'allDocs').resolves({ rows: [{ doc: { _id: 'c1-info', _rev: '1-x' } }] }); + const audit = lib.__get__('audit'); + sinon.stub(audit, 'recordArchiving').resolves(); + + const rejected = await archiveBatch(['c1', 'x1', 'missing']); + + chai.expect(rejected).to.deep.equal(['x1', 'missing']); + chai.expect(db.archive.bulkDocs.args[0][0].map(d => d._id)).to.deep.equal(['c1']); + }); + + it('skips info docs that the sentinel db is missing without crashing the purge call', async () => { + const archiveBatch = freshLib.__get__('archiveBatch'); + clock.setSystemTime(1); + + sinon.stub(db.medic, 'allDocs').resolves({ + rows: [{ doc: { _id: 'r1', _rev: '1-a', type: 'data_record' } }], + }); + sinon.stub(db.archive, 'bulkDocs').resolves(); + sinon.stub(db, 'purge').resolves(); + sinon.stub(db.sentinel, 'allDocs').resolves({ + rows: [{ key: 'r1-info', error: 'not_found' }], // no .doc + }); + const audit = lib.__get__('audit'); + sinon.stub(audit, 'recordArchiving').resolves(); + + await archiveBatch(['r1']); + + // The info-doc purge call gets an empty list (not-found row filtered out). + chai.expect(db.purge.args[0][1]).to.deep.equal([]); + }); + + it('purges from medic only after the archive write, audit, and info-doc purge', async () => { + const archiveBatch = freshLib.__get__('archiveBatch'); + clock.setSystemTime(1); + sinon.stub(db.medic, 'allDocs').resolves({ + rows: [{ doc: { _id: 'r1', _rev: '1-a', type: 'data_record' } }], + }); + const bulkDocs = sinon.stub(db.archive, 'bulkDocs').resolves(); + const purge = sinon.stub(db, 'purge').resolves(); + sinon.stub(db.sentinel, 'allDocs').resolves({ rows: [{ doc: { _id: 'r1-info', _rev: '1-x' } }] }); + const audit = lib.__get__('audit'); + const recordArchiving = sinon.stub(audit, 'recordArchiving').resolves(); + + await archiveBatch(['r1']); + + const medicPurge = purge.getCall(1); + chai.expect(medicPurge.args[1].map(d => d._id)).to.deep.equal(['r1']); + chai.expect(bulkDocs.calledBefore(medicPurge)).to.equal(true); + chai.expect(recordArchiving.calledBefore(medicPurge)).to.equal(true); + chai.expect(purge.getCall(0).calledBefore(medicPurge)).to.equal(true); + }); + + it('does not purge from medic when the audit write fails, leaving the docs recoverable', async () => { + const archiveBatch = freshLib.__get__('archiveBatch'); + clock.setSystemTime(1); + sinon.stub(db.medic, 'allDocs').resolves({ + rows: [{ doc: { _id: 'r1', _rev: '1-a', type: 'data_record' } }], + }); + sinon.stub(db.archive, 'bulkDocs').resolves(); + const purge = sinon.stub(db, 'purge').resolves(); + sinon.stub(db.sentinel, 'allDocs').resolves({ rows: [] }); + const audit = lib.__get__('audit'); + sinon.stub(audit, 'recordArchiving').rejects(new Error('audit down')); + + let caught; + try { + await archiveBatch(['r1']); + } catch (err) { + caught = err; + } + + chai.expect(caught?.message).to.equal('audit down'); + chai.expect(purge.callCount).to.equal(0); + }); + + it('surfaces a failure from the final medic purge after archive + audit have run', async () => { + const archiveBatch = freshLib.__get__('archiveBatch'); + clock.setSystemTime(1); + sinon.stub(db.medic, 'allDocs').resolves({ + rows: [{ doc: { _id: 'r1', _rev: '1-a', type: 'data_record' } }], + }); + const bulkDocs = sinon.stub(db.archive, 'bulkDocs').resolves(); + const purge = sinon.stub(db, 'purge'); + purge.onCall(0).resolves(); // info-doc purge + purge.onCall(1).rejects(new Error('purge down')); // medic purge + sinon.stub(db.sentinel, 'allDocs').resolves({ rows: [{ doc: { _id: 'r1-info', _rev: '1-x' } }] }); + const audit = lib.__get__('audit'); + const recordArchiving = sinon.stub(audit, 'recordArchiving').resolves(); + + let caught; + try { + await archiveBatch(['r1']); + } catch (err) { + caught = err; + } + chai.expect(caught?.message).to.equal('purge down'); + chai.expect(bulkDocs.callCount).to.equal(1); + chai.expect(recordArchiving.callCount).to.equal(1); + }); + }); + + describe('indexViews', () => { + it('fires the three index-warming queries in parallel', async () => { + const freshLib = rewire('../../../src/lib/archiving'); + const indexViews = freshLib.__get__('indexViews'); + sinon.stub(db.medic, 'query').resolves(); + const request = freshLib.__get__('request'); + const environment = freshLib.__get__('environment'); + sinon.stub(environment, 'couchUrl').value('http://couch/medic'); + sinon.stub(request, 'get').resolves(); + + await indexViews(); + + chai.expect(db.medic.query.callCount).to.equal(2); + chai.expect(db.medic.query.args).to.deep.equal([ + ['medic/contacts_by_depth', { limit: 1 }], + ['medic-client/contacts_by_last_visited', { limit: 1 }], + ]); + chai.expect(request.get.args[0]).to.deep.equal([{ + url: 'http://couch/medic/_design/medic/_nouveau/docs_by_replication_key', + qs: { limit: 1, q: '*:*' }, + }]); + }); + }); + + describe('recordError catch path', () => { + it('logs and swallows when the error-recording put itself fails', async () => { + const recordError = lib.__get__('recordError'); + const failing = { _id: 'archive:1', _rev: '1-a' }; + sinon.stub(db.sentinel, 'get').rejects(new Error('couch down')); + sinon.stub(db.sentinel, 'put'); + const logger = lib.__get__('logger'); + sinon.stub(logger, 'error'); + + // Should not throw — recordError owns its own try/catch. + await recordError(failing, new Error('original')); + + chai.expect(db.sentinel.put.callCount).to.equal(0); + chai.expect(logger.error.callCount).to.equal(1); + chai.expect(logger.error.args[0][0]).to.match(/could not record error on job archive:1/); + }); + }); + + describe('processJob exercises indexViews every 10 batches', () => { + it('calls indexViews after the 10th saveJob', async () => { + // 20 docs at BATCH_SIZE=2 = 10 batches in one cycle. + const pending = job({ _id: 'archive:1', total: 20 }); + stubQueue([pending]); + sinon.stub(db.sentinel, 'getAttachment').resolves( + Buffer.from(Array.from({ length: 20 }, (_, i) => `d${i}`).join('\n'), 'utf8') + ); + lib.__set__('BATCH_SIZE', 2); + + const archiveBatch = sinon.stub().resolves(); + lib.__set__('archiveBatch', archiveBatch); + const indexViews = sinon.stub().resolves(); + lib.__set__('indexViews', indexViews); + + await lib.archive(); + + chai.expect(archiveBatch.callCount).to.equal(10); + // indexViews fires after batch 10. + chai.expect(indexViews.callCount).to.equal(1); + }); + }); +}); 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..236b27f164b --- /dev/null +++ b/sentinel/tests/unit/lib/bulk-operations/archive.spec.js @@ -0,0 +1,49 @@ +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('returns the ids that failed to be archived', async () => { + archiveBatch.resolves([ 'a' ]); + + const failed = await archive([ { id: 'a' }, {} ], 'action-1'); + + // The id-less op is failed up front; archiveBatch then rejects 'a'. + expect(failed).to.deep.equal([ {}, { id: 'a' } ]); + 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..6d2ac5513d0 --- /dev/null +++ b/sentinel/tests/unit/lib/bulk-operations/index.spec.js @@ -0,0 +1,231 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const rewire = require('rewire'); +const logger = require('@medic/logger'); + +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', () => { + let processAction; + beforeEach(() => { + processAction = service.__get__('processAction'); + sinon.stub(db.sentinel, 'get'); + sinon.stub(db.sentinel, 'put'); + sinon.stub(db.sentinel, 'getAttachment'); + sinon.stub(db.medicLogs, 'get'); + sinon.stub(db.medicLogs, 'put'); + }); + + 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) => { + db.sentinel.get.resolves(action); + const put = db.sentinel.put.resolves(); + db.sentinel.getAttachment.resolves(Buffer.from(JSON.stringify(operations))); + const log = { _id: action.bulk_operation_id, actions: {} }; + db.medicLogs.get.resolves(log); + db.medicLogs.put.resolves(); + return { put, log }; + }; + + it('fetches the action, runs the handler in batches, records the log, and deletes the action', async () => { + const action = buildAction(); + const { put, log } = stubDb(action, [ { id: 'a' }, { id: 'b' } ]); + const handler = sinon.stub().resolves([]); + service.__set__('HANDLERS', { 'set-contact': handler }); + + await processAction(actionId); + + 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', async () => { + const action = buildAction(); + const { log } = stubDb(action, [ { id: 'a' }, { id: 'b' } ]); + service.__set__('HANDLERS', { 'set-contact': sinon.stub().resolves([ { id: 'b' } ]) }); + + await processAction(actionId); + + 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', async () => { + const { put, log } = stubDb(buildAction({ action: 'not-found' }), []); + + await expect(processAction(actionId)).to.be + .rejectedWith(Error, `bulk-operations: no handler for action "not-found"`); + + 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', async () => { + const { put, log } = stubDb(buildAction(), [ { id: 'a' }, { id: 'b' } ]); + service.__set__('HANDLERS', { 'set-contact': sinon.stub().rejects(new Error('boom')) }); + + await processAction(actionId); + + 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', async () => { + db.sentinel.get.rejects({ status: 404 }); + + await processAction(actionId); + + expect(db.medicLogs.get.called).to.equal(false); + }); + + it('skips recording when the log doc is missing, without crashing', async () => { + const action = buildAction(); + db.sentinel.get.resolves(action); + const put = db.sentinel.put.resolves(); + db.sentinel.getAttachment.resolves(Buffer.from(JSON.stringify([ { id: 'a' }, { id: 'b' } ]))); + db.medicLogs.get.rejects({ status: 404 }); + const logPut = db.medicLogs.put.resolves(); + service.__set__('HANDLERS', { 'set-contact': sinon.stub().resolves([]) }); + + await processAction(actionId); + + 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', async () => { + const action = buildAction({ total: 0 }); + const { put } = stubDb(action, []); + service.__set__('HANDLERS', { 'set-contact': sinon.stub().resolves([]) }); + + await processAction(actionId); + + expect(put.args.some(callArgs => callArgs[0]._deleted === true)).to.equal(true); + }); + }); + + describe('listen', () => { + beforeEach(() => { + sinon.stub(db.sentinel, 'changes'); + sinon.stub(db.sentinel, 'allDocs'); + }); + + it('registers the feed before loading the queue, enqueues ids, dedupes, ignores irrelevant changes', async () => { + 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 = db.sentinel.changes.returns({ + on(event, cb) { + if (event === 'change') { + onChange = cb; + } + return this; + }, + }); + const allDocs = db.sentinel.allDocs.resolves({ rows: [ { id: 'bulk-operation-action:op:1' } ] }); + + await service.listen(); + + 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 + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(processStub.args.map(a => a[0])).to.deep.equal([ + 'bulk-operation-action:op:1', + 'bulk-operation-action:op:2', + ]); + }); + + it('logs and swallows a failure driven through the queue', async () => { + const errorLog = sinon.stub(logger, 'error'); + sinon.stub(db.sentinel, 'get').rejects({ status: 500, message: 'couch down' }); + db.sentinel.changes.returns({ + on() { + return this; + }, + }); + db.sentinel.allDocs.resolves({ rows: [ { id: 'bulk-operation-action:op:1' } ] }); + + await service.listen(); + await new Promise(resolve => setTimeout(resolve, 20)); + + expect(errorLog.args).to.deep.equal([[ + 'bulk-operations: error processing action bulk-operation-action:op:1: %o', + { message: 'couch down', status: 500 } + ]]); + }); + + it('logs a changes-feed error and re-registers the feed after RETRY_TIMEOUT', async () => { + const errorLog = sinon.stub(logger, 'error'); + const setTimeoutStub = sinon.stub(); + service.__set__('setTimeout', setTimeoutStub); + + let onError; + const changes = db.sentinel.changes.returns({ + on(event, cb) { + if (event === 'error') { + onError = cb; + } + return this; + }, + }); + db.sentinel.allDocs.resolves({ rows: [] }); + + await service.listen(); + + expect(changes.callCount).to.equal(1); + + onError(new Error('feed boom')); + expect(errorLog.calledOnce).to.equal(true); + expect(errorLog.args[0][0]).to.contain('changes feed error'); + + expect(changes.callCount).to.equal(1); + expect(setTimeoutStub.calledOnce).to.equal(true); + expect(setTimeoutStub.args[0][1]).to.equal(60000); + + // Firing the scheduled callback re-registers the feed. + setTimeoutStub.args[0][0](); + expect(changes.callCount).to.equal(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/sentinel/tests/unit/schedule/archiving.spec.js b/sentinel/tests/unit/schedule/archiving.spec.js new file mode 100644 index 00000000000..00b682090bd --- /dev/null +++ b/sentinel/tests/unit/schedule/archiving.spec.js @@ -0,0 +1,128 @@ +const chai = require('chai'); +const sinon = require('sinon'); +const rewire = require('rewire'); + +const config = require('../../../src/config'); +const later = require('later'); +const archiveLib = require('../../../src/lib/archiving'); + +let clock; +let scheduler; + +describe('Archiving Schedule', () => { + beforeEach(() => { + clock = sinon.useFakeTimers({ now: new Date() }); + scheduler = rewire('../../../src/schedule/archiving'); + }); + + afterEach(() => { + clearTimeout(scheduler.__get__('archiveTimeout')); + sinon.restore(); + clock.restore(); + }); + + it('aborts when no archive configuration is present', () => { + sinon.stub(config, 'get'); + sinon.stub(later.parse, 'text'); + sinon.stub(later.parse, 'cron'); + sinon.stub(archiveLib, 'archive'); + return scheduler.execute().then(() => { + chai.expect(config.get.callCount).to.equal(1); + chai.expect(config.get.args[0]).to.deep.equal(['archive']); + chai.expect(later.parse.text.callCount).to.equal(0); + chai.expect(later.parse.cron.callCount).to.equal(0); + chai.expect(archiveLib.archive.callCount).to.equal(0); + }); + }); + + it('aborts when the schedule expression is malformed', () => { + sinon.stub(config, 'get').returns({ cron: '* * nope' }); + sinon.stub(later.parse, 'cron').returns(false); + sinon.stub(archiveLib, 'archive'); + + return scheduler.execute().then(() => { + chai.expect(later.parse.cron.callCount).to.equal(1); + chai.expect(archiveLib.archive.callCount).to.equal(0); + }); + }); + + it('schedules an archive run with the parsed duration', async () => { + sinon.stub(config, 'get').returns({ cron: '* 1 * * *', duration: '4 hours' }); + sinon.stub(archiveLib, 'archive').resolves(); + const setTimeoutSpy = sinon.spy(clock, 'setTimeout'); + + await scheduler.execute(); + + chai.expect(setTimeoutSpy.callCount).to.equal(1); + const [callback] = setTimeoutSpy.args[0]; + + callback(); + + chai.expect(archiveLib.archive.callCount).to.equal(1); + chai.expect(archiveLib.archive.args[0][0]).to.deep.equal({ duration: 4 * 60 * 60 * 1000 }); + }); + + it('passes duration=null when archive.duration is missing', async () => { + sinon.stub(config, 'get').returns({ cron: '* 1 * * *' }); + sinon.stub(archiveLib, 'archive').resolves(); + const setTimeoutSpy = sinon.spy(clock, 'setTimeout'); + + await scheduler.execute(); + setTimeoutSpy.args[0][0](); + + chai.expect(archiveLib.archive.args[0][0]).to.deep.equal({ duration: null }); + }); + + it('passes duration=null when archive.duration is malformed', async () => { + sinon.stub(config, 'get').returns({ cron: '* 1 * * *', duration: 'lots of time' }); + sinon.stub(archiveLib, 'archive').resolves(); + const setTimeoutSpy = sinon.spy(clock, 'setTimeout'); + + await scheduler.execute(); + setTimeoutSpy.args[0][0](); + + chai.expect(archiveLib.archive.args[0][0]).to.deep.equal({ duration: null }); + }); + + it('clears the previous timeout when re-run', () => { + sinon.stub(config, 'get').returns({ cron: '* 1 * * *' }); + sinon.stub(archiveLib, 'archive'); + const setTimeoutSpy = sinon.spy(clock, 'setTimeout'); + const clearTimeoutSpy = sinon.spy(clock, 'clearTimeout'); + + return scheduler.execute() + .then(() => { + chai.expect(setTimeoutSpy.callCount).to.equal(1); + chai.expect(clearTimeoutSpy.callCount).to.equal(0); + }) + .then(() => scheduler.execute()) + .then(() => { + chai.expect(setTimeoutSpy.callCount).to.equal(2); + chai.expect(clearTimeoutSpy.callCount).to.equal(1); + }); + }); + + describe('parseDuration', () => { + let parseDuration; + beforeEach(() => { + parseDuration = scheduler.__get__('parseDuration'); + }); + + it('parses " " expressions into milliseconds', () => { + chai.expect(parseDuration('4 hours')).to.equal(4 * 60 * 60 * 1000); + chai.expect(parseDuration('30 minutes')).to.equal(30 * 60 * 1000); + chai.expect(parseDuration('1 day')).to.equal(24 * 60 * 60 * 1000); + chai.expect(parseDuration(' 90 seconds ')).to.equal(90 * 1000); + }); + + it('returns null for missing, malformed, or non-positive durations', () => { + chai.expect(parseDuration()).to.equal(null); + chai.expect(parseDuration(null)).to.equal(null); + chai.expect(parseDuration(42)).to.equal(null); + chai.expect(parseDuration('forever')).to.equal(null); + chai.expect(parseDuration('-1 hours')).to.equal(null); + chai.expect(parseDuration('0 hours')).to.equal(null); + chai.expect(parseDuration('4 lightyears')).to.equal(null); + }); + }); +}); diff --git a/sentinel/tests/unit/schedule/index.spec.js b/sentinel/tests/unit/schedule/index.spec.js index f9ebb9834e1..3547ca8ed96 100644 --- a/sentinel/tests/unit/schedule/index.spec.js +++ b/sentinel/tests/unit/schedule/index.spec.js @@ -13,6 +13,7 @@ const replications = require('../../../src/schedule/replications'); const outbound = require('../../../src/schedule/outbound'); const purgeLib = require('../../../src/lib/purging'); const purging = require('../../../src/schedule/purging'); +const archiving = require('../../../src/schedule/archiving'); const backgroundCleanup = require('../../../src/schedule/background-cleanup'); let unit; @@ -27,6 +28,7 @@ const ALL_SCHEDULED_TASKS = [ 'replications', 'outbound', 'purging', + 'archiving', 'transitionsDisabledReminder', 'backgroundCleanup', ]; @@ -42,6 +44,7 @@ describe('scheduler', () => { sinon.stub(replications, 'execute'); sinon.stub(outbound, 'execute'); sinon.stub(purging, 'execute'); + sinon.stub(archiving, 'execute'); sinon.stub(backgroundCleanup, 'execute'); }); @@ -60,6 +63,7 @@ describe('scheduler', () => { replications.execute.resolves(); outbound.execute.resolves(); purging.execute.resolves(); + archiving.execute.resolves(); backgroundCleanup.execute.resolves(); unit.init(); @@ -69,6 +73,7 @@ describe('scheduler', () => { assert.equal(replications.execute.callCount, 1); assert.equal(outbound.execute.callCount, 1); assert.equal(purging.execute.callCount, 1); + assert.equal(archiving.execute.callCount, 1); assert.equal(backgroundCleanup.execute.callCount, 1); assertOngoingTasks(ALL_SCHEDULED_TASKS); @@ -79,6 +84,7 @@ describe('scheduler', () => { assert.equal(replications.execute.callCount, 1); assert.equal(outbound.execute.callCount, 1); assert.equal(purging.execute.callCount, 1); + assert.equal(archiving.execute.callCount, 1); assert.equal(backgroundCleanup.execute.callCount, 1); assert.equal(unit.__get__('sendable').callCount, 1); assertOngoingTasks(ALL_SCHEDULED_TASKS); @@ -101,6 +107,7 @@ describe('scheduler', () => { replications.execute.resolves(); outbound.execute.resolves(); purging.execute.resolves(); + archiving.execute.resolves(); backgroundCleanup.execute.resolves(); unit.init(); @@ -110,6 +117,7 @@ describe('scheduler', () => { assert.equal(replications.execute.callCount, 1); assert.equal(outbound.execute.callCount, 1); assert.equal(purging.execute.callCount, 1); + assert.equal(archiving.execute.callCount, 1); assert.equal(backgroundCleanup.execute.callCount, 1); assertOngoingTasks(ALL_SCHEDULED_TASKS); @@ -126,6 +134,7 @@ describe('scheduler', () => { assert.equal(replications.execute.callCount, 2); assert.equal(outbound.execute.callCount, 2); assert.equal(purging.execute.callCount, 2); + assert.equal(archiving.execute.callCount, 2); assert.equal(backgroundCleanup.execute.callCount, 2); assertOngoingTasks(ALL_SCHEDULED_TASKS); @@ -142,6 +151,7 @@ describe('scheduler', () => { assert.equal(replications.execute.callCount, 3); assert.equal(outbound.execute.callCount, 3); assert.equal(purging.execute.callCount, 3); + assert.equal(archiving.execute.callCount, 3); assert.equal(backgroundCleanup.execute.callCount, 3); assertOngoingTasks(ALL_SCHEDULED_TASKS); }); @@ -153,6 +163,7 @@ describe('scheduler', () => { replications.execute.resolves(); outbound.execute.resolves(); purging.execute.resolves(); + archiving.execute.resolves(); let dueTasksTaskResolve; let remindersTaskResolve; @@ -172,6 +183,7 @@ describe('scheduler', () => { assert.equal(replications.execute.callCount, 1); assert.equal(outbound.execute.callCount, 1); assert.equal(purging.execute.callCount, 1); + assert.equal(archiving.execute.callCount, 1); assert.equal(backgroundCleanup.execute.callCount, 1); return nextTick() @@ -186,6 +198,7 @@ describe('scheduler', () => { assert.equal(replications.execute.callCount, 2); assert.equal(outbound.execute.callCount, 2); assert.equal(purging.execute.callCount, 2); + assert.equal(archiving.execute.callCount, 2); assert.equal(backgroundCleanup.execute.callCount, 1); return nextTick(); @@ -209,6 +222,7 @@ describe('scheduler', () => { assert.equal(replications.execute.callCount, 3); assert.equal(outbound.execute.callCount, 3); assert.equal(purging.execute.callCount, 3); + assert.equal(archiving.execute.callCount, 3); assert.equal(backgroundCleanup.execute.callCount, 1); remindersTaskResolve(); @@ -231,6 +245,7 @@ describe('scheduler', () => { replications.execute.resolves(); outbound.execute.rejects({ err: 2 }); purging.execute.resolves(); + archiving.execute.resolves(); backgroundCleanup.execute.resolves(); unit.init(); @@ -240,6 +255,7 @@ describe('scheduler', () => { assert.equal(replications.execute.callCount, 1); assert.equal(outbound.execute.callCount, 1); assert.equal(purging.execute.callCount, 1); + assert.equal(archiving.execute.callCount, 1); assert.equal(backgroundCleanup.execute.callCount, 1); assertOngoingTasks(ALL_SCHEDULED_TASKS); @@ -361,6 +377,7 @@ describe('scheduler', () => { sinon.stub(reminders, 'execute').resolves(); sinon.stub(replications, 'execute').resolves(); sinon.stub(outbound, 'execute').resolves(); + sinon.stub(archiving, 'execute').resolves(); sinon.stub(backgroundCleanup, 'execute').resolves(); sinon.stub(purgeLib, 'purge').callsFake(() => { diff --git a/shared-libs/archiving-utils/package.json b/shared-libs/archiving-utils/package.json new file mode 100644 index 00000000000..692b14b5b2d --- /dev/null +++ b/shared-libs/archiving-utils/package.json @@ -0,0 +1,11 @@ +{ + "name": "@medic/archiving-utils", + "version": "1.0.0", + "description": "Archiving common utility functions", + "main": "src/index.js", + "scripts": { + "test": "nyc --nycrcPath='../nyc.config.js' mocha ./test" + }, + "author": "", + "license": "Apache-2.0" +} diff --git a/shared-libs/archiving-utils/src/index.js b/shared-libs/archiving-utils/src/index.js new file mode 100644 index 00000000000..b01b2064f26 --- /dev/null +++ b/shared-libs/archiving-utils/src/index.js @@ -0,0 +1,13 @@ +const ATTACHMENT_NAME = 'ids'; +const ATTACHMENT_TYPE = 'text/plain'; + +const encodeIds = (ids) => Buffer.from(ids.join('\n'), 'utf8'); + +const decodeIds = (buffer) => buffer.toString('utf8').split('\n'); + +module.exports = { + ATTACHMENT_NAME, + ATTACHMENT_TYPE, + encodeIds, + decodeIds, +}; diff --git a/shared-libs/archiving-utils/test/index.js b/shared-libs/archiving-utils/test/index.js new file mode 100644 index 00000000000..8ed7f635ed4 --- /dev/null +++ b/shared-libs/archiving-utils/test/index.js @@ -0,0 +1,33 @@ +const chai = require('chai'); +const utils = require('../src/index'); + +describe('Archiving Utils', () => { + describe('constants', () => { + it('exposes the attachment metadata used to carry the id list', () => { + chai.expect(utils.ATTACHMENT_NAME).to.equal('ids'); + chai.expect(utils.ATTACHMENT_TYPE).to.equal('text/plain'); + }); + }); + + describe('encodeIds / decodeIds', () => { + it('round-trips a list of ids through the attachment payload', () => { + const ids = ['doc-1', 'doc-2', 'doc-3']; + const encoded = utils.encodeIds(ids); + chai.expect(Buffer.isBuffer(encoded)).to.equal(true); + chai.expect(encoded.toString('utf8')).to.equal('doc-1\ndoc-2\ndoc-3'); + chai.expect(utils.decodeIds(encoded)).to.deep.equal(ids); + }); + + it('encodes an empty list to an empty buffer', () => { + const encoded = utils.encodeIds([]); + chai.expect(encoded.length).to.equal(0); + chai.expect(utils.decodeIds(encoded)).to.deep.equal(['']); + }); + + it('preserves ids that contain non-ascii characters', () => { + const ids = ['ünïcødé', 'mañana']; + const encoded = utils.encodeIds(ids); + chai.expect(utils.decodeIds(encoded)).to.deep.equal(ids); + }); + }); +}); diff --git a/shared-libs/audit/src/index.js b/shared-libs/audit/src/index.js index 17d7f6a3d5d..f92a12aacc7 100644 --- a/shared-libs/audit/src/index.js +++ b/shared-libs/audit/src/index.js @@ -151,7 +151,32 @@ const expressCallback = async (req, responseBody, requestMetadata) => { await recordAudit(body, requestMetadata); }; +/** + * Records the archiving of documents with the given IDs at the specified date. + * + * @param {string[]} ids - Array of document IDs to be archived. + * @param {Date} date - The date at which the documents are being archived. + * @returns {Promise} - A promise that resolves when the archiving process has been completed. + */ +const recordArchiving = async (ids, date) => { + const existingAuditDocs = (await db.allDocs({ keys: ids, include_docs: true })); + + const newAuditDocs = []; + ids.forEach((id, idx) => { + const auditDoc = existingAuditDocs.rows[idx].doc || getAuditDoc({ id }); + + if (auditDoc.history.some(entry => entry.archived)) { + return; + } + + auditDoc.history.push({ date, archived: true }); + newAuditDocs.push(auditDoc); + }); + await db.bulkDocs(newAuditDocs); +}; + module.exports = { fetchCallback, expressCallback, + recordArchiving, }; diff --git a/shared-libs/audit/test/index.js b/shared-libs/audit/test/index.js index f7ce73baeff..f16bc5a7527 100644 --- a/shared-libs/audit/test/index.js +++ b/shared-libs/audit/test/index.js @@ -455,4 +455,130 @@ describe('Audit', () => { }] }]]); }); + + describe('recordArchiving', () => { + it('appends an archive entry to the existing audit doc for each id', async () => { + const date = 1234567890; + const existing = { + _id: 'doc-a', + _rev: '1-a', + history: [{ rev: '1-a', user: 'admin', date: new Date(500), service: 'api' }], + }; + db.allDocs.resolves({ rows: [{ id: 'doc-a', doc: existing }] }); + + await lib.recordArchiving(['doc-a'], date); + + expect(db.allDocs.callCount).to.equal(1); + expect(db.allDocs.args[0]).to.deep.equal([{ keys: ['doc-a'], include_docs: true }]); + expect(db.bulkDocs.callCount).to.equal(1); + const written = db.bulkDocs.args[0][0]; + expect(written).to.have.lengthOf(1); + expect(written[0]._id).to.equal('doc-a'); + expect(written[0]._rev).to.equal('1-a'); + expect(written[0].history).to.deep.equal([ + { rev: '1-a', user: 'admin', date: new Date(500), service: 'api' }, + { date, archived: true }, + ]); + }); + + it('creates a fresh audit doc with the archive entry when none exists', async () => { + const date = 99; + db.allDocs.resolves({ rows: [{ key: 'doc-new', error: 'not_found' }] }); + + await lib.recordArchiving(['doc-new'], date); + + expect(db.bulkDocs.callCount).to.equal(1); + expect(db.bulkDocs.args[0][0]).to.deep.equal([{ + _id: 'doc-new', + _rev: undefined, + history: [{ date, archived: true }], + }]); + }); + + it('records archive entries for a mix of existing and new audit docs in one bulk write', async () => { + const date = 7777; + db.allDocs.resolves({ + rows: [ + { id: 'doc-a', doc: { _id: 'doc-a', _rev: '1-a', history: [] } }, + { key: 'doc-b', error: 'not_found' }, + { id: 'doc-c', doc: { _id: 'doc-c', _rev: '3-c', history: [{ rev: '3-c', date: new Date(1) }] } }, + ], + }); + + await lib.recordArchiving(['doc-a', 'doc-b', 'doc-c'], date); + + expect(db.bulkDocs.callCount).to.equal(1); + const written = db.bulkDocs.args[0][0]; + expect(written).to.have.lengthOf(3); + + expect(written[0]._id).to.equal('doc-a'); + expect(written[0]._rev).to.equal('1-a'); + expect(written[0].history).to.deep.equal([{ date, archived: true }]); + + expect(written[1]._id).to.equal('doc-b'); + expect(written[1]._rev).to.be.undefined; + expect(written[1].history).to.deep.equal([{ date, archived: true }]); + + expect(written[2]._id).to.equal('doc-c'); + expect(written[2]._rev).to.equal('3-c'); + expect(written[2].history).to.deep.equal([ + { rev: '3-c', date: new Date(1) }, + { date, archived: true }, + ]); + }); + + it('writes nothing when called with an empty id list', async () => { + db.allDocs.resolves({ rows: [] }); + + await lib.recordArchiving([], 1000); + + expect(db.allDocs.callCount).to.equal(1); + expect(db.allDocs.args[0]).to.deep.equal([{ keys: [], include_docs: true }]); + expect(db.bulkDocs.callCount).to.equal(1); + expect(db.bulkDocs.args[0][0]).to.deep.equal([]); + }); + + it('preserves the prior history when appending the archive entry', async () => { + const date = 42; + const existingHistory = [ + { rev: '1-a', user: 'alice', date: new Date(10), service: 'api' }, + { rev: '2-b', user: 'bob', date: new Date(20), service: 'sentinel' }, + ]; + db.allDocs.resolves({ rows: [{ + id: 'doc-a', + doc: { _id: 'doc-a', _rev: '2-b', history: [...existingHistory] }, + }] }); + + await lib.recordArchiving(['doc-a'], date); + + const written = db.bulkDocs.args[0][0][0]; + expect(written.history).to.deep.equal([ + ...existingHistory, + { date, archived: true }, + ]); + }); + + it('does not re-append an archive entry for a doc already recorded as archived', async () => { + const alreadyArchived = { + _id: 'doc-a', + _rev: '2-b', + history: [ + { rev: '1-a', user: 'alice', date: new Date(10), service: 'api' }, + { date: 100, archived: true }, + ], + }; + db.allDocs.resolves({ rows: [ + { id: 'doc-a', doc: alreadyArchived }, + { key: 'doc-b', error: 'not_found' }, + ] }); + + await lib.recordArchiving(['doc-a', 'doc-b'], 200); + + expect(db.bulkDocs.callCount).to.equal(1); + const written = db.bulkDocs.args[0][0]; + expect(written).to.have.lengthOf(1); + expect(written[0]._id).to.equal('doc-b'); + expect(written[0].history).to.deep.equal([{ date: 200, archived: true }]); + }); + }); }); diff --git a/shared-libs/constants/src/index.js b/shared-libs/constants/src/index.js index ae4309d9984..914d7a2c168 100644 --- a/shared-libs/constants/src/index.js +++ b/shared-libs/constants/src/index.js @@ -65,8 +65,26 @@ const DB_ADMIN_ROLES = [USER_ROLES.ADMIN, USER_ROLES.COUCHDB_ADMIN]; const PREFIXES = { COUCH_USER: 'org.couchdb.user:', TRANSLATIONS: 'messages-', + UI_EXTENSION: `${DOC_TYPES.UI_EXTENSION}:`, FORM: 'form:', - UI_EXTENSION: `${DOC_TYPES.UI_EXTENSION}:` + 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 = { @@ -79,4 +97,5 @@ module.exports = { CONTACT_TYPES, STANDARD_HTTP_HEADERS, PREFIXES, + BULK_OPERATIONS, }; diff --git a/tests/e2e/default/db/archive.wdio-spec.js b/tests/e2e/default/db/archive.wdio-spec.js new file mode 100644 index 00000000000..8a11452bbdd --- /dev/null +++ b/tests/e2e/default/db/archive.wdio-spec.js @@ -0,0 +1,79 @@ +const commonElements = require('@page-objects/default/common/common.wdio.page.js'); +const utils = require('@utils'); +const sentinelUtils = require('@utils/sentinel'); +const loginPage = require('@page-objects/default/login/login.wdio.page'); +const userFactory = require('@factories/cht/users/users'); +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); +const genericReportFactory = require('@factories/cht/reports/generic-report'); +const { CONTACT_TYPES } = require('@medic/constants'); + +/* global window */ + +describe('archive', function () { + this.timeout(2 * 120000); + + const places = placeFactory.generateHierarchy(); + const healthCenter = places.get(CONTACT_TYPES.HEALTH_CENTER); + + const contact = personFactory.build({ parent: { _id: healthCenter._id, parent: healthCenter.parent } }); + const patient = personFactory.build({ parent: { _id: healthCenter._id, parent: healthCenter.parent } }); + const user = userFactory.build({ username: 'offlineuser-archive', place: healthCenter._id }); + const reportToArchive = genericReportFactory + .report() + .build({ form: 'home_visit' }, { patient, submitter: contact }); + + const postCsv = (csv) => utils.request({ + path: '/api/v1/archive', + method: 'POST', + body: csv, + headers: { 'Content-Type': 'text/csv' }, + }); + + const getLocalDoc = (id) => browser.executeAsync((docId, done) => { + window.CHTCore.DB + .get() + .get(docId) + .then(doc => done({ ok: true, doc })) + .catch(err => done({ ok: false, status: err.status })); + }, id); + + before(async () => { + await utils.saveDocs([...places.values(), contact, patient]); + await utils.createUsers([user]); + await utils.saveDocs([reportToArchive]); + }); + + afterEach(async () => { + await utils.revertSettings(true); + await utils.deleteUsers([user]); + await utils.revertDb([/^form:/], true); + await commonElements.reloadSession(); + }); + + it('removes an archived doc from the offline user device on the next sync', async () => { + await loginPage.login(user); + + // Confirm the report replicated to the user's device before archiving. + let local = await getLocalDoc(reportToArchive._id); + expect(local.ok).to.equal(true); + expect(local.doc.form).to.equal('home_visit'); + + // Kick off the archive flow on the server. + const { jobs } = await postCsv(reportToArchive._id); + expect(jobs).to.have.lengthOf(1); + + await utils.updateSettings({ archive: { text_expression: 'every 1 seconds' } }, { ignoreReload: true }); + await utils.runSentinelTasks(); + await sentinelUtils.waitForArchiveCompletion(); + + const serverRows = await utils.db.allDocs({ keys: [reportToArchive._id] }); + expect(serverRows.rows[0].error).to.equal('not_found'); + + await commonElements.sync(); + + local = await getLocalDoc(reportToArchive._id); + expect(local.ok).to.equal(false); + expect(local.status).to.equal(404); + }); +}); diff --git a/tests/e2e/default/purge/purge.wdio-spec.js b/tests/e2e/default/db/purge.wdio-spec.js similarity index 100% rename from tests/e2e/default/purge/purge.wdio-spec.js rename to tests/e2e/default/db/purge.wdio-spec.js diff --git a/tests/e2e/default/suites.js b/tests/e2e/default/suites.js index 4b485f78223..93614acda4f 100644 --- a/tests/e2e/default/suites.js +++ b/tests/e2e/default/suites.js @@ -23,7 +23,6 @@ const SUITES = { ], data: [ './db/**/*.wdio-spec.js', - './purge/**/*.wdio-spec.js', './telemetry/**/*.wdio-spec.js' ], lowLevel: [ diff --git a/tests/integration/api/controllers/archive.spec.js b/tests/integration/api/controllers/archive.spec.js new file mode 100644 index 00000000000..b78a8182bd1 --- /dev/null +++ b/tests/integration/api/controllers/archive.spec.js @@ -0,0 +1,141 @@ +const utils = require('@utils'); + +const ID_PREFIX = 'archive:'; + +const postCsv = (csv, opts = {}) => utils.request({ + path: '/api/v1/archive', + method: 'POST', + body: csv, + headers: { 'Content-Type': 'text/csv' }, + ...opts, +}); + +const listJobs = () => utils.sentinelDb.allDocs({ + startkey: ID_PREFIX, + endkey: `${ID_PREFIX}\ufff0`, +}); + +const cleanupJobs = async () => { + const result = await listJobs(); + if (!result.rows.length) { + return; + } + await utils.sentinelDb.bulkDocs(result.rows.map(row => ({ + _id: row.id, + _rev: row.value.rev, + _deleted: true, + }))); +}; + +describe('POST /api/v1/archive', () => { + afterEach(async () => { + await cleanupJobs(); + }); + + it('creates a job doc in the sentinel db with the ids stored as an attachment', async () => { + const csv = ['doc-a', 'doc-b', 'doc-c'].join('\n'); + const response = await postCsv(csv); + + expect(response.jobs).to.have.lengthOf(1); + expect(response.jobs[0]).to.have.keys('id', 'count'); + expect(response.jobs[0].count).to.equal(3); + expect(response.jobs[0].id).to.match(/^archive:/); + + const doc = await utils.sentinelDb.get(response.jobs[0].id); + expect(doc).to.include({ type: 'archive:', total: 3, cursor: 0 }); + expect(doc).to.not.have.property('status'); + expect(doc).to.have.property('date'); + expect(doc._attachments.ids.content_type).to.equal('text/plain'); + + const attachment = await utils.sentinelDb.getAttachment(response.jobs[0].id, 'ids'); + expect(attachment.toString('utf8')).to.equal('doc-a\ndoc-b\ndoc-c'); + }); + + it('skips blank lines and strips surrounding double quotes', async () => { + const csv = ['', '"doc-1"', ' doc-2 ', ''].join('\n'); + const response = await postCsv(csv); + + expect(response.jobs[0].count).to.equal(2); + const attachment = await utils.sentinelDb.getAttachment(response.jobs[0].id, 'ids'); + expect(attachment.toString('utf8')).to.equal('doc-1\ndoc-2'); + }); + + it('rejects a body of only whitespace with 400', async () => { + let err; + try { + await postCsv(' \n \n'); + } catch (caught) { + err = caught; + } + expect(err, 'expected request to be rejected').to.exist; + expect(err.status).to.equal(400); + expect(err.body).to.deep.match(/No valid doc IDs/i); + + const list = await listJobs(); + expect(list.rows).to.have.lengthOf(0); + }); + + it('rejects an empty body with 400', async () => { + let err; + try { + await postCsv(''); + } catch (caught) { + err = caught; + } + expect(err, 'expected request to be rejected').to.exist; + expect(err.status).to.equal(400); + + const list = await listJobs(); + expect(list.rows).to.have.lengthOf(0); + }); + + it('rejects a non-text/csv content-type with 415', async () => { + let err; + try { + await utils.request({ + path: '/api/v1/archive', + method: 'POST', + body: { ids: ['doc-a', 'doc-b'] }, + }); + } catch (caught) { + err = caught; + } + expect(err, 'expected request to be rejected').to.exist; + expect(err.status).to.equal(415); + expect(err.body).to.deep.include({ code: 415 }); + + const list = await listJobs(); + expect(list.rows).to.have.lengthOf(0); + }); + + it('splits an upload that exceeds MAX_IDS_PER_JOB into multiple job docs', async function () { + this.timeout(60000); + + const MAX = 100000; + const overflow = 5; + const total = MAX + overflow; + const lines = []; + for (let i = 0; i < total; i++) { + lines.push(`doc-${i}`); + } + const csv = lines.join('\n'); + + const response = await postCsv(csv); + + expect(response.jobs).to.have.lengthOf(2); + expect(response.jobs.map(j => j.count)).to.deep.equal([MAX, overflow]); + expect(response.jobs[0].id).to.not.equal(response.jobs[1].id); + + const [first, second] = await Promise.all( + response.jobs.map(j => utils.sentinelDb.get(j.id)) + ); + expect(first.total).to.equal(MAX); + expect(second.total).to.equal(overflow); + + const secondAttachment = await utils.sentinelDb.getAttachment(response.jobs[1].id, 'ids'); + const tailIds = secondAttachment.toString('utf8').split('\n'); + expect(tailIds).to.have.lengthOf(overflow); + expect(tailIds[0]).to.equal(`doc-${MAX}`); + expect(tailIds[overflow - 1]).to.equal(`doc-${total - 1}`); + }); +}); 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..509a114c918 --- /dev/null +++ b/tests/integration/api/controllers/bulk-operations.spec.js @@ -0,0 +1,168 @@ +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 { CONTACT_TYPES, PREFIXES, BULK_OPERATIONS } = require('@medic/constants'); +const { expect } = require('chai'); + +describe('Bulk operations API', () => { + const place = utils.deepFreeze(placeFactory.place().build({ + name: 'place', + type: CONTACT_TYPES.DISTRICT_HOSPITAL, + contact: {} + })); + + const offlineUser = utils.deepFreeze(userFactory.build({ + username: 'offline-bulk', + place: place._id, + contact: { + _id: 'fixture:user:offline-bulk', + name: 'Offline User', + }, + roles: ['chw'] + })); + + const getBulkOperationLogs = (keys) => utils.logsDb + .allDocs({ keys, include_docs: true }) + .then(({ rows }) => rows.map(({ doc }) => doc).filter(Boolean)); + const getBulkOperationActions = (keys) => utils.sentinelDb + .allDocs({ keys, include_docs: true }) + .then(({ rows }) => rows.map(({ doc }) => doc).filter(Boolean)); + + before(async () => { + await utils.saveDoc(place); + 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 it is processed', async () => { + const person = personFactory.build(); + await utils.saveDoc(person); + + const { id } = await utils.request({ path: `/api/v1/person/${person._id}`, method: 'DELETE' }); + + const log = await utils.waitForBulkOperation(id); + expect(log._id).to.equal(id); + expect(new Date(log.start_date).getTime()).to.be.closeTo(Date.now(), 60000); + const [[actionId, action], ...additional] = Object.entries(log.actions); + expect(actionId.slice(PREFIXES.BULK_OPERATION_ACTION.length) + .startsWith(id.slice(PREFIXES.BULK_OPERATION_LOG.length))).to.be.true; + expect(additional).to.be.empty; + expect(action).excluding('updated_date').to.deep.equal({ + action: 'archive', + status: 'completed', + total_changes_count: 1 + }); + expect(new Date(action.updated_date).getTime()).to.be.closeTo(Date.now(), 60000); + }); + }); + + it('processes a large number of operations in the same action', async () => { + const parent = utils.deepFreeze(placeFactory.place().build({ + name: 'place', + type: CONTACT_TYPES.DISTRICT_HOSPITAL, + contact: {} + })); + const persons = Array + .from({ length: 3000}) + .map((_, i) => personFactory.build({ name: `person${i}`, parent })); + await utils.saveDocs([parent, ...persons]); + + const { + id, + summary: { archive: { contacts } } + } = await utils.request({ path: `/api/v1/place/${parent._id}`, method: 'DELETE' }); + + await utils.waitForBulkOperation(id, 1000); + + expect(contacts).to.equal(3001); + const deleted = await utils.getDocs([parent._id, ...persons.map(({ _id }) => _id)]); + expect(deleted.filter(Boolean)).to.be.empty; + }); + + it('queues multiple actions and performs them when Sentinel starts', async () => { + const persons = Array + .from({ length: 3}) + .map((_, i) => personFactory.build({ name: `person${i}`})); + await utils.saveDocs(persons); + await utils.stopSentinel(); + + const bulkOperationLogIds = await Promise.all(persons.map(({ _id }) => utils + .request({ path: `/api/v1/person/${_id}`, method: 'DELETE' }) + .then(({ id }) => id))); + const bulkOperationLogs = await getBulkOperationLogs(bulkOperationLogIds); + const actionIds = bulkOperationLogs.flatMap(({ actions }) => Object.keys(actions)); + const bulkOperationActions = await getBulkOperationActions(actionIds); + + expect(bulkOperationLogs).to.have.lengthOf(3); + expect(bulkOperationLogs[0]).excludingEvery(['_rev', 'start_date', 'updated_date']).to.deep.equal({ + _id: bulkOperationLogIds[0], + actions: { [bulkOperationActions[0]._id]: { + action: 'archive', + status: 'queued', + total_changes_count: 1 + } } + }); + expect(bulkOperationActions).to.have.lengthOf(3); + bulkOperationActions.forEach((action, i) => expect(action) + .excluding(['_attachments', '_rev']) + .to.deep.equal({ + _id: actionIds[i], + action: 'archive', + bulk_operation_id: bulkOperationLogs[i]._id, + cursor: 0, + total: 1 + })); + + const buffer = await utils.sentinelDb.getAttachment(actionIds[0], BULK_OPERATIONS.OPERATIONS_ATTACHMENT); + const attachment = JSON.parse(buffer.toString()); + expect(attachment).to.deep.equal([{ id: persons[0]._id }]); + + // Add invalid operations to test failure scenario + const updatedAttachment = [{ id: 'notfound0' }, ...attachment, { notid: 'notfound1' }]; + await utils.sentinelDb.putAttachment( + actionIds[0], + BULK_OPERATIONS.OPERATIONS_ATTACHMENT, + bulkOperationActions[0]._rev, + Buffer.from(JSON.stringify(updatedAttachment)).toString('base64'), + 'application/json' + ); + + await utils.startSentinel(); + await Promise.all(bulkOperationLogIds.map(id => utils.waitForBulkOperation(id, 100))); + + expect(await getBulkOperationActions(actionIds)).to.be.empty; + const [failedLog, ...completedLogs] = await getBulkOperationLogs(bulkOperationLogIds); + expect(completedLogs).to.have.lengthOf(2); + completedLogs.forEach((log, i) => expect(log.actions[actionIds[i + 1]].status).to.equal('completed')); + expect(failedLog.actions[actionIds[0]]).excluding('updated_date').to.deep.equal({ + action: 'archive', + status: 'failed', + total_changes_count: 1, + failed_operations: [ + { notid: 'notfound1' }, + { id: 'notfound0' } + ] + }); + }); +}); diff --git a/tests/integration/api/controllers/person.spec.js b/tests/integration/api/controllers/person.spec.js index 5eea2894d1a..aa07948e1bc 100644 --- a/tests/integration/api/controllers/person.spec.js +++ b/tests/integration/api/controllers/person.spec.js @@ -2,7 +2,9 @@ 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 { USER_ROLES, CONTACT_TYPES } = require('@medic/constants'); +const reportFactory = require('@factories/cht/reports/generic-report'); +const { v7: uuid } = require('uuid'); +const { USER_ROLES, CONTACT_TYPES, PREFIXES } = require('@medic/constants'); const { expect } = require('chai'); describe('Person API', () => { @@ -492,4 +494,117 @@ describe('Person API', () => { }); }); }); + + describe('DELETE /api/v1/person/:uuid', () => { + const endpoint = '/api/v1/person'; + + const place3Id = uuid(); + const person0 = utils.deepFreeze(personFactory.build({ + name: 'person0', + patient_id: 'person-with-data', + role: 'chw', + parent: { _id: place3Id, parent: place1 } + })); + const person1 = utils.deepFreeze(personFactory.build({ patient_id: 'person-without-data', role: 'patient' })); + const place3 = utils.deepFreeze(placeFactory.place().build({ + _id: place3Id, + type: CONTACT_TYPES.DISTRICT_HOSPITAL, + parent: place1, + contact: person0 + })); + const userToDelete = utils.deepFreeze(userFactory.build({ + username: 'user-to-delete', + place: place3._id, + contact: person0._id, + roles: [USER_ROLES.ONLINE] + })); + const deletedUserId = `${PREFIXES.COUCH_USER}${userToDelete.username}`; + const reports = utils.deepFreeze([ + reportFactory.report().build({ form: 'test-report' }, { patient: person0, submitter: person0 }), + reportFactory.report().build({ form: 'test-report' }, { patient: person0, submitter: person0 }), + ]); + + const expectArchived = async (doc) => { + expect(await utils.archiveDb.get(doc._id)).excludingEvery(['_rev', 'reported_date', 'archive_date']) + .to.deep.equal(doc); + await expect(utils.getDoc(doc._id)).to.be.rejectedWith('404 - {"error":"not_found","reason":"missing"}'); + }; + + before(async () => { + await utils.saveDocs([person0, person1, place3, ...reports]); + await utils.createUsers([userToDelete]); + }); + + after(() => utils.deleteUsers([userToDelete])); + + it('returns a dry-run summary and deletes nothing when passing dry_run', async () => { + const response = await utils.request({ + path: `${endpoint}/${person0._id}`, + method: 'DELETE', + qs: { dry_run: true, delete_users: true }, + }); + + expect(response).to.deep.equal({ + summary: { archive: { contacts: 1, reports: 2 }, 'set-contact': 1, 'delete-user': 1 }, + }); + await expect(utils.getDoc(person0._id)).to.be.fulfilled; + await expect(utils.getDoc(reports[0]._id)).to.be.fulfilled; + await expect(utils.getDoc(reports[1]._id)).to.be.fulfilled; + const updatedPlace = await utils.getDoc(place3Id); + expect(updatedPlace.contact._id).to.equal(person0._id); + await expect(utils.usersDb.get(deletedUserId)).to.be.fulfilled; + }); + + it('throws 404 when the id is not a person', async () => { + await expect(utils.request({ path: `${endpoint}/${place0._id}`, method: 'DELETE' })) + .to.be.rejectedWith('404 - {"code":404,"error":"Person not found"}'); + }); + + it('throws 400 when deleting a person with a user when delete_users is not passed', async () => { + await expect(utils.request({ path: `${endpoint}/${person0._id}`, method: 'DELETE' })) + .to.be.rejectedWith( + '400 - {"code":400,"error":"1 user(s) are linked to contacts in this hierarchy. ' + + 'Set delete_users=true (requires can_delete_users) to remove them."}' + ); + }); + + [ + ['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"}'); + }); + }); + + it('archives a person with minimal data', async () => { + const { id, summary } = await utils.request({ path: `${endpoint}/${person1._id}`, method: 'DELETE' }); + await utils.waitForBulkOperation(id); + + expect(summary).to.deep.equal({ archive: { contacts: 1, reports: 0 }, 'set-contact': 0, 'delete-user': 0 }); + await expectArchived(person1); + }); + + it('archives a person with related entities', async () => { + const { id, summary } = await utils.request({ + path: `${endpoint}/${person0._id}`, + method: 'DELETE', + qs: { delete_users: true }, + }); + await utils.waitForBulkOperation(id); + + expect(summary).to.deep.equal({ archive: { contacts: 1, reports: 2 }, 'set-contact': 1, 'delete-user': 1 }); + await expectArchived(person0); + await expectArchived(reports[0]); + await expectArchived(reports[1]); + const updatedPlace = await utils.getDoc(place3Id); + expect(updatedPlace.contact).to.be.undefined; + await expect(utils.usersDb.get(deletedUserId)).to.be.rejectedWith('deleted'); + }); + }); }); diff --git a/tests/integration/api/controllers/place.spec.js b/tests/integration/api/controllers/place.spec.js index f58fe1f9d1b..ad69e97a174 100644 --- a/tests/integration/api/controllers/place.spec.js +++ b/tests/integration/api/controllers/place.spec.js @@ -2,8 +2,10 @@ 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 { USER_ROLES, CONTACT_TYPES } = require('@medic/constants'); +const reportFactory = require('@factories/cht/reports/generic-report'); +const { USER_ROLES, CONTACT_TYPES, PREFIXES } = require('@medic/constants'); const { expect } = require('chai'); +const { v7: uuid } = require('uuid'); describe('Place API', () => { const contact0 = utils.deepFreeze(personFactory.build({ name: 'contact0', role: 'chw' })); @@ -591,4 +593,158 @@ describe('Place API', () => { }); }); }); + + describe('DELETE /api/v1/place/:uuid', () => { + const endpoint = '/api/v1/place'; + + const place0Id = uuid(); + const place1Id = uuid(); + const place2Id = uuid(); + const person0 = utils.deepFreeze(personFactory.build({ + name: 'person0', + role: 'program_officer', + parent: { _id: place1Id, parent: { _id: place0Id } } + })); + const person1 = utils.deepFreeze(personFactory.build({ + name: 'person1', + role: 'chw_supervisor', + parent: { _id: place1Id, parent: { _id: place0Id } } + })); + const person2 = utils.deepFreeze(personFactory.build({ + name: 'person2', + role: 'chw', + parent: { _id: place2Id, parent: { _id: place1Id, parent: { _id: place0Id } } } + })); + const place0 = utils.deepFreeze(placeFactory.place().build({ + _id: place0Id, + name: 'place0', + type: CONTACT_TYPES.DISTRICT_HOSPITAL, + // Primary contact is actually child of place1. + contact: person0 + })); + const place1 = utils.deepFreeze(placeFactory.place().build({ + _id: place1Id, + name: 'place1', + type: CONTACT_TYPES.HEALTH_CENTER, + contact: person1, + parent: place0 + })); + const place2 = utils.deepFreeze(placeFactory.place().build({ + _id: place2Id, + name: 'place2', + type: CONTACT_TYPES.CLINIC, + contact: person2, + parent: place1 + })); + const place3 = utils.deepFreeze(placeFactory.place().build({ + name: 'place3', + type: CONTACT_TYPES.DISTRICT_HOSPITAL, + contact: {} + })); + const userToDelete = utils.deepFreeze(userFactory.build({ + username: 'user-to-delete', + place: place2._id, + contact: person2._id, + roles: [USER_ROLES.ONLINE] + })); + const deletedUserId = `${PREFIXES.COUCH_USER}${userToDelete.username}`; + const reports = utils.deepFreeze([ + reportFactory.report().build({ form: 'test-report' }, { patient: person0, submitter: person0 }), + reportFactory.report().build({ form: 'test-report' }, { patient: person1, submitter: person1 }), + reportFactory.report().build({ form: 'test-report' }, { patient: person2, submitter: person2 }), + ]); + + const expectArchived = async (doc) => { + expect(await utils.archiveDb.get(doc._id)).excludingEvery(['_rev', 'reported_date', 'archive_date']) + .to.deep.equal(doc); + await expect(utils.getDoc(doc._id)).to.be.rejectedWith('404 - {"error":"not_found","reason":"missing"}'); + }; + + before(async () => { + await utils.saveDocs([person0, person1, person2, place0, place1, place2, place3, ...reports]); + await utils.createUsers([userToDelete]); + }); + + after(() => utils.deleteUsers([userToDelete])); + + it('returns a dry-run summary of the subtree and deletes nothing when passing dry_run', async () => { + const response = await utils.request({ + path: `${endpoint}/${place1._id}`, + method: 'DELETE', + qs: { dry_run: true, delete_users: true }, + }); + + expect(response).to.deep.equal({ + summary: { archive: { contacts: 5, reports: 3 }, 'set-contact': 1, 'delete-user': 1 }, + }); + await expect(utils.getDoc(person0._id)).to.be.fulfilled; + await expect(utils.getDoc(person1._id)).to.be.fulfilled; + await expect(utils.getDoc(person2._id)).to.be.fulfilled; + await expect(utils.getDoc(place1._id)).to.be.fulfilled; + await expect(utils.getDoc(place2._id)).to.be.fulfilled; + await expect(utils.getDoc(reports[0]._id)).to.be.fulfilled; + await expect(utils.getDoc(reports[1]._id)).to.be.fulfilled; + await expect(utils.getDoc(reports[2]._id)).to.be.fulfilled; + const updatedPlace = await utils.getDoc(place0Id); + expect(updatedPlace.contact._id).to.equal(person0._id); + await expect(utils.usersDb.get(deletedUserId)).to.be.fulfilled; + }); + + it('throws 400 when a linked user would be left behind and delete_users is not set', async () => { + await expect(utils.request({ path: `${endpoint}/${place1._id}`, method: 'DELETE' })) + .to.be.rejectedWith( + '400 - {"code":400,"error":"1 user(s) are linked to contacts in this hierarchy. ' + + 'Set delete_users=true (requires can_delete_users) to remove them."}' + ); + }); + + it('throws 404 when the id is 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}/${place1._id}`, + method: 'DELETE', + auth: { username: user.username, password: user.password }, + }; + await expect(utils.request(opts)).to.be.rejectedWith('403 - {"code":403,"error":"Insufficient privileges"}'); + }); + }); + + it('archives a place with minimal data', async () => { + const { id, summary } = await utils.request({ path: `${endpoint}/${place3._id}`, method: 'DELETE' }); + await utils.waitForBulkOperation(id); + + expect(summary).to.deep.equal({ archive: { contacts: 1, reports: 0 }, 'set-contact': 0, 'delete-user': 0 }); + await expectArchived(place3); + }); + + it('archives a place with related entities', async () => { + const { id, summary } = await utils.request({ + path: `${endpoint}/${place1._id}`, + method: 'DELETE', + qs: { delete_users: true }, + }); + await utils.waitForBulkOperation(id); + + expect(summary).to.deep.equal({ archive: { contacts: 5, reports: 3 }, 'set-contact': 1, 'delete-user': 1 }); + await expectArchived(person0); + await expectArchived(person1); + await expectArchived(person2); + await expectArchived(place1); + await expectArchived(place2); + await expectArchived(reports[0]); + await expectArchived(reports[1]); + await expectArchived(reports[2]); + const updatedPlace = await utils.getDoc(place0Id); + expect(updatedPlace.contact).to.be.undefined; + await expect(utils.usersDb.get(deletedUserId)).to.be.rejectedWith('deleted'); + }); + }); }); diff --git a/tests/integration/sentinel/schedules/archive.spec.js b/tests/integration/sentinel/schedules/archive.spec.js new file mode 100644 index 00000000000..bc5724fd367 --- /dev/null +++ b/tests/integration/sentinel/schedules/archive.spec.js @@ -0,0 +1,389 @@ +const PouchDB = require('pouchdb-core'); +PouchDB.plugin(require('pouchdb-adapter-http')); + +const utils = require('@utils'); +const sentinelUtils = require('@utils/sentinel'); +const constants = require('@constants'); +const { DOC_TYPES, DOC_IDS, PREFIXES } = require('@medic/constants'); + +const { archiveDb } = utils; + +const postCsv = (csv, opts = {}) => utils.request({ + path: '/api/v1/archive', + method: 'POST', + body: csv, + headers: { 'Content-Type': 'text/csv' }, + ...opts, +}); + +const cleanupArchiveDb = async () => { + const result = await archiveDb.allDocs(); + const tombstones = result.rows.map(row => ({ _id: row.id, _rev: row.value.rev, _deleted: true })); + await archiveDb.bulkDocs(tombstones, { new_edits: true }); +}; + +const waitForInfoDocs = async (ids) => { + let missingCount = 0; + do { + await utils.delayPromise(500); + const infoIds = ids.map(id => `${id}-info`); + const result = await utils.sentinelDb.allDocs({ keys: infoIds }); + const missing = result.rows.filter(row => row.error); + missingCount = missing.length; + } while (missingCount); +}; + +const cleanupArchiveJobs = async () => { + const result = await utils.sentinelDb.allDocs({ + startkey: PREFIXES.ARCHIVE_JOB, + endkey: `${PREFIXES.ARCHIVE_JOB}\ufff0`, + }); + if (!result.rows.length) { + return; + } + await utils.sentinelDb.bulkDocs(result.rows.map(row => ({ + _id: row.id, + _rev: row.value.rev, + _deleted: true, + }))); +}; + +// allDocs wrapper that returns only rows for docs that are present (live) — drops both +// missing rows (`error: 'not_found'`) and deleted tombstone rows (`value.deleted: true`). +const liveRows = async (db, opts = {}) => { + const result = await db.allDocs(opts); + return result.rows.filter(row => row.value && !row.value.deleted); +}; + +const expectFullyPurgedFromMedic = async (ids) => { + const allDocs = await utils.db.allDocs({ keys: ids }); + const stillPresent = allDocs.rows.filter(row => !row.error); + expect(stillPresent).to.have.lengthOf(0); + + const changes = await utils.request({ + path: `/${constants.DB_NAME}/_changes`, + method: 'POST', + qs: { since: 0, filter: '_doc_ids' }, + body: { doc_ids: ids }, + }); + expect(changes.results).to.have.lengthOf(0); +}; + +const expectInfoDocsPurged = async (ids) => { + const infoIds = ids.map(id => `${id}-info`); + const result = await utils.sentinelDb.allDocs({ keys: infoIds }); + const stillPresent = result.rows.filter(row => !row.error); + expect(stillPresent).to.deep.equal([]); +}; + +const expectAuditedArchive = async (ids) => { + const result = await utils.auditDb.allDocs({ keys: ids, include_docs: true }); + const audited = result.rows.filter(row => row.doc); + expect(audited.map(row => row.id), ).to.have.members(ids); + for (const row of audited) { + const archiveEntries = row.doc.history.filter(h => h.archived); + const latest = archiveEntries[archiveEntries.length - 1]; + expect(latest.archived).to.equal(true); + } +}; + +const waitForJobStatus = async (id, status) => { + let doc; + do { + await utils.delayPromise(1000); + doc = await utils.sentinelDb.get(id); + } while (doc.status !== status); + return doc; +}; + +describe('sentinel processes archive jobs', () => { + const fixtures = [ + { _id: 'archive-e2e-contact', type: 'contact', name: 'Archived contact' }, + { _id: 'archive-e2e-report', type: DOC_TYPES.DATA_RECORD, form: 'visit', reported_date: 1 }, + { _id: 'archive-e2e-task', type: 'task', state: 'Completed' }, + { _id: 'archive-e2e-target', type: 'target', targets: [] }, + { _id: 'archive-e2e-random', type: 'random', info: 'not archivable' }, + ]; + const archivableIds = fixtures + .filter(d => d._id !== 'archive-e2e-random') + .map(d => d._id); + const nonArchivableIds = [ + 'messages-en', + '_design/medic', + 'form:pregnancy', + ...Object.values(DOC_IDS), + ]; + const allIds = fixtures.map(d => d._id); + + // Some entries in `nonArchivableIds` are present by default in a fresh test db + // (e.g. `_design/medic`, `settings`) and others (e.g. `partners`) + // are not. + const ensureExists = async (id) => { + try { + await utils.db.get(id); + } catch (err) { + if (err.status !== 404) { + throw err; + } + await utils.db.put({ _id: id, type: 'archive-e2e-stub' }); + } + }; + + const updateSettings = async (duration = '2 hours') => { + await utils.updateSettings( + { archive: { text_expression: 'every 1 seconds', duration: duration } }, + { ignoreReload: true } + ); + await utils.toggleSentinelTransitions(); + await sentinelUtils.skipToSeq(); + }; + + const runArchiving = async () => { + await utils.runSentinelTasks(); + await sentinelUtils.waitForArchiveCompletion(); + }; + + before(async () => { + await utils.saveDocs(fixtures); + await Promise.all(nonArchivableIds.map(ensureExists)); + }); + + after(async () => { + await cleanupArchiveJobs(); + await cleanupArchiveDb(); + }); + + beforeEach(async () => { + await sentinelUtils.skipToSeq(); + }); + + afterEach(async () => { + await utils.revertSettings(true); + await cleanupArchiveJobs(); + }); + + it('moves archivable docs to the archive db and purges them from medic', async function () { + this.timeout(60000); + + // Snapshot the originals so we can compare body-for-body after archive runs. + const originals = await utils.db.allDocs({ keys: archivableIds, include_docs: true }); + const originalById = Object.fromEntries(originals.rows.map(row => [row.id, row.doc])); + + const csv = [...allIds, ...nonArchivableIds].join('\n'); + const { jobs } = await postCsv(csv); + expect(jobs).to.have.lengthOf(1); + + await updateSettings(); + await runArchiving(); + + const archiveRows = await liveRows(archiveDb, { keys: archivableIds, include_docs: true }); + expect(archiveRows).to.have.lengthOf(archivableIds.length); + for (const row of archiveRows) { + expect(row.doc).excluding('archive_date').to.deep.equal(originalById[row.id]); + } + + await expectFullyPurgedFromMedic(archivableIds); + await expectInfoDocsPurged(archivableIds); + await expectAuditedArchive(archivableIds); + + const survivors = ['archive-e2e-random', ...nonArchivableIds]; + const medicSurvivorIds = (await liveRows(utils.db, { keys: survivors })).map(row => row.id); + expect(medicSurvivorIds ).to.have.members(survivors); + + const archiveSurvivorIds = (await liveRows(archiveDb, { keys: survivors })).map(row => row.id); + expect(archiveSurvivorIds).to.deep.equal([]); + }); + + it('processes a multi-batch payload, archiving thousands of docs', async function () { + this.timeout(180000); + await updateSettings(); + + // Larger than BATCH_SIZE (1000) so the archive loop has to take more than one batch + // and persist the cursor between them. + const COUNT = 15000; + const bulkDocs = Array.from({ length: COUNT }, (_, i) => ({ + _id: `archive-e2e-bulk-${String(i).padStart(5, '0')}`, + type: DOC_TYPES.DATA_RECORD, + form: 'visit', + fields: { patient_id: `p-${i}` }, + reported_date: 1, + })); + + await utils.saveDocs(bulkDocs); + const ids = bulkDocs.map(d => d._id); + + await waitForInfoDocs(ids); + + const { jobs } = await postCsv(ids.join('\n')); + expect(jobs).to.have.lengthOf(1); + expect(jobs[0].count).to.equal(COUNT); + + await runArchiving(); + + const archived = await liveRows(archiveDb, { keys: ids }); + expect(archived).to.have.lengthOf(COUNT); + + await expectFullyPurgedFromMedic(ids); + await expectInfoDocsPurged(ids); + await expectAuditedArchive(ids); + }); + + it('exits at the duration deadline and resumes on the next run', async function () { + this.timeout(180000); + + const COUNT = 2000; + const bulkDocs = Array.from({ length: COUNT }, (_, i) => ({ + _id: `archive-e2e-resume-${String(i).padStart(5, '0')}`, + type: DOC_TYPES.DATA_RECORD, + form: 'visit', + fields: { patient_id: `p-${i}` }, + reported_date: 1, + })); + await utils.saveDocs(bulkDocs); + const ids = bulkDocs.map(d => d._id); + + const { jobs } = await postCsv(ids.join('\n')); + expect(jobs).to.have.lengthOf(1); + const jobId = jobs[0].id; + + await updateSettings('20 milliseconds'); + + const firstRunDone = await utils.waitForSentinelLogs(true, /Finished archiving/); + await utils.runSentinelTasks(); + await firstRunDone.promise; + + const partial = await utils.sentinelDb.get(jobId); + expect(partial.cursor).to.be.greaterThan(0); + expect(partial.cursor).to.be.lessThan(COUNT); + + const partiallyArchived = await liveRows(archiveDb, { keys: ids }); + expect(partiallyArchived).to.have.lengthOf(partial.cursor); + const stillInMedic = await liveRows(utils.db, { keys: ids }); + expect(stillInMedic).to.have.lengthOf(COUNT - partial.cursor); + + await utils.runSentinelTasks(); + await sentinelUtils.waitForArchiveCompletion(); + + const archived = await liveRows(archiveDb, { keys: ids }); + expect(archived).to.have.lengthOf(COUNT); + await expectFullyPurgedFromMedic(ids); + await expectInfoDocsPurged(ids); + await expectAuditedArchive(ids); + }); + + it('preserves attachments when archiving', async function () { + this.timeout(60000); + + const id = 'archive-e2e-with-attachment'; + const payload = 'hello archived world'; + const doc = { + _id: id, + type: DOC_TYPES.DATA_RECORD, + form: 'visit', + reported_date: 1, + _attachments: { + 'note.txt': { + content_type: 'text/plain', + data: Buffer.from(payload, 'utf8').toString('base64'), + }, + }, + }; + await utils.saveDocs([doc]); + + await postCsv(id); + + await updateSettings(); + await runArchiving(); + + const archived = await archiveDb.get(id, { attachments: true }); + expect(archived._attachments['note.txt'].content_type).to.equal('text/plain'); + const restored = Buffer.from(archived._attachments['note.txt'].data, 'base64').toString('utf8'); + expect(restored).to.equal(payload); + + await expectFullyPurgedFromMedic([id]); + await expectInfoDocsPurged([id]); + await expectAuditedArchive([id]); + }); + + it('purges every revision when the source doc has conflicts', async function () { + this.timeout(60000); + + const id = 'archive-e2e-with-conflict'; + + const revA = '1-a000000000000000000000000000000a'; + const revB = '1-b000000000000000000000000000000b'; + const docA = { _id: id, _rev: revA, type: DOC_TYPES.DATA_RECORD, form: 'visit', reported_date: 1, source: 'A' }; + const docB = { _id: id, _rev: revB, type: DOC_TYPES.DATA_RECORD, form: 'visit', reported_date: 1, source: 'B' }; + + await utils.request({ + path: `/${constants.DB_NAME}/_bulk_docs`, + method: 'POST', + body: { docs: [docA, docB], new_edits: false }, + }); + + // Sanity check: the doc has a conflict before we archive. + const beforeArchive = await utils.db.get(id, { conflicts: true }); + expect(beforeArchive._conflicts).to.have.lengthOf(1); + + await postCsv(id); + + await updateSettings(); + await runArchiving(); + + const archived = await archiveDb.get(id); + expect(archived._rev).to.equal(revB); + expect(archived.source).to.equal('B'); + + await expectFullyPurgedFromMedic([id]); + await expectInfoDocsPurged([id]); + await expectAuditedArchive([id]); + }); + + it('quarantines a job that keeps failing and keeps processing the queue behind it', async function () { + this.timeout(60000); + + // A healthy archivable doc with its own job. + const healthyId = 'archive-e2e-quarantine-healthy'; + await utils.saveDocs([{ _id: healthyId, type: DOC_TYPES.DATA_RECORD, form: 'visit', reported_date: 1 }]); + await waitForInfoDocs([healthyId]); + + // A poison job: no `ids` attachment, so readIds throws on every run. Its id sorts before any + // uuid-v7 job id, so the loop hits it first. Seed error_count at the threshold-1 (the lib's + // MAX_JOB_ATTEMPTS is 20) so a single run trips the quarantine. + const poisonId = `${PREFIXES.ARCHIVE_JOB}0000-poison`; + await utils.sentinelDb.put({ + _id: poisonId, + type: PREFIXES.ARCHIVE_JOB, + date: Date.now(), + total: 1, + cursor: 0, + error_count: 19, + }); + + const { jobs } = await postCsv(healthyId); + expect(jobs).to.have.lengthOf(1); + const healthyJobId = jobs[0].id; + // Sanity: the poison job is encountered before the healthy job. + expect(poisonId < healthyJobId).to.equal(true); + + await updateSettings(); + + // Can't use waitForArchiveCompletion here — the quarantined job intentionally stays behind. + await utils.runSentinelTasks(); + const poison = await waitForJobStatus(poisonId, 'failed'); + + expect(poison.error_count).to.equal(20); + expect(poison.status).to.equal('failed'); + // The job doc is kept (not deleted) for an admin to inspect. + expect(poison._deleted).to.not.equal(true); + + // The healthy job behind the poison job was not blocked — it ran to completion and was deleted. + const healthyJobRow = (await utils.sentinelDb.allDocs({ keys: [healthyJobId] })).rows[0]; + expect(healthyJobRow.error || healthyJobRow.value.deleted).to.be.ok; + + const archived = await liveRows(archiveDb, { keys: [healthyId] }); + expect(archived).to.have.lengthOf(1); + await expectFullyPurgedFromMedic([healthyId]); + await expectAuditedArchive([healthyId]); + }); +}); diff --git a/tests/utils/index.js b/tests/utils/index.js index 104ea449d77..253d76c9f2e 100644 --- a/tests/utils/index.js +++ b/tests/utils/index.js @@ -70,6 +70,7 @@ const sentinelDb = new PouchDB(`${constants.BASE_URL}/${constants.DB_NAME}-senti const usersDb = new PouchDB(`${constants.BASE_URL}/_users`, { auth }); const logsDb = new PouchDB(`${constants.BASE_URL}/${constants.DB_NAME}-logs`, { auth }); const auditDb = new PouchDB(`${constants.BASE_URL}/${constants.DB_NAME}-audit`, { auth }); +const archiveDb = new PouchDB(`${constants.BASE_URL}/${constants.DB_NAME}-archive`, { auth }); const existingFeedbackDocIds = []; const MINIMUM_BROWSER_VERSION = '107'; const KUBECTL_CONTEXT = `-n ${PROJECT_NAME} --context k3d-${PROJECT_NAME}`; @@ -1210,7 +1211,17 @@ const waitForAuditCount = async (docId, expectedCount, retries = 15) => { return waitForAuditCount(docId, expectedCount, retries - 1); }; - +const waitForBulkOperation = async (id, tries = 30) => { + for (let i = 0; i < tries; i++) { + const log = await request({ path: `/api/v1/bulk-operations/${id}` }); + const actions = Object.values(log.actions || {}); + if (actions.every(action => action.status !== 'queued')) { + return log; + } + await delayPromise(100); + } + throw new Error(`bulk operation ${id} did not complete`); +}; const getDefaultSettings = () => { const pathToDefaultAppSettings = path.join(__dirname, '../config.default.json'); @@ -1870,6 +1881,7 @@ module.exports = { logsDb, usersDb, auditDb, + archiveDb, SW_SUCCESSFUL_REGEX, ONE_YEAR_IN_S, @@ -1919,8 +1931,8 @@ module.exports = { setTransitionSeqToNow, waitForDocRev, waitForAuditCount, + waitForBulkOperation, getDefaultSettings, - addTranslations, enableLanguage, enableLanguages, diff --git a/tests/utils/sentinel.js b/tests/utils/sentinel.js index aa3d5338c33..70660fb02e3 100644 --- a/tests/utils/sentinel.js +++ b/tests/utils/sentinel.js @@ -1,6 +1,7 @@ const utils = require('@utils'); const querystring = require('querystring'); const constants = require('@constants'); +const { PREFIXES } = require('@medic/constants'); const _ = require('lodash'); const { SENTINEL_METADATA: { @@ -136,6 +137,22 @@ const skipToSeq = async (seq) => { await utils.sentinelDb.put(backlogDoc); }; +const getARchivingJobs = async () => { + const result = await utils.sentinelDb.allDocs({ + startkey: PREFIXES.ARCHIVE_JOB, + endkey: `${PREFIXES.ARCHIVE_JOB}\ufff0`, + }); + return result.rows.filter(row => row.value && !row.value.deleted); +}; + +const waitForArchiveCompletion = async () => { + let jobs; + do { + await utils.delayPromise(1000); + jobs = await getARchivingJobs(); + } while (jobs.length > 0); +}; + module.exports = { waitForSentinel: docIds => waitForSeq(TRANSITIONS_SEQ, docIds), waitForBackgroundCleanup: docIds => waitForSeq(BACKGROUND_SEQ, docIds), @@ -148,4 +165,5 @@ module.exports = { getPurgeDbs: getPurgeDbs, getBacklogCount: getBacklogCount, skipToSeq: skipToSeq, + waitForArchiveCompletion, };