import type { Services } from '../../typings'; import { MongoDbConnector, ObjectId } from '@getanthill/mongodb-connector'; import crypto from 'crypto'; import setup from '../../../test/setup'; import { archive, apply, create, createSnapshot, decrypt, deleteEntity, encrypt, find, get, getEvents, getGraphData, patch, restore, timetravel, update, unarchive, } from './controllers'; import fixtureUsers from '../../../test/fixtures/users'; describe('controllers/models', () => { let app; let services: Services; let mongodb: MongoDbConnector; let models; beforeAll(async () => { app = await setup.build(); services = app.services; mongodb = services.mongodb; models = await setup.initModels(services, [fixtureUsers]); }); beforeEach(async () => { try { const Users = models.getModel(fixtureUsers.name); await Promise.all([ Users.getStatesCollection(Users.db(mongodb)).deleteMany({}), Users.getEventsCollection(Users.db(mongodb)).deleteMany({}), Users.getSnapshotsCollection(Users.db(mongodb)).deleteMany({}), ]); } catch (err) { // Possibly the User model does not exist } }); afterEach(() => { jest.restoreAllMocks(); }); afterAll(async () => { await setup.teardownDb(mongodb); }); describe('#create', () => { let error; let req; let res; let next; beforeEach(() => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { header: (h) => req.headers[h], params: {}, body: {}, headers: {}, }; res = { locals: {} }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = create({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = create({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a validation error in case invalid body schema validation', async () => { const controller = create({ ...services, models }); req.params.model = 'users'; req.body = { firstname: 12 }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Event schema validation error'); expect(error.details).toMatchObject([ { instancePath: '/firstname', keyword: 'type', message: 'must be string', params: { type: 'string' }, schemaPath: '#/properties/firstname/type', }, { event: { firstname: 12 } }, { model: 'users' }, ]); expect(error.status).toEqual(400); }); it('returns a generic error otherwise', async () => { const controller = create({ ...services, models }); req.params.model = 'users'; req.body = { firstname: 'John' }; next = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the created entity', async () => { const controller = create({ ...services, models }); req.params.model = 'users'; req.body = { firstname: 'John' }; await controller(req, res, next); expect(next).toHaveBeenCalledWith(); expect(res.body).toMatchObject({ firstname: 'John', version: 0, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); }); it('returns the created entity with forced value on `created_at`', async () => { const controller = create({ ...services, models }); req.params.model = 'users'; req.body = { firstname: 'John' }; req.headers['created-at'] = new Date('2021-01-01').toISOString(); await controller(req, res, next); expect(next).toHaveBeenCalledWith(); expect(res.body).toMatchObject({ created_at: new Date(req.headers['created-at']), updated_at: new Date(req.headers['created-at']), firstname: 'John', version: 0, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); }); it('returns a 409 Conflict error in case of index violation', async () => { const controller = create({ ...services, models }); req.params.model = 'users'; req.body = { firstname: 'John', email: 'john@doe.org' }; await controller(req, res, next); res = { locals: {} }; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(2); expect(error).not.toBe(null); expect(error.message).toContain('E11000 duplicate key error collection'); expect(error.status).toEqual(409); }); }); describe('#update', () => { let error; let req; let res; let next; beforeEach(() => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { header: (h) => req.headers[h], params: {}, body: {}, headers: {}, }; res = { locals: {} }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = update({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = update({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a 422 Unprocessable Entity in case of an update applied on a non created entity', async () => { const controller = update({ ...services, models }); req.params.model = 'users'; req.params.correlation_id = new ObjectId().toString(); req.body = { firstname: 'John' }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Entity must be created first'); expect(error.status).toEqual(422); }); it('returns a validation error in case invalid body schema validation', async () => { const controller = update({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { firstname: 12, // <-- invalid, must be string }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Event schema validation error'); expect(error.details).toMatchObject([ { instancePath: '/firstname', keyword: 'type', message: 'must be string', params: { type: 'string' }, schemaPath: '#/properties/firstname/type', }, { event: { firstname: 12 } }, { model: 'users' }, ]); expect(error.status).toEqual(400); }); it('returns a 405 `Entity is readonly` in case of an update temptative on a readonly entity', async () => { const controller = update({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John', is_readonly: true }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { firstname: 'Jack' }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Entity is readonly'); expect(error.status).toEqual(405); }); it('returns a 412 Precondition Failed in case of an imperative condition not satisfied', async () => { const controller = update({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { firstname: 'Jack' }; req.headers.version = '12'; // Invalid await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Imperative condition failed'); expect(error.status).toEqual(412); }); it('returns a generic error otherwise', async () => { const controller = update({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { firstname: 'John' }; next = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the updated entity', async () => { const controller = update({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { firstname: 'Jack' }; await controller(req, res, next); expect(next).toHaveBeenCalledWith(); expect(res.body).toMatchObject({ firstname: 'Jack', version: 1, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); expect(res.body.created_at).not.toEqual(res.body.updated_at); }); it('returns the updated entity with forced value on `updated_at`', async () => { const controller = update({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { firstname: 'Jack' }; req.headers['created-at'] = new Date('2022-01-01').toISOString(); await controller(req, res, next); expect(next).toHaveBeenCalledWith(); expect(res.body.created_at).not.toEqual(res.body.updated_at); expect(res.body).toMatchObject({ updated_at: new Date(req.headers['created-at']), firstname: 'Jack', version: 1, is_enabled: true, }); }); it('returns the updated entity even on not already created entity with the upsert header set to true', async () => { const controller = update({ ...services, models }); req.params.model = 'users'; req.params.correlation_id = new ObjectId().toString(); req.body = { user_id: req.params.correlation_id, firstname: 'Jack' }; req.headers['upsert'] = 'true'; await controller(req, res, next); expect(next).toHaveBeenCalledWith(); expect(res.body).toMatchObject({ firstname: 'Jack', version: 0, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); expect(res.body.created_at).toEqual(res.body.updated_at); }); it('returns the updated entity on upsert and entity already created', async () => { const controller = update({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { user_id: req.params.correlation_id, firstname: 'Jack' }; req.headers['upsert'] = 'true'; await controller(req, res, next); expect(next).toHaveBeenCalledWith(); expect(res.body).toMatchObject({ firstname: 'Jack', version: 1, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); expect(res.body.created_at).not.toEqual(res.body.updated_at); }); it('supports concurrent upsert requests', async () => { const controller = update({ ...services, models }); const correlationId = new ObjectId().toString(); req.params.model = 'users'; req.params.correlation_id = correlationId; req.headers['upsert'] = 'true'; let responses = [ { locals: {} }, { locals: {} }, { locals: {} }, { locals: {} }, { locals: {} }, ]; await Promise.all( responses.map((r) => controller( { ...req, body: { user_id: req.params.correlation_id, firstname: 'Jack' }, }, r, next, ), ), ); expect(next).toHaveBeenCalledWith(); const versions = responses .map((r) => r.body.version) .sort((a, b) => a - b); const jack = models.factory('users', correlationId); const state = await jack.getState(); const events = await jack.getEvents().toArray(); expect(state.version).toEqual(4); expect(Math.max(...versions)).toEqual(4); expect(events.map((e) => e.version).sort()).toEqual([0, 1, 2, 3, 4]); }); it('returns a 409 Conflict error in case of index violation', async () => { const controller = update({ ...services, models }); const alice = models.factory('users'); await alice.create({ firstname: 'John', email: 'john@doe.org' }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { email: 'john@doe.org' }; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(error).not.toBe(null); expect(error.message).toContain('E11000 duplicate key error collection'); expect(error.status).toEqual(409); }); }); describe('#patch', () => { let error; let req; let res; let next; beforeEach(() => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { header: (h) => req.headers[h], headers: {}, params: {}, body: {}, }; res = { locals: {} }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = patch({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = patch({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a 422 Unprocessable Entity in case of an update applied on a non created entity', async () => { const controller = patch({ ...services, models }); req.params.model = 'users'; req.params.correlation_id = new ObjectId().toString(); req.body = { json_patch: [{ op: 'replace', path: '/firstname', value: 'John' }], }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Entity must be created first'); expect(error.status).toEqual(422); }); it('returns a 405 `Entity is readonly` in case of a patch on readonly entity', async () => { const controller = patch({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John', is_readonly: true }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { json_patch: [{ op: 'replace', path: '/firstname', value: 'Jack' }], }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Entity is readonly'); expect(error.status).toEqual(405); }); it('returns a 412 Precondition Failed in case of an imperative condition not satisfied', async () => { const controller = patch({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.headers.version = '12'; // invalid req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { json_patch: [{ op: 'replace', path: '/firstname', value: 'Jack' }], }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Imperative condition failed'); expect(error.status).toEqual(412); }); it('returns a validation error in case invalid body schema validation', async () => { const controller = patch({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { firstname: 'John', // Must only accept `json_patch` json_patch: [], }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Event schema validation error'); expect(error.details).toMatchObject([ { instancePath: '', keyword: 'additionalProperties', message: 'must NOT have additional properties', params: { additionalProperty: 'firstname' }, schemaPath: '#/additionalProperties', }, { event: { firstname: 'John', json_patch: [] } }, { model: 'users' }, ]); expect(error.status).toEqual(400); }); it('returns a generic error otherwise', async () => { const controller = patch({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { json_patch: [{ op: 'replace', path: '/firstname', value: 'John' }], }; next = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the patched entity', async () => { const controller = patch({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { json_patch: [{ op: 'replace', path: '/firstname', value: 'Jack' }], }; await controller(req, res, next); expect(next).toHaveBeenCalledWith(); expect(res.body).toMatchObject({ firstname: 'Jack', version: 1, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); expect(res.body.created_at).not.toEqual(res.body.updated_at); }); it('returns the patched entity with forced `created_at` value', async () => { const controller = patch({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { json_patch: [{ op: 'replace', path: '/firstname', value: 'Jack' }], }; req.headers['created-at'] = new Date('2021-01-01').toISOString(); await controller(req, res, next); expect(next).toHaveBeenCalledWith(); expect(res.body).toMatchObject({ updated_at: new Date(req.headers['created-at']), firstname: 'Jack', version: 1, is_enabled: true, }); }); it('returns a 409 Conflict error in case of index violation', async () => { const controller = patch({ ...services, models }); const alice = models.factory('users'); await alice.create({ firstname: 'John', email: 'john@doe.org' }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.body = { json_patch: [{ op: 'replace', path: '/email', value: 'john@doe.org' }], }; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(error).not.toBe(null); expect(error.message).toContain('E11000 duplicate key error collection'); expect(error.status).toEqual(409); }); }); describe('#apply', () => { let error; let req; let res; let next; beforeEach(() => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { header: (h) => req.headers[h], headers: {}, params: {}, body: {}, }; res = { locals: {} }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = apply({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = apply({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a 422 Unprocessable Entity in case of an update applied on a non created entity', async () => { const controller = apply({ ...services, models }); req.params.model = 'users'; req.params.correlation_id = new ObjectId().toString(); req.params.event_type = 'firstname_updated'; req.params.event_version = '0_0_0'; req.body = { firstname: 'Jack' }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Entity must be created first'); expect(error.status).toEqual(422); }); it('returns a 405 `Entity is readonly` in case of an apply on a readonly entity', async () => { const controller = apply({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John', is_readonly: true }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.event_type = 'firstname_updated'; req.params.event_version = '0_0_0'; req.body = { firstname: 'Jack' }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Entity is readonly'); expect(error.status).toEqual(405); }); it('returns a 412 Precondition failed in case of an imperative condition not satisfied', async () => { const controller = apply({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.headers.version = '12'; // Invalid req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.event_type = 'firstname_updated'; req.params.event_version = '0_0_0'; req.body = { firstname: 'Jack' }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Imperative condition failed'); expect(error.status).toEqual(412); }); it('returns a validation error in case invalid body schema validation', async () => { const controller = apply({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.event_type = 'firstname_updated'; req.params.event_version = '0_0_0'; req.body = { firstname: 12 }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Event schema validation error'); expect(error.details).toMatchObject([ { instancePath: '/firstname', keyword: 'type', message: 'must be string', params: { type: 'string' }, schemaPath: '#/properties/firstname/type', }, { event: { firstname: 12 } }, { model: 'users' }, ]); expect(error.status).toEqual(400); }); it('returns a generic error otherwise', async () => { const controller = apply({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.event_type = 'firstname_updated'; req.params.event_version = '0_0_0'; req.body = { firstname: 'Jack' }; next = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the updated entity', async () => { const controller = apply({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.event_type = 'firstname_updated'; req.params.event_version = '0_0_0'; req.body = { firstname: 'Jack' }; await controller(req, res, next); expect(next).toHaveBeenCalledWith(); expect(res.body).toMatchObject({ firstname: 'Jack', version: 1, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); expect(res.body.created_at).not.toEqual(res.body.updated_at); }); it('returns the updated entity with forced `created_at` value', async () => { const controller = apply({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.event_type = 'firstname_updated'; req.params.event_version = '0_0_0'; req.body = { firstname: 'Jack' }; req.headers['created-at'] = new Date('2021-01-01').toISOString(); await controller(req, res, next); expect(next).toHaveBeenCalledWith(); expect(res.body).toMatchObject({ updated_at: new Date(req.headers['created-at']), firstname: 'Jack', version: 1, is_enabled: true, }); }); it('returns the updated entity with defined `retryDuration` handle options', async () => { const controller = apply({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.event_type = 'firstname_updated'; req.params.event_version = '0_0_0'; req.body = { firstname: 'Jack' }; req.headers['created-at'] = new Date('2021-01-01').toISOString(); req.headers['retry-duration'] = 0; // <-- Disable the retry for a specific event not 5000ms const iterations = new Array(20).fill(1); await Promise.all(iterations.map((_, i) => controller(req, res, next))); await new Promise((resolve) => setTimeout(resolve, 2000)); expect((await user.getEvents().toArray()).length < 21).toEqual(true); }); it('returns the updated entity after an event `replay`', async () => { const controller = apply({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); const statetoRestore = user.state; const Users = models.getModel(fixtureUsers.name); const events = await Users.getEventsCollection(Users.db(mongodb)) .find({ user_id: user.correlationId }) .toArray(); // Here we entirely remove events from the database: await Users.getStatesCollection(Users.db(mongodb)).deleteMany({ user_id: user.correlationId, }); await Users.getEventsCollection(Users.db(mongodb)).deleteMany({ user_id: user.correlationId, }); for (const event of events) { res.body = null; req.params.model = 'users'; req.params.correlation_id = event.user_id; req.params.event_type = event.type; req.params.event_version = event.v; req.body = event; req.headers['replay'] = 'true'; await controller(req, res, next); } expect(res.body).toMatchObject(statetoRestore); }); it('returns the updated entity after an event `replay` event on event already replayed', async () => { const controller = apply({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); const statetoRestore = user.state; const Users = models.getModel(fixtureUsers.name); const events = await Users.getEventsCollection(Users.db(mongodb)) .find({ user_id: user.correlationId }) .toArray(); // Here we do not remove events from the database for (const event of events) { res.body = null; req.params.model = 'users'; req.params.correlation_id = event.user_id; req.params.event_type = event.type; req.params.event_version = event.v; req.body = event; req.headers['replay'] = 'true'; await controller(req, res, next); } expect(res.body).toMatchObject(statetoRestore); }); it('returns a 409 Conflict error in case of index violation', async () => { const controller = apply({ ...services, models }); const alice = models.factory('users'); await alice.create({ firstname: 'John', email: 'john@doe.org' }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.event_type = 'email_updated'; req.params.event_version = '0_0_0'; req.body = { email: 'john@doe.org' }; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(error).not.toBe(null); expect(error.message).toContain('E11000 duplicate key error collection'); expect(error.status).toEqual(409); }); }); describe('#get', () => { let error; let req; let res; let next; beforeEach(() => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, body: {}, header: (h) => req.headers[h.toLowerCase()], query: {}, headers: {}, }; res = { locals: {} }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = get({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = get({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a 404 Not Found if the state of the entity is null (not created)', async () => { const controller = get({ ...services, models }); req.params.model = 'users'; req.params.correlation_id = new ObjectId().toString(); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Not Found'); expect(error.status).toEqual(404); }); it('returns a generic error otherwise', async () => { const controller = get({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); next = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the entity', async () => { const controller = get({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.body).toMatchObject({ firstname: 'John', version: 0, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); }); it('returns the encrypted entity with decrypt header if not authorized', async () => { const Model = services.models.getModel('users'); jest .spyOn(Model, 'getEncryptionKeys') .mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']); jest .spyOn(Model, 'getHashesEncryptionKeys') .mockImplementation(() => [ Model.hashValue('c98d0a9c30d3cdd1493ad3c20efda4f4'), ]); const decryptMock = jest.spyOn(Model, 'decrypt'); const controller = get({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John', sensitive_data: 'this is private', }); req.headers.decrypt = 'true'; req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(decryptMock).toHaveBeenCalledTimes(0); expect(res.body).toMatchObject({ firstname: 'John', version: 0, is_enabled: true, }); expect(res.body.sensitive_data).not.toEqual('this is private'); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); }); it('returns the encrypted entity with decrypt header if authorized', async () => { const Model = services.models.getModel('users'); jest .spyOn(Model, 'getEncryptionKeys') .mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']); jest .spyOn(Model, 'getHashesEncryptionKeys') .mockImplementation(() => [ Model.hashValue('c98d0a9c30d3cdd1493ad3c20efda4f4'), ]); const decryptMock = jest.spyOn(Model, 'decrypt'); const controller = get({ ...services, models, config: { ...services.config, security: { ...services.config.security, tokens: [ { id: 'read', level: 'read', token: 'read' }, { id: 'decrypt', level: 'decrypt', token: 'decrypt' }, { id: 'write', level: 'write', token: 'write' }, { id: 'admin', level: 'admin', token: 'admin' }, ], }, }, }); const user = models.factory('users'); await user.create({ firstname: 'John', sensitive_data: 'this is private', }); req.headers.authorization = 'decrypt'; req.headers.decrypt = 'true'; req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(decryptMock).toHaveBeenCalledWith(user.state, undefined); expect(res.body).toMatchObject({ firstname: 'John', version: 0, is_enabled: true, }); expect(res.body.sensitive_data).toEqual('this is private'); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); }); }); describe('#timetravel', () => { let error; let req; let res; let next; beforeEach(() => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: { version: '0' }, body: {}, headers: {}, header: (h) => req.headers[h.toLowerCase()], }; res = { locals: {} }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = timetravel({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = timetravel({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a 404 Not Found if the state of the entity is null (not created)', async () => { const controller = timetravel({ ...services, models }); req.params.model = 'users'; req.params.correlation_id = new ObjectId().toString(); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Not Found'); expect(error.status).toEqual(404); }); it('returns a generic error otherwise', async () => { const controller = timetravel({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); next = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the entity', async () => { const controller = timetravel({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); await user.update({ firstname: 'William' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.version = 1; await controller(req, res, next); expect(res.body).toMatchObject({ firstname: 'Jack', version: 1, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); }); it('returns the entity at a given date', async () => { const controller = timetravel({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await new Promise((resolve) => setTimeout(resolve, 100)); await user.update({ firstname: 'Jack' }); const target = user.state.updated_at; await new Promise((resolve) => setTimeout(resolve, 100)); await user.update({ firstname: 'William' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.version = new Date( new Date(target).getTime() + 50, ).toISOString(); await controller(req, res, next); expect(res.body).toMatchObject({ firstname: 'Jack', version: 1, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); }); it('returns the entity at a given date', async () => { const controller = timetravel({ ...services, models }); const alice = models.factory('users'); const user = models.factory('users'); await user.create({ firstname: 'John' }); await alice.create({ firstname: 'Alice' }); await new Promise((resolve) => setTimeout(resolve, 100)); await user.update({ firstname: 'Jack' }); await alice.update({ firstname: 'Alizz' }); const target = user.state.updated_at; await new Promise((resolve) => setTimeout(resolve, 100)); await user.update({ firstname: 'William' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.version = new Date( new Date(target).getTime() + 50, ).toISOString(); await controller(req, res, next); expect(res.body).toMatchObject({ firstname: 'Jack', version: 1, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); }); it('returns the entity skipping the response validation with with-response-validation header set to false', async () => { const controller = timetravel({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); await user.update({ firstname: 'William' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.version = 1; req.headers['with-response-validation'] = 'false'; res.json = jest.fn(); await controller(req, res, next); expect(res.body).toMatchObject({ firstname: 'Jack', version: 1, is_enabled: true, }); expect(next).toHaveBeenCalledTimes(0); expect(res.json).toHaveBeenCalledWith(res.body); }); it('returns a 404 Not Found error in case of date prior to the creation', async () => { const controller = timetravel({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); const target = new Date(new Date(user.state.updated_at).getTime() - 1); await user.update({ firstname: 'Jack' }); await user.update({ firstname: 'William' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.version = target.toISOString(); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Not Found'); expect(error.status).toEqual(404); }); }); describe('#restore', () => { let error; let req; let res; let next; beforeEach(() => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: { version: '0' }, body: {} }; res = { locals: {} }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = restore({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = restore({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a 404 Not Found if the state of the entity is null (not created)', async () => { const controller = restore({ ...services, models }); req.params.model = 'users'; req.params.correlation_id = new ObjectId().toString(); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Not Found'); expect(error.status).toEqual(404); }); it('returns a 404 Not Found if the target state does not exist', async () => { const controller = restore({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.version = '23'; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('State version does not exist'); expect(error.status).toEqual(404); }); it('returns a 405 `Entity is readonly` if the entity is readonly', async () => { const controller = restore({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); await user.update({ firstname: 'William', is_readonly: true }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.version = 1; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Entity is readonly'); expect(error.status).toEqual(405); }); it('returns a generic error otherwise', async () => { const controller = restore({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); next = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the entity', async () => { const controller = restore({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); await user.update({ firstname: 'William' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.version = 1; await controller(req, res, next); expect(res.body).toMatchObject({ firstname: 'Jack', version: 3 }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); }); it('returns a 409 Conflict error in case of index violation', async () => { const controller = restore({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John', email: 'john@doe.org' }); await user.update({ firstname: 'Jack', email: 'bernard@doe.org' }); // Alice is having now the ownership of this email address: const alice = models.factory('users'); await alice.create({ firstname: 'John', email: 'john@doe.org' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.params.version = 0; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(error).not.toBe(null); expect(error.message).toEqual('Can not rollback a restoration event'); expect(error.status).toEqual(409); await user.getState(); expect(user.state).toMatchObject({ version: 3, email: 'bernard@doe.org', }); }); }); describe('#find', () => { let error; let req; let res; let next; beforeEach(() => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { header: (h) => req.headers[h.toLowerCase()], params: {}, body: {}, query: {}, headers: { page: 0, 'page-size': 100 }, }; res = { locals: {}, set: jest.fn() }; const Model = models.getModel('users'); Model.encryptionKeys = null; Model.hashedEncryptionKeys = null; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = find({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = find({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a generic error otherwise', async () => { const controller = find({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; next = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns a list of entities', async () => { const controller = find({ ...services, models }); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}` })), ); req.params.model = 'users'; await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(4); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); it('returns a list of entities skipping the first page', async () => { const controller = find({ ...services, models }); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; // for loop to ensure insertion order: for (const [index, user] of users.entries()) { await user.create({ firstname: `Joe_${index}` }); } req.params.model = 'users'; req.headers['page'] = 1; req.headers['page-size'] = 2; await controller(req, res, next); expect(res.body).toMatchObject([ { firstname: 'Joe_2' }, { firstname: 'Joe_3' }, ]); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); it('returns a list of entities using the last cursor ID value in place of page', async () => { const controller = find({ ...services, models }); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; // for loop to ensure insertion order: for (const [index, user] of users.entries()) { await user.create({ firstname: `Joe_${index}` }); } req.params.model = 'users'; req.headers['page-size'] = 2; await controller(req, res, next); const headers = res.set.mock.calls[0][0]; delete res.body; req.params.model = 'users'; req.headers['cursor-last-id'] = headers['cursor-last-id']; req.headers['cursor-last-correlation-id'] = headers['cursor-last-correlation-id']; req.headers['page-size'] = 2; await controller(req, res, next); expect(res.body).toMatchObject([ { firstname: 'Joe_2' }, { firstname: 'Joe_3' }, ]); expect(res.set.mock.calls[1][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); it('returns a list of entities using the last cursor ID value in place of page inn reverse order', async () => { const controller = find({ ...services, models }); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; // for loop to ensure insertion order: for (const [index, user] of users.entries()) { await user.create({ firstname: `Joe_${index}` }); } req.params.model = 'users'; req.headers['page-size'] = 2; req.query = { _sort: { firstname: -1 } }; await controller(req, res, next); const headers = res.set.mock.calls[0][0]; delete res.body; req.params.model = 'users'; req.headers['cursor-last-id'] = headers['cursor-last-id']; req.headers['cursor-last-correlation-id'] = headers['cursor-last-correlation-id']; req.headers['page-size'] = 2; await controller(req, res, next); expect(res.body).toMatchObject([ { firstname: 'Joe_1' }, { firstname: 'Joe_0' }, ]); expect(res.set.mock.calls[1][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); it('returns a list of entities skipping the response validation with with-response-validation header set to false', async () => { const controller = find({ ...services, models }); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}` })), ); req.params.model = 'users'; req.headers['with-response-validation'] = 'false'; res.json = jest.fn(); await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(4); expect(next).toHaveBeenCalledTimes(0); expect(res.json).toHaveBeenCalledWith(res.body); }); it('returns a list of entities of max page_size', async () => { const controller = find({ ...services, models }); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; // for loop to ensure insertion order: for (const [index, user] of users.entries()) { await user.create({ firstname: `Joe_${index}` }); } req.params.model = 'users'; req.headers['page-size'] = 2; await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(2); expect(res.body[0]).toMatchObject({ firstname: 'Joe_0' }); expect(res.body[1]).toMatchObject({ firstname: 'Joe_1' }); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); req.headers['page'] = 1; delete res.body; res.set = jest.fn(); await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(2); expect(res.body[0]).toMatchObject({ firstname: 'Joe_2' }); expect(res.body[1]).toMatchObject({ firstname: 'Joe_3' }); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); it('returns a list of entities matching several values in query params', async () => { const controller = find({ ...services, models }); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}` })), ); req.params.model = 'users'; req.query.firstname = ['Joe_0', 'Joe_1']; await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(2); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); it('returns no result if given empty array values in query params', async () => { const controller = find({ ...services, models }); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}` })), ); req.params.model = 'users'; req.query.firstname = []; await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(0); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', count: 0, page: 0, 'page-count': 0, 'page-size': 100, }); }); it('returns no result on invalid query params', async () => { const controller = find({ ...services, models }); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}` })), ); req.params.model = 'users'; req.query.invalid = true; await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(0); }); /** * @deprecated This test must be removed once the * deprecation of the parameter `_must_hash` is * effective. */ it('(backward) returns results based on raw queries', async () => { const controller = find({ ...services, models }); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}`, count: index, last_seen: new Date(2020, 0, index + 1).toISOString(), nested: { birth_date: new Date(2012, index, 1).toISOString() }, }), ), ); req.params.model = 'users'; req.query._q = JSON.stringify({ last_seen: { $gte: new Date(2020, 0, 1).toISOString(), $lt: new Date(2020, 0, 3).toISOString(), }, }); await controller(req, res, next); expect(res.body).toHaveLength(2); res.body = null; req.query._q = JSON.stringify({ last_seen: { $gte: new Date(2020, 0, 1).toISOString(), $lt: new Date(2020, 0, 3).toISOString(), }, nested: { birth_date: new Date(2012, 0, 1).toISOString() }, }); await controller(req, res, next); expect(res.body).toHaveLength(1); }); /** * @fixme Model is destroyed in this test * * Running fine with `` not chained with * other tests */ it.skip('hashes the query object on encrypted keys before calling the find method', async () => { const controller = find({ ...services, models }); const Model = models.getModel('users'); jest.spyOn(Model, 'find').mockImplementation(jest.fn); jest .spyOn(Model, 'getEncryptionKeys') .mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']); jest .spyOn(Model, 'getEncryptedFields') .mockImplementation(() => ['email', 'firstname']); jest.spyOn(models, 'getModel').mockImplementation(() => Model); req.params.model = 'users'; // req.query._must_hash = true; req.query['hash(email)'] = 'john@doe.org'; req.query['hash(firstname)'] = ['Alice', 'Bernard']; req.query.phone = '+3334455667'; await controller(req, res, next); expect(models.getModel).toHaveBeenCalledTimes(1); const query = Model.find.mock.calls[0][1]; expect(query).toMatchObject({ phone: '+3334455667', 'email.hash': 'b8cccea15437aef415090bda6acb3b0ad3d4cf7d3e4cf816772e4b43e8f9d08af392bb98b8d532e07249f0d1304e6d65e007205c39913ee5db95578be398f4bd', 'firstname.hash': { $in: [ 'c1a518e720e60b55d4dc467def2b6cc1893444a206d2566d3cbb5a72cae52106df1bd33035581392a7cb55e16b99d6090402d2642a86da87d1c8f5d7cec3f45e', 'fd629996265b2856b54d284ab176342a09316826f37eb222c446067b6d81ac7acc9049585189ca1fc282a8bc07eb72bcb5af3a60c66d4d098dd66cfbec4d6274', ], }, }); }); /** * @deprecated This test must be removed once the * deprecation of the parameter `_must_hash` is * effective. */ it.skip('(backward) hashes the query object on encrypted keys before calling the find method if the field is flagged as encrypted and global _must_hash parameter is true', async () => { const controller = find({ ...services, models }); const Model = models.getModel('users'); jest.spyOn(Model, 'find').mockImplementation(jest.fn); jest .spyOn(Model, 'getEncryptionKeys') .mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']); jest .spyOn(Model, 'getEncryptedFields') .mockImplementation(() => ['email', 'firstname']); jest.spyOn(models, 'getModel').mockImplementation(() => Model); req.params.model = 'users'; req.query._must_hash = true; req.query['email'] = 'john@doe.org'; req.query['firstname'] = ['Alice', 'Bernard']; req.query.phone = '+3334455667'; await controller(req, res, next); expect(models.getModel).toHaveBeenCalledTimes(1); const query = Model.find.mock.calls[0][1]; expect(query).toMatchObject({ phone: '+3334455667', 'email.hash': 'b8cccea15437aef415090bda6acb3b0ad3d4cf7d3e4cf816772e4b43e8f9d08af392bb98b8d532e07249f0d1304e6d65e007205c39913ee5db95578be398f4bd', 'firstname.hash': { $in: [ 'c1a518e720e60b55d4dc467def2b6cc1893444a206d2566d3cbb5a72cae52106df1bd33035581392a7cb55e16b99d6090402d2642a86da87d1c8f5d7cec3f45e', 'fd629996265b2856b54d284ab176342a09316826f37eb222c446067b6d81ac7acc9049585189ca1fc282a8bc07eb72bcb5af3a60c66d4d098dd66cfbec4d6274', ], }, }); }); it('returns a list of entities with criterias on encrypted fields', async () => { const controller = find({ ...services, models }); const Model = models.getModel('users'); Model.options.services.config.security.encryptionKeys = { all: ['c98d0a9c30d3cdd1493ad3c20efda4f4'], }; const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}`, sensitive_data: `this is private: ${index}`, }), ), ); req.params.model = 'users'; // req.query._must_hash = true; req.query['hash(sensitive_data)'] = `this is private: 0`; await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(1); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); /** * @deprecated This test must be removed once the * deprecation of the parameter `_must_hash` is * effective. */ it('(backward) returns a list of entities with criterias on encrypted fields', async () => { const controller = find({ ...services, models }); const Model = models.getModel('users'); jest .spyOn(Model, 'getEncryptionKeys') .mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}`, sensitive_data: `this is private: ${index}`, }), ), ); req.params.model = 'users'; req.query._must_hash = true; req.query['sensitive_data'] = `this is private: 0`; await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(1); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); it('returns a list of entities with criterias on encrypted fields', async () => { const controller = find({ ...services, models }); const Model = models.getModel('users'); jest .spyOn(Model, 'getEncryptionKeys') .mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']); jest .spyOn(Model, 'getHashesEncryptionKeys') .mockImplementation(() => [ Model.hashValue('c98d0a9c30d3cdd1493ad3c20efda4f4'), ]); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}`, sensitive_data: `this is private: ${index}`, }), ), ); req.params.model = 'users'; // req.query._must_hash = true; req.query['hash(sensitive_data)'] = [ 'this is private: 0', 'this is private: 1', ]; req.headers['page-size'] = undefined; // Default await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(2); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); it('returns a list of encrypted entities with criterias on encrypted fields if not authorized', async () => { const controller = find({ ...services, models }); const Model = models.getModel('users'); jest .spyOn(Model, 'getEncryptionKeys') .mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']); jest .spyOn(Model, 'getHashesEncryptionKeys') .mockImplementation(() => [ Model.hashValue('c98d0a9c30d3cdd1493ad3c20efda4f4'), ]); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; const encryptedUsers: any[] = []; for (const [index, user] of users.entries()) { const encryptedUser = await user.create({ firstname: `Joe_${index}`, sensitive_data: `this is private: ${index}`, }); encryptedUsers.push(encryptedUser); } req.params.model = 'users'; // req.query._must_hash = true; req.query['hash(sensitive_data)'] = [ 'this is private: 0', 'this is private: 1', ]; req.headers.decrypt = 'true'; req.headers['page-size'] = undefined; // Default await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(2); expect(res.body[0]).toMatchObject(encryptedUsers[0].state); expect(res.body[1]).toMatchObject(encryptedUsers[1].state); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); it('returns a list of decrypted entities with criterias on encrypted fields if authorized', async () => { const controller = find({ ...services, models, config: { ...services.config, security: { ...services.config.security, tokens: [ { id: 'read', level: 'read', token: 'read' }, { id: 'decrypt', level: 'decrypt', token: 'decrypt' }, { id: 'write', level: 'write', token: 'write' }, { id: 'admin', level: 'admin', token: 'admin' }, ], }, }, }); const Model = models.getModel('users'); jest .spyOn(Model, 'getEncryptionKeys') .mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']); jest .spyOn(Model, 'getHashesEncryptionKeys') .mockImplementation(() => [ Model.hashValue('c98d0a9c30d3cdd1493ad3c20efda4f4'), ]); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}`, sensitive_data: `this is private: ${index}`, }), ), ); req.params.model = 'users'; // req.query._must_hash = true; req.query._sort = { firstname: 1 }; req.query['hash(sensitive_data)'] = [ 'this is private: 0', 'this is private: 1', ]; req.headers.authorization = 'decrypt'; req.headers.decrypt = 'true'; req.headers['page-size'] = undefined; // Default await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(2); expect(res.body[0]).toMatchObject({ firstname: `Joe_0`, sensitive_data: `this is private: 0`, }); expect(res.body[1]).toMatchObject({ firstname: `Joe_1`, sensitive_data: `this is private: 1`, }); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); /** * @deprecated This test must be removed once the * deprecation of the parameter `_must_hash` is * effective. */ it('(backward) returns a list of entities with criterias on encrypted fields', async () => { const controller = find({ ...services, models }); const Model = models.getModel('users'); jest .spyOn(Model, 'getEncryptionKeys') .mockImplementation(() => ['c98d0a9c30d3cdd1493ad3c20efda4f4']); jest .spyOn(Model, 'getHashesEncryptionKeys') .mockImplementation(() => [ Model.hashValue('c98d0a9c30d3cdd1493ad3c20efda4f4'), ]); const users = [ models.factory('users'), models.factory('users'), models.factory('users'), models.factory('users'), ]; await Promise.all( users.map((user, index) => user.create({ firstname: `Joe_${index}`, sensitive_data: `this is private: ${index}`, }), ), ); req.params.model = 'users'; req.query._must_hash = true; req.query['sensitive_data'] = [ 'this is private: 0', 'this is private: 1', ]; req.headers['page-size'] = undefined; // Default await controller(req, res, next); expect(res.body.length).toBeGreaterThanOrEqual(2); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); }); describe('#getEvents', () => { let error; let req; let res; let next; beforeEach(() => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { header: (h) => req.headers[h], params: {}, body: {}, query: {}, headers: { 'page-size': 100 }, }; res = { locals: {}, set: jest.fn() }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = getEvents({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = getEvents({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a 404 Not Found if the entity does not exist yet', async () => { const controller = getEvents({ ...services, models }); req.params.model = 'users'; req.params.correlation_id = new ObjectId().toString(); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Not Found'); expect(error.status).toEqual(404); }); it('returns a generic error otherwise', async () => { const controller = getEvents({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); /** * @fixme should be next if the response validation * is not skipped */ res.json = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the events associated to this entity', async () => { const controller = getEvents({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.body).toMatchObject([ { type: 'CREATED', v: '0_0_0', firstname: 'John', version: 0, is_enabled: true, }, ]); }); it('returns the events with version greater than the version given in query', async () => { const controller = getEvents({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.query.version = '0'; await controller(req, res, next); expect(res.body).toMatchObject([ { type: 'UPDATED', v: '0_0_0', firstname: 'Jack', version: 1, is_enabled: true, }, ]); }); it('returns the nth first events if the limit of page_size', async () => { const controller = getEvents({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); req.headers['page-size'] = 1; req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.body).toMatchObject([ { type: 'CREATED', v: '0_0_0', firstname: 'John', version: 0, is_enabled: true, }, ]); }); it('returns the nth first events in respect to the page number requested', async () => { const controller = getEvents({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); req.headers['page-size'] = 1; req.headers['page'] = 1; req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.body).toMatchObject([ { type: 'UPDATED', v: '0_0_0', firstname: 'Jack', version: 1, is_enabled: true, }, ]); }); it('returns the pagination information in the response headers', async () => { const controller = getEvents({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.headers['page-size'] = undefined; // Default await controller(req, res, next); expect(res.set.mock.calls[0][0]).toMatchObject({ 'correlation-field': 'user_id', }); }); it('returns the next cursor page if available in headers for a given entity', async () => { const controller = getEvents({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.update({ firstname: 'Jack' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.headers['page-size'] = 1; await controller(req, res, next); const cursorHeaders = res.set.mock.calls[0][0]; expect(cursorHeaders).toMatchObject({ 'correlation-field': 'user_id' }); delete res.body; req.headers['cursor-last-id'] = cursorHeaders['cursor-last-id']; req.headers['cursor-last-correlation-id'] = cursorHeaders['cursor-last-correlation-id']; await controller(req, res, next); expect(res.body).toMatchObject([ { type: 'UPDATED', firstname: 'Jack', version: 1 }, ]); }); it('returns the next cursor page if available in headers for all events', async () => { const controller = getEvents({ ...services, models }); const alice = models.factory('users'); await alice.create({ firstname: 'Alice' }); const bernard = models.factory('users'); await bernard.create({ firstname: 'Bernard' }); req.params.model = 'users'; req.headers['page-size'] = 1; await controller(req, res, next); const cursorHeaders = res.set.mock.calls[0][0]; expect(cursorHeaders).toMatchObject({ 'correlation-field': 'user_id' }); delete res.body; req.headers['cursor-last-id'] = cursorHeaders['cursor-last-id']; req.headers['cursor-last-correlation-id'] = cursorHeaders['cursor-last-correlation-id']; await controller(req, res, next); expect(res.body).toMatchObject([ { type: 'CREATED', firstname: 'Bernard', version: 0 }, ]); }); it('returns the nth first inserted events whatever the correlation ID if not present', async () => { const controller = getEvents({ ...services, models }); const alice = models.factory('users'); const bernard = models.factory('users'); await alice.create({ firstname: 'Alice' }); await bernard.create({ firstname: 'Bernard' }); await alice.update({ firstname: 'Jack' }); req.params.model = 'users'; await controller(req, res, next); expect(res.body).toMatchObject([ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', version: 0, is_enabled: true, }, { type: 'CREATED', v: '0_0_0', firstname: 'Bernard', version: 0, is_enabled: true, }, { type: 'UPDATED', v: '0_0_0', firstname: 'Jack', version: 1, is_enabled: true, }, ]); }); it('returns the nth first inserted events matching the query params', async () => { const controller = getEvents({ ...services, models }); const alice = models.factory('users'); const bernard = models.factory('users'); await alice.create({ firstname: 'Alice' }); await bernard.create({ firstname: 'Bernard' }); await alice.update({ firstname: 'Jack' }); req.params.model = 'users'; req.query.firstname = 'Alice'; await controller(req, res, next); expect(res.body).toMatchObject([ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', version: 0, is_enabled: true, }, ]); }); it('returns the nth first inserted events matching the query params given as multiple values', async () => { const controller = getEvents({ ...services, models }); const alice = models.factory('users'); const bernard = models.factory('users'); await alice.create({ firstname: 'Alice' }); await bernard.create({ firstname: 'Bernard' }); await alice.update({ firstname: 'Jack' }); req.params.model = 'users'; req.query.firstname = ['Alice', 'Jack']; await controller(req, res, next); expect(res.body).toMatchObject([ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', version: 0, is_enabled: true, }, { type: 'UPDATED', v: '0_0_0', firstname: 'Jack', version: 1, is_enabled: true, }, ]); }); it('returns no event if [] is given as query param', async () => { const controller = getEvents({ ...services, models }); const alice = models.factory('users'); const bernard = models.factory('users'); await alice.create({ firstname: 'Alice' }); await bernard.create({ firstname: 'Bernard' }); await alice.update({ firstname: 'Jack' }); req.params.model = 'users'; req.query.firstname = []; await controller(req, res, next); expect(res.body).toMatchObject([]); }); }); describe('#createSnapshot', () => { let error; let req; let res; let next; beforeEach(() => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, body: {}, query: {} }; res = { locals: {} }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = createSnapshot({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = createSnapshot({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a 422 Unprocessable Entity of a snapshot is requested on a non created entity', async () => { const controller = createSnapshot({ ...services, models }); req.params.model = 'users'; req.params.correlation_id = new ObjectId().toString(); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Snapshot state is invalid'); expect(error.status).toEqual(422); }); it('returns a generic error otherwise', async () => { const controller = createSnapshot({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); next = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the created snapshot', async () => { const controller = createSnapshot({ ...services, models }); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.body).toMatchObject({ firstname: 'John', version: 0, is_enabled: true, }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('user_id'); }); }); describe('#encrypt', () => { let originalEncryptionKeys; let error; let req; let res; let next; beforeEach(() => { originalEncryptionKeys = services.config.security.encryptionKeys; error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, query: {}, body: {} }; res = { locals: {} }; }); afterEach(() => { jest.restoreAllMocks(); services.config.security.encryptionKeys = originalEncryptionKeys; }); it('calls next if res.body is already set', async () => { const controller = encrypt({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = encrypt({ ...services, models }); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a generic error otherwise', async () => { const controller = encrypt({ ...services, models }); req.params.model = 'users'; req.body = []; next = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the encrypted data based on model encryption keys', async () => { services.config.security.encryptionKeys = { encrypted_users: [crypto.randomBytes(16).toString('hex')], }; models = await setup.initModels(services, [ { ...fixtureUsers, name: 'encrypted_users', encrypted_fields: ['firstname'], }, ]); const user = models.factory('encrypted_users'); await user.create({ firstname: 'John' }); const controller = encrypt({ ...services, models }); req.params.model = 'encrypted_users'; req.body = [user.state]; await controller(req, res, next); expect(res.body[0].firstname).not.toEqual('John'); expect(res.body[0].firstname).toHaveProperty('hash'); expect(res.body[0].firstname).toHaveProperty('encrypted'); }); it('returns the encrypted data on additional fields', async () => { services.config.security.encryptionKeys = { encrypted_users: [crypto.randomBytes(16).toString('hex')], }; models = await setup.initModels(services, [ { ...fixtureUsers, name: 'encrypted_users', encrypted_fields: [], // <-- voluntarily empty }, ]); const user = models.factory('encrypted_users'); await user.create({ firstname: 'John' }); const controller = encrypt({ ...services, models }); req.params.model = 'encrypted_users'; req.query.fields = ['firstname']; req.body = [user.state]; await controller(req, res, next); expect(res.body[0].firstname).not.toEqual('John'); expect(res.body[0].firstname).toHaveProperty('hash'); expect(res.body[0].firstname).toHaveProperty('encrypted'); const decryptController = decrypt({ ...services, models }); req.body = res.body; res.body = null; await decryptController(req, res, next); expect(res.body).toMatchObject([{ firstname: 'John' }]); }); it('returns the encrypted data on additional fields defined with safe `q` param', async () => { services.config.security.encryptionKeys = { encrypted_users: [crypto.randomBytes(16).toString('hex')], }; models = await setup.initModels(services, [ { ...fixtureUsers, name: 'encrypted_users', encrypted_fields: [], // <-- voluntarily empty }, ]); const user = models.factory('encrypted_users'); await user.create({ firstname: 'John' }); const controller = encrypt({ ...services, models }); req.params.model = 'encrypted_users'; req.query.q = JSON.stringify({ fields: ['firstname'] }); req.body = [user.state]; await controller(req, res, next); expect(res.body[0].firstname).not.toEqual('John'); expect(res.body[0].firstname).toHaveProperty('hash'); expect(res.body[0].firstname).toHaveProperty('encrypted'); const decryptController = decrypt({ ...services, models }); req.body = res.body; res.body = null; await decryptController(req, res, next); expect(res.body).toMatchObject([{ firstname: 'John' }]); }); }); describe('#decrypt', () => { let originalEncryptionKeys; let error; let req; let res; let next; beforeEach(() => { originalEncryptionKeys = services.config.security.encryptionKeys; error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, query: {}, body: {} }; res = { locals: {} }; }); afterEach(() => { global.infoMock = jest .spyOn(services.telemetry.logger, 'info') .mockImplementation(() => null); jest.restoreAllMocks(); services.config.security.encryptionKeys = originalEncryptionKeys; }); it('calls next if res.body is already set', async () => { const controller = decrypt({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns an error if the model is invalid', async () => { const controller = decrypt({ ...services, models }); req.body = [{ a: 1 }]; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Invalid Model'); expect(error.status).toEqual(400); }); it('returns a generic error otherwise', async () => { const controller = decrypt({ ...services, models }); req.params.model = 'users'; req.body = { map: () => { throw new Error('Oooops'); }, }; next = jest.fn().mockImplementation((err) => (error = err)); await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Oooops'); }); it('returns the decrypted data if possible', async () => { services.config.security.encryptionKeys = { encrypted_users: [crypto.randomBytes(16).toString('hex')], }; models = await setup.initModels(services, [ { ...fixtureUsers, name: 'encrypted_users', encrypted_fields: ['firstname'], }, ]); const user = models.factory('encrypted_users'); await user.create({ firstname: 'John' }); const controller = decrypt({ ...services, models }); req.params.model = 'encrypted_users'; req.body = [user.state]; res.locals = { id: 'decrypt', level: 'decrypt' }; await controller(req, res, next); expect(res.body).toMatchObject([ { created_at: user.state.created_at, firstname: 'John' }, ]); }); it('returns the decrypted data with data processing activated and valid token id', async () => { services.config.security.encryptionKeys = { encrypted_users: [crypto.randomBytes(16).toString('hex')], }; models = await setup.initModels(services, [ { ...fixtureUsers, name: 'encrypted_users', encrypted_fields: ['firstname'], }, ]); const user = models.factory('encrypted_users'); await user.create({ firstname: 'John' }); const controller = decrypt({ ...services, models, config: { ...services.config, features: { ...services.config.features, api: { ...services.config.features.api, checkProcessingAuthorization: true, }, }, }, }); req.params.model = 'encrypted_users'; req.body = [user.state]; res.locals = { id: 'decrypt', level: 'decrypt' }; await controller(req, res, next); expect(res.body).toMatchObject([ { created_at: user.state.created_at, firstname: 'John' }, ]); }); it('returns an error on decryption attempt with data processing activated and invalid token id', async () => { services.config.security.encryptionKeys = { encrypted_users: [crypto.randomBytes(16).toString('hex')], }; models = await setup.initModels(services, [ { ...fixtureUsers, name: 'encrypted_users', encrypted_fields: ['firstname'], }, ]); const user = models.factory('encrypted_users'); await user.create({ firstname: 'John' }); const controller = decrypt({ ...services, models, config: { ...services.config, features: { ...services.config.features, api: { ...services.config.features.api, checkProcessingAuthorization: true, }, }, }, }); req.params.model = 'encrypted_users'; req.body = [user.state]; res.locals = { id: 'invalid', level: 'decrypt' }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Unauthorized field processing'); expect(error.status).toEqual(403); }); it('adds a log on the decryption action', async () => { services.config.security.encryptionKeys = { encrypted_users: [crypto.randomBytes(16).toString('hex')], }; models = await setup.initModels(services, [ { ...fixtureUsers, name: 'encrypted_users', encrypted_fields: ['firstname'], }, ]); const user = models.factory('encrypted_users'); await user.create({ firstname: 'John' }); const controller = decrypt({ ...services, models }); res.locals.id = 'token_id'; res.locals.level = 'read'; req.params.model = 'encrypted_users'; req.body = [user.state]; await controller(req, res, next); await new Promise((resolve) => setTimeout(resolve, 1000)); const Logs = models.getModel('_logs'); const log = await Logs.find(services.mongodb, { correlation_id: user.state.user_id, }).toArray(); expect(log).toMatchObject([ { level: 50, message: '[decrypt] Entity decrypted', model: 'encrypted_users', correlation_id: user.state.user_id, context: { id: 'token_id', level: 'read' }, }, ]); }); it('returns the decrypted data if the field is set in query', async () => { services.config.security.encryptionKeys = { encrypted_users: [crypto.randomBytes(16).toString('hex')], }; models = await setup.initModels(services, [ { ...fixtureUsers, name: 'encrypted_users', encrypted_fields: [], // <-- voluntarily empty }, ]); const user = models.factory('encrypted_users'); await user.create({ firstname: 'John' }); const controller = encrypt({ ...services, models }); req.params.model = 'encrypted_users'; req.query.fields = ['firstname']; req.body = [user.state]; await controller(req, res, next); expect(res.body[0].firstname).not.toEqual('John'); expect(res.body[0].firstname).toHaveProperty('hash'); expect(res.body[0].firstname).toHaveProperty('encrypted'); const decryptController = decrypt({ ...services, models }); req.body = res.body; res.body = null; await decryptController(req, res, next); expect(res.body).toMatchObject([{ firstname: 'John' }]); }); it('returns the decrypted data if the field is set in query with safe `q` parameter', async () => { services.config.security.encryptionKeys = { encrypted_users: [crypto.randomBytes(16).toString('hex')], }; models = await setup.initModels(services, [ { ...fixtureUsers, name: 'encrypted_users', encrypted_fields: [], // <-- voluntarily empty }, ]); const user = models.factory('encrypted_users'); await user.create({ firstname: 'John' }); const controller = encrypt({ ...services, models }); req.params.model = 'encrypted_users'; req.query.q = JSON.stringify({ fields: ['firstname'] }); req.body = [user.state]; await controller(req, res, next); expect(res.body[0].firstname).not.toEqual('John'); expect(res.body[0].firstname).toHaveProperty('hash'); expect(res.body[0].firstname).toHaveProperty('encrypted'); const decryptController = decrypt({ ...services, models }); req.body = res.body; res.body = null; await decryptController(req, res, next); expect(res.body).toMatchObject([{ firstname: 'John' }]); }); }); describe('#archive', () => { let error; let req; let res; let next; let _services; beforeEach(async () => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, query: {}, body: {} }; res = { locals: {}, json: jest.fn() }; models = await setup.initModels(services, [{ ...fixtureUsers }]); _services = { ...services, models }; models.services = _services; }); afterEach(() => { jest.restoreAllMocks(); }); it('archives the entity', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const controller = archive(_services); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.json.mock.calls[0][0]).toMatchObject([ { created_at: user.state.created_at, firstname: 'John', version: 1, is_archived: true, }, ]); }); it('archives only entities on given model', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const controller = archive(_services); req.query.models = ['devices']; req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.json.mock.calls[0][0]).toMatchObject([ { created_at: user.state.created_at, firstname: 'John', version: 0 }, ]); }); it('archives only entities on given model defined with safe `q` param', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const controller = archive(_services); req.query.q = JSON.stringify({ models: ['devices'] }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.json.mock.calls[0][0]).toMatchObject([ { created_at: user.state.created_at, firstname: 'John', version: 0 }, ]); }); it('archives the entity with deep models', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const controller = archive(_services); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); req.query.deep = 'true'; await controller(req, res, next); expect(res.json.mock.calls[0][0]).toMatchObject([ { created_at: user.state.created_at, firstname: 'John', version: 1, is_archived: true, }, ]); }); }); describe('#unarchive', () => { let error; let req; let res; let next; let _services; beforeEach(async () => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, query: {}, body: {} }; res = { locals: {}, json: jest.fn() }; models = await setup.initModels(services, [{ ...fixtureUsers }]); _services = { ...services, models }; models.services = _services; }); afterEach(() => { jest.restoreAllMocks(); }); it('unarchives the entity', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.archive(); const controller = unarchive(_services); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.json.mock.calls[0][0]).toMatchObject([ { created_at: user.state.created_at, firstname: 'John', version: 2, is_archived: false, is_readonly: false, }, ]); }); }); describe('#deleteEntity', () => { let error; let req; let res; let next; let _services; beforeEach(async () => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, query: {}, body: {} }; res = { locals: {}, json: jest.fn() }; models = await setup.initModels( { ...services, config: { ...services.config, security: { ...services.config.security, encryptionKeys: { users: [crypto.randomBytes(16).toString('hex')], }, }, features: { ...services.config.features, deleteAfterArchiveDurationInSeconds: 0, }, }, }, [{ ...fixtureUsers, encrypted_fields: ['firstname'] }], ); _services = { ...models.services, models }; models.services = _services; }); afterEach(() => { jest.restoreAllMocks(); }); it('deletes the entity', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); await user.archive(); const controller = deleteEntity({ ...services, models }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.json.mock.calls[0][0]).toMatchObject([ { created_at: user.state.created_at, version: 2, is_archived: true, is_readonly: true, is_deleted: true, }, ]); }); }); describe('#getGraphData', () => { let error; let req; let res; let next; let _services; beforeEach(async () => { error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, query: {}, body: {} }; res = { locals: {}, json: jest.fn() }; models = await setup.initModels(services, [{ ...fixtureUsers }]); _services = { ...services, models }; models.services = _services; }); afterEach(() => { jest.restoreAllMocks(); }); it('returns data associated to an entity', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const controller = getGraphData(_services); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.json.mock.calls[0][0]).toMatchObject([ { model: 'users', entity: { created_at: user.state.created_at, firstname: 'John', version: 0, }, }, ]); }); it('returns only data associated to an entity and required models', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const controller = getGraphData(_services); req.query.models = ['devices']; req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.json.mock.calls[0][0]).toMatchObject([]); }); it('returns only data associated to an entity and required models defined with safe `q` param', async () => { const user = models.factory('users'); await user.create({ firstname: 'John' }); const controller = getGraphData(_services); req.query.q = JSON.stringify({ models: ['devices'] }); req.params.model = 'users'; req.params.correlation_id = user.state.user_id.toString(); await controller(req, res, next); expect(res.json.mock.calls[0][0]).toMatchObject([]); }); }); });