UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

1,970 lines (1,627 loc) 54.2 kB
import crypto from 'crypto'; import { merge } from 'lodash'; import { MongoDbConnector } from '@getanthill/mongodb-connector'; import reducerFactory from './reducer'; import GenericFactory from './Generic'; import fixtureUsers from '../../test/fixtures/users'; import setup from '../../test/setup'; import { Services } from '../typings'; import * as utils from '../utils'; import * as c from '../constants'; import { Models } from './index'; describe('models/Generic', () => { const DEFAULT_SCHEMAS = { $id: 'events', components: {}, events: { CREATED: { '0_0_0': { type: 'object', properties: { name: { type: 'string', }, }, }, }, UPDATED: { '0_0_0': { type: 'object', properties: { name: { type: 'string', }, }, }, }, ARCHIVED: { '0_0_0': { type: 'object', additionalProperties: true, }, }, DELETED: { '0_0_0': { type: 'object', additionalProperties: true, }, }, RESTORED: { '0_0_0': { type: 'object', properties: { name: { type: 'string', }, }, }, }, ROLLBACKED: { '0_0_0': { type: 'object', properties: { name: { type: 'string', }, }, }, }, AGE_UPDATED: { '0_0_0': { upsert: true, type: 'object', properties: { age: { type: 'integer', }, }, }, }, }, }; const DEFAULT_DEFINITION = { DATABASE: 'datastore', COLLECTION: 'users', COLLECTION_OPTIONS: {}, INDEXES: [], VALIDATOR_SCHEMA: {}, VALIDATOR_OPTIONS: {}, }; const ORIGINAL_SCHEMA = fixtureUsers.schema; const MODEL_CONFIG = fixtureUsers; let schemas; let reducer; let models: Models; let app; let services: Services; let mongodb: MongoDbConnector; let DateMock; let DateNowMock; beforeAll(async () => { app = await setup.build(); services = app.services; mongodb = services.mongodb; }); beforeEach(async () => { schemas = merge({}, DEFAULT_SCHEMAS); reducer = reducerFactory(schemas); models = await setup.initModels(services, [fixtureUsers]); 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({}), ]); DateMock = jest .spyOn(utils, 'getDate') .mockReturnValue(new Date('2020-11-10T00:00:00.000Z')); DateNowMock = jest .spyOn(utils, 'getDateNow') .mockReturnValue(new Date('2020-11-10T00:00:00.000Z').getTime()); }); afterEach(async () => { jest.restoreAllMocks(); }); afterAll(async () => { await setup.teardownDb(mongodb); }); it('instanciates a new Generic model from definition', () => { const Model = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); expect(Model).toHaveProperty('getSchema'); expect(Model).toHaveProperty('getCorrelationField'); expect(Model).toHaveProperty('getCollectionName'); expect(Model).toHaveProperty('db'); // Event Sourced expect(Model).toHaveProperty('getState'); expect(Model).toHaveProperty('find'); }); it('allows to create and store a new model in the database', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); expect(Users.getModelConfig()).toEqual(MODEL_CONFIG); }); it('allows to create and store a new model in the database', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED }); const data = await Users.find(mongodb, { user_id: user.state.user_id, }).toArray(); expect(data).toMatchObject([ { user_id: user.state.user_id, firstname: 'John', }, ]); }); it('allows to update a model existing in the database', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED }); await user.update({ firstname: 'Jack', type: c.EVENT_TYPE_UPDATED }); const data = await Users.find(mongodb, { user_id: user.state.user_id, }).toArray(); expect(data).toMatchObject([ { user_id: user.state.user_id, firstname: 'Jack', }, ]); }); it('allows to upsert a model existing in database if does not exists yet', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); await user.upsert({ firstname: 'John' }); const data = await Users.find(mongodb, { user_id: user.state.user_id, }).toArray(); expect(data).toMatchObject([ { user_id: user.state.user_id, firstname: 'John', }, ]); }); it('throws an exception if entity not created but imperative version is greater than 0', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); let error; try { await user.upsert( { firstname: 'John' }, { imperativeVersion: 1, }, ); } catch (err) { error = err; } expect(error.message).toEqual('Entity must be created first'); }); it('allows to update a model existing in database already', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); await user.create({ firstname: 'John' }); await user.upsert({ firstname: 'Jack' }); const data = await Users.find(mongodb, { user_id: user.state.user_id, }).toArray(); expect(data).toMatchObject([ { user_id: user.state.user_id, firstname: 'Jack', }, ]); }); it('allows to performs parallel upserts', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: { ...schemas, retry_duration: 5000, }, ORIGINAL_SCHEMA: { ...ORIGINAL_SCHEMA, retry_duration: 5000, }, MODEL_CONFIG, services, }); const user = new Users(services); await Promise.all( new Array(10) .fill(1) .map((_, i) => user.upsert({ firstname: `Jack ${i}` })), ); const [data] = await Users.find(mongodb, { user_id: user.state.user_id, }).toArray(); expect(data.version).toBeGreaterThan(5); }); it('throws an exception if an error occured on second update trial', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: { ...schemas, retry_duration: 5000, }, ORIGINAL_SCHEMA: { ...ORIGINAL_SCHEMA, retry_duration: 5000, }, MODEL_CONFIG, services, }); const user = new Users(services); jest .spyOn(user, 'update') .mockRejectedValueOnce(new Error('Entity must be created first')) .mockRejectedValue(new Error('Ooops')); jest .spyOn(user, 'create') .mockRejectedValue(new Error('Entity already created')); let error; try { await Promise.all( new Array(10) .fill(1) .map((_, i) => user.upsert({ firstname: `Jack ${i}` })), ); } catch (err) { error = err; } expect(error.message).toEqual('Ooops'); }); it('throws an exception if an error occured on create trial', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); jest .spyOn(user, 'update') .mockRejectedValueOnce(new Error('Entity must be created first')); jest.spyOn(user, 'create').mockRejectedValue(new Error('Ooops')); let error; try { await user.upsert({ firstname: 'Jack' }); } catch (err) { error = err; } expect(error.message).toEqual('Ooops'); }); it('blocks the update of a model flagged as readonly', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); await user.create({ firstname: 'John', is_readonly: true, type: c.EVENT_TYPE_CREATED, }); let error; try { await user.update({ firstname: 'Jack', type: c.EVENT_TYPE_UPDATED }); } catch (err) { error = err; } expect(error.message).toEqual('Entity is readonly'); }); it('allows to restore a model at a precedent version', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED }); await user.update({ firstname: 'Jack', type: c.EVENT_TYPE_UPDATED }); await user.restore(0); expect(user.state).toMatchObject({ user_id: user.state.user_id, firstname: 'John', version: 2, }); }); it('allows to count entities in the database', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); let user = new Users(services); await user.create({ firstname: 'John' }); const count = await Users.count(services.mongodb, { firstname: 'John', }); expect(count).toEqual(1); }); it('allows to find entities in the database', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); let user = new Users(services); await user.create({ firstname: 'John' }); const users = await Users.find(services.mongodb, { firstname: 'John', }).toArray(); expect(users).toMatchObject([user.state]); }); it('allows to explain a find query', async () => { const servicesWithExplainFeature = merge({}, services, { config: { features: { mongodb: { explain: true, }, }, }, }); models = await setup.initModels(servicesWithExplainFeature, [fixtureUsers]); const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services: servicesWithExplainFeature, }); const user = new Users(services); await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED }); const users = await Users.find(services.mongodb, { firstname: 'John', }).toArray(); await new Promise((resolve) => setTimeout(resolve, 1000)); expect( global.infoMock.mock.calls.filter( (call) => call[0] === '[Generic#find] Query explain' && call[1].model_name === 'users', ), ).toMatchObject([ [ '[Generic#find] Query explain', { model_name: 'users', query: { firstname: 'John' }, stage: 'COLLSCAN', // executation_time_ms: 0, index_name: undefined, keys_examined: 0, docs_examined: 1, }, ], ]); }); it('logs a warning on slow query detected', async () => { const servicesWithExplainFeature = merge({}, services, { config: { features: { mongodb: { explain: true, slowQueryThresholdInMilliseconds: 1000, }, }, }, }); models = await setup.initModels(servicesWithExplainFeature, [fixtureUsers]); const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services: servicesWithExplainFeature, }); await Users.explain( { explain: jest.fn().mockResolvedValue({ executionStats: { executionTimeMillis: 2000, }, }), }, {}, ); expect(global.warnMock.mock.calls[0][0]).toEqual( '[Generic#find] Slow query detected', ); }); it('is safe on query explain plan error', async () => { const servicesWithExplainFeature = merge({}, services, { config: { features: { mongodb: { explain: true, }, }, }, }); const error = new Error('Ooops'); global.debugMock = jest .spyOn(services.telemetry.logger, 'debug') .mockImplementation((msg, context?) => { if (msg === '[Generic#find] Query explain') { throw error; } return this; }); models = await setup.initModels(servicesWithExplainFeature, [fixtureUsers]); const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services: servicesWithExplainFeature, }); const user = new Users(services); await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED }); const users = await Users.find(services.mongodb, { firstname: 'John', }).toArray(); await new Promise((resolve) => setTimeout(resolve, 1000)); expect( global.errorMock.mock.calls.filter( (call) => call[0] === '[Generic#find] Query explain error' && call[1].model_name === 'users', ), ).toEqual([ [ '[Generic#find] Query explain error', { model_name: 'users', query: { firstname: 'John' }, err: error, }, ], ]); }); it('allows to retrieve events for a given entity', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED }); await user.update({ firstname: 'Jack', type: c.EVENT_TYPE_UPDATED }); const events = await user.getEvents().toArray(); expect(events).toMatchObject([ { type: 'CREATED', v: '0_0_0', firstname: 'John', version: 0, }, { type: 'UPDATED', v: '0_0_0', firstname: 'Jack', version: 1, }, ]); }); it('allows to apply arbitrary events', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED }); await user.apply('AGE_UPDATED', { age: 12, type: 'AGE_UPDATED', }); expect(user.state).toMatchObject({ firstname: 'John', age: 12, }); }); it('allows to apply arbitrary events without creation', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services, setup.uuid()); await user.apply('AGE_UPDATED', { age: 12, type: 'AGE_UPDATED', }); expect(user.state).toMatchObject({ version: 0, age: 12, }); }); it('allows to apply multiply arbitrary events without creation', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); const user = new Users(services); await user.create({ firstname: 'John', type: c.EVENT_TYPE_CREATED }); await user.apply('AGE_UPDATED', { age: 13, type: 'AGE_UPDATED', }); expect(user.state).toMatchObject({ version: 1, age: 13, }); }); describe('#archive / #unarchive / #delete', () => { let Users; let user; let _services; beforeEach(async () => { _services = { ...services, config: { ...services.config, security: { ...services.config.security, encryptionKeys: { archive: [crypto.randomBytes(16).toString('hex')], users: [crypto.randomBytes(16).toString('hex')], }, }, features: { ...services.config.features, deleteAfterArchiveDurationInSeconds: 1209600, }, }, }; models = await setup.initModels(_services, [ { ...fixtureUsers, encrypted_fields: ['firstname'], }, ]); reducer = reducerFactory(models.getModel('users').getSchema()); Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG: { ...MODEL_CONFIG, encrypted_fields: ['firstname'], }, services: _services, }); user = new Users(services); }); afterEach(() => { jest.restoreAllMocks(); }); it('sets the entity as readonly', async () => { await user.create({ firstname: 'John', age: 24, }); await user.archive(); expect(user.state).toMatchObject({ age: 24, version: 1, is_readonly: true, }); }); it('sets the entity as archived', async () => { await user.create({ firstname: 'John', age: 24, }); await user.archive(); expect(user.state).toMatchObject({ age: 24, version: 1, is_archived: true, }); }); it('sets the entity as not deleted yet', async () => { await user.create({ firstname: 'John', age: 24, }); await user.archive(); expect(user.state).toMatchObject({ age: 24, version: 1, is_deleted: false, }); }); it('sets the entity as archived with date fields', async () => { await user.create({ firstname: 'John', last_seen: new Date(), }); user = new Users(services, user.correlationId); await user.archive(); expect(user.state).toMatchObject({ version: 1, is_archived: true, }); }); it('noops on entity already archived', async () => { await user.create({ firstname: 'John', age: 24, }); await user.archive(); await user.archive(); expect(user.state).toMatchObject({ age: 24, version: 1, is_archived: true, }); }); it('archives and encrypts values if an archive encryption key is available', async () => { await user.create({ firstname: 'John', age: 24, is_readonly: true, }); const stateBeforeArchive = user.state; await user.archive(); const stateAfterArchive = user.state; expect(stateAfterArchive.firstname).not.toEqual( stateBeforeArchive.firstname, ); expect(stateAfterArchive).toMatchObject({ is_readonly: true, is_archived: true, version: 1, }); }); it('allows to restore the value prior to the archive operation', async () => { await user.create({ firstname: 'John', age: 24, is_readonly: true, }); const stateBeforeArchive = user.state; await user.archive(); const stateAfterArchive = user.state; expect(stateAfterArchive).not.toEqual(stateBeforeArchive); expect(stateAfterArchive).toMatchObject({ is_readonly: true, is_archived: true, version: 1, }); await user.unarchive(); expect(user.state).toMatchObject({ version: 2, firstname: stateBeforeArchive.firstname, is_readonly: true, }); }); it('keeps the value of the `is_readonly` flag prior to the archive process', async () => { await user.create({ firstname: 'John', age: 24, is_readonly: false, }); const stateBeforeArchive = user.state; await user.archive(); const stateAfterArchive = user.state; expect(stateAfterArchive).not.toEqual(stateBeforeArchive); expect(stateAfterArchive).toMatchObject({ is_readonly: true, is_archived: true, version: 1, }); await user.unarchive(); expect(user.state).toMatchObject({ version: 2, firstname: stateBeforeArchive.firstname, is_readonly: false, }); }); it('restores the value of the `is_readonly` flag to `false` if none has been defined', async () => { await user.create({ firstname: 'John', age: 24, }); const stateBeforeArchive = user.state; await user.archive(); const stateAfterArchive = user.state; expect(stateAfterArchive).not.toEqual(stateBeforeArchive); expect(stateAfterArchive).toMatchObject({ is_readonly: true, is_archived: true, version: 1, }); await user.unarchive(); expect(user.state).toMatchObject({ version: 2, firstname: stateBeforeArchive.firstname, is_readonly: false, }); }); it('archives and encrypts values with a model valid encryption key is no archive one is provided', async () => { Users.options.services.config.security.encryptionKeys = { users: Users.options.services.config.security.encryptionKeys.users, }; await user.create({ firstname: 'John', age: 24, is_readonly: true, }); const stateBeforeArchive = user.state; await user.archive(); const stateAfterArchive = user.state; expect(stateAfterArchive.firstname).not.toEqual( stateBeforeArchive.firstname, ); expect(stateAfterArchive).toMatchObject({ is_readonly: true, is_archived: true, version: 1, }); }); it('allows to restore the value prior to the archive operation with a model valid encryption key is no archive one is provided', async () => { Users.options.services.config.security.encryptionKeys = { users: Users.options.services.config.security.encryptionKeys.users, }; await user.create({ firstname: 'John', age: 24, is_readonly: true, }); const stateBeforeArchive = user.state; await user.archive(); await user.unarchive(); expect(user.state.version).toEqual(2); expect(user.state.firstname).toEqual(stateBeforeArchive.firstname); }); it('performs a noop on non archived entities', async () => { await user.create({ firstname: 'John', age: 24, is_readonly: true, }); const stateBeforeUnarchive = user.state; await user.unarchive(); expect(user.state).toEqual(stateBeforeUnarchive); }); it('performs a noop on a already deleted entities', async () => { _services.config.features.deleteAfterArchiveDurationInSeconds = 0; await user.create({ firstname: 'John', age: 24, is_readonly: true, }); await user.archive(); await user.delete(); await user.unarchive(); expect(user.state).toMatchObject({ version: 2, is_readonly: true, is_archived: true, is_deleted: true, }); }); it('throws an exception on not already archived entity', async () => { await user.create({ firstname: 'John', age: 24, is_readonly: true, }); let error; try { await user.delete(); } catch (err) { error = err; } expect(error.message).toEqual('Entity must be archived first'); }); it('allows to delete an archived entity', async () => { DateNowMock.mockRestore(); DateNowMock = jest .spyOn(utils, 'getDateNow') .mockReturnValue(new Date('2021-11-10T00:00:00.000Z').getTime()); await user.create({ firstname: 'John', age: 24, is_readonly: true, }); await user.archive(); await user.delete(); const events = await user.getEvents().toArray(); expect(user.state).toMatchObject({ version: 2, is_deleted: true, }); expect(user.state.firstname).toEqual(undefined); expect(events[0].firstname).toEqual(undefined); expect(events[1].firstname).toEqual(undefined); expect(events[2].firstname).toEqual(undefined); }); it('throws an exception on entity archived too recently', async () => { await user.create({ firstname: 'John', age: 24, is_readonly: true, }); await user.archive(); let error; try { await user.delete(); } catch (err) { error = err; } expect(error.message).toEqual('Entity archived too recently'); }); }); describe('#handleWithRetry', () => { let Users; beforeEach(() => { Users = models.getModel(fixtureUsers.name); DateNowMock.mockRestore(); }); it('handles an event without retry configured', async () => { const alice = new Users(services); const state = await alice.handleWithRetry( () => [ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', }, ], 0, (err) => { throw err; }, ); expect(state).toMatchObject({ version: 0, firstname: 'Alice', }); }); it('handles an event update with imperative condition failure successfully', async () => { const alice = new Users(services); let error; try { const state = await alice.handleWithRetry( () => [ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', }, ], 0, (err) => { throw err; }, { imperativeVersion: 1, // Impossible on create }, ); } catch (err) { error = err; } expect(error).toEqual(Users.ERRORS.HANDLER_IMPERATIVE_CONDITION_FAILED); }); it('handles an event update with imperative condition success', async () => { const alice = new Users(services); const state = await alice.handleWithRetry( () => [ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', }, ], 0, (err) => { throw err; }, 0, ); expect(state).toMatchObject({ version: 0, }); }); it('handles an event with a retry duration configured', async () => { const alice = new Users(services); const state = await alice.handleWithRetry( () => [ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', }, ], 1000, (err) => { throw err; }, ); expect(state).toMatchObject({ version: 0, firstname: 'Alice', }); }); it('handles a temporary event failure correctly', async () => { Users.RETRY_ERRORS.push('Ooops'); const alice = new Users(services); alice.handle = jest .fn() .mockImplementationOnce(() => { throw new Error('Ooops'); }) .mockImplementation(() => ({ version: 0, firstname: 'Alice', })); const state = await alice.handleWithRetry( () => [ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', }, ], 1000, (err) => { throw err; }, ); expect(state).toMatchObject({ version: 0, firstname: 'Alice', }); }); it('returns the entity state on correlation_id_unicity error', async () => { const alice = new Users(services); alice.getState = jest.fn().mockImplementation(() => ({ version: 0, firstname: 'Alice', })); alice.handle = jest.fn().mockImplementationOnce(() => { throw new Error('correlation_id_unicity'); }); const state = await alice.handleWithRetry( () => [ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', }, ], 1000, (err) => { throw err; }, ); expect(state).toMatchObject({ version: 0, firstname: 'Alice', }); }); it('invokes the storeStateErrorHandler in case of a non whitelisted error', async () => { const alice = new Users(services); let error = new Error('Not registered error'); let catchedError; alice.handle = jest .fn() .mockImplementationOnce(() => { throw error; }) .mockImplementation(() => ({ version: 0, firstname: 'Alice', })); try { const state = await alice.handleWithRetry( () => [ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', }, ], 1000, (err) => { throw err; }, ); } catch (err) { catchedError = err; } expect(catchedError).toEqual(error); }); it('invokes the storeStateErrorHandler in case of long terms error', async () => { Users.RETRY_ERRORS.push('Ooops'); const alice = new Users(services); let error = new Error('Ooops'); let catchedError; alice.handle = jest.fn().mockImplementation(() => { throw error; }); try { const state = await alice.handleWithRetry( () => [ { type: 'CREATED', v: '0_0_0', firstname: 'Alice', }, ], 10, (err) => { throw err; }, ); } catch (err) { catchedError = err; } expect(catchedError).toEqual(error); }); }); describe('retry', () => { beforeEach(() => { DateNowMock.mockRestore(); }); it('handles concurrent events smoothly if required on retryable model', async () => { models = await setup.initModels(services, [ { ...fixtureUsers, retry_duration: 3000, }, ]); const Users = models.getModel(fixtureUsers.name); const alice = new Users(services); await alice.create({ firstname: 'Alice' }); const iterations = new Array(20).fill(1); await Promise.all( iterations.map((_, i) => alice.update({ firstname: `${i}` })), ); await new Promise((resolve) => setTimeout(resolve, 2000)); expect(alice.state).toMatchObject({ /** * We do not have any information about the final value * of Alice because events have been inserted in parallel */ // firstname: 'Alice', version: 20, }); expect(await alice.getEvents().toArray()).toHaveLength(21); }); it('handles concurrent events smoothly on retryable events', async () => { models = await setup.initModels(services, [ { is_enabled: fixtureUsers.is_enabled, db: fixtureUsers.db, name: fixtureUsers.name, correlation_field: fixtureUsers.correlation_field, encrypted_fields: fixtureUsers.encrypted_fields, indexes: fixtureUsers.indexes, schema: fixtureUsers.schema, }, ]); const Users = models.getModel(fixtureUsers.name); const alice = new Users(services); await alice.create({ firstname: 'Alice' }); const iterations = new Array(20).fill(1); await Promise.all( iterations.map((_, i) => alice.update( { firstname: `${i}` }, { retryDuration: 3000, }, ), ), ); await new Promise((resolve) => setTimeout(resolve, 2000)); expect(alice.state).toMatchObject({ /** * We do not have any information about the final value * of Alice because events have been inserted in parallel */ // firstname: 'Alice', version: 20, }); expect(await alice.getEvents().toArray()).toHaveLength(21); }); it('throws an exception if the retry duration is exceeded', async () => { models = await setup.initModels(services, [ { ...fixtureUsers, retry_duration: 10, }, ]); const Users = models.getModel(fixtureUsers.name); const alice = new Users(services); await alice.create({ firstname: 'Alice' }); let error; try { const iterations = new Array(100).fill(1); await Promise.all( iterations.map((i) => alice.update({ firstname: `${i}` })), ); } catch (err) { error = err; } expect(error.message.includes('correlation_id_version_unicity')).toEqual( true, ); expect(alice.state).not.toMatchObject({ firstname: 'Alice', version: 101, }); expect((await alice.getEvents().toArray()).length).toBeLessThan(101); }); }); describe('#getIsReadonlyProperty', () => { let Users; beforeEach(() => { Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG: { ...MODEL_CONFIG, is_readonly: 'is_readonly', }, services, }); }); it('returns property from modelConfig given as input', () => { expect( Users.getIsReadonlyProperty({ is_readonly: 'is_really_readonly' }), ).toEqual('is_really_readonly'); }); it('returns property from modelConfig', () => { expect(Users.getIsReadonlyProperty()).toEqual('is_readonly'); }); it('returns property from configuration', () => { services.config.features.properties.is_readonly = 'iz_readonly'; Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG: { ...MODEL_CONFIG, // is_readonly: 'is_readonly', // <-- missing }, services, }); expect(Users.getIsReadonlyProperty()).toEqual('iz_readonly'); }); }); describe('#encrypt / #decrypt', () => { let _services; let Users; let user; beforeEach(() => { _services = { ...services, config: { ...services.config, security: { ...services.config.security, encryptionKeys: { users: [crypto.randomBytes(16).toString('hex')], }, }, }, }; Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG: { ...MODEL_CONFIG, encrypted_fields: ['firstname', 'deep.property', 'object'], }, services: _services, }); user = new Users(services); }); afterEach(() => { jest.restoreAllMocks(); }); it('does nothing if the model has no encrypted key', async () => { jest.spyOn(Users, 'getEncryptionKeys').mockImplementation(() => []); const encryptedUser = Users.encrypt({ firstname: 'John', age: 24, }); expect(encryptedUser).toMatchObject({ firstname: 'John', age: 24, }); }); it('does nothing if the model has no encrypted field', async () => { Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG: MODEL_CONFIG, services: _services, }); Users.getEncryptionKeys = jest.fn().mockImplementation(() => []); Users.getEncryptedFields = jest.fn().mockImplementation(() => []); const decryptedUser = Users.decrypt({ firstname: 'John', age: 24, }); expect(decryptedUser).toMatchObject({ firstname: 'John', age: 24, }); }); it('does nothing if the value has no encrypted field', async () => { const decryptedUser = Users.decrypt({ firstname: 'John', age: 24, }); expect(decryptedUser).toMatchObject({ firstname: 'John', age: 24, }); }); it('encrypt the value at the given field if an encryption key is available', async () => { const originalData = { firstname: 'John', age: 24, non_initially_encrypted: 'clear text', }; const encryptedUser = Users.encrypt(originalData, [ 'non_initially_encrypted', ]); expect(encryptedUser.non_initially_encrypted).not.toEqual('clear text'); const decryptedUserWithoutAdditionalEncryptionFields = Users.decrypt(encryptedUser); expect(decryptedUserWithoutAdditionalEncryptionFields).not.toMatchObject( originalData, ); const decryptedUser = Users.decrypt(encryptedUser, [ 'non_initially_encrypted', ]); expect(decryptedUser).toMatchObject(originalData); }); it('encrypt the value at the given field if an encryption key is available and with a randomized key taken', async () => { _services = { ...services, config: { ...services.config, security: { ...services.config.security, activeNumberEncryptionKeys: 3, encryptionKeys: { users: [ crypto.randomBytes(16).toString('hex'), crypto.randomBytes(16).toString('hex'), crypto.randomBytes(16).toString('hex'), ], }, }, }, }; Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG: { ...MODEL_CONFIG, encrypted_fields: ['firstname', 'deep.property', 'object'], }, services: _services, }); user = new Users(services); const originalData = { firstname: 'John', age: 24, non_initially_encrypted: 'clear text', }; const encryptedUser = Users.encrypt(originalData, [ 'non_initially_encrypted', ]); expect(encryptedUser.non_initially_encrypted).not.toEqual('clear text'); const decryptedUserWithoutAdditionalEncryptionFields = Users.decrypt(encryptedUser); expect(decryptedUserWithoutAdditionalEncryptionFields).not.toMatchObject( originalData, ); const decryptedUser = Users.decrypt(encryptedUser, [ 'non_initially_encrypted', ]); expect(decryptedUser).toMatchObject(originalData); }); it('allows to encrypt data on specific keys at the root level', async () => { const encryptedUser = Users.encrypt({ firstname: 'John', age: 24 }); expect(encryptedUser).not.toMatchObject({ firstname: 'John', age: 24, }); const decryptedUser = Users.decrypt(encryptedUser); expect(decryptedUser).toMatchObject({ firstname: 'John', age: 24, }); }); it('allows to encrypt data on specific keys on a deep property', async () => { const originalData = { firstname: 'John', deep: { property: 24 }, }; const encryptedUser = Users.encrypt(originalData); expect(encryptedUser).not.toMatchObject(originalData); const decryptedUser = Users.decrypt(encryptedUser); expect(decryptedUser).toMatchObject(originalData); }); it('allows to encrypt data on specific keys on an object property', async () => { const originalData = { firstname: 'John', object: { property: 24 }, }; const encryptedUser = Users.encrypt(originalData); expect(encryptedUser).not.toMatchObject(originalData); const decryptedUser = Users.decrypt(encryptedUser); expect(decryptedUser).toMatchObject(originalData); }); it('allows to decrypt data on previous historical encryption key', async () => { const originalData = { firstname: 'John', object: { property: 24 }, }; const encryptedUser = Users.encrypt(originalData); expect(encryptedUser).not.toMatchObject(originalData); _services.config.security.encryptionKeys = { users: [ crypto.randomBytes(16).toString('hex'), ..._services.config.security.encryptionKeys.users, // Used encrypted key ], }; const decryptedUser = Users.decrypt(encryptedUser); expect(decryptedUser).toMatchObject(originalData); }); /** * @warn this test is there to check the backward * compatibility with the previous implementation * of the 'aes-256-cbc' algorithm which is now * deprecated */ it('allows to decrypt data on encryption performed with deprecated `aes-256-cbc` algorithm', async () => { const originalData = { firstname: 'John', object: { property: 24 }, }; const encryptedUser = Users.encrypt(originalData); expect(encryptedUser).not.toMatchObject(originalData); // Encode with 'aes-256-cbc' algorithm: const key = _services.config.security.encryptionKeys.users[0]; const iv = crypto.randomBytes(16); const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(key), iv); const value = JSON.stringify('John'); const encrypted = Buffer.concat([cipher.update(value), cipher.final()]); encryptedUser.firstname.encrypted = key.slice(0, 6) + ':' + iv.toString('hex') + ':' + encrypted.toString('hex'); // << End of previous implementation _services.config.security.encryptionKeys = { users: [ crypto.randomBytes(16).toString('hex'), ..._services.config.security.encryptionKeys.users, // Used encrypted key ], }; const decryptedUser = Users.decrypt(encryptedUser); expect(decryptedUser).toMatchObject(originalData); }); it('returns encrypted data if the encryption key is not found', async () => { const originalData = { firstname: 'John', object: { property: 24 }, }; const encryptedUser = Users.encrypt(originalData); expect(encryptedUser).not.toMatchObject(originalData); _services.config.security.encryptionKeys = { users: [crypto.randomBytes(16).toString('hex')], }; Users.encryptionKeys = null; Users.hashedEncryptionKeys = null; const decryptedUser = Users.decrypt(encryptedUser); expect(decryptedUser).not.toMatchObject(originalData); }); it('encrypts data in state collection', async () => { const alice = await user.create({ firstname: 'Alice', lastname: 'Doe' }); const state = await Users.find(mongodb, { user_id: alice.state.user_id, }).toArray(); expect(state.firstname).not.toEqual('Alice'); }); it('encrypts data in events collection', async () => { const alice = await user.create({ firstname: 'Alice', lastname: 'Doe' }); const events = await alice.getEvents().toArray(); expect(events[0].firstname).not.toEqual('Alice'); }); it('does not encrypt if no key is configured', async () => { Users.options.services.config.security.encryptionKeys = {}; Users.encryptionKeys = null; Users.hashedEncryptionKeys = null; const originalData = { firstname: 'John', object: { property: 24 }, }; const encryptedUser = Users.encrypt(originalData); expect(encryptedUser).toMatchObject(originalData); }); it('does not decrypt if no key is configured', async () => { const originalData = { firstname: 'John', object: { property: 24 }, }; const encryptedUser = Users.encrypt(originalData); expect(encryptedUser).not.toMatchObject(originalData); Users.options.services.config.security.encryptionKeys = {}; Users.encryptionKeys = null; Users.hashedEncryptionKeys = null; const decryptedUser = Users.decrypt(encryptedUser); expect(decryptedUser).toMatchObject(encryptedUser); }); }); describe('#getSchema', () => { it('returns the schema of the model', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); expect(Users.getSchema()).toEqual(schemas); }); }); describe('#getCorrelationField', () => { it('returns the correlation field of the model', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); expect(Users.getCorrelationField()).toEqual('user_id'); }); }); describe('#getCollectionName', () => { it('returns the collection name of the states collection', async () => { const Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); expect(Users.getCollectionName()).toEqual('users'); }); }); describe('#rollback', () => { let Users; beforeEach(async () => { Users = GenericFactory(DEFAULT_DEFINITION, reducer, { CORRELATION_FIELD: 'user_id', SCHEMA: schemas, ORIGINAL_SCHEMA, MODEL_CONFIG, services, }); await Promise.all([ Users.getStatesCollection(Users.db(mongodb)).deleteMany({}), Users.getEventsCollection(Users.db(mongodb)).deleteMany({}), Users.getSnapshotsCollection(Users.db(mongodb)).deleteMany({}), ]); await Users.getStatesCollection(Users.db(mongodb)).createIndex( { firstname: 1, lastname: 1 }, { name: 'firstname_lastname_unicity', unique: true }, ); }); afterEach(async () => { await Users.getStatesCollection(Users.db(mongodb)).dropIndex( 'firstname_lastname_unicity', ); }); it('rollbacks a newly created model not matching database constraints (events removed from the db) and throws an error', async () => { const alice = new Users(services); await alice.create({ firstname: 'Alice', lastname: 'Doe' }); const alice2 = new Users(services); let error; try { await alice2.create({ firstname: 'Alice', lastname: 'Doe' }); } catch (err) { error = err; } expect(error).toBeInstanceOf(Error); expect(error.message).toContain('E11000 duplicate key error collection'); expect(alice.state).toMatchObject({ firstname: 'Alice', lastname: 'Doe', }); expect(alice2.state).toEqual(null); const states = await Users.getStatesCollection(Users.db(mongodb)) .find({}) .toArray(); expect(states.length).toEqual(1); expect(states[0]).toMatchObject({ firstname: 'Alice', lastname: 'Doe', user_id: alice.correlationId, }); const events = await Users.getEventsCollection(Users.db(mongodb)) .find({}) .toArray(); expect(events.length).toEqual(1);