UI/database mssql (#11231)

Add MSSQL plugin support in database secrets engine
This commit is contained in:
Chelsea Shaw 2021-04-14 16:07:07 -05:00 committed by GitHub
parent cc107171e2
commit a3c396991c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 498 additions and 351 deletions

3
changelog/11231.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:feature
ui: Add database secret engine support for MSSQL
```

View File

@ -10,6 +10,7 @@ export default class DatabaseRoleEdit extends Component {
@service router;
@service flashMessages;
@service wizard;
@service store;
constructor() {
super(...arguments);
@ -41,12 +42,16 @@ export default class DatabaseRoleEdit extends Component {
}
get databaseType() {
if (this.args.model?.database) {
// TODO: Calculate this
return 'mongodb-database-plugin';
}
const backend = this.args.model?.backend;
const dbs = this.args.model?.database || [];
if (!backend || dbs.length === 0) {
return null;
}
return this.store
.queryRecord('database/connection', { id: dbs[0], backend })
.then(record => record.plugin_name)
.catch(() => null);
}
@action
generateCreds(roleId) {

View File

@ -18,34 +18,26 @@ import Component from '@glimmer/component';
// Below fields are intended to be dynamic based on type of role and db.
// example of usage: FIELDS[roleType][db]
const ROLE_FIELDS = {
static: {
default: ['ttl', 'max_ttl', 'username', 'rotation_period'],
'mongodb-database-plugin': ['username', 'rotation_period'],
},
dynamic: {
default: ['ttl', 'max_ttl', 'username', 'rotation_period'],
'mongodb-database-plugin': ['ttl', 'max_ttl'],
},
static: ['username', 'rotation_period'],
dynamic: ['ttl', 'max_ttl'],
};
const STATEMENT_FIELDS = {
static: {
default: ['creation_statements', 'revocation_statements', 'rotation_statements'],
'mongodb-database-plugin': 'NONE', // will not show the section
default: ['rotation_statements'],
'mongodb-database-plugin': [],
'mssql-database-plugin': [],
},
dynamic: {
default: ['creation_statements', 'revocation_statements', 'rotation_statements'],
default: ['creation_statements', 'revocation_statements', 'rollback_statements', 'renew_statements'],
'mongodb-database-plugin': ['creation_statement', 'revocation_statement'],
'mssql-database-plugin': ['creation_statements', 'revocation_statements'],
},
};
export default class DatabaseRoleSettingForm extends Component {
get settingFields() {
const type = this.args.roleType;
if (!type) return null;
const db = this.args.dbType || 'default';
const dbValidFields = ROLE_FIELDS[type][db];
if (!Array.isArray(dbValidFields)) return dbValidFields;
if (!this.args.roleType) return null;
let dbValidFields = ROLE_FIELDS[this.args.roleType];
return this.args.attrs.filter(a => {
return dbValidFields.includes(a.name);
});
@ -53,10 +45,12 @@ export default class DatabaseRoleSettingForm extends Component {
get statementFields() {
const type = this.args.roleType;
const plugin = this.args.dbType;
if (!type) return null;
const db = this.args.dbType || 'default';
const dbValidFields = STATEMENT_FIELDS[type][db];
if (!Array.isArray(dbValidFields)) return dbValidFields;
let dbValidFields = STATEMENT_FIELDS[type].default;
if (STATEMENT_FIELDS[type][plugin]) {
dbValidFields = STATEMENT_FIELDS[type][plugin];
}
return this.args.attrs.filter(a => {
return dbValidFields.includes(a.name);
});

View File

@ -9,22 +9,63 @@ const AVAILABLE_PLUGIN_TYPES = [
value: 'mongodb-database-plugin',
displayName: 'MongoDB',
fields: [
{ attr: 'name' },
{ attr: 'plugin_name' },
{ attr: 'name' },
{ attr: 'connection_url' },
{ attr: 'verify_connection' },
{ attr: 'password_policy' },
{ attr: 'username', group: 'pluginConfig' },
{ attr: 'password', group: 'pluginConfig' },
{ attr: 'connection_url', group: 'pluginConfig' },
{ attr: 'write_concern' },
{ attr: 'creation_statements' },
{ attr: 'username', group: 'pluginConfig', show: false },
{ attr: 'password', group: 'pluginConfig', show: false },
{ attr: 'write_concern', group: 'pluginConfig' },
{ attr: 'username_template', group: 'pluginConfig' },
{ attr: 'tls', group: 'pluginConfig', subgroup: 'TLS options' },
{ attr: 'tls_ca', group: 'pluginConfig', subgroup: 'TLS options' },
],
},
{
value: 'mssql-database-plugin',
displayName: 'MSSQL',
fields: [
{ attr: 'plugin_name' },
{ attr: 'name' },
{ attr: 'connection_url' },
{ attr: 'verify_connection' },
{ attr: 'password_policy' },
{ attr: 'username', group: 'pluginConfig', show: false },
{ attr: 'password', group: 'pluginConfig', show: false },
{ attr: 'username_template', group: 'pluginConfig' },
{ attr: 'max_open_connections', group: 'pluginConfig' },
{ attr: 'max_idle_connections', group: 'pluginConfig' },
{ attr: 'max_connection_lifetime', group: 'pluginConfig' },
],
},
];
/**
* fieldsToGroups helper fn
* @param {array} arr any subset of "fields" from AVAILABLE_PLUGIN_TYPES
* @param {*} key item by which to group the fields. If item has no group it will be under "default"
* @returns array of objects where the key is default or the name of the option group, and the value is an array of attr names
*/
const fieldsToGroups = function(arr, key = 'subgroup') {
const fieldGroups = [];
const byGroup = arr.reduce(function(rv, x) {
(rv[x[key]] = rv[x[key]] || []).push(x);
return rv;
}, {});
Object.keys(byGroup).forEach(key => {
const attrsArray = byGroup[key].map(obj => obj.attr);
const group = key === 'undefined' ? 'default' : key;
fieldGroups.push({ [group]: attrsArray });
});
return fieldGroups;
};
export default Model.extend({
backend: attr('string', {
readOnly: true,
}),
// required
name: attr('string', {
label: 'Connection Name',
}),
@ -33,33 +74,46 @@ export default Model.extend({
possibleValues: AVAILABLE_PLUGIN_TYPES,
noDefault: true,
}),
// standard
verify_connection: attr('boolean', {
label: 'Connection will be verified',
defaultValue: true,
}),
allowed_roles: attr('array', {
readOnly: true,
}),
password_policy: attr('string', {
label: 'Use custom password policy',
editType: 'optionalText',
subText:
subText: 'Specify the name of an existing password policy.',
defaultSubText:
'Unless a custom policy is specified, Vault will use a default: 20 characters with at least 1 uppercase, 1 lowercase, 1 number, and 1 dash character.',
defaultShown: 'Default',
docLink: 'https://www.vaultproject.io/docs/concepts/password-policies',
}),
hosts: attr('string', {}),
host: attr('string', {}),
url: attr('string', {}),
port: attr('string', {}),
// connection_details
username: attr('string', {}),
password: attr('string', {
editType: 'password',
}),
// common fields
connection_url: attr('string', {
subText: 'The connection string used to connect to the database.',
}),
url: attr('string', {
subText:
'The connection string used to connect to the database. This allows for simple templating of username and password of the root user.',
}),
username: attr('string', {
subText: 'Optional. The name of the user to use as the "root" user when connecting to the database.',
}),
password: attr('string', {
subText:
'Optional. The password to use when connecting to the database. Typically used in the connection_url field via the templating directive {{password}}.',
editType: 'password',
}),
// optional
hosts: attr('string', {}),
host: attr('string', {}),
port: attr('string', {}),
write_concern: attr('string', {
subText: 'Optional. Must be in JSON. See our documentation for help.',
editType: 'json',
@ -67,9 +121,23 @@ export default Model.extend({
defaultShown: 'Default',
// defaultValue: '# For example: { "wmode": "majority", "wtimeout": 5000 }',
}),
max_open_connections: attr('string', {}),
max_idle_connections: attr('string'),
max_connection_lifetime: attr('string'),
username_template: attr('string', {
editType: 'optionalText',
subText: 'Enter the custom username template to use.',
defaultSubText:
'Template describing how dynamic usernames are generated. Vault will use the default for this plugin.',
docLink: 'https://www.vaultproject.io/docs/concepts/username-templating',
defaultShown: 'Default',
}),
max_open_connections: attr('number', {
defaultValue: 4,
}),
max_idle_connections: attr('number', {
defaultValue: 0,
}),
max_connection_lifetime: attr('string', {
defaultValue: '0s',
}),
tls: attr('string', {
label: 'TLS Certificate Key',
helpText:
@ -88,65 +156,44 @@ export default Model.extend({
defaultShown: 'Default',
}),
allowedFields: computed(function() {
return [
// required
'plugin_name',
'name',
// fields
'connection_url', // * MongoDB, HanaDB, MSSQL, MySQL/MariaDB, Oracle, PostgresQL, Redshift
'verify_connection', // default true
'password_policy', // default ""
// plugin config
'username',
'password',
'hosts',
'host',
'url',
'port',
'write_concern',
'max_open_connections',
'max_idle_connections',
'max_connection_lifetime',
'tls',
'tls_ca',
];
}),
// for both create and edit fields
mainFields: computed('plugin_name', function() {
return ['plugin_name', 'name', 'connection_url', 'verify_connection', 'password_policy', 'pluginConfig'];
}),
showAttrs: computed('plugin_name', function() {
const f = [
'name',
'plugin_name',
'connection_url',
'write_concern',
'verify_connection',
'allowed_roles',
];
return expandAttributeMeta(this, f);
const fields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name)
.fields.filter(f => f.show !== false)
.map(f => f.attr);
fields.push('allowed_roles');
return expandAttributeMeta(this, fields);
}),
fieldAttrs: computed('plugin_name', function() {
// for both create and edit fields
let fields = ['plugin_name', 'name', 'connection_url', 'verify_connection', 'password_policy'];
if (this.plugin_name) {
fields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name)
.fields.filter(f => !f.group)
.map(field => field.attr);
}
return expandAttributeMeta(this, fields);
}),
pluginFieldGroups: computed('plugin_name', function() {
if (!this.plugin_name) {
return null;
}
let groups = [{ default: ['username', 'password', 'write_concern'] }];
// TODO: Get plugin options based on plugin
groups.push({
'TLS options': ['tls', 'tls_ca'],
});
let pluginFields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name).fields.filter(
f => f.group === 'pluginConfig'
);
let groups = fieldsToGroups(pluginFields, 'subgroup');
return fieldToAttrs(this, groups);
}),
fieldAttrs: computed('mainFields', function() {
// Main Field Attrs only
return expandAttributeMeta(this, this.mainFields);
statementFields: computed('plugin_name', function() {
if (!this.plugin_name) {
return expandAttributeMeta(this, ['root_rotation_statements']);
}
let fields = AVAILABLE_PLUGIN_TYPES.find(a => a.value === this.plugin_name)
.fields.filter(f => f.group === 'statements')
.map(field => field.attr);
return expandAttributeMeta(this, fields);
}),
/* CAPABILITIES */

View File

@ -46,11 +46,10 @@ export default Model.extend({
editType: 'ttl',
defaultValue: '24h',
subText:
'Specifies the amount of time Vault should wait before rotating the password. The minimum is 5 seconds.',
'Specifies the amount of time Vault should wait before rotating the password. The minimum is 5 seconds. Default is 24 hours.',
}),
creation_statements: attr('array', {
editType: 'stringArray',
defaultShown: 'Default',
}),
revocation_statements: attr('array', {
editType: 'stringArray',
@ -60,6 +59,14 @@ export default Model.extend({
editType: 'stringArray',
defaultShown: 'Default',
}),
rollback_statements: attr('array', {
editType: 'stringArray',
defaultShown: 'Default',
}),
renew_statements: attr('array', {
editType: 'stringArray',
defaultShown: 'Default',
}),
creation_statement: attr('string', {
editType: 'json',
theme: 'hashi short',
@ -100,6 +107,8 @@ export default Model.extend({
'revocation_statements',
'revocation_statement', // only for MongoDB (styling difference)
'rotation_statements',
'rollback_statements',
'renew_statements',
];
return expandAttributeMeta(this, allRoleSettingFields);
}),

View File

@ -312,3 +312,7 @@ label {
position: absolute;
right: 8px;
}
fieldset.form-fieldset {
border: none;
}

View File

@ -40,10 +40,10 @@
Reset connection
</ConfirmAction>
{{/if}}
{{#if (and @model.canReset @model.canDelete)}}
{{#if (or @model.canReset @model.canDelete)}}
<div class="toolbar-separator" />
{{/if}}
{{#if @model.canRotate }}
{{#if @model.canRotateRoot }}
<ConfirmAction
@buttonClasses="toolbar-link"
@onConfirmAction={{this.rotate}}
@ -83,10 +83,15 @@
{{#if (eq @mode 'create')}}
<form {{on 'submit' this.handleCreateConnection}}>
{{#each @model.fieldAttrs as |attr|}}
{{#if (eq attr.name "pluginConfig")}}
{{#if (not-eq attr.options.readOnly true)}}
{{form-field data-test-field attr=attr model=@model}}
{{/if}}
{{/each}}
{{!-- Plugin Config Section --}}
<div class="form-section">
<h3 class="title is-5">Plugin config</h3>
<div class="form-section box is-shadowless is-fullwidth">
<fieldset class="form-fieldset">
<legend class="title is-5">Plugin config</legend>
{{#unless @model.pluginFieldGroups}}
<EmptyState
@title="No plugin selected"
@ -96,9 +101,26 @@
{{#each @model.pluginFieldGroups as |fieldGroup|}}
{{#each-in fieldGroup as |group fields|}}
{{#if (eq group "default")}}
<div class="columns is-desktop is-multiline">
{{#each fields as |attr|}}
{{#if (contains
attr.name
(array
"max_open_connections"
"max_idle_connections"
"max_connection_lifetime"
)
)}}
<div class="column is-one-third">
{{form-field data-test-field attr=attr model=@model}}
</div>
{{else}}
<div class="column is-full">
{{form-field data-test-field attr=attr model=@model}}
</div>
{{/if}}
{{/each}}
</div>
{{else}}
<ToggleButton @class="is-block" @toggleAttr={{concat "show" (camelize group)}} @toggleTarget={{this}} @openLabel={{concat "Hide " group}} @closedLabel={{group}} @data-test-toggle-group={{group}} />
{{#if (get this (concat "show" (camelize group)))}}
@ -112,11 +134,26 @@
{{/each-in}}
{{/each}}
{{/unless}}
</fieldset>
</div>
{{else if (not-eq attr.options.readOnly true)}}
{{!-- Statements Section --}}
{{#unless (and @model.plugin_name (not @model.statementFields))}}
<div class="form-section box is-shadowless is-fullwidth">
<h3 class="title is-5">Statements</h3>
{{#if (eq @model.statementFields null)}}
<EmptyState
@title="No plugin selected"
@message="Select a plugin type to be able to configure it."
/>
{{else}}
{{#each @model.statementFields as |attr|}}
{{log attr}}
{{form-field data-test-field attr=attr model=@model}}
{{/if}}
{{/each}}
{{/if}}
</div>
{{/unless}}
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="field is-grouped">
@ -141,14 +178,35 @@
{{else if (eq @mode 'edit')}}
<form {{on 'submit' this.handleUpdateConnection}}>
{{#each @model.fieldAttrs as |attr|}}
{{#if (eq attr.name "pluginConfig")}}
<div class="form-section">
<h3 class="title is-5">Plugin config</h3>
{{#if (or (eq attr.name 'name') (eq attr.name 'plugin_name'))}}
<ReadonlyFormField @attr={{attr}} @value={{get @model attr.name}} />
{{else if (not-eq attr.options.readOnly true)}}
{{form-field data-test-field attr=attr model=@model}}
{{/if}}
{{/each}}
{{!-- Plugin Config Edit --}}
<div class="form-section box is-shadowless is-fullwidth">
<fieldset class="form-fieldset">
<legend class="title is-5">Plugin config</legend>
{{#each @model.pluginFieldGroups as |fieldGroup|}}
{{#each-in fieldGroup as |group fields|}}
{{#if (eq group "default")}}
<div class="columns is-desktop is-multiline">
{{#each fields as |attr|}}
{{#if (eq attr.name "password")}}
{{#if (contains
attr.name
(array
"max_open_connections"
"max_idle_connections"
"max_connection_lifetime"
)
)}}
<div class="column is-one-third">
{{form-field data-test-field attr=attr model=@model}}
</div>
{{else if (eq attr.name "password")}}
<div class="column is-full">
<label for="{{attr.name}}" class="is-label">
{{capitalize (or attr.options.label attr.name)}}
</label>
@ -178,10 +236,14 @@
{{/if}}
</Toggle>
</div>
</div>
{{else}}
<div class="column is-full">
{{form-field data-test-field attr=attr model=@model}}
</div>
{{/if}}
{{/each}}
</div>
{{else}}
<ToggleButton @class="is-block" @toggleAttr={{concat "show" (camelize group)}} @toggleTarget={{this}} @openLabel={{concat "Hide " group}} @closedLabel={{group}} @data-test-toggle-group={{group}} />
{{#if (get this (concat "show" (camelize group)))}}
@ -194,13 +256,20 @@
{{/if}}
{{/each-in}}
{{/each}}
</fieldset>
</div>
{{else if (or (eq attr.name 'name') (eq attr.name 'plugin_name'))}}
<ReadonlyFormField @attr={{attr}} @value={{get @model attr.name}} />
{{else if (not-eq attr.options.readOnly true)}}
{{!-- Statements Edit Section --}}
{{#unless (and @model.plugin_name (not @model.statementFields))}}
<div class="form-section box is-shadowless is-fullwidth">
<fieldset class="form-fieldset">
<legend class="title is-5">Statements</legend>
{{#each @model.statementFields as |attr|}}
{{form-field data-test-field attr=attr model=@model}}
{{/if}}
{{/each}}
</fieldset>
</div>
{{/unless}}
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="field is-grouped">

View File

@ -95,7 +95,7 @@
@roleType={{@model.type}}
@model={{@model}}
@mode={{@mode}}
@dbType={{this.databaseType}}
@dbType={{await this.databaseType}}
/>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">

View File

@ -19,7 +19,7 @@
{{/unless}}
</div>
{{#unless (eq this.statementFields 'NONE')}}
{{#unless (and @roleType (not this.statementFields))}}
<div class="box is-sideless is-fullwidth is-marginless" data-test-statements-section>
<h3 class="title is-5">Statements</h3>
{{#unless this.statementFields}}

View File

@ -33,6 +33,15 @@ export default Component.extend({
disabled: false,
showHelpText: true,
subText: '',
// This is only used internally for `optional-text` editType
showInput: false,
init() {
this._super(...arguments);
const valuePath = this.attr.options?.fieldValue || this.attr.name;
const modelValue = this.model[valuePath];
this.set('showInput', !!modelValue);
},
onChange() {},
@ -84,9 +93,6 @@ export default Component.extend({
model: null,
// This is only used internally for `optional-text` editType
showInput: false,
/*
* @private
* @param object

View File

@ -28,7 +28,7 @@
{{/if}}
</label>
{{#if attr.options.subText}}
<p class="sub-text">{{attr.options.subText}}</p>
<p class="sub-text">{{attr.options.subText}} {{#if attr.options.docLink}}<a href="{{attr.options.docLink}}" target="_blank" rel="noopener noreferrer">See our documentation</a> for help.{{/if}}</p>
{{/if}}
{{/unless}}
{{#if attr.options.possibleValues}}
@ -124,8 +124,8 @@
<TtlPicker2
@onChange={{action (action "setAndBroadcastTtl" valuePath)}}
@label={{labelString}}
@helperTextDisabled="Vault will use the default lease duration"
@helperTextEnabled="Lease will expire after"
@helperTextDisabled={{or attr.subText "Vault will use the default lease duration"}}
@helperTextEnabled={{or attr.subText "Lease will expire after"}}
@description={{attr.helpText}}
@initialValue={{or (get model valuePath) attr.options.setDefault}}
/>
@ -142,7 +142,11 @@
>
<span class="ttl-picker-label is-large">{{labelString}}</span><br/>
<div class="description has-text-grey">
<span>{{attr.options.subText}}</span>
{{#if showInput}}
<span>{{attr.options.subText}} {{#if attr.options.docLink}}<a href="{{attr.options.docLink}}" target="_blank" rel="noopener noreferrer">See our documentation</a> for help.{{/if}}</span>
{{else}}
<span>{{or attr.options.defaultSubText "Vault will use the engine default."}} {{#if attr.options.docLink}}<a href="{{attr.options.docLink}}" target="_blank" rel="noopener noreferrer">See our documentation</a> for help.{{/if}}</span>
{{/if}}
</div>
{{#if showInput}}
<input
@ -205,7 +209,7 @@
{{/if}}
</label>
{{#if attr.options.subText}}
<p class="sub-text">{{attr.options.subText}}</p>
<p class="sub-text">{{attr.options.subText}} {{#if attr.options.docLink}}<a href="{{attr.options.docLink}}" target="_blank" rel="noopener noreferrer">See our documentation</a> for help.{{/if}}</p>
{{/if}}
<JsonEditor
data-test-input={{attr.name}}

View File

@ -1,22 +1,3 @@
{{#unless
(or
(eq @attr.type "boolean")
(contains
@attr.options.editType
(array
"boolean"
"optionalText"
"searchSelect"
"mountAccessor"
"kv"
"file"
"ttl"
"stringArray"
"json"
)
)
)
}}
<label for="{{@attr.name}}" class="is-label" data-test-readonly-label>
{{labelString}}
{{#if @attr.options.helpText}}
@ -30,7 +11,6 @@
{{#if @attr.options.subText}}
<p class="sub-text">{{@attr.options.subText}}</p>
{{/if}}
{{/unless}}
{{#if @attr.options.possibleValues}}
<div class="control is-expanded field is-readOnly">

View File

@ -1,5 +1,5 @@
{{#if label}}
<label class="title is-5" data-test-string-list-label="true">
<label class="title is-label" data-test-string-list-label="true">
{{label}}
{{#if helpText}}
{{#info-tooltip}}{{helpText}}{{/info-tooltip}}

View File

@ -105,6 +105,7 @@
"ember-maybe-import-regenerator": "^0.1.6",
"ember-maybe-in-element": "^0.4.0",
"ember-power-select-with-create": "^0.6.2",
"ember-promise-helpers": "^1.0.9",
"ember-qunit": "^4.6.0",
"ember-radio-button": "^2.0.1",
"ember-resolver": "^8.0.0",

View File

@ -1,6 +1,6 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { currentURL, settled, click, visit, fillIn, findAll } from '@ember/test-helpers';
import { currentURL, settled, click, visit, fillIn } from '@ember/test-helpers';
import { create } from 'ember-cli-page-object';
import { selectChoose, clickTrigger } from 'ember-power-select/test-support/helpers';
@ -29,10 +29,10 @@ const mount = async () => {
return path;
};
const newConnection = async backend => {
const newConnection = async (backend, plugin = 'mongodb-database-plugin') => {
const name = `connection-${Date.now()}`;
await connectionPage.visitCreate({ backend });
await connectionPage.dbPlugin('mongodb-database-plugin');
await connectionPage.dbPlugin(plugin);
await connectionPage.name(name);
await connectionPage.url(`mongodb://127.0.0.1:4321/${name}`);
await connectionPage.toggleVerify();
@ -41,6 +41,38 @@ const newConnection = async backend => {
return name;
};
const connectionTests = [
{
name: 'mongodb-connection',
plugin: 'mongodb-database-plugin',
url: `mongodb://127.0.0.1:4321/test`,
requiredFields: async (assert, name) => {
assert.dom('[data-test-input="username').exists(`Username field exists for ${name}`);
assert.dom('[data-test-input="password').exists(`Password field exists for ${name}`);
assert.dom('[data-test-input="write_concern').exists(`Write concern field exists for ${name}`);
assert.dom('[data-test-toggle-group="TLS options"]').exists('TLS options toggle exists');
},
},
{
name: 'mssql-connection',
plugin: 'mssql-database-plugin',
url: `mssql://127.0.0.1:4321/test`,
requiredFields: async (assert, name) => {
assert.dom('[data-test-input="username').exists(`Username field exists for ${name}`);
assert.dom('[data-test-input="password').exists(`Password field exists for ${name}`);
assert
.dom('[data-test-input="max_open_connections"]')
.exists(`Max open connections exists for ${name}`);
assert
.dom('[data-test-input="max_idle_connections"]')
.exists(`Max idle connections exists for ${name}`);
assert
.dom('[data-test-input="max_connection_lifetime"]')
.exists(`Max connection lifetime exists for ${name}`);
},
},
];
module('Acceptance | secrets/database/*', function(hooks) {
setupApplicationTest(hooks);
@ -71,6 +103,56 @@ module('Acceptance | secrets/database/*', function(hooks) {
assert.dom('[data-test-secret-list-tab="Roles"]').exists('Has Roles tab');
});
test('Connection create and edit form for each plugin', async function(assert) {
const backend = await mount();
for (let testCase of connectionTests) {
await connectionPage.visitCreate({ backend });
assert.equal(currentURL(), `/vault/secrets/${backend}/create`, 'Correct creation URL');
assert
.dom('[data-test-empty-state-title')
.hasText('No plugin selected', 'No plugin is selected by default and empty state shows');
await connectionPage.dbPlugin(testCase.plugin);
assert.dom('[data-test-empty-state]').doesNotExist('Empty state goes away after plugin selected');
await connectionPage.name(testCase.name);
await connectionPage.url(testCase.url);
testCase.requiredFields(assert, testCase.name);
await connectionPage.toggleVerify();
await connectionPage.save();
await connectionPage.enable();
assert
.dom('[data-test-modal-title]')
.hasText('Rotate your root credentials?', 'Modal appears asking to rotate root credentials');
await connectionPage.enable();
assert.ok(
currentURL().startsWith(`/vault/secrets/${backend}/show/${testCase.name}`),
`Saves connection and takes you to show page for ${testCase.name}`
);
assert
.dom(`[data-test-row-value="Password"]`)
.doesNotExist(`Does not show Password value on show page for ${testCase.name}`);
await connectionPage.edit();
assert.ok(
currentURL().startsWith(`/vault/secrets/${backend}/edit/${testCase.name}`),
`Edit connection button and takes you to edit page for ${testCase.name}`
);
assert.dom(`[data-test-input="name"]`).hasAttribute('readonly');
assert.dom(`[data-test-input="plugin_name"]`).hasAttribute('readonly');
assert.dom('[data-test-input="password"]').doesNotExist('Password is not displayed on edit form');
assert.dom('[data-test-toggle-input="show-password"]').exists('Update password toggle exists');
await connectionPage.toggleVerify();
await connectionPage.save();
// click "Add Role"
await connectionPage.addRole();
await settled();
assert.equal(
searchSelectComponent.selectedOptions[0].text,
testCase.name,
'Database connection is pre-selected on the form'
);
await click('[data-test-secret-breadcrumb]');
}
});
test('Can create and delete a connection', async function(assert) {
const backend = await mount();
const connectionDetails = {
@ -134,70 +216,6 @@ module('Acceptance | secrets/database/*', function(hooks) {
.hasText('No connections in this backend', 'No connections listed because it was deleted');
});
test('Connection edit form happy path works as expected', async function(assert) {
await mount();
const connectionDetails = {
plugin: 'mongodb-database-plugin',
id: 'horses-db',
fields: [
{ label: 'Connection Name', name: 'name', value: 'horses-db' },
{ label: 'Connection url', name: 'connection_url', value: 'mongodb://127.0.0.1:235/horses' },
{ label: 'Username', name: 'username', value: 'user', hideOnShow: true },
{ label: 'Password', name: 'password', password: 'so-secure', hideOnShow: true },
{ label: 'Write concern', name: 'write_concern' },
],
};
await connectionPage.createLink();
await connectionPage.dbPlugin(connectionDetails.plugin);
connectionDetails.fields.forEach(async ({ name, value }) => {
assert
.dom(`[data-test-input="${name}"]`)
.exists(`Field ${name} exists for ${connectionDetails.plugin}`);
if (value) {
await fillIn(`[data-test-input="${name}"]`, value);
}
});
// uncheck verify for the save step to work
await connectionPage.toggleVerify();
await connectionPage.save();
assert
.dom('[data-test-modal-title]')
.hasText('Rotate your root credentials?', 'Modal appears asking to ');
await connectionPage.enable();
connectionDetails.fields.forEach(({ label, name, value, hideOnShow }) => {
if (hideOnShow) {
assert
.dom(`[data-test-row-value="${label}"]`)
.doesNotExist(`Does not show ${name} value on show page for ${connectionDetails.plugin}`);
} else if (!value) {
assert.dom(`[data-test-row-value="${label}"]`).hasText('Default');
} else {
assert.dom(`[data-test-row-value="${label}"]`).hasText(value);
}
});
// go back and edit write_concern
await connectionPage.edit();
assert.dom(`[data-test-input="name"]`).hasAttribute('readonly');
assert.dom(`[data-test-input="plugin_name"]`).hasAttribute('readonly');
// assert password is hidden
assert.dom('[data-test-input="password"]').doesNotExist('Password field is not shown on edit');
findAll('.CodeMirror')[0].CodeMirror.setValue(JSON.stringify({ wtimeout: 5000 }));
// uncheck verify for the save step to work
await connectionPage.toggleVerify();
await connectionPage.save();
assert
.dom(`[data-test-row-value="Write concern"]`)
.hasText('{ "wtimeout": 5000 }', 'Write concern is now showing on the table');
// click "Add Role"
await click('[data-test-secret-create="true"]');
await settled();
assert.equal(
searchSelectComponent.selectedOptions[0].text,
connectionDetails.id,
'Database connection is pre-selected on the form'
);
});
test('buttons show up for managing connection', async function(assert) {
const backend = await mount();
const connection = await newConnection(backend);
@ -275,6 +293,7 @@ path "${backend}/config/*" {
assert
.dom('[data-test-toggle-input="Generated credentialss maximum Time-to-Live (Max TTL)"]')
.exists('Max TTL field exists for dynamic');
// Real connection (actual running db) required to save role, so we aren't testing that flow yet
});
test('root and limited access', async function(assert) {

View File

@ -4,18 +4,35 @@ import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
const STANDARD_FIELDS = [
const testCases = [
{
// default case should show all possible fields for each type
pluginType: '',
staticRoleFields: ['name', 'username', 'rotation_period', 'rotation_statements'],
dynamicRoleFields: [
'name',
'ttl',
'max_ttl',
'username',
'rotation_period',
'creation_statements',
'revocation_statements',
'rotation_statements',
'rollback_statements',
'renew_statements',
],
},
{
pluginType: 'mongodb-database-plugin',
staticRoleFields: ['username', 'rotation_period'],
dynamicRoleFields: ['creation_statement', 'revocation_statement', 'ttl', 'max_ttl'],
statementsHidden: true,
},
{
pluginType: 'mssql-database-plugin',
staticRoleFields: ['username', 'rotation_period'],
dynamicRoleFields: ['creation_statements', 'revocation_statements', 'ttl', 'max_ttl'],
},
];
const MONGODB_STATIC_FIELDS = ['username', 'rotation_period'];
const MONGODB_DYNAMIC_FIELDS = ['creation_statement', 'revocation_statement', 'ttl', 'max_ttl'];
// used to calculate checks that fields do NOT show up
const ALL_ATTRS = [
{ name: 'ttl', type: 'string', options: {} },
{ name: 'max_ttl', type: 'string', options: {} },
@ -26,6 +43,8 @@ const ALL_ATTRS = [
{ name: 'revocation_statements', type: 'string', options: {} },
{ name: 'revocation_statement', type: 'string', options: {} },
{ name: 'rotation_statements', type: 'string', options: {} },
{ name: 'rollback_statements', type: 'string', options: {} },
{ name: 'renew_statements', type: 'string', options: {} },
];
const getFields = nameArray => {
const show = ALL_ATTRS.filter(attr => nameArray.indexOf(attr.name) >= 0);
@ -51,7 +70,7 @@ module('Integration | Component | database-role-setting-form', function(hooks) {
assert.dom('[data-test-component="empty-state"]').exists({ count: 2 }, 'Two empty states exist');
});
test('it shows appropriate fields based on roleType with default db type', async function(assert) {
test('it shows appropriate fields based on roleType and db plugin', async function(assert) {
this.set('roleType', 'static');
this.set('dbType', '');
await render(hbs`
@ -63,68 +82,47 @@ module('Integration | Component | database-role-setting-form', function(hooks) {
/>
`);
assert.dom('[data-test-component="empty-state"]').doesNotExist('Does not show empty states');
const defaultFields = getFields(STANDARD_FIELDS);
defaultFields.show.forEach(attr => {
assert
.dom(`[data-test-input="${attr.name}"]`)
.exists(`${attr.name} attribute exists for default db type`);
});
defaultFields.hide.forEach(attr => {
assert
.dom(`[data-test-input="${attr.name}"]`)
.doesNotExist(`${attr.name} attribute does not exist for default db type`);
});
this.set('roleType', 'dynamic');
defaultFields.show.forEach(attr => {
assert
.dom(`[data-test-input="${attr.name}"]`)
.exists(`${attr.name} attribute exists for default db with dynamic type`);
});
defaultFields.hide.forEach(attr => {
assert
.dom(`[data-test-input="${attr.name}"]`)
.doesNotExist(`${attr.name} attribute does not exist for default db with dynamic type`);
});
});
test('it shows appropriate fields based on roleType with mongodb', async function(assert) {
for (let testCase of testCases) {
let staticFields = getFields(testCase.staticRoleFields);
let dynamicFields = getFields(testCase.dynamicRoleFields);
this.set('dbType', testCase.pluginType);
this.set('roleType', 'static');
this.set('dbType', 'mongodb-database-plugin');
await render(hbs`
<DatabaseRoleSettingForm
@attrs={{model.attrs}}
@model={{model}}
@roleType={{roleType}}
@dbType={{dbType}}
/>
`);
assert.dom('[data-test-component="empty-state"]').doesNotExist('Does not show empty states');
const staticFields = getFields(MONGODB_STATIC_FIELDS);
staticFields.show.forEach(attr => {
assert
.dom(`[data-test-input="${attr.name}"]`)
.exists(`${attr.name} attribute exists for mongodb static role`);
.exists(
`${attr.name} attribute exists on static role for ${testCase.pluginType || 'default'} db type`
);
});
staticFields.hide.forEach(attr => {
assert
.dom(`[data-test-input="${attr.name}"]`)
.doesNotExist(`${attr.name} attribute does not exist for mongodb static role`);
.doesNotExist(
`${attr.name} attribute does not exist on static role for ${testCase.pluginType ||
'default'} db type`
);
});
if (testCase.statementsHidden) {
assert
.dom('[data-test-statements-section]')
.doesNotExist('Statements section is hidden for dynamic mongodb role');
.doesNotExist(`Statements section is hidden for static ${testCase.pluginType} role`);
}
this.set('roleType', 'dynamic');
const dynamicFields = getFields(MONGODB_DYNAMIC_FIELDS);
dynamicFields.show.forEach(attr => {
assert
.dom(`[data-test-input="${attr.name}"]`)
.exists(`${attr.name} attribute exists for mongodb dynamic role`);
.exists(
`${attr.name} attribute exists on dynamic role for ${testCase.pluginType || 'default'} db type`
);
});
dynamicFields.hide.forEach(attr => {
assert
.dom(`[data-test-input="${attr.name}"]`)
.doesNotExist(`${attr.name} attribute does not exist for mongodb dynamic role`);
});
.doesNotExist(
`${attr.name} attribute does not exist on dynamic role for ${testCase.pluginType ||
'default'} db type`
);
});
}
});
});

View File

@ -12,6 +12,7 @@ export default create({
toggleVerify: clickable('[data-test-input="verify_connection"]'),
url: fillable('[data-test-input="connection_url"'),
save: clickable('[data-test-secret-save=""]'),
addRole: clickable('[data-test-secret-create="true"]'), // only from connection show
enable: clickable('[data-test-enable-connection=""]'),
edit: clickable('[data-test-edit-link="true"]'),
delete: clickable('[data-test-database-connection-delete]'),

View File

@ -9972,6 +9972,13 @@ ember-power-select@^2.0.0:
ember-text-measurer "^0.5.0"
ember-truth-helpers "^2.1.0"
ember-promise-helpers@^1.0.9:
version "1.0.9"
resolved "https://registry.yarnpkg.com/ember-promise-helpers/-/ember-promise-helpers-1.0.9.tgz#4559dd4a09be1a2725eddd7146169d2d53a92e7a"
integrity sha512-BDcx2X28baEL0YMKSmDGQzvdyfiFSlx4Byf34hVrAw6W7VO17YYLjeXPebVBOXLA1BMWmUo0Bb2pZRZ09MQYYA==
dependencies:
ember-cli-babel "^6.16.0"
ember-qunit@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/ember-qunit/-/ember-qunit-4.6.0.tgz#ad79fd3ff00073a8779400cc5a4b44829517590f"