UNPKG

@getanthill/datastore

Version:

Event-Sourced Datastore

897 lines (701 loc) 24.1 kB
import { MongoDbConnector } from '@getanthill/mongodb-connector'; import { init } from '../../models'; import { create, createModelIndexes, getModels, getGraph, getSchema, rotateEncryptionKeys, update, updateApiDefinition, } from './controllers'; import setup from '../../../test/setup'; import fixtureUsers from '../../../test/fixtures/users'; import { Services } from '../../typings'; describe('controllers/admin', () => { let app; let services: Services; let mongodb: MongoDbConnector; let models; beforeAll(async () => { app = await setup.build(); services = app.services; mongodb = services.mongodb; await mongodb .db('datastore_write') .collection('internal_models') .deleteMany({}); }); afterAll(async () => { await setup.teardownDb(mongodb); }); describe('#getModels', () => { let error; let req; let res; let next; beforeEach(() => { models = init({ models: [fixtureUsers] }, services); error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, body: {}, query: {} }; res = { set: jest.fn(), json: jest.fn().mockImplementation((data) => { res.body = data; return data; }), }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = getModels({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns a generic error otherwise', async () => { const controller = getModels({ ...services, models }); req.params.model = 'users'; 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 list of models managed by the Datastore', async () => { const controller = getModels({ ...services, models }); await controller(req, res, next); expect(res.json).toHaveBeenCalledTimes(1); expect(res.json.mock.calls[0][0]).toMatchObject({ users: { db: 'datastore', name: 'users' }, }); }); it('filters the list of models managed by the Datastore', async () => { models = init( { models: [fixtureUsers, { ...fixtureUsers, name: 'accounts' }] }, services, ); const controller = getModels({ ...services, models }); req.query.model = 'accounts'; await controller(req, res, next); expect(res.json).toHaveBeenCalledTimes(1); expect(res.json.mock.calls[0][0]).toMatchObject({ accounts: { db: 'datastore', name: 'accounts' }, }); }); }); describe('#getGraph', () => { let error; let req; let res; let next; beforeEach(() => { models = init({ models: [fixtureUsers] }, services); error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, body: {}, query: {} }; res = { set: jest.fn(), json: jest.fn().mockImplementation((data) => { res.body = data; return data; }), }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = getGraph({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns a generic error otherwise', async () => { const controller = getGraph({ ...services, models }); req.params.model = 'users'; 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 graph of models', async () => { const controller = getGraph({ ...services, models }); await controller(req, res, next); expect(res.json).toHaveBeenCalledTimes(1); expect(res.json.mock.calls[0][0]).toMatchObject({ nodes: [{ id: 'users' }], edges: [], }); }); }); describe('#getSchema', () => { let error; let req; let res; let next; beforeEach(() => { models = init({ models: [fixtureUsers] }, services); error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, body: {} }; res = { set: jest.fn(), json: jest.fn().mockImplementation((data) => { res.body = data; return data; }), }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = getSchema({ ...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 = getSchema({ ...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 = getSchema({ ...services, models }); req.params.model = 'users'; 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 model schemas', async () => { const controller = getSchema({ ...services, models }); req.params.model = 'users'; await controller(req, res, next); expect(res.body).toHaveProperty('model'); expect(res.body).toHaveProperty('events'); }); }); describe('#create', () => { let error; let req; let res; let next; let openApi; beforeEach(() => { models = init({ models: [] }, services); openApi = { update: jest.fn() }; error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, body: {} }; res = { json: jest.fn().mockImplementation((data) => { res.body = data; return data; }), }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = create({ ...services, models }, openApi); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns a generic error otherwise', async () => { const controller = create({ ...services, models }, openApi); req.params.model = 'tokens'; req.body = { is_enabled: true, db: 'datastore', name: 'tokens', correlation_field: 'token_id', }; models.factory = 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 newly created model', async () => { expect(models.hasModel('tokens')).toEqual(false); const controller = create({ ...services, models }, openApi); req.params.model = 'tokens'; req.body = { is_enabled: true, db: 'datastore', name: 'tokens', correlation_field: 'token_id', schema: { model: { properties: { name: { type: 'string' } } } }, }; await controller(req, res, next); expect(res.body).toMatchObject({ db: 'datastore', name: 'tokens', correlation_field: 'token_id', }); expect(models.hasModel('tokens')).toEqual(true); }); it('updates the OpenAPI middleware update method', async () => { expect(models.hasModel('tokens')).toEqual(false); const controller = create({ ...services, models }, openApi); req.params.model = 'tokens'; req.body = { is_enabled: true, db: 'datastore', name: 'tokens', correlation_field: 'token_id', schema: {}, }; await controller(req, res, next); expect(openApi.update).toHaveBeenCalledTimes(1); }); it('returns a 400 in case of invalid model schema', async () => { const controller = create({ ...services, models }, openApi); req.params.model = 'tokens'; req.body = { is_enabled: true, db: 123, // <-- invalid name: 'tokens', correlation_field: 'token_id', schema: {}, }; await controller(req, res, next); delete res.body; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Event schema validation error'); expect(error.status).toEqual(400); }); it('refuses to create an already created model', async () => { const controller = create({ ...services, models }, openApi); req.params.model = 'tokens'; req.body = { is_enabled: true, db: 'datastore', name: 'tokens', correlation_field: 'token_id', schema: { model: { properties: { name: { type: 'string' } } } }, }; await controller(req, res, next); delete res.body; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Model already exists'); expect(error.status).toEqual(409); }); it('rollbacks a model added but not persisted in database', async () => { const controller = create({ ...services, models }, openApi); models.factory = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); req.params.model = 'tokens'; req.body = { db: 'datastore', name: 'tokens', correlation_field: 'token_id', }; await controller(req, res, next); expect(models.hasModel('tokens')).toEqual(false); }); it('rollbacks a model even not added in database', async () => { const controller = create({ ...services, models }, openApi); models.addModel = jest .fn() .mockImplementationOnce(() => { throw new Error('Oooops'); }) .mockImplementation((err) => (error = err)); req.params.model = 'tokens'; req.body = { db: 'datastore', name: 'tokens', correlation_field: 'token_id', }; await controller(req, res, next); expect(models.hasModel('tokens')).toEqual(false); }); }); describe('#update', () => { let error; let req; let res; let next; let openApi; beforeEach(async () => { models = await setup.initModels(services, [fixtureUsers]); openApi = { update: jest.fn() }; error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, body: {} }; res = { json: jest.fn().mockImplementation((data) => { res.body = data; return data; }), }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = update({ ...services, models }, openApi); 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 }, openApi); 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 = update({ ...services, models }, openApi); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.body = { correlation_field: 123 }; await controller(req, res, next); expect(error).not.toBe(null); expect(error.message).toEqual('Event schema validation error'); expect(error.details).toMatchObject([ { instancePath: '/correlation_field', keyword: 'type', message: 'must be string', params: { type: 'string' }, schemaPath: '#/properties/correlation_field/type', }, { event: { correlation_field: 123 } }, ]); expect(error.status).toEqual(400); }); it('returns a generic error otherwise', async () => { const controller = update({ ...services, models }, openApi); const user = models.factory('users'); await user.create({ firstname: 'John' }); req.params.model = 'users'; req.body = { correlation_field: 'users_super_id' }; 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 updated model', async () => { const controller = update({ ...services, models }, openApi); req.params.model = 'users'; req.body = { correlation_field: 'users_super_id' }; await controller(req, res, next); expect(res.body).toMatchObject({ correlation_field: 'users_super_id' }); expect(res.body).toHaveProperty('created_at'); expect(res.body).toHaveProperty('updated_at'); expect(res.body).toHaveProperty('model_id'); expect(res.body.created_at).not.toEqual(res.body.updated_at); }); it('updates the OpenAPI middleware update method', async () => { const controller = update({ ...services, models }, openApi); req.params.model = 'users'; req.body = { correlation_field: 'users_super_id' }; await controller(req, res, next); expect(openApi.update).toHaveBeenCalledTimes(1); }); }); describe('#createModelIndexes', () => { let error; let req; let res; let next; beforeEach(async () => { models = init({ models: [] }, services); error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, body: {} }; res = { json: jest.fn().mockImplementation((data) => { res.body = data; return data; }), }; await models.createModel(fixtureUsers); }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = createModelIndexes({ ...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 = createModelIndexes({ ...services, models }); req.params.model = 'invalid'; 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 = createModelIndexes({ ...services, models }); req.params.model = 'tokens'; req.body = { db: 'datastore', name: 'tokens', correlation_field: 'token_id', }; models.getModel = 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('creates and returns the indexes created for the model from the database', async () => { const updatedUsersModel = { ...fixtureUsers, name: 'tmp_models', indexes: [ { collection: 'tmp_models', fields: { firstname: 1 }, opts: { name: 'firstname' }, }, ], }; models.addModel(updatedUsersModel); const controller = createModelIndexes({ ...services, models }); req.params.model = 'tmp_models'; await controller(req, res, next); expect(error).toEqual(null); expect(res.body.length).toEqual(3); expect(res.body[0].length).toBeGreaterThan(1); expect(res.body[1].length).toBeGreaterThan(1); expect(res.body[2].length).toBeGreaterThan(1); expect(res.body[0]).toEqual( expect.arrayContaining([ { key: { _id: 1 }, name: '_id_', v: 2 }, { key: { user_id: 1 }, name: 'correlation_id_unicity', unique: true, v: 2, }, { key: { _id: 1, created_at: 1 }, name: 'created_at', v: 2 }, { key: { _id: 1, updated_at: 1 }, name: 'updated_at', v: 2 }, { key: { _id: 1, firstname: 1 }, name: 'firstname', v: 2 }, { key: { _id: 1, version: 1 }, name: 'version_1', v: 2 }, ]), ); }); it('creates and returns the indexes created for the model from the request payload', async () => { const updatedUsersModel = { ...fixtureUsers, name: 'tmp_models_2', indexes: [ { collection: 'tmp_models_2', fields: { firstname: 1 }, opts: { name: 'firstname' }, }, ], }; models.addModel(updatedUsersModel); const controller = createModelIndexes({ ...services, models }); req.params.model = 'tmp_models_2'; req.body = { ...fixtureUsers, name: 'tmp_models_2', indexes: [ { collection: 'tmp_models_2', fields: { firstname: 1 }, opts: { name: 'firstname' }, }, { collection: 'tmp_models_2', fields: { lastname: 1 }, opts: { name: 'lastname' }, }, ], }; await controller(req, res, next); expect(error).toEqual(null); expect(res.body.length).toEqual(3); expect(res.body[0].length).toBeGreaterThan(1); expect(res.body[1].length).toBeGreaterThan(1); expect(res.body[2].length).toBeGreaterThan(1); expect(res.body[0]).toEqual( expect.arrayContaining([ { key: { _id: 1 }, name: '_id_', v: 2 }, { key: { user_id: 1 }, name: 'correlation_id_unicity', unique: true, v: 2, }, { key: { _id: 1, firstname: 1 }, name: 'firstname', v: 2 }, { key: { _id: 1, lastname: 1 }, name: 'lastname', v: 2 }, { key: { _id: 1, created_at: 1 }, name: 'created_at_1', v: 2 }, { key: { _id: 1, version: 1 }, name: 'version_1', v: 2 }, ]), ); }); }); describe('#updateApiDefinition', () => { let openApi; beforeEach(() => { models = init({ models: [] }, services); openApi = { update: jest.fn() }; }); it('updates the api definition if config mode is development', async () => { await updateApiDefinition( { ...services, models, config: { ...services.config, features: { api: { updateSpecOnModelsChange: false } }, }, }, openApi, ); expect(openApi.update).toHaveBeenCalledTimes(1); }); it('updates the api definition if updateSpecOnModelsChange is true', async () => { await updateApiDefinition( { ...services, models, config: { ...services.config, mode: 'production', features: { api: { updateSpecOnModelsChange: true } }, }, }, openApi, ); expect(openApi.update).toHaveBeenCalledTimes(1); }); it('does not update the api definition if none of the condition are present', async () => { await updateApiDefinition( { ...services, models, config: { ...services.config, mode: 'production', features: { api: { updateSpecOnModelsChange: false } }, }, }, openApi, ); expect(openApi.update).toHaveBeenCalledTimes(0); }); }); describe('#rotateEncryptionKeys', () => { let error; let req; let res; let next; beforeEach(async () => { models = await setup.initModels(services, [fixtureUsers]); error = null; next = jest.fn().mockImplementation((err) => (error = err)); req = { params: {}, body: {}, query: {} }; res = { status: jest.fn().mockImplementation(() => ({ end: jest.fn() })), }; }); afterEach(() => { jest.restoreAllMocks(); }); it('calls next if res.body is already set', async () => { const controller = rotateEncryptionKeys({ ...services, models }); res.body = {}; await controller(req, res, next); expect(next).toHaveBeenCalledTimes(1); expect(next).toHaveBeenLastCalledWith(); }); it('returns a 202 immediately', async () => { const controller = rotateEncryptionKeys({ ...services, models }); await controller(req, res, next); expect(res.status).toHaveBeenCalledWith(202); }); it('invokes the encryption key rotation on all models by default', async () => { const rotateEncryptionKeyMock = jest.spyOn(models, 'rotateEncryptionKey'); const controller = rotateEncryptionKeys({ ...services, models }); await controller(req, res, next); expect(rotateEncryptionKeyMock).toHaveBeenCalledTimes(1); expect(rotateEncryptionKeyMock).toHaveBeenCalledWith(undefined); }); it('invokes the encryption key rotation only on selected models if available in query', async () => { const rotateEncryptionKeyMock = jest.spyOn(models, 'rotateEncryptionKey'); const controller = rotateEncryptionKeys({ ...services, models }); req.query.q = JSON.stringify({ models: ['users'] }); await controller(req, res, next); expect(rotateEncryptionKeyMock).toHaveBeenCalledTimes(1); expect(rotateEncryptionKeyMock).toHaveBeenCalledWith(['users']); }); it('logs an error in case of error', async () => { const error = new Error('Ooops'); const rotateEncryptionKeyMock = jest .spyOn(models, 'rotateEncryptionKey') .mockImplementation(() => { throw error; }); const loggerErrorMock = jest.spyOn(services.telemetry.logger, 'error'); const controller = rotateEncryptionKeys({ ...services, models }); await controller(req, res, next); expect(loggerErrorMock).toHaveBeenCalledWith( '[admin] Encryption key rotation failed', { err: error }, ); }); }); });