diff --git a/ui/app/components/secret-engine/list.hbs b/ui/app/components/secret-engine/list.hbs new file mode 100644 index 0000000000..3323919c1d --- /dev/null +++ b/ui/app/components/secret-engine/list.hbs @@ -0,0 +1,116 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +}} + + + + + + + + + Enable new engine + + + + +{{#each this.sortedDisplayableBackends as |backend|}} + +
+
+ {{#if backend.icon}} + + + + {{/if}} + {{#if backend.path}} + {{#if backend.isSupportedBackend}} + + {{backend.path}} + + {{else}} + {{backend.path}} + {{/if}} + {{/if}} +
+ {{#if backend.accessor}} + + {{if (eq backend.version 2) (concat "v2 " backend.accessor) backend.accessor}} + + {{/if}} + {{#if backend.description}} + + {{backend.description}} + + {{/if}} +
+
+ + + + View configuration + + {{#if (not-eq backend.type "cubbyhole")}} + Disable + {{/if}} + +
+
+{{/each}} + +{{#if this.engineToDisable}} + +{{/if}} \ No newline at end of file diff --git a/ui/app/components/secret-engine/list.ts b/ui/app/components/secret-engine/list.ts new file mode 100644 index 0000000000..6571acc0a8 --- /dev/null +++ b/ui/app/components/secret-engine/list.ts @@ -0,0 +1,112 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { dropTask } from 'ember-concurrency'; +import errorMessage from 'vault/utils/error-message'; + +import type FlashMessageService from 'vault/services/flash-messages'; +import SecretEngineModel from 'vault/models/secret-engine'; + +/** + * @module SecretEngineList handles the display of the list of secret engines, including the filtering. + * + * @example + * + * + * @param {array} secretEngineModels - An array of Secret Engine models returned from query on the parent route. + */ + +interface Args { + secretEngineModels: Array; +} + +export default class SecretListItem extends Component { + @service declare readonly flashMessages: FlashMessageService; + + @tracked secretEngineOptions: Array | [] = []; + @tracked selectedEngineType = ''; + @tracked selectedEngineName = ''; + @tracked engineToDisable: SecretEngineModel | undefined = undefined; + + get displayableBackends() { + return this.args.secretEngineModels.filter((backend) => backend.shouldIncludeInList); + } + + get sortedDisplayableBackends() { + // show supported secret engines first and then organize those by id. + const sortedBackends = this.displayableBackends.sort( + (a, b) => Number(b.isSupportedBackend) - Number(a.isSupportedBackend) || a.id.localeCompare(b.id) + ); + + // return an options list to filter by engine type, ex: 'kv' + if (this.selectedEngineType) { + // check first if the user has also filtered by name. + if (this.selectedEngineName) { + return sortedBackends.filter((backend) => this.selectedEngineName === backend.id); + } + // otherwise filter by engine type + return sortedBackends.filter((backend) => this.selectedEngineType === backend.engineType); + } + + // return an options list to filter by engine name, ex: 'secret' + if (this.selectedEngineName) { + return sortedBackends.filter((backend) => this.selectedEngineName === backend.id); + } + // no filters, return full sorted list. + return sortedBackends; + } + + // Filtering & searching + get secretEngineArrayByType() { + const arrayOfAllEngineTypes = this.sortedDisplayableBackends.map((modelObject) => modelObject.engineType); + // filter out repeated engineTypes (e.g. [kv, kv] => [kv]) + const arrayOfUniqueEngineTypes = [...new Set(arrayOfAllEngineTypes)]; + + return arrayOfUniqueEngineTypes.map((engineType) => ({ + name: engineType, + id: engineType, + })); + } + + get secretEngineArrayByName() { + return this.sortedDisplayableBackends.map((modelObject) => ({ + name: modelObject.id, + id: modelObject.id, + })); + } + + @action + filterEngineType(type: string[]) { + const [selectedType] = type; + this.selectedEngineType = selectedType || ''; + } + + @action + filterEngineName(name: string[]) { + const [selectedName] = name; + this.selectedEngineName = selectedName || ''; + } + + @dropTask + *disableEngine(engine: SecretEngineModel) { + const { engineType, path } = engine; + try { + yield engine.destroyRecord(); + this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`); + } catch (err) { + this.flashMessages.danger( + `There was an error disabling the ${engineType} Secrets Engines at ${path}: ${errorMessage(err)}.` + ); + } finally { + this.engineToDisable = undefined; + } + } +} diff --git a/ui/app/controllers/vault/cluster/secrets/backends.js b/ui/app/controllers/vault/cluster/secrets/backends.js index b2fc83e7ee..6e816bd73c 100644 --- a/ui/app/controllers/vault/cluster/secrets/backends.js +++ b/ui/app/controllers/vault/cluster/secrets/backends.js @@ -2,88 +2,6 @@ * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ -/* eslint ember/no-computed-properties-in-native-classes: 'warn' */ import Controller from '@ember/controller'; -import { service } from '@ember/service'; -import { action } from '@ember/object'; -import { tracked } from '@glimmer/tracking'; -export default class VaultClusterSecretsBackendController extends Controller { - @service flashMessages; - - @tracked secretEngineOptions = []; - @tracked selectedEngineType = null; - @tracked selectedEngineName = null; - @tracked engineToDisable = null; - - get displayableBackends() { - return this.model.filter((backend) => backend.shouldIncludeInList); - } - - get sortedDisplayableBackends() { - // show supported secret engines first and then organize those by id. - const sortedBackends = this.displayableBackends.sort( - (a, b) => b.isSupportedBackend - a.isSupportedBackend || a.id - b.id - ); - - // return an options list to filter by engine type, ex: 'kv' - if (this.selectedEngineType) { - // check first if the user has also filtered by name. - if (this.selectedEngineName) { - return sortedBackends.filter((backend) => this.selectedEngineName === backend.id); - } - // otherwise filter by engine type - return sortedBackends.filter((backend) => this.selectedEngineType === backend.engineType); - } - - // return an options list to filter by engine name, ex: 'secret' - if (this.selectedEngineName) { - return sortedBackends.filter((backend) => this.selectedEngineName === backend.id); - } - // no filters, return full sorted list. - return sortedBackends; - } - - get secretEngineArrayByType() { - const arrayOfAllEngineTypes = this.sortedDisplayableBackends.map((modelObject) => modelObject.engineType); - // filter out repeated engineTypes (e.g. [kv, kv] => [kv]) - const arrayOfUniqueEngineTypes = [...new Set(arrayOfAllEngineTypes)]; - - return arrayOfUniqueEngineTypes.map((engineType) => ({ - name: engineType, - id: engineType, - })); - } - - get secretEngineArrayByName() { - return this.sortedDisplayableBackends.map((modelObject) => ({ - name: modelObject.id, - id: modelObject.id, - })); - } - - @action - filterEngineType([type]) { - this.selectedEngineType = type; - } - - @action - filterEngineName([name]) { - this.selectedEngineName = name; - } - - @action - async disableEngine(engine) { - const { engineType, path } = engine; - try { - await engine.destroyRecord(); - this.flashMessages.success(`The ${engineType} Secrets Engine at ${path} has been disabled.`); - } catch (err) { - this.flashMessages.danger( - `There was an error disabling the ${engineType} Secrets Engine at ${path}: ${err.errors.join(' ')}.` - ); - } finally { - this.engineToDisable = null; - } - } -} +export default class VaultClusterSecretsBackendController extends Controller {} diff --git a/ui/app/templates/vault/cluster/secrets/backends.hbs b/ui/app/templates/vault/cluster/secrets/backends.hbs index f014c169e8..f8f1168395 100644 --- a/ui/app/templates/vault/cluster/secrets/backends.hbs +++ b/ui/app/templates/vault/cluster/secrets/backends.hbs @@ -11,114 +11,4 @@ - - - - - - - - Enable new engine - - - - -{{#each this.sortedDisplayableBackends as |backend|}} - -
-
- {{#if backend.icon}} - - - - {{/if}} - {{#if backend.path}} - {{#if backend.isSupportedBackend}} - - {{backend.path}} - - {{else}} - {{backend.path}} - {{/if}} - {{/if}} -
- {{#if backend.accessor}} - - {{if (eq backend.version 2) (concat "v2 " backend.accessor) backend.accessor}} - - {{/if}} - {{#if backend.description}} - - {{backend.description}} - - {{/if}} -
-
- - - - View configuration - - {{#if (not-eq backend.type "cubbyhole")}} - Disable - {{/if}} - -
-
-{{/each}} - -{{#if this.engineToDisable}} - -{{/if}} \ No newline at end of file + \ No newline at end of file diff --git a/ui/lib/core/addon/components/secret-list-header.hbs b/ui/lib/core/addon/components/secret-list-header.hbs index f733215498..27a54423be 100644 --- a/ui/lib/core/addon/components/secret-list-header.hbs +++ b/ui/lib/core/addon/components/secret-list-header.hbs @@ -7,7 +7,7 @@ - + { await click(SES.createSecretLink); await fillIn(SES.secretPath('create'), path); - await fillIn(SES.secretKey('create'), key); await fillIn(GENERAL.inputByAttr(key), value); await click(GENERAL.saveButton); @@ -34,7 +33,60 @@ module('Acceptance | secret-engine list view', function (hooks) { return login(); }); - test('it allows you to disable an engine', async function (assert) { + test('after enabling an unsupported engine it takes you to list page', async function (assert) { + await visit('/vault/secrets'); + await page.enableEngine(); + await click(MOUNT_BACKEND_FORM.mountType('nomad')); + await click(GENERAL.saveButton); + + assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backends', 'navigates to the list page'); + // cleanup + await runCmd(deleteEngineCmd('nomad')); + }); + + test('after enabling a supported engine it takes you to mount page, can see configure and clicking breadcrumb takes you back to list page', async function (assert) { + await visit('/vault/secrets'); + await page.enableEngine(); + await click(MOUNT_BACKEND_FORM.mountType('aws')); + await click(GENERAL.saveButton); + + assert.dom(SES.configTab).exists(); + + await click(GENERAL.breadcrumbLink('Secrets')); + assert.strictEqual( + currentRouteName(), + 'vault.cluster.secrets.backends', + 'breadcrumb navigates to the list page' + ); + // cleanup + await runCmd(deleteEngineCmd('aws')); + }); + + test('enterprise: cannot view list without permissions inside namespace', async function (assert) { + this.version = 'enterprise'; + this.backend = `bk-${this.uid}`; + this.namespace = `ns-${this.uid}`; + await runCmd([`write sys/namespaces/${this.namespace} -force`]); + await loginNs(this.namespace, ' '); + + await visit('/vault/secrets'); + assert.dom(SES.secretsBackendLink('cubbyhole')).doesNotExist(); + + await logout(); + }); + + test('enterprise: can view list with permissions inside namespace', async function (assert) { + this.version = 'enterprise'; + this.backend = `bk-${this.uid}`; + this.namespace = `ns-${this.uid}`; + await runCmd([`write sys/namespaces/${this.namespace} -force`]); + await loginNs(this.namespace); + await visit('/vault/secrets'); + + assert.dom(SES.secretsBackendLink('cubbyhole')).exists(); + }); + + test('after disabling it stays on the list view', async function (assert) { // first mount an engine so we can disable it. const enginePath = `alicloud-disable-${this.uid}`; await runCmd(mountEngineCmd('alicloud', enginePath)); @@ -51,74 +103,6 @@ module('Acceptance | secret-engine list view', function (hooks) { 'vault.cluster.secrets.backends', 'redirects to the backends list page' ); - assert.dom(SES.secretsBackendLink(enginePath)).doesNotExist('does not show the disabled engine'); - // remove the filter as it may cause issues in the next tests - await click(GENERAL.searchSelect.removeSelected); - }); - - test('it adds disabled css styling to unsupported secret engines', async function (assert) { - assert.expect(16); - const allEnginesArray = mountableEngines(); - for (const engineObject of allEnginesArray) { - const engine = engineObject.type; - const enginePath = `${engine}-${this.uid}`; - await runCmd(mountEngineCmd(engine, enginePath)); - await visit('/vault/cluster/dashboard'); - await visit('/vault/secrets'); - if (UNSUPPORTED_ENGINES.includes(engine)) { - assert - .dom(SES.secretsBackendLink(enginePath)) - .doesNotHaveClass( - 'linked-block', - `the linked-block class is not added to the unsupported ${engine}, which effectively disables it.` - ); - } else { - assert - .dom(SES.secretsBackendLink(enginePath)) - .hasClass('linked-block', `linked-block class is added to supported ${engine} engines.`); - } - // cleanup - await runCmd(deleteEngineCmd(enginePath)); - } - }); - - test('it filters by name and engine type', async function (assert) { - const enginePath1 = `aws-1-${this.uid}`; - const enginePath2 = `aws-2-${this.uid}`; - - await await runCmd(mountEngineCmd('aws', enginePath1)); - await await runCmd(mountEngineCmd('aws', enginePath2)); - await visit('/vault/secrets'); - // filter by type - await clickTrigger('#filter-by-engine-type'); - await click(GENERAL.searchSelect.option()); - - const rows = findAll(SES.secretsBackendLink()); - const rowsAws = Array.from(rows).filter((row) => row.innerText.includes('aws')); - - assert.strictEqual(rows.length, rowsAws.length, 'all rows returned are aws'); - // filter by name - await clickTrigger('#filter-by-engine-name'); - const firstItemToSelect = find(GENERAL.searchSelect.option()).innerText; - await click(GENERAL.searchSelect.option()); - const singleRow = document.querySelectorAll(SES.secretsBackendLink()); - assert.strictEqual(singleRow.length, 1, 'returns only one row'); - assert.dom(singleRow[0]).includesText(firstItemToSelect, 'shows the filtered by name engine'); - // clear filter by engine name - await click(`#filter-by-engine-name ${GENERAL.searchSelect.removeSelected}`); - const rowsAgain = document.querySelectorAll(SES.secretsBackendLink()); - assert.ok(rowsAgain.length > 1, 'filter has been removed'); - - // cleanup - await runCmd(deleteEngineCmd(enginePath1)); - await runCmd(deleteEngineCmd(enginePath2)); - }); - - test('it applies overflow styling', async function (assert) { - await visit('/vault/secrets'); - // not using the secret-engine-selector "secretPath" because I want to return the first node of a querySelectorAll - const firstSecretEngine = document.querySelectorAll('[data-test-secret-path]')[0]; - assert.dom(firstSecretEngine).hasClass('overflow-wrap', 'secret engine name has overflow class '); }); test('it allows navigation to a non-nested secret with pagination', async function (assert) { diff --git a/ui/tests/integration/components/list-test.js b/ui/tests/integration/components/list-test.js new file mode 100644 index 0000000000..73704cc62c --- /dev/null +++ b/ui/tests/integration/components/list-test.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'vault/tests/helpers'; +import { render, click, find, findAll } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { v4 as uuidv4 } from 'uuid'; +import sinon from 'sinon'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import { overrideResponse } from 'vault/tests/helpers/stubs'; +import { clickTrigger } from 'ember-power-select/test-support/helpers'; + +import { createSecretsEngine } from 'vault/tests/helpers/secret-engine/secret-engine-helpers'; +import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors'; +import { GENERAL } from 'vault/tests/helpers/general-selectors'; + +module('Integration | Component | secret-engine/list', function (hooks) { + setupRenderingTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.server.post('/sys/capabilities-self', () => ({ + data: { + capabilities: ['root'], + }, + })); + this.store = this.owner.lookup('service:store'); + this.version = this.owner.lookup('service:version'); + this.flashMessages = this.owner.lookup('service:flash-messages'); + this.flashMessages.registerTypes(['success', 'danger']); + this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success'); + this.flashDangerSpy = sinon.spy(this.flashMessages, 'danger'); + this.uid = uuidv4(); + // generate a model of cubbyhole, kv2, and nomad + this.secretEngineModels = [ + createSecretsEngine(this.store, 'cubbyhole', 'cubbyhole-test'), + createSecretsEngine(this.store, 'kv', 'kv2-test'), + createSecretsEngine(this.store, 'aws', 'aws-1'), + createSecretsEngine(this.store, 'aws', 'aws-2'), + createSecretsEngine(this.store, 'nomad', 'nomad-test'), + ]; + }); + + test('it allows you to disable an engine', async function (assert) { + const enginePath = 'kv2-test'; + this.server.delete(`sys/mounts/${enginePath}`, () => { + assert.true(true, 'Destroy record is called and deletes the engine'); + return overrideResponse(204); + }); + await render(hbs``); + + assert.dom(SES.secretsBackendLink(enginePath)).exists('shows the link for the kvv2 secrets engine'); + const row = SES.secretsBackendLink(enginePath); + await click(`${row} ${GENERAL.menuTrigger}`); + await click(GENERAL.menuItem('disable-engine')); + await click(GENERAL.confirmButton); + + assert.true( + this.flashSuccessSpy.calledWith(`The kv Secrets Engine at ${enginePath}/ has been disabled.`), + 'Flash message shows that engine was disabled.' + ); + }); + + test('it adds disabled css styling to unsupported secret engines', async function (assert) { + await render(hbs``); + assert + .dom(SES.secretsBackendLink('nomad-test')) + .doesNotHaveClass( + 'linked-block', + `the linked-block class is not added to the unsupported nomad engine, which effectively disables it.` + ); + + assert + .dom(SES.secretsBackendLink('aws-1')) + .hasClass('linked-block', `linked-block class is added to supported aws engines.`); + }); + + test('it filters by name and engine type', async function (assert) { + await render(hbs``); + // filter by type + await clickTrigger('#filter-by-engine-type'); + await click(GENERAL.searchSelect.option()); + + const rows = findAll(SES.secretsBackendLink()); + const rowsAws = Array.from(rows).filter((row) => row.innerText.includes('aws')); + + assert.strictEqual(rows.length, rowsAws.length, 'all rows returned are aws'); + // filter by name + await clickTrigger('#filter-by-engine-name'); + const firstItemToSelect = find(GENERAL.searchSelect.option()).innerText; + await click(GENERAL.searchSelect.option()); + const singleRow = document.querySelectorAll(SES.secretsBackendLink()); + assert.strictEqual(singleRow.length, 1, 'returns only one row'); + assert.dom(singleRow[0]).includesText(firstItemToSelect, 'shows the filtered by name engine'); + + // clear filter by engine name + await click(`#filter-by-engine-name ${GENERAL.searchSelect.removeSelected}`); + const rowsAgain = document.querySelectorAll(SES.secretsBackendLink()); + assert.true(rowsAgain.length > 1, 'filter has been removed'); + }); + + test('it applies overflow styling', async function (assert) { + await render(hbs``); + // not using the secret-engine-selector "secretPath" because I want to return the first node of a querySelectorAll + const firstSecretEngine = document.querySelectorAll('[data-test-secret-path]')[0]; + assert.dom(firstSecretEngine).hasClass('overflow-wrap', 'secret engine name has overflow class '); + }); +}); diff --git a/ui/tests/pages/secrets/backend/database/connection.js b/ui/tests/pages/secrets/backend/database/connection.js index acd3af642a..278dc76943 100644 --- a/ui/tests/pages/secrets/backend/database/connection.js +++ b/ui/tests/pages/secrets/backend/database/connection.js @@ -18,7 +18,8 @@ export default create({ url: fillable('[data-test-input="url"]'), username: fillable('[data-test-input="username"]'), password: fillable('[data-test-input="password"]'), - addRole: clickable('[data-test-add-role]'), // only from connection show + save: clickable('[data-test-secret-save]'), + addRole: clickable('[data-test-add-role]'), enable: clickable('[data-test-enable-connection]'), edit: clickable('[data-test-edit-link]'), delete: clickable('[data-test-database-connection-delete]'), diff --git a/ui/types/vault/models/secret-engine.d.ts b/ui/types/vault/models/secret-engine.d.ts index f5c56fa473..0b07a1abdf 100644 --- a/ui/types/vault/models/secret-engine.d.ts +++ b/ui/types/vault/models/secret-engine.d.ts @@ -40,6 +40,7 @@ export default class SecretEngineModel extends Model { get localDisplay(): string; get formFields(): Array; get formFieldGroups(): FormFieldGroups; + destroyRecord(): void; saveZeroAddressConfig(): Promise; validate(): ModelValidations; // need to override isNew which is a computed prop and ts will complain since it sees it as a function