UI: Database fixes (#24947)

This commit is contained in:
Chelsea Shaw
2024-01-24 12:04:44 -06:00
committed by GitHub
parent b87318b35e
commit a4611fbfaa
13 changed files with 597 additions and 43 deletions

3
changelog/24947.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:bug
ui: Fixed minor bugs with database secrets engine
```

View File

@@ -56,6 +56,7 @@ export default ApplicationAdapter.extend({
return {
data: {
id,
name: id,
...data,
},
};

View File

@@ -144,7 +144,7 @@ export default ApplicationAdapter.extend({
async _updateAllowedRoles(store, { role, backend, db, type = 'add' }) {
const connection = await store.queryRecord('database/connection', { backend, id: db });
const roles = [...connection.allowed_roles];
const roles = [...(connection.allowed_roles || [])];
const allowedRoles = type === 'add' ? addToArray([roles, role]) : removeFromArray([roles, role]);
connection.allowed_roles = allowedRoles;
return connection.save();

View File

@@ -59,8 +59,6 @@ export default class DatabaseConnectionEdit extends Component {
async handleCreateConnection(evt) {
evt.preventDefault();
const secret = this.args.model;
const secretId = secret.name;
secret.set('id', secretId);
secret
.save()
.then(() => {

View File

@@ -49,9 +49,7 @@ export default Model.extend({
label: 'Connection will be verified',
defaultValue: true,
}),
allowed_roles: attr('array', {
readOnly: true,
}),
allowed_roles: attr('array'),
password_policy: attr('string', {
label: 'Use custom password policy',
editType: 'optionalText',

View File

@@ -38,7 +38,7 @@ export default RESTSerializer.extend({
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
const nullResponses = ['updateRecord', 'createRecord', 'deleteRecord'];
const connections = nullResponses.includes(requestType)
? { name: id, backend: payload.backend }
? { name: payload.data.name, backend: payload.data.backend }
: this.normalizeSecrets(payload);
const { modelName } = primaryModelClass;
let transformedPayload = { [modelName]: connections };
@@ -63,7 +63,8 @@ export default RESTSerializer.extend({
// filter data to only allow plugin specific attrs
const allowedAttributes = Object.keys(data).filter((dataAttrs) => pluginAttributes.includes(dataAttrs));
for (const key in data) {
if (!allowedAttributes.includes(key)) {
// All connections allow allowed_roles but it's not shown on the form
if (key !== 'allowed_roles' && !allowedAttributes.includes(key)) {
delete data[key];
}
}

View File

@@ -40,7 +40,7 @@
</EmptyState>
{{/unless}}
{{#if (and (not @model.errorMessage) (eq @roleType "dynamic"))}}
<Hds::Alert @type="inline" @color="warning" class="has-top-bottom-margin" as |A|>
<Hds::Alert @type="inline" @color="warning" class="has-top-bottom-margin" data-test-credentials-warning as |A|>
<A.Title>Warning</A.Title>
<A.Description>
You will not be able to access these credentials later, so please copy them now.

View File

@@ -0,0 +1,24 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { Factory } from 'ember-cli-mirage';
// For the purposes of testing, we only use a subset of fields relevant to mysql
export default Factory.extend({
backend: 'database',
name: 'connection',
plugin_name: 'mysql-database-plugin',
verify_connection: true,
connection_url: '{{username}}:{{password}}@tcp(127.0.0.1:33060)/',
username: 'admin',
max_open_connections: 4,
max_idle_connections: 0,
max_connection_lifetime: '0s',
allowed_roles: () => [],
root_rotation_statements: () => [
'SELECT user from mysql.user',
"GRANT ALL PRIVILEGES ON *.* to 'sudo'@'%'",
],
});

View File

@@ -0,0 +1,124 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { Response } from 'miragejs';
export default function (server) {
const getRecord = (schema, req, dbKey) => {
const { backend, name } = req.params;
const record = schema.db[dbKey].findBy({ name, backend });
if (record) {
delete record.backend;
delete record.id;
}
return record ? { data: record } : new Response(404, {}, { errors: [] });
};
const createOrUpdateRecord = (schema, req, key) => {
const { backend, name } = req.params;
const payload = JSON.parse(req.requestBody);
const record = schema[key].findOrCreateBy({ name, backend });
record.update(payload);
return new Response(204);
};
const deleteRecord = (schema, req, dbKey) => {
const { name } = req.params;
const record = schema.db[dbKey].findBy({ name });
if (record) {
schema.db[dbKey].remove(record.id);
}
return new Response(204);
};
// Connection mgmt
server.get('/:backend/config/:name', (schema, req) => {
return getRecord(schema, req, 'database/connections');
});
server.get('/:backend/config', (schema) => {
const keys = schema.db['databaseConnections'].map((record) => record.name);
if (!keys.length) {
return new Response(404, {}, { errors: [] });
}
return {
data: {
keys,
},
};
});
server.post('/:backend/config/:name', (schema, req) => {
const { name } = req.params;
const { username } = JSON.parse(req.requestBody);
if (name === 'bad-connection') {
return new Response(
500,
{},
{
errors: [
`error creating database object: error verifying - ping: Error 1045 (28000): Access denied for user '${username}'@'192.168.65.1' (using password: YES)`,
],
}
);
}
return createOrUpdateRecord(schema, req, 'database/connections');
});
server.delete('/:backend/config/:name', (schema, req) => {
return deleteRecord(schema, req, 'database-connection');
});
// Rotate root
server.post('/:backend/rotate-root/:name', (schema, req) => {
const { name } = req.params;
if (name === 'fail-rotate') {
return new Response(
500,
{},
{
errors: [
"1 error occurred:\n\t* failed to update user: failed to change password: Error 1045 (28000): Access denied for user 'admin'@'%' (using password: YES)\n\n",
],
}
);
}
return new Response(204);
});
// Generate credentials
server.get('/:backend/creds/:role', (schema, req) => {
const { role } = req.params;
if (role === 'static-role') {
// static creds
return {
request_id: 'static-1234',
lease_id: '',
renewable: false,
lease_duration: 0,
data: {
last_vault_rotation: '2024-01-18T10:45:47.227193-06:00',
password: 'generated-password',
rotation_period: 86400,
ttl: 3600,
username: 'static-username',
},
wrap_info: null,
warnings: null,
auth: null,
mount_type: 'database',
};
}
// dynamic creds
return {
request_id: 'dynamic-1234',
lease_id: `database/creds/${role}/abcd`,
renewable: true,
lease_duration: 3600,
data: {
password: 'generated-password',
username: 'generated-username',
},
wrap_info: null,
warnings: null,
auth: null,
mount_type: 'database',
};
});
}

View File

@@ -1,32 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export default function (server) {
server.get('/database/static-roles', function () {
return {
data: { keys: ['dev-static', 'prod-static'] },
};
});
server.get('/database/static-roles/:rolename', function (db, req) {
if (req.params.rolename.includes('tester')) {
return new Response(400);
}
return {
data: {
rotation_statements: [
'{ "db": "admin", "roles": [{ "role": "readWrite" }, {"role": "read", "db": "foo"}] }',
],
db_name: 'connection',
username: 'alice',
rotation_period: '1h',
},
};
});
server.post('/database/rotate-role/:rolename', function () {
return new Response(204);
});
}

View File

@@ -9,7 +9,7 @@ import base from './base';
import chrootNamespace from './chroot-namespace';
import customMessages from './custom-messages';
import clients from './clients';
import db from './db';
import database from './database';
import hcpLink from './hcp-link';
import kms from './kms';
import kubernetes from './kubernetes';
@@ -24,7 +24,7 @@ export {
base,
chrootNamespace,
clients,
db,
database,
hcpLink,
kms,
kubernetes,

View File

@@ -0,0 +1,336 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { v4 as uuidv4 } from 'uuid';
import { Response } from 'miragejs';
import { click, currentURL, fillIn, visit } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { create } from 'ember-cli-page-object';
import ENV from 'vault/config/environment';
import { setupApplicationTest } from 'vault/tests/helpers';
import authPage from 'vault/tests/pages/auth';
import flashMessage from 'vault/tests/pages/components/flash-message';
import { deleteEngineCmd, mountEngineCmd, runCmd } from 'vault/tests/helpers/commands';
const flash = create(flashMessage);
const PAGE = {
// GENERIC
emptyStateTitle: '[data-test-empty-state-title]',
emptyStateAction: '[data-test-secret-create="connections"]',
infoRow: '[data-test-component="info-table-row"]',
infoRowLabel: (label) => `[data-test-row-label="${label}"]`,
infoRowValue: (label) => `[data-test-row-value="${label}"]`,
infoRowValueDiv: (label) => `[data-test-value-div="${label}"]`,
// CONNECTIONS
rotateModal: '[data-test-db-connection-modal-title]',
confirmRotate: '[data-test-enable-rotate-connection]',
skipRotate: '[data-test-enable-connection]',
// ROLES
addRole: '[data-test-secret-create]',
roleSettingsSection: '[data-test-role-settings-section]',
statementsSection: '[data-test-statements-section]',
editRole: '[data-test-edit-link]',
generateCredentials: (type = 'dynamic') => `[data-test-database-role-creds="${type}"]`,
};
const FORM = {
inputByAttr: (attr) => `[data-test-input="${attr}"]`,
creationStatement: (idx = 0) =>
`[data-test-input="creation_statements"] [data-test-string-list-input="${idx}"]`,
saveBtn: '[data-test-secret-save]',
};
async function fillOutConnection(name) {
await fillIn(FORM.inputByAttr('name'), name);
await fillIn(FORM.inputByAttr('plugin_name'), 'mysql-database-plugin');
await fillIn(FORM.inputByAttr('connection_url'), '{{username}}:{{password}}@tcp(127.0.0.1:33060)/');
await fillIn(FORM.inputByAttr('username'), 'admin');
await fillIn(FORM.inputByAttr('password'), 'very-secure');
}
/**
* This test set is for testing the flow for database secrets engine.
*/
module('Acceptance | database workflow', function (hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.before(function () {
ENV['ember-cli-mirage'].handler = 'database';
});
hooks.after(function () {
ENV['ember-cli-mirage'].handler = null;
});
hooks.beforeEach(async function () {
this.backend = `db-workflow-${uuidv4()}`;
this.store = this.owner.lookup('service:store');
await authPage.login();
await runCmd(mountEngineCmd('database', this.backend), false);
});
hooks.afterEach(async function () {
await authPage.login();
return runCmd(deleteEngineCmd(this.backend));
});
module('connections', function (hooks) {
hooks.beforeEach(function () {
this.expectedRows = [
{ label: 'Database plugin', value: 'mysql-database-plugin' },
{ label: 'Connection name', value: `connect-${this.backend}` },
{ label: 'Use custom password policy', value: 'Default' },
{ label: 'Connection URL', value: '{{username}}:{{password}}@tcp(127.0.0.1:33060)/' },
{ label: 'Max open connections', value: '4' },
{ label: 'Max idle connections', value: '0' },
{ label: 'Max connection lifetime', value: '0s' },
{ label: 'Username template', value: 'Default' },
{
label: 'Root rotation statements',
value: `Default`,
},
];
});
test('create with rotate', async function (assert) {
assert.expect(24);
this.server.post('/:backend/rotate-root/:name', () => {
assert.ok(true, 'rotate root called');
new Response(204);
});
await visit(`/vault/secrets/${this.backend}/overview`);
assert.dom(PAGE.emptyStateTitle).hasText('Connect a database', 'empty state title is correct');
await click(PAGE.emptyStateAction);
assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/create`, 'Takes you to create page');
// fill in connection details
await fillOutConnection(`connect-${this.backend}`);
await click(FORM.saveBtn);
assert.dom(PAGE.rotateModal).hasText('Rotate your root credentials?', 'rotate modal is shown');
await click(PAGE.confirmRotate);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/show/connect-${this.backend}`,
'Takes you to details page for connection'
);
assert.dom(PAGE.infoRow).exists({ count: this.expectedRows.length }, 'correct number of rows');
this.expectedRows.forEach(({ label, value }) => {
assert.dom(PAGE.infoRowLabel(label)).hasText(label, `Label for ${label} is correct`);
assert.dom(PAGE.infoRowValue(label)).hasText(value, `Value for ${label} is correct`);
});
});
test('create without rotate', async function (assert) {
assert.expect(23);
this.server.post('/:backend/rotate-root/:name', () => {
assert.notOk(true, 'rotate root called when it should not have been');
new Response(204);
});
await visit(`/vault/secrets/${this.backend}/overview`);
assert.dom(PAGE.emptyStateTitle).hasText('Connect a database', 'empty state title is correct');
await click(PAGE.emptyStateAction);
assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/create`, 'Takes you to create page');
// fill in connection details
await fillOutConnection(`connect-${this.backend}`);
await click(FORM.saveBtn);
assert.dom(PAGE.rotateModal).hasText('Rotate your root credentials?', 'rotate modal is shown');
await click(PAGE.skipRotate);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/show/connect-${this.backend}`,
'Takes you to details page for connection'
);
assert.dom(PAGE.infoRow).exists({ count: this.expectedRows.length }, 'correct number of rows');
this.expectedRows.forEach(({ label, value }) => {
assert.dom(PAGE.infoRowLabel(label)).hasText(label, `Label for ${label} is correct`);
assert.dom(PAGE.infoRowValue(label)).hasText(value, `Value for ${label} is correct`);
});
});
test('create failure', async function (assert) {
assert.expect(25);
this.server.post('/:backend/rotate-root/:name', (schema, req) => {
const okay = req.params.name !== 'bad-connection';
assert.ok(okay, 'rotate root called but not for bad-connection');
new Response(204);
});
await visit(`/vault/secrets/${this.backend}/overview`);
assert.dom(PAGE.emptyStateTitle).hasText('Connect a database', 'empty state title is correct');
await click(PAGE.emptyStateAction);
assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/create`, 'Takes you to create page');
// fill in connection details
await fillOutConnection(`bad-connection`);
await click(FORM.saveBtn);
assert.strictEqual(
flash.latestMessage,
`error creating database object: error verifying - ping: Error 1045 (28000): Access denied for user 'admin'@'192.168.65.1' (using password: YES)`,
'shows the error message from API'
);
await fillIn(FORM.inputByAttr('name'), `connect-${this.backend}`);
await click(FORM.saveBtn);
assert.dom(PAGE.rotateModal).hasText('Rotate your root credentials?', 'rotate modal is shown');
await click(PAGE.confirmRotate);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/show/connect-${this.backend}`,
'Takes you to details page for connection'
);
assert.dom(PAGE.infoRow).exists({ count: this.expectedRows.length }, 'correct number of rows');
this.expectedRows.forEach(({ label, value }) => {
assert.dom(PAGE.infoRowLabel(label)).hasText(label, `Label for ${label} is correct`);
assert.dom(PAGE.infoRowValue(label)).hasText(value, `Value for ${label} is correct`);
});
});
test('create connection with rotate failure', async function (assert) {
await visit(`/vault/secrets/${this.backend}/overview`);
assert.dom(PAGE.emptyStateTitle).hasText('Connect a database', 'empty state title is correct');
await click(PAGE.emptyStateAction);
assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/create`, 'Takes you to create page');
// fill in connection details
await fillOutConnection(`fail-rotate`);
await click(FORM.saveBtn);
assert.dom(PAGE.rotateModal).hasText('Rotate your root credentials?', 'rotate modal is shown');
await click(PAGE.confirmRotate);
assert.strictEqual(
flash.latestMessage,
`Error rotating root credentials: 1 error occurred: * failed to update user: failed to change password: Error 1045 (28000): Access denied for user 'admin'@'%' (using password: YES)`,
'shows the error message from API'
);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/show/fail-rotate`,
'Takes you to details page for connection'
);
});
});
module('roles', function (hooks) {
hooks.beforeEach(async function () {
this.connection = `connect-${this.backend}`;
await visit(`/vault/secrets/${this.backend}/create`);
await fillOutConnection(this.connection);
await click(FORM.saveBtn);
await visit(`/vault/secrets/${this.backend}/show/${this.connection}`);
});
test('it creates a dynamic role attached to the current connection', async function (assert) {
const roleName = 'dynamic-role';
await click(PAGE.addRole);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/create?initialKey=${this.connection}&itemType=role`,
'Takes you to create role page'
);
assert
.dom(`${PAGE.roleSettingsSection} ${PAGE.emptyStateTitle}`)
.hasText('No role type selected', 'roles section shows empty state before selecting role type');
assert
.dom(`${PAGE.statementsSection} ${PAGE.emptyStateTitle}`)
.hasText('No role type selected', 'statements section shows empty state before selecting role type');
await fillIn(FORM.inputByAttr('name'), roleName);
assert.dom('[data-test-selected-option]').hasText(this.connection, 'Connection is selected by default');
await fillIn(FORM.inputByAttr('type'), 'dynamic');
assert
.dom(`${PAGE.roleSettingsSection} ${PAGE.emptyStateTitle}`)
.doesNotExist('roles section no longer has empty state');
assert
.dom(`${PAGE.statementsSection} ${PAGE.emptyStateTitle}`)
.doesNotExist('statements section no longer has empty state');
// Fill in multiple creation statements
await fillIn(FORM.creationStatement(), `GRANT SELECT ON *.* TO '{{name}}'@'%'`);
await click(`[data-test-string-list-row="0"] [data-test-string-list-button="add"]`);
await fillIn(FORM.creationStatement(1), `GRANT CREATE ON *.* TO '{{name}}'@'%'`);
await click(FORM.saveBtn);
// DETAILS
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/show/role/${roleName}`,
'Takes you to details page for role after save'
);
assert.dom(PAGE.infoRow).exists({ count: 7 }, 'correct number of info rows displayed');
[
{ label: 'Role name', value: roleName },
{ label: 'Connection name', value: this.connection },
{ label: 'Type of role', value: 'dynamic' },
{ label: 'Generated credentialss Time-to-Live (TTL)', value: '1 hour' },
{ label: 'Generated credentialss maximum Time-to-Live (Max TTL)', value: '1 day' },
{
label: 'Creation statements',
value: `GRANT SELECT ON *.* TO '{{name}}'@'%',GRANT CREATE ON *.* TO '{{name}}'@'%'`,
},
{ label: 'Revocation statements', value: 'Default' },
].forEach(({ label, value }) => {
const valueSelector =
label === 'Creation statements' ? PAGE.infoRowValueDiv(label) : PAGE.infoRowValue(label);
assert.dom(PAGE.infoRowLabel(label)).hasText(label, `Label for ${label} is correct`);
assert.dom(valueSelector).hasText(value, `Value for ${label} is correct`);
});
// EDIT
await click(PAGE.editRole);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/edit/role/${roleName}?itemType=role`,
'Takes you to edit page for role'
);
// TODO: these should be readonly not disabled
assert.dom(FORM.inputByAttr('name')).isDisabled('Name is read-only');
assert.dom(FORM.inputByAttr('database')).isDisabled('Database is read-only');
assert.dom(FORM.inputByAttr('type')).isDisabled('Type is read-only');
await fillIn('[data-test-ttl-value="Generated credentialss Time-to-Live (TTL)"]', '2');
await click(FORM.saveBtn);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/show/role/${roleName}`,
'Takes you to details page for role after save'
);
assert
.dom(PAGE.infoRowValue('Generated credentialss Time-to-Live (TTL)'))
.hasText('2 hours', 'Shows updated TTL');
// CREDENTIALS
await click(PAGE.generateCredentials());
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/credentials/${roleName}?roleType=dynamic`,
'Takes you to credentials page for role'
);
assert
.dom('[data-test-credentials-warning]')
.exists('shows warning about credentials only being available once');
assert
.dom(`[data-test-value-div="Username"] [data-test-masked-input]`)
.hasText('***********', 'Username is masked');
await click(`[data-test-value-div="Username"] [data-test-button="toggle-masked"]`);
assert
.dom(`[data-test-value-div="Username"] [data-test-masked-input]`)
.hasText('generated-username', 'Username is generated');
assert
.dom(`[data-test-value-div="Password"] [data-test-masked-input]`)
.hasText('***********', 'Password is masked');
await click(`[data-test-value-div="Password"] [data-test-button="toggle-masked"]`);
assert
.dom(`[data-test-value-div="Password"] [data-test-masked-input]`)
.hasText('generated-password', 'Password is generated');
assert.dom(PAGE.infoRowValue('Lease Duration')).hasText('3600', 'shows lease duration from response');
assert
.dom(PAGE.infoRowValue('Lease ID'))
.hasText(`database/creds/${roleName}/abcd`, 'shows lease ID from response');
});
});
});

View File

@@ -0,0 +1,101 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
module('Unit | Serializer | database/connection', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
this.uid = uuidv4();
this.store = this.owner.lookup('service:store');
});
test('it should serialize only keys that are valid for the database type (elasticsearch)', function (assert) {
const backend = `db-serializer-test-${this.uid}`;
const name = `elastic-test-${this.uid}`;
const record = this.store.createRecord('database/connection', {
plugin_name: 'elasticsearch-database-plugin',
backend,
name,
allowed_roles: ['readonly'],
connection_url: 'http://localhost:9200',
url: 'http://localhost:9200',
username: 'elastic',
password: 'changeme',
tls_ca: 'some-value',
ca_cert: undefined, // does not send undefined values
});
const expectedResult = {
plugin_name: 'elasticsearch-database-plugin',
backend,
name,
verify_connection: true,
allowed_roles: ['readonly'],
url: 'http://localhost:9200',
username: 'elastic',
password: 'changeme',
insecure: false,
};
const serializedRecord = record.serialize();
assert.deepEqual(
serializedRecord,
expectedResult,
'invalid elasticsearch options were not added to the payload'
);
});
test('it should normalize values for the database type (elasticsearch)', function (assert) {
const serializer = this.owner.lookup('serializer:database/connection');
const normalized = serializer.normalizeSecrets({
request_id: 'request-id',
lease_id: '',
renewable: false,
lease_duration: 0,
data: {
allowed_roles: ['readonly'],
connection_details: {
backend: 'database',
insecure: false,
url: 'https://localhost:9200',
username: 'root',
},
password_policy: '',
plugin_name: 'elasticsearch-database-plugin',
plugin_version: '',
root_credentials_rotate_statements: [],
},
wrap_info: null,
warnings: null,
auth: null,
mount_type: 'database',
backend: 'database',
id: 'elastic-test',
});
const expectedResult = {
allowed_roles: ['readonly'],
backend: 'database',
connection_details: {
backend: 'database',
insecure: false,
url: 'https://localhost:9200',
username: 'root',
},
id: 'elastic-test',
insecure: false,
name: 'elastic-test',
password_policy: '',
plugin_name: 'elasticsearch-database-plugin',
plugin_version: '',
root_credentials_rotate_statements: [],
root_rotation_statements: [],
url: 'https://localhost:9200',
username: 'root',
};
assert.deepEqual(normalized, expectedResult, `Normalizes and flattens database response`);
});
});