UI/database mssql (#11231)
Add MSSQL plugin support in database secrets engine
This commit is contained in:
parent
cc107171e2
commit
a3c396991c
|
@ -0,0 +1,3 @@
|
|||
```release-note:feature
|
||||
ui: Add database secret engine support for MSSQL
|
||||
```
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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);
|
||||
}),
|
||||
|
|
|
@ -312,3 +312,7 @@ label {
|
|||
position: absolute;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
fieldset.form-fieldset {
|
||||
border: none;
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 credentials’s 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) {
|
||||
|
|
|
@ -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`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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]'),
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue