Restructure SSH and AWS configuration screens (#27831)

* setup the toggle to display mount configuration options

* whew.. getting there. aws only, borked for ssh

* another round, better than before

* masked things

* changelog

* fix broken oss test

* move to component

* handle ssh things and cleanup

* wip test coverage

* test coverage for the component

* copywrite header miss

* update no model error

* setup configuration aws acceptance tests

* update CONFIURABLE_SECRET_ENGINES

* acceptance tests for aws

* ssh configuration

* clean up

* remove comment

* move to confirm model before destructuring

* pr comments

* fix check for ssh config error

* add message check in api error test

* pr comments
This commit is contained in:
Angel Garbarino 2024-07-29 19:52:42 -06:00 committed by GitHub
parent d75aee21b8
commit 1f982bf13a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 559 additions and 24 deletions

3
changelog/27831.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:improvement
ui: For AWS and SSH secret engines hide mount configuration details in toggle and display configuration details or cta.
```

View file

@ -0,0 +1,20 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationAdapter from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default class AwsRootConfig extends ApplicationAdapter {
namespace = 'v1';
// For now this is only being used on the vault.cluster.secrets.backend.configuration route. This is a read-only route.
// Eventually, this will be used to create the root config for the AWS secret backend, replacing the requests located on the secret-engine adapter.
queryRecord(store, type, query) {
const { backend } = query;
return this.ajax(`${this.buildURL()}/${encodePath(backend)}/config/root`, 'GET').then((resp) => {
resp.id = backend;
return resp;
});
}
}

View file

@ -0,0 +1,20 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationAdapter from '../application';
import { encodePath } from 'vault/utils/path-encoding-helpers';
export default class SshCaConfig extends ApplicationAdapter {
namespace = 'v1';
// For now this is only being used on the vault.cluster.secrets.backend.configuration route. This is a read-only route.
// Eventually, this will be used to create the ca config for the SSH secret backend, replacing the requests located on the secret-engine adapter.
queryRecord(store, type, query) {
const { backend } = query;
return this.ajax(`${this.buildURL()}/${encodePath(backend)}/config/ca`, 'GET').then((resp) => {
resp.id = backend;
return resp;
});
}
}

View file

@ -0,0 +1,44 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
{{#if this.configError}}
{{! Surface API errors not associated with empty configuration details }}
<Page::Error @error={{this.configError}} />
{{else if this.configModel}}
{{#each this.configModel.attrs as |attr|}}
{{#if attr.options.sensitive}}
<InfoTableRow
alwaysRender={{not (is-empty-value (get @model attr.name))}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{get this.configModel (or attr.options.fieldValue attr.name)}}
>
{{#if attr.options.sensitive}}
<MaskedInput @value={{get @model attr.name}} @name={{attr.name}} @displayOnly={{true}} @allowCopy={{true}} />
{{/if}}
</InfoTableRow>
{{else}}
<InfoTableRow
@alwaysRender={{not (is-empty-value (get @model attr.name))}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{get this.configModel (or attr.options.fieldValue attr.name)}}
/>
{{/if}}
{{/each}}
{{else}}
{{! Prompt for a user to configure the secret engine }}
<EmptyState
data-test-config-cta
@title="{{this.typeDisplay}} not configured"
@message="Get started by configuring your {{this.typeDisplay}} engine."
>
<Hds::Link::Standalone
@icon="chevron-right"
@iconPosition="trailing"
@text="Configure {{this.typeDisplay}}"
@route="vault.cluster.settings.configure-secret-backend"
@model={{@model.id}}
/>
</EmptyState>
{{/if}}

View file

@ -0,0 +1,94 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { service } from '@ember/service';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { allEngines } from 'vault/helpers/mountable-secret-engines';
import type Store from '@ember-data/store';
import type SecretEngineModel from 'vault/models/secret-engine';
import type AdapterError from '@ember-data/adapter';
import type Model from '@ember-data/model';
/**
* @module ConfigurationDetails
* `ConfigurationDetails` is used by configurable secret engines (AWS, SSH) to show either an API error, configuration details, or a prompt to configure the engine. Which of these is shown is determined by the engine type and whether the user has configured the engine yet.
*
* @example
* ```js
* <SecretEngine::ConfigurationDetails @model={{this.model}} />
* ```
*
* @param {object} model - The secret-engine model to be configured.
*/
interface Args {
model: SecretEngineModel | null;
}
interface ConfigError {
httpStatus: number | null;
message: string | null;
errors: object | null;
}
export default class ConfigurationDetails extends Component<Args> {
@service declare readonly store: Store;
@tracked configError: ConfigError | null = null;
@tracked configModel: Model | null = null;
constructor(owner: unknown, args: Args) {
super(owner, args);
const { model } = this.args;
// Should not be able to get here without a model, but in case an upstream change allows it, handle the error higher up.
if (!model) {
return;
}
const { id, type } = model;
// Fetch the config for the engine type.
switch (type) {
case 'aws':
this.fetchAwsRootConfig(id);
break;
case 'ssh':
this.fetchSshCaConfig(id);
break;
}
}
async fetchAwsRootConfig(backend: string) {
try {
this.configModel = await this.store.queryRecord('aws/root-config', { backend });
} catch (e: AdapterError) {
// If the error is something other than 404 "not found" then an API error has come back and this will be displayed to the user as an error.
// If it's 404 then configError is not set nor is the configModel and a prompt to configure will be shown.
if (e.httpStatus !== 404) {
this.configError = e;
}
return;
}
}
async fetchSshCaConfig(backend: string) {
try {
this.configModel = await this.store.queryRecord('ssh/ca-config', { backend });
} catch (e: AdapterError) {
// The SSH api does not return a 404 not found but a 400 error after first mounting the engine with the
// message that keys have not been configured yet.
// We need to check the message of the 400 error and if it's the keys message, return a prompt instead of a configError.
if (e.httpStatus !== 404 && e.errors[0] !== `keys haven't been configured yet`) {
this.configError = e;
}
return;
}
}
get typeDisplay() {
if (!this.args.model) return;
const { type } = this.args.model;
return allEngines().find((engine) => engine.type === type)?.displayName;
}
}

View file

@ -0,0 +1,32 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
export default class AwsRootConfig extends Model {
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
@attr('string') accessKey;
@attr('string') region;
@attr('string', {
label: 'IAM endpoint',
})
iamEndpoint;
@attr('string', {
label: 'STS endpoint',
})
stsEndpoint;
@attr('number', {
defaultValue: -1,
label: 'Maximum retries',
subText: 'Number of max retries the client should use for recoverable errors. Default is -1.',
})
maxRetries;
// there are more options available on the API, but the UI does not support them yet.
get attrs() {
const keys = ['accessKey', 'region', 'iamEndpoint', 'stsEndpoint', 'maxRetries'];
return expandAttributeMeta(this, keys);
}
}

View file

@ -0,0 +1,20 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
import { expandAttributeMeta } from 'vault/utils/field-to-attrs';
export default class SshCaConfig extends Model {
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord
@attr('string', { sensitive: true }) privateKey; // obfuscated, never returned by API
@attr('string', { sensitive: true }) publicKey;
@attr('boolean', { defaultValue: true })
generateSigningKey;
// there are more options available on the API, but the UI does not support them yet.
get attrs() {
const keys = ['publicKey', 'generateSigningKey'];
return expandAttributeMeta(this, keys);
}
}

View file

@ -26,23 +26,28 @@
</ToolbarLink>
</ToolbarActions>
</Toolbar>
{{/if}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.model.attrs as |attr|}}
{{#if (eq attr.type "object")}}
<InfoTableRow
@alwaysRender={{not (is-empty-value (get this.model attr.name))}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{stringify (get this.model (or attr.options.fieldValue attr.name))}}
/>
{{else}}
<InfoTableRow
@alwaysRender={{and (not (is-empty-value (get this.model attr.name))) (not-eq attr.name "version")}}
@formatTtl={{eq attr.options.editType "ttl"}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{get this.model (or attr.options.fieldValue attr.name)}}
/>
{{/if}}
{{/each}}
</div>
<SecretEngine::ConfigurationDetails @model={{this.model}} />
<SecretsEngineMountConfig @model={{this.model}} class="has-top-margin-xl has-bottom-margin-xl" data-test-mount-config />
{{else}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
{{#each this.model.attrs as |attr|}}
{{#if (eq attr.type "object")}}
<InfoTableRow
@alwaysRender={{not (is-empty-value (get this.model attr.name))}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{stringify (get this.model (or attr.options.fieldValue attr.name))}}
/>
{{else}}
<InfoTableRow
@alwaysRender={{and (not (is-empty-value (get this.model attr.name))) (not-eq attr.name "version")}}
@formatTtl={{eq attr.options.editType "ttl"}}
@label={{or attr.options.label (to-label attr.name)}}
@value={{get this.model (or attr.options.fieldValue attr.name)}}
/>
{{/if}}
{{/each}}
</div>
{{/if}}

View file

@ -8,17 +8,17 @@
data-test-page-error
>
{{#if (eq @error.httpStatus 404)}}
<h1 class="title is-3 has-text-grey-light">
<h1 class="title is-3 has-text-grey-light" data-test-page-error-title={{@error.httpStatus}}>
404 Not Found
</h1>
<p>Sorry, we were unable to find any content at <code>{{@error.path}}</code>.</p>
{{else if (eq @error.httpStatus 403)}}
<h1 class="title is-3 has-text-grey-light">
<h1 class="title is-3 has-text-grey-light" data-test-page-error-title={{@error.httpStatus}}>
Not authorized
</h1>
<p>You are not authorized to access content at <code>{{@error.path}}</code>.</p>
{{else}}
<h1 class="title is-3 has-text-grey-light">
<h1 class="title is-3 has-text-grey-light" data-test-page-error-title={{@error.httpStatus}}>
Error
</h1>
<p>

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, fillIn, currentURL } from '@ember/test-helpers';
import { click, fillIn, visit, currentURL } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
@ -15,6 +15,11 @@ import { setupMirage } from 'ember-cli-mirage/test-support';
import { runCmd } from 'vault/tests/helpers/commands';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
import {
createConfig,
expectedConfigKeys,
expectedValueOfConfigKeys,
} from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
module('Acceptance | aws | configuration', function (hooks) {
setupApplicationTest(hooks);
@ -30,6 +35,20 @@ module('Acceptance | aws | configuration', function (hooks) {
return authPage.login();
});
test('it should prompt configuration after mounting the aws engine', async function (assert) {
const path = `aws-${this.uid}`;
// in this test go through the full mount process. Bypass this step in later tests.
await visit('/vault/settings/mount-secret-backend');
await click(SES.mountType('aws'));
await fillIn(GENERAL.inputByAttr('path'), path);
await click(SES.mountSubmit);
await click(SES.configTab);
assert.dom(GENERAL.emptyStateTitle).hasText('AWS not configured');
assert.dom(GENERAL.emptyStateActions).hasText('Configure AWS');
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it should transition to configure page on Configure click from toolbar', async function (assert) {
const path = `aws-${this.uid}`;
await enablePage.enable('aws', path);
@ -93,4 +112,62 @@ module('Acceptance | aws | configuration', function (hooks) {
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it show AWS configuration details', async function (assert) {
assert.expect(12);
const path = `aws-${this.uid}`;
const type = 'aws';
this.server.get(`${path}/config/root`, (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.ok(true, 'request made to config/root when navigating to the configuration page.');
return { data: { id: path, type, attributes: payload } };
});
await enablePage.enable(type, path);
createConfig(this.store, path, type); // create the aws root config in the store
await click(SES.configTab);
for (const key of expectedConfigKeys(type)) {
assert.dom(GENERAL.infoRowLabel(key)).exists(`${key} on the ${type} config details exists.`);
const responseKeyAndValue = expectedValueOfConfigKeys(type, key);
assert
.dom(GENERAL.infoRowValue(key))
.hasText(responseKeyAndValue, `value for ${key} on the ${type} config details exists.`);
}
// check mount configuration details are present and accurate.
await click(SES.configurationToggle);
assert
.dom(GENERAL.infoRowValue('Path'))
.hasText(`${path}/`, 'mount path is displayed in the configuration details');
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
test('it should update AWS configuration details after editing', async function (assert) {
assert.expect(4);
const path = `aws-${this.uid}`;
const type = 'aws';
await enablePage.enable(type, path);
// create accessKey with value foo and confirm it shows up in the details page.
await click(SES.configTab);
await click(SES.configure);
await fillIn(GENERAL.inputByAttr('accessKey'), 'foo');
await click(GENERAL.saveButtonId('root'));
await click(SES.viewBackend);
await click(SES.configTab);
assert.dom(GENERAL.infoRowValue('Access key')).hasText('foo', 'Access key is foo');
assert
.dom(GENERAL.infoRowValue('Region'))
.doesNotExist('Region has not been added therefor it does not show up on the details view.');
// edit accessKey and another field and confirm the details page is updated.
await click(SES.configure);
await fillIn(GENERAL.inputByAttr('accessKey'), 'hello');
await click(GENERAL.menuTrigger);
await fillIn(GENERAL.selectByAttr('region'), 'ca-central-1');
await click(GENERAL.saveButtonId('root'));
await click(SES.viewBackend);
await click(SES.configTab);
assert.dom(GENERAL.infoRowValue('Access key')).hasText('hello', 'Access key has been updated to hello');
assert.dom(GENERAL.infoRowValue('Region')).hasText('ca-central-1', 'Region has been added');
// cleanup
await runCmd(`delete sys/mounts/${path}`);
});
});

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { click, currentURL, waitFor } from '@ember/test-helpers';
import { click, fillIn, currentURL, waitFor, visit } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { v4 as uuidv4 } from 'uuid';
@ -11,6 +11,7 @@ import { v4 as uuidv4 } from 'uuid';
import authPage from 'vault/tests/pages/auth';
import enablePage from 'vault/tests/pages/settings/mount-secret-backend';
import { runCmd } from 'vault/tests/helpers/commands';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
module('Acceptance | ssh | configuration', function (hooks) {
@ -21,6 +22,20 @@ module('Acceptance | ssh | configuration', function (hooks) {
return authPage.login();
});
test('it should prompt configuration after mounting ssh engine', async function (assert) {
const sshPath = `ssh-${this.uid}`;
// in this test go through the full mount process. Bypass this step in later tests.
await visit('/vault/settings/mount-secret-backend');
await click(SES.mountType('ssh'));
await fillIn(GENERAL.inputByAttr('path'), sshPath);
await click(SES.mountSubmit);
await click(SES.configTab);
assert.dom(GENERAL.emptyStateTitle).hasText('SSH not configured');
assert.dom(GENERAL.emptyStateActions).hasText('Configure SSH');
// cleanup
await runCmd(`delete sys/mounts/${sshPath}`);
});
test('it should show a public key after saving default configuration', async function (assert) {
const sshPath = `ssh-${this.uid}`;
await enablePage.enable('ssh', sshPath);
@ -44,6 +59,15 @@ module('Acceptance | ssh | configuration', function (hooks) {
await waitFor(SES.ssh.sshInput('public-key'));
assert.dom(SES.ssh.sshInput('public-key')).exists('renders the public key input on form page');
assert.dom(SES.ssh.sshInput('public-key')).hasClass('masked-input', 'public key is masked');
await click(SES.viewBackend);
await click(SES.configTab);
assert
.dom(`[data-test-value-div="Public key"] [data-test-masked-input]`)
.hasText('***********', 'value for Public key is on config details and is masked');
assert
.dom(GENERAL.infoRowValue('Generate signing key'))
.hasText('Yes', 'value for Generate signing key displays default of true/yes.');
// cleanup
await runCmd(`delete sys/mounts/${sshPath}`);
});

View file

@ -50,6 +50,12 @@ export const GENERAL = {
validation: (attr: string) => `[data-test-field-validation=${attr}]`,
validationWarning: (attr: string) => `[data-test-validation-warning=${attr}]`,
messageError: '[data-test-message-error]',
pageError: {
error: '[data-test-page-error]',
errorTitle: (httpStatus: number) => `[data-test-page-error-title="${httpStatus}"]`,
errorMessage: '[data-test-page-error-message]',
errorDetails: '[data-test-page-error-details]',
},
kvObjectEditor: {
deleteRow: (idx = 0) => `[data-test-kv-delete-row="${idx}"]`,
},

View file

@ -0,0 +1,107 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export const createSecretsEngine = (store, type, path) => {
store.pushPayload('secret-engine', {
modelName: 'secret-engine',
id: path,
path: `${path}/`,
type: type,
data: {
type: type,
},
});
return store.peekRecord('secret-engine', path);
};
const createAwsRootConfig = (store, backend) => {
store.pushPayload('aws/root-config', {
id: backend,
modelName: 'aws/root-config',
data: {
backend: backend,
region: 'us-west-2',
access_key: '123-key',
iam_endpoint: 'iam-endpoint',
sts_endpoint: 'sts-endpoint',
},
});
return store.peekRecord('aws/root-config', backend);
};
const createSshCaConfig = (store, backend) => {
store.pushPayload('ssh/ca-config', {
id: backend,
modelName: 'ssh/ca-config',
data: {
backend: backend,
public_key: 'public-key',
generate_signing_key: true,
},
});
return store.peekRecord('ssh/ca-config', backend);
};
export function configUrl(type, backend) {
switch (type) {
case 'aws':
return `${backend}/config/root`;
case 'ssh':
return `/${backend}/config/ca`;
default:
return `/${backend}/config`;
}
}
// send the type of config you want and the name of the backend path to push the config to the store.
export const createConfig = (store, backend, type) => {
switch (type) {
case 'aws':
return createAwsRootConfig(store, backend);
case 'ssh':
return createSshCaConfig(store, backend);
}
};
// Used in tests to assert the expected keys in the config details of configurable secret engines
export const expectedConfigKeys = (type) => {
switch (type) {
case 'aws':
return ['Access key', 'Region', 'IAM endpoint', 'STS endpoint', 'Maximum retries'];
case 'ssh':
return ['Public key', 'Generate signing key'];
}
};
const valueOfAwsKeys = (string) => {
switch (string) {
case 'Access key':
return '123-key';
case 'Region':
return 'us-west-2';
case 'IAM endpoint':
return 'iam-endpoint';
case 'STS endpoint':
return 'sts-endpoint';
case 'Maximum retries':
return '-1';
}
};
const valueOfSshKeys = (string) => {
switch (string) {
case 'Public key':
return '***********';
case 'Generate signing key':
return 'Yes';
}
};
// Used in tests to assert the expected values in the config details of configurable secret engines
export const expectedValueOfConfigKeys = (type, string) => {
switch (type) {
case 'aws':
return valueOfAwsKeys(string);
case 'ssh':
return valueOfSshKeys(string);
}
};

View file

@ -0,0 +1,82 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { overrideResponse, allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { CONFIGURABLE_SECRET_ENGINES } from 'vault/helpers/mountable-secret-engines';
import {
createSecretsEngine,
createConfig,
configUrl,
expectedConfigKeys,
expectedValueOfConfigKeys,
} from 'vault/tests/helpers/secret-engine/secret-engine-helpers';
module('Integration | Component | SecretEngine::configuration-details', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
this.store = this.owner.lookup('service:store');
});
test('it shows prompt message if no config is returned', async function (assert) {
assert.expect(CONFIGURABLE_SECRET_ENGINES.length * 2);
for (const type of CONFIGURABLE_SECRET_ENGINES) {
const title = type.toUpperCase();
const backend = `test-404-${type}`;
this.model = createSecretsEngine(this.store, type, backend);
this.server.get(configUrl(type, backend), () => {
return overrideResponse(404);
});
await render(hbs`<SecretEngine::ConfigurationDetails @model={{this.model}}/>`);
assert.dom(GENERAL.emptyStateTitle).hasText(`${title} not configured`);
assert.dom(GENERAL.emptyStateMessage).hasText(`Get started by configuring your ${title} engine.`);
}
});
test('it shows API error', async function (assert) {
assert.expect(CONFIGURABLE_SECRET_ENGINES.length * 2);
for (const type of CONFIGURABLE_SECRET_ENGINES) {
const backend = `test-400-${type}`;
this.model = createSecretsEngine(this.store, type, backend);
this.server.get(configUrl(type, backend), () => {
return overrideResponse(400, { errors: ['bad request'] });
});
await render(hbs`<SecretEngine::ConfigurationDetails @model={{this.model}}/>`);
assert.dom(GENERAL.pageError.errorTitle(400)).hasText('Error');
assert.dom(GENERAL.pageError.errorDetails).hasText('bad request');
}
});
test('it shows config details if config data is returned', async function (assert) {
assert.expect(14);
for (const type of CONFIGURABLE_SECRET_ENGINES) {
const backend = `test-${type}`;
this.model = createSecretsEngine(this.store, type, backend);
createConfig(this.store, backend, type);
this.server.get(configUrl(type, backend), () => {
return overrideResponse(200);
});
await render(hbs`<SecretEngine::ConfigurationDetails @model={{this.model}}/>`);
for (const key of expectedConfigKeys(type)) {
assert.dom(GENERAL.infoRowLabel(key)).exists(`${key} on the ${type} config details exists.`);
const responseKeyAndValue = expectedValueOfConfigKeys(type, key);
assert
.dom(GENERAL.infoRowValue(key))
.hasText(responseKeyAndValue, `${key} value for the ${type} config details exists.`);
}
}
});
});

View file

@ -9,6 +9,7 @@ import type { ModelValidations, FormField, FormFieldGroups } from 'vault/app-typ
import type MountConfigModel from 'vault/models/mount-config';
export default class SecretEngineModel extends Model {
id: string;
path: string;
type: string;
description: string;