[UI] Ember Data Migration - KV List/Config (#9493) (#9676)

* 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:
Vault Automation 2025-10-01 12:35:31 -04:00 committed by GitHub
parent 2906d02959
commit 425e80a933
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 352 additions and 257 deletions

View file

@ -97,6 +97,7 @@ export default class App extends Application {
kv: {
dependencies: {
services: [
'api',
'capabilities',
'control-group',
'download',

View file

@ -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'}`,

View file

@ -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-".
*/

View file

@ -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>

View 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;
};
}

View file

@ -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

View file

@ -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;

View file

@ -17,6 +17,7 @@ export default class KvEngine extends Engine {
Resolver = Resolver;
dependencies = {
services: [
'api',
'capabilities',
'control-group',
'download',

View file

@ -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' },
];
}

View file

@ -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) {

View file

@ -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}} />

View file

@ -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}}
/>

View file

@ -11,4 +11,5 @@
@failedDirectoryQuery={{this.model.failedDirectoryQuery}}
@breadcrumbs={{this.breadcrumbs}}
@currentRouteParams={{array this.model.backend}}
@capabilities={{this.model.capabilities}}
/>

View file

@ -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);

View file

@ -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',
},

View file

@ -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');
});
});

View file

@ -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('115 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('13 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'
);
});
});