diff --git a/ui/app/app.js b/ui/app/app.js index 863b96d6b6..6ae15248eb 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -97,6 +97,7 @@ export default class App extends Application { kv: { dependencies: { services: [ + 'api', 'capabilities', 'control-group', 'download', diff --git a/ui/app/utils/constants/capabilities.ts b/ui/app/utils/constants/capabilities.ts index c4cfe46069..aadea31619 100644 --- a/ui/app/utils/constants/capabilities.ts +++ b/ui/app/utils/constants/capabilities.ts @@ -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'}`, diff --git a/ui/lib/kv/addon/components/kv-list-filter.js b/ui/lib/kv/addon/components/kv-list-filter.js index 49bad80571..59106dbe37 100644 --- a/ui/lib/kv/addon/components/kv-list-filter.js +++ b/ui/lib/kv/addon/components/kv-list-filter.js @@ -21,11 +21,9 @@ import { task, timeout } from 'ember-concurrency'; * route will reload the model and completely refresh the page. * * * - * @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-". */ diff --git a/ui/lib/kv/addon/components/page/configuration.hbs b/ui/lib/kv/addon/components/page/configuration.hbs index 782f157ddb..71f0f5de6f 100644 --- a/ui/lib/kv/addon/components/page/configuration.hbs +++ b/ui/lib/kv/addon/components/page/configuration.hbs @@ -3,35 +3,20 @@ SPDX-License-Identifier: BUSL-1.1 }} - + <:tabLinks>
  • Secrets
  • Configuration
  • -{{! engine configuration }} -{{#if @engineConfig.canRead}} -
    - {{#each @engineConfig.formFields as |attr|}} - - {{/each}} -
    -{{/if}} - -{{! mount configuration }}
    - {{#each @mountConfig.attrs as |attr|}} - {{#if (not (includes attr.name @engineConfig.displayFields))}} - - {{/if}} - {{/each}} + {{#each-in @config as |key value|}} + + {{/each-in}}
    \ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/configuration.js b/ui/lib/kv/addon/components/page/configuration.js new file mode 100644 index 0000000000..0565d8524f --- /dev/null +++ b/ui/lib/kv/addon/components/page/configuration.js @@ -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; + }; +} diff --git a/ui/lib/kv/addon/components/page/list.hbs b/ui/lib/kv/addon/components/page/list.hbs index c60c47bbe0..457c7a51f3 100644 --- a/ui/lib/kv/addon/components/page/list.hbs +++ b/ui/lib/kv/addon/components/page/list.hbs @@ -19,9 +19,8 @@ <:toolbarFilters> - {{#if (and (not-eq @secrets 403) (or @secrets @filterValue))}} - + {{/if}} @@ -37,6 +36,7 @@
    + {{#if (eq @secrets 403)}}
    @@ -73,59 +73,80 @@
    {{else}} {{#if @secrets}} - {{#each @secrets as |metadata|}} - -
    -
    -
    - - - {{metadata.path}} - + {{#each @secrets as |secretPath|}} + {{#let (this.isDirectory secretPath) (this.fullSecretPath secretPath) as |isDir fullPath|}} + +
    +
    +
    + + + {{secretPath}} + +
    -
    -
    -
    - - - {{#if metadata.pathIsDirectory}} - Content - {{else}} - Overview - Secret data - {{#if metadata.canReadMetadata}} - - View version history - {{/if}} - {{#if metadata.canDeleteMetadata}} +
    +
    + + + {{#if isDir}} Permanently delete + @route="list-directory" + @models={{array @backend fullPath}} + data-test-list-menu-item="Content" + > + Content + + {{else}} + + Overview + + + Secret data + + {{#if @capabilities.canRead}} + + View version history + + {{/if}} + {{#if @capabilities.canDelete}} + + Permanently delete + + {{/if}} {{/if}} - {{/if}} - + +
    -
    -
    + + {{/let}} {{/each}} {{#if this.metadataToDelete}} 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; diff --git a/ui/lib/kv/addon/engine.js b/ui/lib/kv/addon/engine.js index 3e2457d0d6..c5e27da7bd 100644 --- a/ui/lib/kv/addon/engine.js +++ b/ui/lib/kv/addon/engine.js @@ -17,6 +17,7 @@ export default class KvEngine extends Engine { Resolver = Resolver; dependencies = { services: [ + 'api', 'capabilities', 'control-group', 'download', diff --git a/ui/lib/kv/addon/routes/configuration.js b/ui/lib/kv/addon/routes/configuration.js index 63e897ab61..76a7dd882c 100644 --- a/ui/lib/kv/addon/routes/configuration.js +++ b/ui/lib/kv/addon/routes/configuration.js @@ -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' }, ]; } diff --git a/ui/lib/kv/addon/routes/list-directory.js b/ui/lib/kv/addon/routes/list-directory.js index 0b6e787f6f..b35ab3e2fa 100644 --- a/ui/lib/kv/addon/routes/list-directory.js +++ b/ui/lib/kv/addon/routes/list-directory.js @@ -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) { diff --git a/ui/lib/kv/addon/templates/configuration.hbs b/ui/lib/kv/addon/templates/configuration.hbs index f2daac229f..05b67cd2ed 100644 --- a/ui/lib/kv/addon/templates/configuration.hbs +++ b/ui/lib/kv/addon/templates/configuration.hbs @@ -3,8 +3,4 @@ SPDX-License-Identifier: BUSL-1.1 }} - \ No newline at end of file + \ No newline at end of file diff --git a/ui/lib/kv/addon/templates/list-directory.hbs b/ui/lib/kv/addon/templates/list-directory.hbs index c4ed2bad7c..289dcee75a 100644 --- a/ui/lib/kv/addon/templates/list-directory.hbs +++ b/ui/lib/kv/addon/templates/list-directory.hbs @@ -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}} /> \ No newline at end of file diff --git a/ui/lib/kv/addon/templates/list.hbs b/ui/lib/kv/addon/templates/list.hbs index ba561ccc33..d8836980f9 100644 --- a/ui/lib/kv/addon/templates/list.hbs +++ b/ui/lib/kv/addon/templates/list.hbs @@ -11,4 +11,5 @@ @failedDirectoryQuery={{this.model.failedDirectoryQuery}} @breadcrumbs={{this.breadcrumbs}} @currentRouteParams={{array this.model.backend}} + @capabilities={{this.model.capabilities}} /> \ No newline at end of file diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-delete-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-delete-test.js index 03fb793e71..2e665347b8 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-delete-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-delete-test.js @@ -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); diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js index c4299aa6d1..f11b3664b1 100644 --- a/ui/tests/helpers/kv/kv-selectors.js +++ b/ui/tests/helpers/kv/kv-selectors.js @@ -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', }, diff --git a/ui/tests/integration/components/kv/page/kv-page-configuration-test.js b/ui/tests/integration/components/kv/page/kv-page-configuration-test.js index 392be73b69..dca2789981 100644 --- a/ui/tests/integration/components/kv/page/kv-page-configuration-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-configuration-test.js @@ -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` `, { 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` - - `, - { 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'); }); }); diff --git a/ui/tests/integration/components/kv/page/kv-page-list-test.js b/ui/tests/integration/components/kv/page/kv-page-list-test.js index bd4b5864f3..665ae0242f 100644 --- a/ui/tests/integration/components/kv/page/kv-page-list-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-list-test.js @@ -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` + `, + { 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``, - { - 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' + ); }); });