mirror of
https://github.com/hashicorp/vault.git
synced 2026-06-08 16:24:51 -04:00
* adds error handling for control groups to api service as post request middleware * updates kv list route to use api service * updates kv config route to use api service * adds waitFor to async middleware in api service to attempt to fix race conditions in tests * adds kvMetadata path to capabilities path map * fixes kv list item delete test selector Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
This commit is contained in:
parent
2906d02959
commit
425e80a933
17 changed files with 352 additions and 257 deletions
|
|
@ -97,6 +97,7 @@ export default class App extends Application {
|
|||
kv: {
|
||||
dependencies: {
|
||||
services: [
|
||||
'api',
|
||||
'capabilities',
|
||||
'control-group',
|
||||
'download',
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const PATH_MAP = {
|
|||
syncSetAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/set`,
|
||||
syncRemoveAssociation: apiPath`sys/sync/destinations/${'type'}/${'name'}/associations/remove`,
|
||||
kvConfig: apiPath`${'path'}/config`,
|
||||
kvMetadata: apiPath`${'backend'}/metadata/${'path'}`,
|
||||
authMethodConfig: apiPath`auth/${'path'}/config`,
|
||||
authMethodConfigAws: apiPath`auth/${'path'}/config/client`,
|
||||
authMethodDelete: apiPath`sys/auth/${'path'}`,
|
||||
|
|
|
|||
|
|
@ -21,11 +21,9 @@ import { task, timeout } from 'ember-concurrency';
|
|||
* route will reload the model and completely refresh the page.
|
||||
* *
|
||||
* <KvListFilter
|
||||
* @secrets={{this.model.secrets}}
|
||||
* @mountPoint={{this.model.mountPoint}}
|
||||
* @filterValue="beep/my-"
|
||||
* />
|
||||
* @param {array} secrets - An array of secret models.
|
||||
* @param {string} mountPoint - Where in the router files we're located. For this component it will always be vault.cluster.secrets.backend.kv
|
||||
* @param {string} filterValue - Full initial search value. A concatenation between the list-directory's dynamic path "path-to-secret" and the queryParam "pageFilter". For example, if we're inside the beep/ directory searching for any secret that starts with "my-" this value will equal "beep/my-".
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -3,35 +3,20 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @mountName={{@mountConfig.id}}>
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @mountName={{@backend}}>
|
||||
<:tabLinks>
|
||||
<li><LinkTo @route="list" data-test-secrets-tab="Secrets">Secrets</LinkTo></li>
|
||||
<li><LinkTo @route="configuration" data-test-secrets-tab="Configuration">Configuration</LinkTo></li>
|
||||
</:tabLinks>
|
||||
</KvPageHeader>
|
||||
|
||||
{{! engine configuration }}
|
||||
{{#if @engineConfig.canRead}}
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
|
||||
{{#each @engineConfig.formFields as |attr|}}
|
||||
<InfoTableRow
|
||||
@alwaysRender={{true}}
|
||||
@label={{or attr.options.label (to-label attr.name)}}
|
||||
@value={{if (eq attr.name "deleteVersionAfter") @engineConfig.displayDeleteTtl (get @engineConfig attr.name)}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{! mount configuration }}
|
||||
<div class="box is-fullwidth is-sideless is-paddingless is-marginless">
|
||||
{{#each @mountConfig.attrs as |attr|}}
|
||||
{{#if (not (includes attr.name @engineConfig.displayFields))}}
|
||||
<InfoTableRow
|
||||
@formatTtl={{eq attr.options.editType "ttl"}}
|
||||
@label={{or attr.options.label (to-label attr.name)}}
|
||||
@value={{get @mountConfig (or attr.options.fieldValue attr.name)}}
|
||||
/>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{#each-in @config as |key value|}}
|
||||
<InfoTableRow
|
||||
@alwaysRender={{true}}
|
||||
@formatTtl={{includes key (array "default_lease_ttl" "max_lease_ttl")}}
|
||||
@label={{this.label key}}
|
||||
@value={{this.value key value}}
|
||||
/>
|
||||
{{/each-in}}
|
||||
</div>
|
||||
40
ui/lib/kv/addon/components/page/configuration.js
Normal file
40
ui/lib/kv/addon/components/page/configuration.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { toLabel } from 'core/helpers/to-label';
|
||||
import { duration } from 'core/helpers/format-duration';
|
||||
|
||||
/**
|
||||
* @module KvConfigPageComponent
|
||||
* KvConfigPageComponent is a component to show secrets mount and engine configuration data
|
||||
*
|
||||
* @param {object} config - config data for mount and engine
|
||||
* @param {string} backend - The name of the kv secret engine.
|
||||
* @param {array} breadcrumbs - Breadcrumbs as an array of objects that contain label, route, and modelId. They are updated via the util kv-breadcrumbs to handle dynamic *pathToSecret on the list-directory route.
|
||||
*/
|
||||
|
||||
export default class KvConfigPageComponent extends Component {
|
||||
label = (key) => {
|
||||
const label = toLabel([key]);
|
||||
// map specific fields to custom labels
|
||||
return (
|
||||
{
|
||||
cas_required: 'Require check and set',
|
||||
delete_version_after: 'Automate secret deletion',
|
||||
max_versions: 'Maximum number of versions',
|
||||
default_lease_ttl: 'Default Lease TTL',
|
||||
max_lease_ttl: 'Max Lease TTL',
|
||||
}[key] || label
|
||||
);
|
||||
};
|
||||
|
||||
value = (key, value) => {
|
||||
if (key === 'delete_version_after') {
|
||||
return value === '0s' ? 'Never delete' : duration([value]);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
}
|
||||
|
|
@ -19,9 +19,8 @@
|
|||
</:tabLinks>
|
||||
|
||||
<:toolbarFilters>
|
||||
|
||||
{{#if (and (not-eq @secrets 403) (or @secrets @filterValue))}}
|
||||
<KvListFilter @secrets={{@secrets}} @mountPoint={{this.mountPoint}} @filterValue={{@filterValue}} />
|
||||
<KvListFilter @mountPoint={{this.mountPoint}} @filterValue={{@filterValue}} />
|
||||
{{/if}}
|
||||
</:toolbarFilters>
|
||||
|
||||
|
|
@ -37,6 +36,7 @@
|
|||
</ToolbarLink>
|
||||
</:toolbarActions>
|
||||
</KvPageHeader>
|
||||
|
||||
{{#if (eq @secrets 403)}}
|
||||
<div class="box is-fullwidth is-shadowless has-tall-padding">
|
||||
<div class="selectable-card-container one-card">
|
||||
|
|
@ -73,59 +73,80 @@
|
|||
</div>
|
||||
{{else}}
|
||||
{{#if @secrets}}
|
||||
{{#each @secrets as |metadata|}}
|
||||
<LinkedBlock
|
||||
data-test-list-item={{metadata.path}}
|
||||
class="list-item-row"
|
||||
@params={{array (if metadata.pathIsDirectory "list-directory" "secret.index") @backend metadata.fullSecretPath}}
|
||||
@linkPrefix={{this.mountPoint}}
|
||||
>
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div>
|
||||
<Icon @name={{if metadata.pathIsDirectory "folder" "file"}} class="has-text-grey-light" />
|
||||
<span class="has-text-weight-semibold is-underline" data-test-path>
|
||||
{{metadata.path}}
|
||||
</span>
|
||||
{{#each @secrets as |secretPath|}}
|
||||
{{#let (this.isDirectory secretPath) (this.fullSecretPath secretPath) as |isDir fullPath|}}
|
||||
<LinkedBlock
|
||||
data-test-list-item={{secretPath}}
|
||||
class="list-item-row"
|
||||
@params={{array (if isDir "list-directory" "secret.index") @backend fullPath}}
|
||||
@linkPrefix={{this.mountPoint}}
|
||||
>
|
||||
<div class="level is-mobile">
|
||||
<div class="level-left">
|
||||
<div>
|
||||
<Icon @name={{if isDir "folder" "file"}} class="has-text-grey-light" />
|
||||
<span class="has-text-weight-semibold is-underline" data-test-path>
|
||||
{{secretPath}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="level-right is-flex is-paddingless is-marginless">
|
||||
<div class="level-item">
|
||||
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
|
||||
<dd.ToggleIcon
|
||||
@icon="more-horizontal"
|
||||
@text="Manage secret"
|
||||
@hasChevron={{false}}
|
||||
data-test-popup-menu-trigger
|
||||
/>
|
||||
{{#if metadata.pathIsDirectory}}
|
||||
<dd.Interactive
|
||||
@route="list-directory"
|
||||
@models={{array @backend metadata.fullSecretPath}}
|
||||
>Content</dd.Interactive>
|
||||
{{else}}
|
||||
<dd.Interactive
|
||||
@route="secret.index"
|
||||
@models={{array @backend metadata.fullSecretPath}}
|
||||
>Overview</dd.Interactive>
|
||||
<dd.Interactive @route="secret.details" @models={{array @backend metadata.fullSecretPath}}>Secret data</dd.Interactive>
|
||||
{{#if metadata.canReadMetadata}}
|
||||
<dd.Interactive @route="secret.metadata.versions" @models={{array @backend metadata.fullSecretPath}}>
|
||||
View version history</dd.Interactive>
|
||||
{{/if}}
|
||||
{{#if metadata.canDeleteMetadata}}
|
||||
<div class="level-right is-flex is-paddingless is-marginless">
|
||||
<div class="level-item">
|
||||
<Hds::Dropdown @isInline={{true}} @listPosition="bottom-right" as |dd|>
|
||||
<dd.ToggleIcon
|
||||
@icon="more-horizontal"
|
||||
@text="Manage secret"
|
||||
@hasChevron={{false}}
|
||||
data-test-popup-menu-trigger
|
||||
/>
|
||||
{{#if isDir}}
|
||||
<dd.Interactive
|
||||
@color="critical"
|
||||
{{on "click" (fn (mut this.metadataToDelete) metadata)}}
|
||||
data-test-popup-metadata-delete
|
||||
>Permanently delete</dd.Interactive>
|
||||
@route="list-directory"
|
||||
@models={{array @backend fullPath}}
|
||||
data-test-list-menu-item="Content"
|
||||
>
|
||||
Content
|
||||
</dd.Interactive>
|
||||
{{else}}
|
||||
<dd.Interactive
|
||||
@route="secret.index"
|
||||
@models={{array @backend fullPath}}
|
||||
data-test-list-menu-item="Overview"
|
||||
>
|
||||
Overview
|
||||
</dd.Interactive>
|
||||
<dd.Interactive
|
||||
@route="secret.details"
|
||||
@models={{array @backend fullPath}}
|
||||
data-test-list-menu-item="Secret data"
|
||||
>
|
||||
Secret data
|
||||
</dd.Interactive>
|
||||
{{#if @capabilities.canRead}}
|
||||
<dd.Interactive
|
||||
@route="secret.metadata.versions"
|
||||
@models={{array @backend fullPath}}
|
||||
data-test-list-menu-item="View version history"
|
||||
>
|
||||
View version history
|
||||
</dd.Interactive>
|
||||
{{/if}}
|
||||
{{#if @capabilities.canDelete}}
|
||||
<dd.Interactive
|
||||
@color="critical"
|
||||
{{on "click" (fn (mut this.metadataToDelete) secretPath)}}
|
||||
data-test-list-menu-item="Permanently delete"
|
||||
>
|
||||
Permanently delete
|
||||
</dd.Interactive>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</Hds::Dropdown>
|
||||
</Hds::Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LinkedBlock>
|
||||
</LinkedBlock>
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
{{#if this.metadataToDelete}}
|
||||
<ConfirmModal
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import { action } from '@ember/object';
|
|||
import { tracked } from '@glimmer/tracking';
|
||||
import { getOwner } from '@ember/owner';
|
||||
import { ancestorKeysForKey } from 'core/utils/key-utils';
|
||||
import errorMessage from 'vault/utils/error-message';
|
||||
import { pathIsDirectory } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
/**
|
||||
|
|
@ -22,16 +21,23 @@ import { pathIsDirectory } from 'kv/utils/kv-breadcrumbs';
|
|||
* @param {string} filterValue - The concatenation of the pathToSecret and pageFilter ex: beep/boop/my-
|
||||
* @param {boolean} failedDirectoryQuery - true if the query was a 403 and the search was for a directory. Used to display inline alert message on the overview card.
|
||||
* @param {array} breadcrumbs - Breadcrumbs as an array of objects that contain label, route, and modelId. They are updated via the util kv-breadcrumbs to handle dynamic *pathToSecret on the list-directory route.
|
||||
* @param {object} capabilities - capabilities for metadata path
|
||||
*/
|
||||
|
||||
export default class KvListPageComponent extends Component {
|
||||
@service flashMessages;
|
||||
@service('app-router') router;
|
||||
@service pagination;
|
||||
@service api;
|
||||
|
||||
@tracked secretPath;
|
||||
@tracked metadataToDelete = null; // set to the metadata intended to delete
|
||||
|
||||
// used for KV list and list-directory view
|
||||
// ex: beep/
|
||||
isDirectory = (path) => pathIsDirectory(path);
|
||||
fullSecretPath = (secret) => `${this.args.pathToSecret}${secret}`;
|
||||
|
||||
get mountPoint() {
|
||||
// mountPoint tells transition where to start. In this case, mountPoint will always be vault.cluster.secrets.backend.kv.
|
||||
return getOwner(this).mountPoint;
|
||||
|
|
@ -53,16 +59,15 @@ export default class KvListPageComponent extends Component {
|
|||
}
|
||||
|
||||
@action
|
||||
async onDelete(model) {
|
||||
async onDelete(secretPath) {
|
||||
try {
|
||||
// The model passed in is a kv/metadata model
|
||||
await model.destroyRecord();
|
||||
this.pagination.clearDataset('kv/metadata'); // Clear out the pagination cache so that the metadata/list view is updated.
|
||||
const message = `Successfully deleted the metadata and all version data of the secret ${model.fullSecretPath}.`;
|
||||
const fullSecretPath = this.fullSecretPath(secretPath);
|
||||
await this.api.secrets.kvV2DeleteMetadataAndAllVersions(fullSecretPath, this.args.backend);
|
||||
const message = `Successfully deleted the metadata and all version data of the secret ${fullSecretPath}.`;
|
||||
this.flashMessages.success(message);
|
||||
// if you've deleted a secret from within a directory, transition to its parent directory.
|
||||
if (this.router.currentRoute.localName === 'list-directory') {
|
||||
const ancestors = ancestorKeysForKey(model.fullSecretPath);
|
||||
const ancestors = ancestorKeysForKey(fullSecretPath);
|
||||
const nearest = ancestors.pop();
|
||||
this.router.transitionTo(`${this.mountPoint}.list-directory`, nearest);
|
||||
} else {
|
||||
|
|
@ -70,7 +75,10 @@ export default class KvListPageComponent extends Component {
|
|||
this.router.transitionTo(`${this.mountPoint}.list`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = errorMessage(error, 'Error deleting secret. Please try again or contact support.');
|
||||
const { message } = await this.api.parseError(
|
||||
error,
|
||||
'Error deleting secret. Please try again or contact support.'
|
||||
);
|
||||
this.flashMessages.danger(message);
|
||||
} finally {
|
||||
this.metadataToDelete = null;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export default class KvEngine extends Engine {
|
|||
Resolver = Resolver;
|
||||
dependencies = {
|
||||
services: [
|
||||
'api',
|
||||
'capabilities',
|
||||
'control-group',
|
||||
'download',
|
||||
|
|
|
|||
|
|
@ -5,27 +5,46 @@
|
|||
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
|
||||
export default class KvConfigurationRoute extends Route {
|
||||
@service store;
|
||||
@service api;
|
||||
|
||||
model() {
|
||||
async model() {
|
||||
const backend = this.modelFor('application');
|
||||
return hash({
|
||||
mountConfig: this.store.query('secret-engine', { path: backend.id }).then((models = []) => models[0]),
|
||||
engineConfig: this.store.findRecord('kv/config', backend.id).catch(() => {
|
||||
// return an empty record so we have access to model capabilities
|
||||
return this.store.createRecord('kv/config', { backend: backend.id });
|
||||
}),
|
||||
});
|
||||
const {
|
||||
type,
|
||||
path,
|
||||
accessor,
|
||||
running_plugin_version,
|
||||
local,
|
||||
seal_wrap,
|
||||
config: { default_lease_ttl, max_lease_ttl },
|
||||
options: { version },
|
||||
} = await this.api.sys.internalUiReadMountInformation(backend.id);
|
||||
// display mount config if engine config request fails
|
||||
const engineConfig = await this.api.secrets.kvV2ReadConfiguration(backend.id).catch(() => {});
|
||||
|
||||
return {
|
||||
...engineConfig,
|
||||
type,
|
||||
path,
|
||||
accessor,
|
||||
running_plugin_version,
|
||||
local,
|
||||
seal_wrap,
|
||||
default_lease_ttl,
|
||||
max_lease_ttl,
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
const { id } = this.modelFor('application');
|
||||
controller.backend = id;
|
||||
controller.breadcrumbs = [
|
||||
{ label: 'Secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.mountConfig.id, route: 'list', model: resolvedModel.engineConfig.backend },
|
||||
{ label: id, route: 'list', model: id },
|
||||
{ label: 'Configuration' },
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,15 @@
|
|||
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
import { pathIsDirectory, breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
|
||||
import { paginate } from 'core/utils/paginate-list';
|
||||
|
||||
export default class KvSecretsListRoute extends Route {
|
||||
@service pagination;
|
||||
@service('app-router') router;
|
||||
@service secretMountPath;
|
||||
@service api;
|
||||
@service capabilities;
|
||||
|
||||
queryParams = {
|
||||
pageFilter: {
|
||||
|
|
@ -23,24 +25,19 @@ export default class KvSecretsListRoute extends Route {
|
|||
};
|
||||
|
||||
async fetchMetadata(backend, pathToSecret, params) {
|
||||
return await this.pagination
|
||||
.lazyPaginatedQuery('kv/metadata', {
|
||||
backend,
|
||||
responsePath: 'data.keys',
|
||||
page: Number(params.page) || 1,
|
||||
pageFilter: params.pageFilter,
|
||||
pathToSecret,
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.httpStatus === 403) {
|
||||
return 403;
|
||||
}
|
||||
if (err.httpStatus === 404) {
|
||||
return [];
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
try {
|
||||
const { keys } = await this.api.secrets.kvV2List(pathToSecret, backend, true);
|
||||
return paginate(keys, { page: Number(params.page) || 1, filter: params.pageFilter });
|
||||
} catch (error) {
|
||||
const { status, response } = await this.api.parseError(error);
|
||||
if (status === 403 && !response.isControlGroupError) {
|
||||
return 403;
|
||||
}
|
||||
if (status === 404) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getPathToSecret(pathParam) {
|
||||
|
|
@ -51,18 +48,22 @@ export default class KvSecretsListRoute extends Route {
|
|||
return pathIsDirectory(pathParam) ? pathParam : `${pathParam}/`;
|
||||
}
|
||||
|
||||
model(params) {
|
||||
async model(params) {
|
||||
const { pageFilter, path_to_secret } = params;
|
||||
const pathToSecret = this.getPathToSecret(path_to_secret);
|
||||
const backend = this.secretMountPath.currentPath;
|
||||
const filterValue = pathToSecret ? (pageFilter ? pathToSecret + pageFilter : pathToSecret) : pageFilter;
|
||||
return hash({
|
||||
secrets: this.fetchMetadata(backend, pathToSecret, params),
|
||||
const secrets = await this.fetchMetadata(backend, pathToSecret, params);
|
||||
const capabilities = await this.capabilities.for('kvMetadata', { backend, path: path_to_secret });
|
||||
|
||||
return {
|
||||
secrets,
|
||||
backend,
|
||||
pathToSecret,
|
||||
filterValue,
|
||||
pageFilter,
|
||||
});
|
||||
capabilities,
|
||||
};
|
||||
}
|
||||
|
||||
setupController(controller, resolvedModel) {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,4 @@
|
|||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<Page::Configuration
|
||||
@engineConfig={{this.model.engineConfig}}
|
||||
@mountConfig={{this.model.mountConfig}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
/>
|
||||
<Page::Configuration @config={{this.model}} @backend={{this.backend}} @breadcrumbs={{this.breadcrumbs}} />
|
||||
|
|
@ -7,9 +7,9 @@
|
|||
@secrets={{this.model.secrets}}
|
||||
@backend={{this.model.backend}}
|
||||
@pathToSecret={{this.model.pathToSecret}}
|
||||
@pageFilter={{this.model.pageFilter}}
|
||||
@filterValue={{this.model.filterValue}}
|
||||
@failedDirectoryQuery={{this.model.failedDirectoryQuery}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@currentRouteParams={{array this.model.backend this.model.pathToSecret}}
|
||||
@capabilities={{this.model.capabilities}}
|
||||
/>
|
||||
|
|
@ -11,4 +11,5 @@
|
|||
@failedDirectoryQuery={{this.model.failedDirectoryQuery}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@currentRouteParams={{array this.model.backend}}
|
||||
@capabilities={{this.model.capabilities}}
|
||||
/>
|
||||
|
|
@ -420,7 +420,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
|
|||
// correct popup menu items appear on list view
|
||||
const popupSelector = `${PAGE.list.item('bad-secret')} ${PAGE.popup}`;
|
||||
await click(popupSelector);
|
||||
assert.dom(PAGE.list.listMenuDelete).exists('shows the option to permanently delete');
|
||||
assert.dom(PAGE.list.menuItem('Permanently delete')).exists('shows the option to permanently delete');
|
||||
});
|
||||
test('can not delete all secret versions from root list view (snc)', async function (assert) {
|
||||
assert.expect(1);
|
||||
|
|
|
|||
|
|
@ -58,8 +58,8 @@ export const PAGE = {
|
|||
list: {
|
||||
createSecret: '[data-test-toolbar-create-secret]',
|
||||
item: (secret) => (!secret ? '[data-test-list-item]' : `[data-test-list-item="${secret}"]`),
|
||||
menuItem: (label) => `[data-test-list-menu-item="${label}"]`,
|
||||
filter: `[data-test-kv-list-filter]`,
|
||||
listMenuDelete: `[data-test-popup-metadata-delete]`,
|
||||
overviewCard: '[data-test-overview-card-container="View secret"]',
|
||||
overviewInput: '[data-test-view-secret] input',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -15,94 +15,57 @@ module('Integration | Component | kv-v2 | Page::Configuration', function (hooks)
|
|||
setupEngine(hooks, 'kv');
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.mountData = {
|
||||
id: 'my-kv',
|
||||
accessor: 'kv_80616825',
|
||||
config: this.store.createRecord('mount-config', {
|
||||
defaultLeaseTtl: '72h',
|
||||
forceNoCache: false,
|
||||
maxLeaseTtl: '123h',
|
||||
}),
|
||||
options: {
|
||||
version: '2',
|
||||
},
|
||||
description: '',
|
||||
path: 'my-kv',
|
||||
sealWrap: false,
|
||||
this.config = {
|
||||
cas_required: true,
|
||||
max_versions: 0,
|
||||
delete_version_after: '0s',
|
||||
type: 'kv',
|
||||
uuid: 'f1739f9d-dfc0-83c8-011f-ec17103a06a1',
|
||||
// TODO: remove when attrs aren't duplicated across models
|
||||
// these kv specific attrs exist on the secret-engine model (for POST request when mounting the engine)
|
||||
// we want to make sure we're rendering values from kv/config while duplicates exist
|
||||
maxVersions: 'this should never render',
|
||||
casRequired: 'test is failing if this shows',
|
||||
deleteVersionAfter: `definitely shouldn't render this`,
|
||||
path: 'my-kv',
|
||||
accessor: 'kv_80616825',
|
||||
running_plugin_version: '2.7.0',
|
||||
local: false,
|
||||
seal_wrap: false,
|
||||
default_lease_ttl: '72h',
|
||||
max_lease_ttl: '123h',
|
||||
version: '2',
|
||||
};
|
||||
this.store.pushPayload('kv/config', {
|
||||
modelName: 'kv/config',
|
||||
id: 'my-config',
|
||||
data: { max_versions: 0, cas_required: false, delete_version_after: '0s' },
|
||||
});
|
||||
|
||||
// this is the route model, not an ember data model
|
||||
this.model = {
|
||||
engineConfig: this.store.peekRecord('kv/config', 'my-config'),
|
||||
mountConfig: this.store.createRecord('secret-engine', this.mountData),
|
||||
};
|
||||
|
||||
this.backend = 'my-kv';
|
||||
this.breadcrumbs = [
|
||||
{ label: 'Secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: this.model.mountConfig.path, route: 'list' },
|
||||
{ label: 'my-kv', route: 'list' },
|
||||
{ label: 'Configuration' },
|
||||
];
|
||||
});
|
||||
|
||||
test('it renders kv configuration details', async function (assert) {
|
||||
assert.expect(11);
|
||||
assert.expect(15);
|
||||
|
||||
await render(
|
||||
hbs`
|
||||
<Page::Configuration
|
||||
@engineConfig={{this.model.engineConfig}}
|
||||
@mountConfig={{this.model.mountConfig}}
|
||||
@config={{this.config}}
|
||||
@backend={{this.backend}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
|
||||
assert.dom(PAGE.title).includesText(this.mountData.path, 'renders engine path as page title');
|
||||
assert.dom(PAGE.infoRowValue('Require check and set')).hasText('No');
|
||||
assert.dom(PAGE.title).includesText('my-kv', 'renders engine path as page title');
|
||||
assert.dom(PAGE.secretTab('Secrets')).exists('renders Secrets tab');
|
||||
assert.dom(PAGE.secretTab('Configuration')).exists('renders Configuration tab');
|
||||
|
||||
assert.dom(PAGE.infoRowValue('Require check and set')).hasText('Yes');
|
||||
assert.dom(PAGE.infoRowValue('Automate secret deletion')).hasText('Never delete');
|
||||
assert.dom(PAGE.infoRowValue('Maximum number of versions')).hasText('0');
|
||||
assert.dom(PAGE.infoRowValue('Accessor')).hasText(this.mountData.accessor);
|
||||
assert.dom(PAGE.infoRowValue('Path')).hasText(this.mountData.path);
|
||||
assert.dom(PAGE.infoRowValue('Type')).hasText(this.mountData.type);
|
||||
assert.dom(PAGE.infoRowValue('Description')).doesNotExist();
|
||||
assert.dom(PAGE.infoRowValue('Type')).hasText('kv');
|
||||
assert.dom(PAGE.infoRowValue('Path')).hasText('my-kv');
|
||||
assert.dom(PAGE.infoRowValue('Accessor')).hasText('kv_80616825');
|
||||
assert.dom(PAGE.infoRowValue('Running plugin version')).hasText('2.7.0');
|
||||
assert.dom(PAGE.infoRowValue('Local')).hasText('No');
|
||||
assert.dom(PAGE.infoRowValue('Seal wrap')).hasText('No');
|
||||
assert.dom(PAGE.infoRowValue('Default Lease TTL')).hasText('3 days');
|
||||
assert.dom(PAGE.infoRowValue('Max Lease TTL')).hasText('5 days 3 hours');
|
||||
});
|
||||
|
||||
test('it renders non default kv engine config data', async function (assert) {
|
||||
assert.expect(3);
|
||||
this.model.engineConfig.maxVersions = 10;
|
||||
this.model.engineConfig.casRequired = true;
|
||||
this.model.engineConfig.deleteVersionAfter = '10d';
|
||||
|
||||
await render(
|
||||
hbs`
|
||||
<Page::Configuration
|
||||
@engineConfig={{this.model.engineConfig}}
|
||||
@mountConfig={{this.model.mountConfig}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
assert.dom(PAGE.infoRowValue('Require check and set')).hasText('Yes');
|
||||
assert.dom(PAGE.infoRowValue('Automate secret deletion')).hasText('10 days');
|
||||
assert.dom(PAGE.infoRowValue('Maximum number of versions')).hasText('10');
|
||||
assert.dom(PAGE.infoRowValue('Version')).hasText('2');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,45 +6,57 @@
|
|||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { setupEngine } from 'ember-engines/test-support';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { render, click, fillIn, typeIn } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { kvMetadataPath } from 'vault/utils/kv-path';
|
||||
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
|
||||
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
|
||||
import { setRunOptions } from 'ember-a11y-testing/test-support';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import sinon from 'sinon';
|
||||
|
||||
const CREATE_RECORDS = (number, store, server) => {
|
||||
const mirageList = server.createList('kv-metadatum', number, 'withCustomPath');
|
||||
mirageList.forEach((record) => {
|
||||
record.data.path = record.path;
|
||||
record.id = kvMetadataPath(record.data.backend, record.data.path);
|
||||
store.pushPayload('kv/metadata', {
|
||||
modelName: 'kv/metadata',
|
||||
...record,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const META = {
|
||||
currentPage: 1,
|
||||
lastPage: 2,
|
||||
nextPage: 2,
|
||||
prevPage: 1,
|
||||
total: 16,
|
||||
filteredTotal: 16,
|
||||
pageSize: 15,
|
||||
};
|
||||
|
||||
module('Integration | Component | kv | Page::List', function (hooks) {
|
||||
module('Integration | Component | kv-v2 | Page::List', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupEngine(hooks, 'kv');
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
|
||||
this.store = this.owner.lookup('service:store');
|
||||
const paginationMeta = {
|
||||
currentPage: 1,
|
||||
lastPage: 2,
|
||||
nextPage: 2,
|
||||
prevPage: 1,
|
||||
total: 5,
|
||||
filteredTotal: 5,
|
||||
pageSize: 3,
|
||||
};
|
||||
this.secrets = ['secret-1', 'my-path/', 'secret-2'];
|
||||
this.secrets.meta = paginationMeta;
|
||||
this.pathToSecret = 'my-kv/';
|
||||
this.backend = 'kv-engine';
|
||||
this.filterValue = '';
|
||||
this.failedDirectoryQuery = false;
|
||||
this.breadcrumbs = [
|
||||
{ label: 'Secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: this.backend, route: 'list' },
|
||||
];
|
||||
this.capabilities = { canRead: true, canDelete: true };
|
||||
|
||||
this.renderComponent = () =>
|
||||
render(
|
||||
hbs`
|
||||
<Page::List
|
||||
@secrets={{this.secrets}}
|
||||
@backend={{this.backend}}
|
||||
@pathToSecret={{this.pathToSecret}}
|
||||
@filterValue={{this.filterValue}}
|
||||
@failedDirectoryQuery={{this.failedDirectoryQuery}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@currentRouteParams={{array this.backend}}
|
||||
@capabilities={{this.capabilities}}
|
||||
/>`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
|
||||
this.transitionTo = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
|
||||
|
||||
setRunOptions({
|
||||
rules: {
|
||||
// TODO: ConfirmAction renders modal within list when @isInDropdown
|
||||
|
|
@ -53,47 +65,95 @@ module('Integration | Component | kv | Page::List', function (hooks) {
|
|||
});
|
||||
});
|
||||
|
||||
test('it renders Pagination and allows you to delete a kv/metadata record', async function (assert) {
|
||||
assert.expect(20);
|
||||
CREATE_RECORDS(15, this.store, this.server);
|
||||
this.model = await this.store.peekAll('kv/metadata');
|
||||
this.model.meta = META;
|
||||
this.backend = 'kv-engine';
|
||||
this.breadcrumbs = [
|
||||
{ label: 'Secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: this.backend, route: 'list' },
|
||||
];
|
||||
this.failedDirectoryQuery = false;
|
||||
await render(
|
||||
hbs`<Page::List
|
||||
@secrets={{this.model}}
|
||||
@backend={{this.backend}}
|
||||
@failedDirectoryQuery={{this.failedDirectoryQuery}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@meta={{this.model.meta}}
|
||||
@currentRouteParams={{array this.backend}}
|
||||
/>`,
|
||||
{
|
||||
owner: this.engine,
|
||||
}
|
||||
test('it should render page title and toolbar elements', async function (assert) {
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(PAGE.title).includesText(this.backend, 'renders mount path as page title');
|
||||
assert.dom(PAGE.secretTab('Secrets')).exists('renders Secrets tab');
|
||||
assert.dom(PAGE.secretTab('Configuration')).exists('renders Configuration tab');
|
||||
assert.dom(PAGE.list.filter).exists('renders filter input');
|
||||
assert.dom(PAGE.list.createSecret).exists('renders create secret action');
|
||||
});
|
||||
|
||||
test('it should render 403 state', async function (assert) {
|
||||
this.secrets = 403;
|
||||
this.failedDirectoryQuery = true;
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(PAGE.list.filter).doesNotExist('filter input is hidden');
|
||||
assert.dom(PAGE.list.overviewCard).exists('renders overview card');
|
||||
assert.dom(PAGE.list.overviewInput).hasValue('my-kv/', 'shows correct path in overview card input');
|
||||
|
||||
await typeIn(PAGE.list.overviewInput, 'my-dir/');
|
||||
await click(GENERAL.submitButton);
|
||||
assert.true(
|
||||
this.transitionTo.calledWith('vault.cluster.secrets.backend.kv.list-directory', 'my-kv/my-dir/'),
|
||||
'transitions to correct route if path is directory'
|
||||
);
|
||||
assert
|
||||
.dom(GENERAL.inlineAlert)
|
||||
.hasText(
|
||||
'You do not have the required permissions or the directory does not exist.',
|
||||
'alert renders for failed directory query'
|
||||
);
|
||||
|
||||
assert.dom(GENERAL.pagination).exists('shows hds pagination component');
|
||||
assert.dom(GENERAL.paginationInfo).hasText('1–15 of 16', 'shows correct page of pages');
|
||||
assert.dom(PAGE.title).includesText(this.backend, 'shows backend as title');
|
||||
await fillIn(PAGE.list.overviewInput, '');
|
||||
await typeIn(PAGE.list.overviewInput, 'secret');
|
||||
await click(GENERAL.submitButton);
|
||||
assert.true(
|
||||
this.transitionTo.calledWith('vault.cluster.secrets.backend.kv.secret.index', 'secret'),
|
||||
'transitions to correct route if path is not a directory'
|
||||
);
|
||||
});
|
||||
|
||||
this.model.forEach((record) => {
|
||||
assert.dom(PAGE.list.item(record.path)).exists('lists all records from 0-14 on the first page');
|
||||
});
|
||||
test('it should render empty states', async function (assert) {
|
||||
this.secrets = [];
|
||||
await this.renderComponent();
|
||||
assert.dom(GENERAL.emptyStateTitle).hasText('No secrets yet', 'empty state renders for no secrets');
|
||||
|
||||
this.server.delete(kvMetadataPath('kv-engine', 'my-secret-0'), () => {
|
||||
assert.ok(true, 'request made to correct endpoint on delete metadata.');
|
||||
});
|
||||
this.filterValue = 'foo';
|
||||
await this.renderComponent();
|
||||
assert
|
||||
.dom(GENERAL.emptyStateTitle)
|
||||
.hasText('There are no secrets matching "foo".', 'empty state renders for no filter results');
|
||||
});
|
||||
|
||||
const popupSelector = `${PAGE.list.item('my-secret-0')} ${PAGE.popup}`;
|
||||
await click(popupSelector);
|
||||
await click('[data-test-popup-metadata-delete]');
|
||||
test('it should render paginated secrets', async function (assert) {
|
||||
await this.renderComponent();
|
||||
|
||||
assert.dom(PAGE.list.item()).exists({ count: 3 }, 'renders 3 secrets for first page');
|
||||
assert.dom(PAGE.list.item('secret-1')).hasText('secret-1', 'secret path renders');
|
||||
assert.dom(GENERAL.pagination).exists('renders hds pagination component');
|
||||
assert.dom(GENERAL.paginationInfo).hasText('1–3 of 5', 'renders correct page information');
|
||||
});
|
||||
|
||||
test('it should render list item menu', async function (assert) {
|
||||
await this.renderComponent();
|
||||
|
||||
await click(`${PAGE.list.item('my-path/')} ${PAGE.popup}`);
|
||||
assert.dom(PAGE.list.menuItem('Content')).exists('renders content menu item for directory');
|
||||
|
||||
await click(`${PAGE.list.item('secret-1')} ${PAGE.popup}`);
|
||||
assert.dom(PAGE.list.menuItem('Overview')).exists('renders overview menu item');
|
||||
assert.dom(PAGE.list.menuItem('Secret data')).exists('renders secret data menu item');
|
||||
assert.dom(PAGE.list.menuItem('View version history')).exists('renders version history menu item');
|
||||
assert.dom(PAGE.list.menuItem('Permanently delete')).exists('renders delete menu item');
|
||||
|
||||
await click(PAGE.list.menuItem('Permanently delete'));
|
||||
assert
|
||||
.dom(GENERAL.confirmMessage)
|
||||
.hasText(
|
||||
'This will permanently delete this secret and all its versions.',
|
||||
'renders confirm modal on delete click'
|
||||
);
|
||||
|
||||
this.deleteStub = sinon
|
||||
.stub(this.owner.lookup('service:api').secrets, 'kvV2DeleteMetadataAndAllVersions')
|
||||
.resolves();
|
||||
await click(GENERAL.confirmButton);
|
||||
assert.dom(PAGE.list.item('my-secret-0')).doesNotExist('deleted the first record from the list');
|
||||
assert.true(
|
||||
this.deleteStub.calledWith('my-kv/secret-1', this.backend),
|
||||
'makes request to delete secret on confirm'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue