@getanthill/datastore
Version:
Event-Sourced Datastore
897 lines (701 loc) • 24.1 kB
text/typescript
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 },
);
});
});
});