From e1f585c9f0058e3ddeff37c82a8427a1ad2d66f7 Mon Sep 17 00:00:00 2001 From: Angel Garbarino Date: Thu, 7 Dec 2023 12:57:37 -0700 Subject: [PATCH] Add directory paths to KV capabilities checks (#24404) * add getter to metadata model * add changelog and data model fix * add test coverage * add nested create coverage * Update 24404.txt * remove from data model * return to how it was --- changelog/24404.txt | 3 + ui/app/models/kv/metadata.js | 226 +-- ui/lib/kv/addon/components/page/list.hbs | 1331 ++++++++++++++-- .../backend/kv/kv-v2-workflow-create-test.js | 1396 +++++------------ .../backend/kv/kv-v2-workflow-delete-test.js | 519 +----- ui/tests/helpers/kv/kv-selectors.js | 269 ++-- ui/tests/helpers/policy-generator/kv.js | 175 ++- 7 files changed, 2006 insertions(+), 1913 deletions(-) create mode 100644 changelog/24404.txt diff --git a/changelog/24404.txt b/changelog/24404.txt new file mode 100644 index 000000000000..6fab70d0bf12 --- /dev/null +++ b/changelog/24404.txt @@ -0,0 +1,3 @@ +```release-note:bug +ui: fix issue where kv v2 capabilities checks were not passing in the full secret path if secret was inside a directory. +``` diff --git a/ui/app/models/kv/metadata.js b/ui/app/models/kv/metadata.js index 1f998f488ad2..11aad70e97fb 100644 --- a/ui/app/models/kv/metadata.js +++ b/ui/app/models/kv/metadata.js @@ -3,112 +3,126 @@ * SPDX-License-Identifier: MPL-2.0 */ -import Model, { attr } from '@ember-data/model'; -import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; -import { withModelValidations } from 'vault/decorators/model-validations'; -import { withFormFields } from 'vault/decorators/model-form-fields'; -import { keyIsFolder } from 'core/utils/key-utils'; -import { isDeleted } from 'kv/utils/kv-deleted'; - -const validations = { - maxVersions: [ - { type: 'number', message: 'Maximum versions must be a number.' }, - { type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' }, - ], +export const PAGE = { + // General selectors that are common between pages + title: '[data-test-header-title]', + breadcrumbs: '[data-test-breadcrumbs]', + breadcrumb: '[data-test-breadcrumbs] li', + breadcrumbAtIdx: (idx) => `[data-test-crumb="${idx}"] a`, + infoRow: '[data-test-component="info-table-row"]', + infoRowValue: (label) => `[data-test-value-div="${label}"]`, + infoRowToggleMasked: (label) => `[data-test-value-div="${label}"] [data-test-button="toggle-masked"]`, + secretTab: (tab) => (tab ? `[data-test-secrets-tab="${tab}"]` : '[data-test-secrets-tab]'), + emptyStateTitle: '[data-test-empty-state-title]', + emptyStateMessage: '[data-test-empty-state-message]', + emptyStateActions: '[data-test-empty-state-actions]', + popup: '[data-test-popup-menu-trigger]', + error: { + title: '[data-test-page-error] h1', + message: '[data-test-page-error] p', + }, + toolbar: 'nav.toolbar', + toolbarAction: 'nav.toolbar-actions .toolbar-link', + secretRow: '[data-test-component="info-table-row"]', // replace with infoRow + // specific page selectors + backends: { + link: (backend) => `[data-test-secrets-backend-link="${backend}"]`, + }, + metadata: { + editBtn: '[data-test-edit-metadata]', + addCustomMetadataBtn: '[data-test-add-custom-metadata]', + customMetadataSection: '[data-test-kv-custom-metadata-section]', + secretMetadataSection: '[data-test-kv-metadata-section]', + deleteMetadata: '[data-test-kv-delete="delete-metadata"]', + }, + detail: { + versionTimestamp: '[data-test-kv-version-tooltip-trigger]', + versionDropdown: '[data-test-version-dropdown]', + version: (number) => `[data-test-version="${number}"]`, + createNewVersion: '[data-test-create-new-version]', + delete: '[data-test-kv-delete="delete"]', + destroy: '[data-test-kv-delete="destroy"]', + undelete: '[data-test-kv-delete="undelete"]', + copy: '[data-test-copy-menu-trigger]', + deleteModal: '[data-test-delete-modal]', + deleteModalTitle: '[data-test-delete-modal] [data-test-modal-title]', + deleteOption: 'input#delete-version', + deleteOptionLatest: 'input#delete-latest-version', + deleteConfirm: '[data-test-delete-modal-confirm]', + }, + edit: { + toggleDiff: '[data-test-toggle-input="Show diff"', + toggleDiffDescription: '[data-test-diff-description]', + }, + list: { + createSecret: '[data-test-toolbar-create-secret]', + item: (secret) => (!secret ? '[data-test-list-item]' : `[data-test-list-item="${secret}"]`), + filter: `[data-test-kv-list-filter]`, + listMenuDelete: `[data-test-popup-metadata-delete]`, + listMenuCreate: `[data-test-popup-create-new-version]`, + overviewCard: '[data-test-overview-card-container="View secret"]', + overviewInput: '[data-test-view-secret] input', + overviewButton: '[data-test-get-secret-detail]', + pagination: '[data-test-pagination]', + paginationInfo: '.hds-pagination-info', + paginationNext: '.hds-pagination-nav__arrow--direction-next', + paginationSelected: '.hds-pagination-nav__number--is-selected', + }, + versions: { + icon: (version) => `[data-test-icon-holder="${version}"]`, + linkedBlock: (version) => + version ? `[data-test-version-linked-block="${version}"]` : '[data-test-version-linked-block]', + versionMenu: (version) => `[data-test-version-linked-block="${version}"] [data-test-popup-menu-trigger]`, + createFromVersion: (version) => `[data-test-create-new-version-from="${version}"]`, + }, + diff: { + visualDiff: '[data-test-visual-diff]', + added: `.jsondiffpatch-added`, + deleted: `.jsondiffpatch-deleted`, + }, + create: { + metadataSection: '[data-test-metadata-section]', + }, + paths: { + copyButton: (label) => `${PAGE.infoRowValue(label)} button`, + codeSnippet: (section) => `[data-test-code-snippet][data-test-commands="${section}"] code`, + snippetCopy: (section) => `[data-test-code-snippet][data-test-commands="${section}"] button`, + }, }; -const formFieldProps = ['customMetadata', 'maxVersions', 'casRequired', 'deleteVersionAfter']; - -@withModelValidations(validations) -@withFormFields(formFieldProps) -export default class KvSecretMetadataModel extends Model { - @attr('string') backend; - @attr('string') path; - @attr('string') fullSecretPath; - - @attr('number', { - defaultValue: 0, - label: 'Maximum number of versions', - subText: - 'The number of versions to keep per key. Once the number of keys exceeds the maximum number set here, the oldest version will be permanently deleted.', - }) - maxVersions; - - @attr('boolean', { - defaultValue: false, - label: 'Require Check and Set', - subText: `Writes will only be allowed if the key's current version matches the version specified in the cas parameter.`, - }) - casRequired; - - @attr('string', { - defaultValue: '0s', - editType: 'ttl', - label: 'Automate secret deletion', - helperTextDisabled: `A secret's version must be manually deleted.`, - helperTextEnabled: 'Delete all new versions of this secret after:', - }) - deleteVersionAfter; - - @attr('object', { - editType: 'kv', - subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.', - }) - customMetadata; - - // Additional Params only returned on the GET response. - @attr('string') createdTime; - @attr('number') currentVersion; - @attr('number') oldestVersion; - @attr('string') updatedTime; - @attr('object') versions; - // used for KV list and list-directory view - get pathIsDirectory() { - // ex: beep/ - return keyIsFolder(this.path); - } - - // cannot use isDeleted due to ember property conflict - get isSecretDeleted() { - return isDeleted(this.deletionTime); - } - - // turns version object into an array for version dropdown menu - get sortedVersions() { - const array = []; - for (const key in this.versions) { - this.versions[key].isSecretDeleted = isDeleted(this.versions[key].deletion_time); - array.push({ version: key, ...this.versions[key] }); - } - // version keys are in order created with 1 being the oldest, we want newest first - return array.reverse(); - } - - // helps in long logic statements for state of a currentVersion - get currentSecret() { - if (!this.versions || !this.currentVersion) return false; - const data = this.versions[this.currentVersion]; - const state = data.destroyed ? 'destroyed' : isDeleted(data.deletion_time) ? 'deleted' : 'created'; - return { - state, - isDeactivated: state !== 'created', - }; - } - - // permissions needed for the list view where kv/data has not yet been called. Allows us to conditionally show action items in the LinkedBlock popups. - @lazyCapabilities(apiPath`${'backend'}/data/${'path'}`, 'backend', 'path') dataPath; - @lazyCapabilities(apiPath`${'backend'}/metadata/${'path'}`, 'backend', 'path') metadataPath; +// Form/Interactive selectors that are common between pages and forms +export const FORM = { + inputByAttr: (attr) => `[data-test-input="${attr}"]`, + fieldByAttr: (attr) => `[data=test=field="${attr}"]`, // formfield + toggleJson: '[data-test-toggle-input="json"]', + toggleMasked: '[data-test-button="toggle-masked"]', + toggleMetadata: '[data-test-metadata-toggle]', + jsonEditor: '[data-test-component="code-mirror-modifier"]', + ttlValue: (name) => `[data-test-ttl-value="${name}"]`, + toggleByLabel: (label) => `[data-test-ttl-toggle="${label}"]`, + dataInputLabel: ({ isJson = false }) => + isJson ? '[data-test-component="json-editor-title"]' : '[data-test-kv-label]', + // + kvLabel: '[data-test-kv-label]', + kvRow: '[data-test-kv-row]', + keyInput: (idx = 0) => `[data-test-kv-key="${idx}"]`, + valueInput: (idx = 0) => `[data-test-kv-value="${idx}"]`, + maskedValueInput: (idx = 0) => `[data-test-kv-value="${idx}"] [data-test-textarea]`, + addRow: (idx = 0) => `[data-test-kv-add-row="${idx}"]`, + deleteRow: (idx = 0) => `[data-test-kv-delete-row="${idx}"]`, + // Alerts & validation + inlineAlert: '[data-test-inline-alert]', + validation: (attr) => `[data-test-field="${attr}"] [data-test-inline-alert]`, + messageError: '[data-test-message-error]', + validationWarning: '[data-test-validation-warning]', + invalidFormAlert: '[data-test-invalid-form-alert]', + versionAlert: '[data-test-secret-version-alert]', + noReadAlert: '[data-test-secret-no-read-alert]', + // Form btns + saveBtn: '[data-test-kv-save]', + cancelBtn: '[data-test-kv-cancel]', +}; - get canDeleteMetadata() { - return this.metadataPath.get('canDelete') !== false; - } - get canReadMetadata() { - return this.metadataPath.get('canRead') !== false; - } - get canUpdateMetadata() { - return this.metadataPath.get('canUpdate') !== false; - } - get canCreateVersionData() { - return this.dataPath.get('canUpdate') !== false; - } -} +export const parseJsonEditor = (find) => { + return JSON.parse(find(FORM.jsonEditor).innerText); +}; diff --git a/ui/lib/kv/addon/components/page/list.hbs b/ui/lib/kv/addon/components/page/list.hbs index 607a0906a79e..4c405611d20e 100644 --- a/ui/lib/kv/addon/components/page/list.hbs +++ b/ui/lib/kv/addon/components/page/list.hbs @@ -1,141 +1,1190 @@ - - <:tabLinks> - Secrets - Configuration - - - <:toolbarFilters> - - {{#if (and (not-eq @secrets 403) (or @secrets @filterValue))}} - - {{/if}} - - - <:toolbarActions> - - Create secret - - - -{{#if (eq @secrets 403)}} -
-
- -
- - - - {{#if @failedDirectoryQuery}} - - {{/if}} -
-
-
-{{else}} - {{#if @secrets}} - {{#each @secrets as |metadata|}} - -
-
-
- - - {{metadata.path}} - -
-
-
-
- - - -
-
-
-
- {{/each}} - {{! Pagination }} - - {{else}} - {{#if @filterValue}} - - {{else}} - - - Create secret - - - {{/if}} - {{/if}} -{{/if}} \ No newline at end of file +/** + * Copyright (c) HashiCorp, Inc. + */ + +import { module, test } from 'qunit'; +import { v4 as uuidv4 } from 'uuid'; +import { click, currentURL, fillIn, typeIn, visit } from '@ember/test-helpers'; + +import { setupApplicationTest } from 'vault/tests/helpers'; +import authPage from 'vault/tests/pages/auth'; +import { deleteEngineCmd, mountEngineCmd, runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands'; +import { personas } from 'vault/tests/helpers/policy-generator/kv'; +import { clearRecords, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands'; +import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors'; +import { grantAccessForWrite, setupControlGroup } from 'vault/tests/helpers/control-groups'; + +/** + * This test set is for testing the flow for creating new secrets and versions. + * Letter(s) in parenthesis at the end are shorthand for the persona, + * for ease of tracking down specific tests failures from CI + */ +module('Acceptance | kv-v2 workflow | secret and version create', function (hooks) { + setupApplicationTest(hooks); + + hooks.beforeEach(async function () { + this.backend = `kv-create-${uuidv4()}`; + this.store = this.owner.lookup('service:store'); + await authPage.login(); + await runCmd(mountEngineCmd('kv-v2', this.backend), false); + await writeVersionedSecret(this.backend, 'app/first', 'foo', 'bar', 2); + }); + + hooks.afterEach(async function () { + await authPage.login(); + return runCmd(deleteEngineCmd(this.backend)); + }); + + module('admin persona', function (hooks) { + hooks.beforeEach(async function () { + const token = await runCmd(tokenWithPolicyCmd('admin', personas.admin(this.backend))); + await authPage.login(token); + clearRecords(this.store); + return; + }); + test('cancel on create clears model (a)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list`); + assert.dom(PAGE.list.item()).exists({ count: 1 }, 'single secret exists on list'); + assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); + await click(PAGE.list.createSecret); + await fillIn(FORM.inputByAttr('path'), 'jk'); + await click(FORM.cancelBtn); + assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); + assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); + await click(PAGE.list.createSecret); + await fillIn(FORM.inputByAttr('path'), 'psych'); + await click(PAGE.breadcrumbAtIdx(1)); + assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); + assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); + }); + test('cancel on new version rolls back model (a)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`); + assert.dom(PAGE.infoRowValue('foo')).exists('key has expected value'); + await click(PAGE.detail.createNewVersion); + await fillIn(FORM.keyInput(), 'bar'); + await click(FORM.cancelBtn); + assert.dom(PAGE.infoRowValue('foo')).exists('secret is previous value'); + await click(PAGE.detail.createNewVersion); + await fillIn(FORM.keyInput(), 'bar'); + await click(PAGE.breadcrumbAtIdx(3)); + assert.dom(PAGE.infoRowValue('foo')).exists('secret is previous value'); + }); + test('create & update root secret with default metadata (a)', async function (assert) { + const backend = this.backend; + const secretPath = 'some secret'; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret form -- validations + await click(FORM.saveBtn); + assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); + assert.dom(FORM.validation('path')).hasText("Path can't be blank."); + await typeIn(FORM.inputByAttr('path'), secretPath); + assert + .dom(FORM.validationWarning) + .hasText( + "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests." + ); + assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); + + // Submit with API errors + await click(FORM.saveBtn); + assert.dom(FORM.messageError).hasText('Error no data provided', 'API error shows on form'); + + await fillIn(FORM.keyInput(), 'api_key'); + await fillIn(FORM.maskedValueInput(), 'partyparty'); + await click(FORM.saveBtn); + + // Details page + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=1`, + 'Goes to details page after save' + ); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); + assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); + assert.dom(PAGE.infoRowValue('api_key')).hasText('***********'); + await click(PAGE.infoRowToggleMasked('api_key')); + assert.dom(PAGE.infoRowValue('api_key')).hasText('partyparty', 'secret value shows after toggle'); + + // Metadata page + await click(PAGE.secretTab('Metadata')); + assert + .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText('No custom metadata', 'No custom metadata empty state'); + assert + .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.infoRow}`) + .exists({ count: 4 }, '4 metadata rows show'); + assert.dom(PAGE.infoRowValue('Maximum versions')).hasText('0', 'max versions shows 0'); + assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('No', 'cas not enforced'); + assert + .dom(PAGE.infoRowValue('Delete version after')) + .hasText('Never delete', 'Delete version after has default 0s'); + + // Add new version + await click(PAGE.secretTab('Secret')); + await click(PAGE.detail.createNewVersion); + assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); + assert.dom(FORM.inputByAttr('path')).hasValue(secretPath); + assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); + assert.dom(FORM.keyInput()).hasValue('api_key'); + assert.dom(FORM.maskedValueInput()).hasValue('partyparty'); + await fillIn(FORM.keyInput(1), 'api_url'); + await fillIn(FORM.maskedValueInput(1), 'hashicorp.com'); + await click(FORM.saveBtn); + + // Back to details page + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2` + ); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); + assert.dom(PAGE.infoRow).exists({ count: 2 }, '2 rows of data shows'); + assert.dom(PAGE.infoRowValue('api_key')).hasText('***********'); + assert.dom(PAGE.infoRowValue('api_url')).hasText('***********'); + await click(PAGE.infoRowToggleMasked('api_key')); + await click(PAGE.infoRowToggleMasked('api_url')); + assert.dom(PAGE.infoRowValue('api_key')).hasText('partyparty', 'secret value shows after toggle'); + assert.dom(PAGE.infoRowValue('api_url')).hasText('hashicorp.com', 'secret value shows after toggle'); + }); + test('create nested secret with metadata (a)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret + await typeIn(FORM.inputByAttr('path'), 'my/'); + assert.dom(FORM.validation('path')).hasText("Path can't end in forward slash '/'."); + await typeIn(FORM.inputByAttr('path'), 'secret'); + assert.dom(FORM.validation('path')).doesNotExist('form validation goes away'); + await fillIn(FORM.keyInput(), 'password'); + await fillIn(FORM.maskedValueInput(), 'kittens1234'); + + await click(FORM.toggleMetadata); + assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled'); + // Check initial values + assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0'); + assert.dom(FORM.inputByAttr('casRequired')).isNotChecked(); + assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked(); + // MaxVersions validation + await fillIn(FORM.inputByAttr('maxVersions'), 'seven'); + await click(FORM.saveBtn); + assert.dom(FORM.validation('maxVersions')).hasText('Maximum versions must be a number.'); + await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999'); + await click(FORM.saveBtn); + assert.dom(FORM.validation('maxVersions')).hasText('You cannot go over 16 characters.'); + await fillIn(FORM.inputByAttr('maxVersions'), '7'); + + // Fill in other metadata + await click(FORM.inputByAttr('casRequired')); + await click(FORM.toggleByLabel('Automate secret deletion')); + await fillIn(FORM.ttlValue('Automate secret deletion'), '1000'); + + // Fill in custom metadata + await fillIn(`${PAGE.create.metadataSection} ${FORM.keyInput()}`, 'team'); + await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); + // Fill in metadata + await click(FORM.saveBtn); + + // Details + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}/details?version=1` + ); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); + assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); + assert.dom(PAGE.infoRowValue('password')).hasText('***********'); + await click(PAGE.infoRowToggleMasked('password')); + assert.dom(PAGE.infoRowValue('password')).hasText('kittens1234', 'secret value shows after toggle'); + + // Metadata + await click(PAGE.secretTab('Metadata')); + assert + .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.infoRow}`) + .exists({ count: 1 }, 'One custom metadata row shows'); + assert.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.infoRowValue('team')}`).hasText('UI'); + + assert + .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.infoRow}`) + .exists({ count: 4 }, '4 metadata rows show'); + assert.dom(PAGE.infoRowValue('Maximum versions')).hasText('7', 'max versions shows 0'); + assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('Yes', 'cas enforced'); + assert + .dom(PAGE.infoRowValue('Delete version after')) + .hasText('16 minutes 40 seconds', 'Delete version after has custom value'); + }); + test('creates a secret at a sub-directory (a)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list/app/`); + assert.dom(PAGE.list.item('first')).exists('Lists first sub-secret'); + assert.dom(PAGE.list.item('new')).doesNotExist('Does not show new secret'); + await click(PAGE.list.createSecret); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/create?initialKey=app%2F`, + 'Goes to create page with initialKey' + ); + await typeIn(FORM.inputByAttr('path'), 'new'); + await fillIn(FORM.keyInput(), 'api_key'); + await fillIn(FORM.maskedValueInput(), 'partyparty'); + await click(FORM.saveBtn); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent('app/new')}/details?version=1`, + 'Redirects to detail after save' + ); + await click(PAGE.breadcrumbAtIdx(2)); + assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, 'sub-dir page'); + assert.dom(PAGE.list.item('new')).exists('Lists new secret in sub-dir'); + }); + test('create new version of secret from older version (a)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details`); + await click(PAGE.detail.versionDropdown); + await click(`${PAGE.detail.version(1)} a`); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`, + 'goes to version 1' + ); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); + await click(PAGE.detail.createNewVersion); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/app%2Ffirst/details/edit?version=1`, + 'Goes to new version page' + ); + assert + .dom(FORM.versionAlert) + .hasText( + 'Warning You are creating a new version based on data from Version 1. The current version for app/first is Version 2.', + 'Shows version warning' + ); + assert.dom(FORM.keyInput()).hasValue('key-1', 'Key input has old value'); + assert.dom(FORM.maskedValueInput()).hasValue('val-1', 'Val input has old value'); + + await fillIn(FORM.keyInput(), 'my-key'); + await fillIn(FORM.maskedValueInput(), 'my-value'); + await click(FORM.saveBtn); + + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/app%2Ffirst/details?version=3`, + 'goes to latest version 3' + ); + await click(PAGE.infoRowToggleMasked('my-key')); + assert.dom(PAGE.infoRowValue('my-key')).hasText('my-value', 'has new value'); + }); + }); + + module('data-reader persona', function (hooks) { + hooks.beforeEach(async function () { + const token = await runCmd(tokenWithPolicyCmd('data-reader', personas.dataReader(this.backend))); + await authPage.login(token); + clearRecords(this.store); + return; + }); + test('cancel on create clears model (dr)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list`); + assert.dom(PAGE.list.item()).doesNotExist('list view has no items'); + await click(PAGE.list.createSecret); + await fillIn(FORM.inputByAttr('path'), 'jk'); + await click(FORM.cancelBtn); + assert.dom(PAGE.list.item()).doesNotExist('list view still has no items'); + await click(PAGE.list.createSecret); + await fillIn(FORM.inputByAttr('path'), 'psych'); + await click(PAGE.breadcrumbAtIdx(1)); + assert.dom(PAGE.list.item()).doesNotExist('list view still has no items'); + }); + test('cancel on new version rolls back model (dr)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`); + assert.dom(PAGE.infoRowValue('foo')).exists('key has expected value'); + assert.dom(PAGE.detail.createNewVersion).doesNotExist(); + }); + test('create & update root secret with default metadata (dr)', async function (assert) { + const backend = this.backend; + const secretPath = 'some secret'; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret form -- validations + await click(FORM.saveBtn); + assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); + assert.dom(FORM.validation('path')).hasText("Path can't be blank."); + await typeIn(FORM.inputByAttr('path'), secretPath); + assert + .dom(FORM.validationWarning) + .hasText( + "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests." + ); + assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); + + // Submit with API errors + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + + // Since this persona can't create a new secret, test update with existing: + await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details`); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); + assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); + assert.dom(PAGE.infoRowValue('foo')).hasText('***********'); + await click(PAGE.infoRowToggleMasked('foo')); + assert.dom(PAGE.infoRowValue('foo')).hasText('bar', 'secret value shows after toggle'); + + // Metadata page + await click(PAGE.secretTab('Metadata')); + assert + .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText('No custom metadata', 'No custom metadata empty state'); + assert + .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText('You do not have access to secret metadata', 'shows no access state on metadata'); + + // Add new version + await click(PAGE.secretTab('Secret')); + assert.dom(PAGE.detail.createNewVersion).doesNotExist('cannot create new version'); + }); + test('create nested secret with metadata (dr)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret + await typeIn(FORM.inputByAttr('path'), 'my/'); + assert.dom(FORM.validation('path')).hasText("Path can't end in forward slash '/'."); + await typeIn(FORM.inputByAttr('path'), 'secret'); + assert.dom(FORM.validation('path')).doesNotExist('form validation goes away'); + await fillIn(FORM.keyInput(), 'password'); + await fillIn(FORM.maskedValueInput(), 'kittens1234'); + + await click(FORM.toggleMetadata); + assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled'); + // Check initial values + assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0'); + assert.dom(FORM.inputByAttr('casRequired')).isNotChecked(); + assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked(); + // MaxVersions validation + await fillIn(FORM.inputByAttr('maxVersions'), 'seven'); + await click(FORM.saveBtn); + assert.dom(FORM.validation('maxVersions')).hasText('Maximum versions must be a number.'); + await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999'); + await click(FORM.saveBtn); + assert.dom(FORM.validation('maxVersions')).hasText('You cannot go over 16 characters.'); + await fillIn(FORM.inputByAttr('maxVersions'), '7'); + + // Fill in other metadata + await click(FORM.inputByAttr('casRequired')); + await click(FORM.toggleByLabel('Automate secret deletion')); + await fillIn(FORM.ttlValue('Automate secret deletion'), '1000'); + + // Fill in custom metadata + await fillIn(`${PAGE.create.metadataSection} ${FORM.keyInput()}`, 'team'); + await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); + // Fill in metadata + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + }); + test('creates a secret at a sub-directory (dr)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list/app/`); + assert.dom(PAGE.list.item()).doesNotExist('Does not list any secrets'); + await click(PAGE.list.createSecret); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/create?initialKey=app%2F`, + 'Goes to create page with initialKey' + ); + await typeIn(FORM.inputByAttr('path'), 'new'); + await fillIn(FORM.keyInput(), 'api_key'); + await fillIn(FORM.maskedValueInput(), 'partyparty'); + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + }); + test('create new version of secret from older version (dr)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`); + assert.dom(PAGE.detail.versionDropdown).doesNotExist('version dropdown does not show'); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); + assert.dom(PAGE.detail.createNewVersion).doesNotExist('cannot create new version'); + }); + }); + + module('data-list-reader persona', function (hooks) { + hooks.beforeEach(async function () { + const token = await runCmd( + tokenWithPolicyCmd('data-list-reader', personas.dataListReader(this.backend)) + ); + await authPage.login(token); + clearRecords(this.store); + return; + }); + test('cancel on create clears model (dlr)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list`); + assert.dom(PAGE.list.item()).exists({ count: 1 }, 'single secret exists on list'); + assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); + await click(PAGE.list.createSecret); + await fillIn(FORM.inputByAttr('path'), 'jk'); + await click(FORM.cancelBtn); + assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); + assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); + await click(PAGE.list.createSecret); + await fillIn(FORM.inputByAttr('path'), 'psych'); + await click(PAGE.breadcrumbAtIdx(1)); + assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); + assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); + }); + test('cancel on new version rolls back model (dlr)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`); + assert.dom(PAGE.infoRowValue('foo')).exists('key has expected value'); + assert.dom(PAGE.detail.createNewVersion).doesNotExist('cannot create new version'); + }); + test('create & update root secret with default metadata (dlr)', async function (assert) { + const backend = this.backend; + const secretPath = 'some secret'; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret form -- validations + await click(FORM.saveBtn); + assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); + assert.dom(FORM.validation('path')).hasText("Path can't be blank."); + await typeIn(FORM.inputByAttr('path'), secretPath); + assert + .dom(FORM.validationWarning) + .hasText( + "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests." + ); + assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); + + // Submit with API errors + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + + // Since this persona can't create a new secret, test update with existing: + await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details`); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); + assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); + assert.dom(PAGE.infoRowValue('foo')).hasText('***********'); + await click(PAGE.infoRowToggleMasked('foo')); + assert.dom(PAGE.infoRowValue('foo')).hasText('bar', 'secret value shows after toggle'); + + // Metadata page + await click(PAGE.secretTab('Metadata')); + assert + .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText('No custom metadata', 'No custom metadata empty state'); + assert + .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText('You do not have access to secret metadata', 'shows no access state on metadata'); + + // Add new version + await click(PAGE.secretTab('Secret')); + assert.dom(PAGE.detail.createNewVersion).doesNotExist('cannot create new version'); + }); + test('create nested secret with metadata (dlr)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret + await typeIn(FORM.inputByAttr('path'), 'my/'); + assert.dom(FORM.validation('path')).hasText("Path can't end in forward slash '/'."); + await typeIn(FORM.inputByAttr('path'), 'secret'); + assert.dom(FORM.validation('path')).doesNotExist('form validation goes away'); + await fillIn(FORM.keyInput(), 'password'); + await fillIn(FORM.maskedValueInput(), 'kittens1234'); + + await click(FORM.toggleMetadata); + assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled'); + // Check initial values + assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0'); + assert.dom(FORM.inputByAttr('casRequired')).isNotChecked(); + assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked(); + // MaxVersions validation + await fillIn(FORM.inputByAttr('maxVersions'), 'seven'); + await click(FORM.saveBtn); + assert.dom(FORM.validation('maxVersions')).hasText('Maximum versions must be a number.'); + await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999'); + await click(FORM.saveBtn); + assert.dom(FORM.validation('maxVersions')).hasText('You cannot go over 16 characters.'); + await fillIn(FORM.inputByAttr('maxVersions'), '7'); + + // Fill in other metadata + await click(FORM.inputByAttr('casRequired')); + await click(FORM.toggleByLabel('Automate secret deletion')); + await fillIn(FORM.ttlValue('Automate secret deletion'), '1000'); + + // Fill in custom metadata + await fillIn(`${PAGE.create.metadataSection} ${FORM.keyInput()}`, 'team'); + await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); + // Fill in metadata + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + }); + test('creates a secret at a sub-directory (dlr)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list/app/`); + assert.dom(PAGE.list.item()).doesNotExist('Does not list any secrets'); + await click(PAGE.list.createSecret); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/create?initialKey=app%2F`, + 'Goes to create page with initialKey' + ); + await typeIn(FORM.inputByAttr('path'), 'new'); + await fillIn(FORM.keyInput(), 'api_key'); + await fillIn(FORM.maskedValueInput(), 'partyparty'); + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + }); + test('create new version of secret from older version (dlr)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`); + assert.dom(PAGE.detail.versionDropdown).doesNotExist('version dropdown does not show'); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); + assert.dom(PAGE.detail.createNewVersion).doesNotExist('cannot create new version'); + }); + }); + + module('metadata-maintainer persona', function (hooks) { + hooks.beforeEach(async function () { + const token = await runCmd( + tokenWithPolicyCmd('data-list-reader', personas.metadataMaintainer(this.backend)) + ); + await authPage.login(token); + clearRecords(this.store); + return; + }); + test('cancel on create clears model (mm)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list`); + assert.dom(PAGE.list.item()).exists({ count: 1 }, 'single secret exists on list'); + assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); + await click(PAGE.list.createSecret); + await fillIn(FORM.inputByAttr('path'), 'jk'); + await click(FORM.cancelBtn); + assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); + assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); + await click(PAGE.list.createSecret); + await fillIn(FORM.inputByAttr('path'), 'psych'); + await click(PAGE.breadcrumbAtIdx(1)); + assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); + assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); + }); + test('cancel on new version rolls back model (mm)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`); + assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); + assert + .dom(PAGE.detail.createNewVersion) + .doesNotExist('create new version button now allowed since user cannot read existing'); + }); + test('create & update root secret with default metadata (mm)', async function (assert) { + const backend = this.backend; + const secretPath = 'some secret'; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret form -- validations + await click(FORM.saveBtn); + assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); + assert.dom(FORM.validation('path')).hasText("Path can't be blank."); + await typeIn(FORM.inputByAttr('path'), secretPath); + assert + .dom(FORM.validationWarning) + .hasText( + "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests." + ); + assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); + + // Submit with API errors + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + + // Since this persona can't create a new secret, test update with existing: + await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details`); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created tooltip does not show'); + assert.dom(PAGE.infoRow).doesNotExist('secret data not shown'); + assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); + + // Metadata page + await click(PAGE.secretTab('Metadata')); + assert + .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText('No custom metadata', 'No custom metadata empty state'); + assert + .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.infoRow}`) + .exists({ count: 4 }, '4 metadata rows show'); + assert.dom(PAGE.infoRowValue('Maximum versions')).hasText('0', 'max versions shows 0'); + assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('No', 'cas not enforced'); + assert + .dom(PAGE.infoRowValue('Delete version after')) + .hasText('Never delete', 'Delete version after has default 0s'); + + // Add new version + await click(PAGE.secretTab('Secret')); + assert.dom(PAGE.detail.createNewVersion).doesNotExist('create new version button not rendered'); + await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details/edit?version=1`); + assert + .dom(FORM.noReadAlert) + .hasText( + 'Warning You do not have read permissions for this secret data. Saving will overwrite the existing secret.', + 'shows alert for no read permissions' + ); + + assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); + assert.dom(FORM.inputByAttr('path')).hasValue('app/first'); + assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); + assert.dom(FORM.keyInput()).hasValue('', 'first row has no key'); + assert.dom(FORM.maskedValueInput()).hasValue('', 'first row has no value'); + await fillIn(FORM.keyInput(), 'api_url'); + await fillIn(FORM.maskedValueInput(), 'hashicorp.com'); + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + }); + test('create nested secret with metadata (mm)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret + await typeIn(FORM.inputByAttr('path'), 'my/'); + assert.dom(FORM.validation('path')).hasText("Path can't end in forward slash '/'."); + await typeIn(FORM.inputByAttr('path'), 'secret'); + assert.dom(FORM.validation('path')).doesNotExist('form validation goes away'); + await fillIn(FORM.keyInput(), 'password'); + await fillIn(FORM.maskedValueInput(), 'kittens1234'); + + await click(FORM.toggleMetadata); + assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled'); + // Check initial values + assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0'); + assert.dom(FORM.inputByAttr('casRequired')).isNotChecked(); + assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked(); + // MaxVersions validation + await fillIn(FORM.inputByAttr('maxVersions'), 'seven'); + await click(FORM.saveBtn); + assert.dom(FORM.validation('maxVersions')).hasText('Maximum versions must be a number.'); + await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999'); + await click(FORM.saveBtn); + assert.dom(FORM.validation('maxVersions')).hasText('You cannot go over 16 characters.'); + await fillIn(FORM.inputByAttr('maxVersions'), '7'); + + // Fill in other metadata + await click(FORM.inputByAttr('casRequired')); + await click(FORM.toggleByLabel('Automate secret deletion')); + await fillIn(FORM.ttlValue('Automate secret deletion'), '1000'); + + // Fill in custom metadata + await fillIn(`${PAGE.create.metadataSection} ${FORM.keyInput()}`, 'team'); + await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); + + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + }); + test('creates a secret at a sub-directory (mm)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list/app/`); + assert.dom(PAGE.list.item('first')).exists('Lists first sub-secret'); + assert.dom(PAGE.list.item('new')).doesNotExist('Does not show new secret'); + await click(PAGE.list.createSecret); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/create?initialKey=app%2F`, + 'Goes to create page with initialKey' + ); + await typeIn(FORM.inputByAttr('path'), 'new'); + await fillIn(FORM.keyInput(), 'api_key'); + await fillIn(FORM.maskedValueInput(), 'partyparty'); + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + }); + test('create new version of secret from older version (mm)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details`); + assert.dom(PAGE.detail.versionDropdown).hasText('Version 2'); + await click(PAGE.detail.versionDropdown); + await click(`${PAGE.detail.version(1)} a`); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`, + 'goes to version 1' + ); + assert.dom(PAGE.detail.versionDropdown).hasText('Version 1'); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('version timestamp not shown'); + assert.dom(PAGE.detail.createNewVersion).doesNotExist('create new version button not rendered'); + await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details/edit?version=1`); + assert + .dom(FORM.noReadAlert) + .hasText( + 'Warning You do not have read permissions for this secret data. Saving will overwrite the existing secret.', + 'shows alert for no read permissions' + ); + + assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); + assert.dom(FORM.inputByAttr('path')).hasValue('app/first'); + assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); + assert.dom(FORM.keyInput()).hasValue('', 'first row has no key'); + assert.dom(FORM.maskedValueInput()).hasValue('', 'first row has no value'); + await fillIn(FORM.keyInput(), 'api_url'); + await fillIn(FORM.maskedValueInput(), 'hashicorp.com'); + await click(FORM.saveBtn); + assert + .dom(FORM.messageError) + .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + }); + }); + + module('secret-creator persona', function (hooks) { + hooks.beforeEach(async function () { + const token = await runCmd(tokenWithPolicyCmd('secret-creator', personas.secretCreator(this.backend))); + await authPage.login(token); + clearRecords(this.store); + return; + }); + test('cancel on create clears model (sc)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list`); + assert.dom(PAGE.list.item()).doesNotExist('list view has no items'); + await click(PAGE.list.createSecret); + await fillIn(FORM.inputByAttr('path'), 'jk'); + await click(FORM.cancelBtn); + assert.dom(PAGE.list.item()).doesNotExist('list view still has no items'); + await click(PAGE.list.createSecret); + await fillIn(FORM.inputByAttr('path'), 'psych'); + await click(PAGE.breadcrumbAtIdx(1)); + assert.dom(PAGE.list.item()).doesNotExist('list view still has no items'); + }); + test('cancel on new version rolls back model (sc)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`); + assert + .dom(PAGE.emptyStateTitle) + .hasText('You do not have permission to read this secret', 'no permissions state shows'); + await click(PAGE.detail.createNewVersion); + await fillIn(FORM.keyInput(), 'bar'); + await click(FORM.cancelBtn); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`, + 'cancel goes to correct url' + ); + assert.dom(PAGE.list.item()).doesNotExist('list view has no items'); + await click(PAGE.detail.createNewVersion); + await fillIn(FORM.keyInput(), 'bar'); + await click(PAGE.breadcrumbAtIdx(3)); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`, + 'breadcrumb goes to correct url' + ); + assert.dom(PAGE.list.item()).doesNotExist('list view has no items'); + }); + test('create & update root secret with default metadata (sc)', async function (assert) { + const backend = this.backend; + const secretPath = 'some secret'; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret form -- validations + await click(FORM.saveBtn); + assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); + assert.dom(FORM.validation('path')).hasText("Path can't be blank."); + await typeIn(FORM.inputByAttr('path'), secretPath); + assert + .dom(FORM.validationWarning) + .hasText( + "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests." + ); + assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); + + // Submit with API errors + await click(FORM.saveBtn); + assert.dom(FORM.messageError).hasText('Error no data provided', 'API error shows on form'); + + await fillIn(FORM.keyInput(), 'api_key'); + await fillIn(FORM.maskedValueInput(), 'partyparty'); + await click(FORM.saveBtn); + + // Details page + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details`, + 'Goes to details page after save' + ); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created not shown'); + assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); + assert + .dom(PAGE.emptyStateTitle) + .hasText('You do not have permission to read this secret', 'shows permissions empty state'); + + // Metadata page + await click(PAGE.secretTab('Metadata')); + assert + .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText( + 'You do not have access to read custom metadata', + 'permissions empty state for custom metadata' + ); + assert + .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText('You do not have access to secret metadata', 'permissions empty state for secret metadata'); + + // Add new version + await click(PAGE.secretTab('Secret')); + await click(PAGE.detail.createNewVersion); + assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); + assert.dom(FORM.inputByAttr('path')).hasValue(secretPath); + assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); + assert.dom(FORM.keyInput()).hasValue('', 'row 1 is empty key'); + assert.dom(FORM.maskedValueInput()).hasValue('', 'row 1 has empty value'); + await fillIn(FORM.keyInput(), 'api_url'); + await fillIn(FORM.maskedValueInput(), 'hashicorp.com'); + await click(FORM.saveBtn); + + // Back to details page + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2`, + 'goes back to details page' + ); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created does not show'); + assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); + assert + .dom(PAGE.emptyStateTitle) + .hasText('You do not have permission to read this secret', 'shows permissions empty state'); + }); + test('create nested secret with metadata (sc)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret + await typeIn(FORM.inputByAttr('path'), 'my/'); + assert.dom(FORM.validation('path')).hasText("Path can't end in forward slash '/'."); + await typeIn(FORM.inputByAttr('path'), 'secret'); + assert.dom(FORM.validation('path')).doesNotExist('form validation goes away'); + await fillIn(FORM.keyInput(), 'password'); + await fillIn(FORM.maskedValueInput(), 'kittens1234'); + + await click(FORM.toggleMetadata); + assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled'); + // Check initial values + assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0'); + assert.dom(FORM.inputByAttr('casRequired')).isNotChecked(); + assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked(); + // MaxVersions validation + await fillIn(FORM.inputByAttr('maxVersions'), 'seven'); + await click(FORM.saveBtn); + assert.dom(FORM.validation('maxVersions')).hasText('Maximum versions must be a number.'); + await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999'); + await click(FORM.saveBtn); + assert.dom(FORM.validation('maxVersions')).hasText('You cannot go over 16 characters.'); + await fillIn(FORM.inputByAttr('maxVersions'), '7'); + + // Fill in other metadata + await click(FORM.inputByAttr('casRequired')); + await click(FORM.toggleByLabel('Automate secret deletion')); + await fillIn(FORM.ttlValue('Automate secret deletion'), '1000'); + + // Fill in custom metadata + await fillIn(`${PAGE.create.metadataSection} ${FORM.keyInput()}`, 'team'); + await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); + // Fill in metadata + await click(FORM.saveBtn); + + // Details + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}/details`, + 'goes back to details page' + ); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('version created not shown'); + assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); + assert + .dom(PAGE.emptyStateTitle) + .hasText('You do not have permission to read this secret', 'shows permissions empty state'); + + // Metadata + await click(PAGE.secretTab('Metadata')); + assert + .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText( + 'You do not have access to read custom metadata', + 'permissions empty state for custom metadata' + ); + assert + .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText('You do not have access to secret metadata', 'permissions empty state for secret metadata'); + }); + test('creates a secret at a sub-directory (sc)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/list/app/`); + assert.dom(PAGE.list.item()).doesNotExist('Does not list any secrets'); + await click(PAGE.list.createSecret); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/create?initialKey=app%2F`, + 'Goes to create page with initialKey' + ); + await typeIn(FORM.inputByAttr('path'), 'new'); + await fillIn(FORM.keyInput(), 'api_key'); + await fillIn(FORM.maskedValueInput(), 'partyparty'); + await click(FORM.saveBtn); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent('app/new')}/details`, + 'Redirects to detail after save' + ); + await click(PAGE.breadcrumbAtIdx(2)); + assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, 'sub-dir page'); + assert.dom(PAGE.list.item()).doesNotExist('Does not list any secrets'); + }); + test('create new version of secret from older version (sc)', async function (assert) { + const backend = this.backend; + await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`); + assert.dom(PAGE.detail.versionDropdown).doesNotExist('version dropdown does not show'); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created not shown'); + await click(PAGE.detail.createNewVersion); + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/app%2Ffirst/details/edit?version=1`, + 'Goes to new version page' + ); + assert + .dom(FORM.noReadAlert) + .hasText( + 'Warning You do not have read permissions for this secret data. Saving will overwrite the existing secret.', + 'shows alert for no read permissions' + ); + assert.dom(FORM.keyInput()).hasValue('', 'Key input has empty value'); + assert.dom(FORM.maskedValueInput()).hasValue('', 'Val input has empty value'); + + await fillIn(FORM.keyInput(), 'my-key'); + await fillIn(FORM.maskedValueInput(), 'my-value'); + await click(FORM.saveBtn); + + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/app%2Ffirst/details?version=3`, + 'redirects to details page' + ); + assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); + assert + .dom(PAGE.emptyStateTitle) + .hasText('You do not have permission to read this secret', 'shows permissions empty state'); + }); + }); + + module('secret-nested-creator persona', function (hooks) { + hooks.beforeEach(async function () { + const token = await runCmd( + tokenWithPolicyCmd('secret-nested-creator', personas.secretNestedCreator(this.backend)) + ); + await authPage.login(token); + clearRecords(this.store); + return; + }); + test('can create a secret from the nested list view (snc)', async function (assert) { + assert.expect(1); + // go to nested secret directory list view + await visit(`/vault/secrets/${this.backend}/kv/list/app/`); + // correct popup menu items appear on list view + const popupSelector = `${PAGE.list.item('first')} ${PAGE.popup}`; + await click(popupSelector); + assert.dom(PAGE.list.listMenuCreate).exists('shows the option to create new version'); + }); + }); + + module('enterprise controlled access persona', function (hooks) { + hooks.beforeEach(async function () { + this.controlGroup = this.owner.lookup('service:control-group'); + const userPolicy = ` +path "${this.backend}/data/*" { + capabilities = ["create", "read", "update"] + control_group = { + max_ttl = "24h" + factor "authorizer" { + controlled_capabilities = ["create", "update"] + identity { + group_names = ["managers"] + approvals = 1 + } + } + } +} + +path "${this.backend}/metadata" { + capabilities = ["list", "read"] +} +path "${this.backend}/metadata/*" { + capabilities = ["list", "read"] +} +`; + const { userToken } = await setupControlGroup({ userPolicy }); + this.userToken = userToken; + await authPage.login(userToken); + clearRecords(this.store); + return; + }); + test('create & update root secret with default metadata (cg)', async function (assert) { + const backend = this.backend; + // Known issue: control groups do not work correctly in UI when encodable characters in path + const secretPath = 'some-secret'; + await visit(`/vault/secrets/${backend}/kv/list`); + await click(PAGE.list.createSecret); + + // Create secret form -- validations + await click(FORM.saveBtn); + assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); + assert.dom(FORM.validation('path')).hasText("Path can't be blank."); + await typeIn(FORM.inputByAttr('path'), secretPath); + assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); + + await fillIn(FORM.keyInput(), 'api_key'); + await fillIn(FORM.maskedValueInput(), 'partyparty'); + await click(FORM.saveBtn); + let tokenToUnwrap = this.controlGroup.tokenToUnwrap; + assert.deepEqual( + Object.keys(tokenToUnwrap), + ['accessor', 'token', 'creation_path', 'creation_time', 'ttl'], + 'stored tokenToUnwrap includes correct keys' + ); + assert.strictEqual( + tokenToUnwrap.creation_path, + `${backend}/data/${secretPath}`, + 'stored tokenToUnwrap includes correct creation path' + ); + assert + .dom(FORM.messageError) + .includesText( + `Error A Control Group was encountered at ${backend}/data/${secretPath}.`, + 'shows control group error' + ); + await grantAccessForWrite({ + accessor: tokenToUnwrap.accessor, + token: tokenToUnwrap.token, + creation_path: `${backend}/data/${secretPath}`, + originUrl: `/vault/secrets/${backend}/kv/create`, + userToken: this.userToken, + }); + // In a real scenario the user would stay on page, but in the test + // we fill in the same info and try again + await typeIn(FORM.inputByAttr('path'), secretPath); + await fillIn(FORM.keyInput(), 'this can be anything'); + await fillIn(FORM.maskedValueInput(), 'this too, gonna use the wrapped data'); + await click(FORM.saveBtn); + assert.strictEqual(this.controlGroup.tokenToUnwrap, null, 'clears tokenToUnwrap after successful save'); + // Details page + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${secretPath}/details?version=1`, + 'Goes to details page after save' + ); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); + assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); + assert.dom(PAGE.infoRowValue('api_key')).hasText('***********'); + await click(PAGE.infoRowToggleMasked('api_key')); + assert.dom(PAGE.infoRowValue('api_key')).hasText('partyparty', 'secret value shows after toggle'); + + // Metadata page + await click(PAGE.secretTab('Metadata')); + assert + .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) + .hasText('No custom metadata', 'No custom metadata empty state'); + assert + .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.infoRow}`) + .exists({ count: 4 }, '4 metadata rows show'); + assert.dom(PAGE.infoRowValue('Maximum versions')).hasText('0', 'max versions shows 0'); + assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('No', 'cas not enforced'); + assert + .dom(PAGE.infoRowValue('Delete version after')) + .hasText('Never delete', 'Delete version after has default 0s'); + + // Add new version + await click(PAGE.secretTab('Secret')); + await click(PAGE.detail.createNewVersion); + assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); + assert.dom(FORM.inputByAttr('path')).hasValue(secretPath); + assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); + assert.dom(FORM.keyInput()).hasValue('api_key'); + assert.dom(FORM.maskedValueInput()).hasValue('partyparty'); + await fillIn(FORM.keyInput(1), 'api_url'); + await fillIn(FORM.maskedValueInput(1), 'hashicorp.com'); + await click(FORM.saveBtn); + tokenToUnwrap = this.controlGroup.tokenToUnwrap; + assert.strictEqual( + tokenToUnwrap.creation_path, + `${backend}/data/${secretPath}`, + 'stored tokenToUnwrap includes correct update path' + ); + assert + .dom(FORM.messageError) + .includesText( + `Error A Control Group was encountered at ${backend}/data/${secretPath}.`, + 'shows control group error' + ); + // Normally the user stays on the page and tries again once approval is granted + // unmark on test so it doesn't use the control group on read at the same path + // when we return to the page after granting access below + this.controlGroup.unmarkTokenForUnwrap(); + await grantAccessForWrite({ + accessor: tokenToUnwrap.accessor, + token: tokenToUnwrap.token, + creation_path: `${backend}/data/${secretPath}`, + originUrl: `/vault/secrets/${backend}/kv/${secretPath}/details/edit`, + userToken: this.userToken, + }); + // Remark for unwrap as if we never left the page. + this.controlGroup.markTokenForUnwrap(tokenToUnwrap.accessor); + // No need to fill in data because we're using the stored wrapped request + // and the path already exists + await click(FORM.saveBtn); + assert.strictEqual( + this.controlGroup.tokenToUnwrap, + null, + 'clears tokenToUnwrap after successful update' + ); + + // Back to details page + assert.strictEqual( + currentURL(), + `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2` + ); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); + assert.dom(PAGE.infoRow).exists({ count: 2 }, '2 rows of data shows'); + assert.dom(PAGE.infoRowValue('api_key')).hasText('***********'); + assert.dom(PAGE.infoRowValue('api_url')).hasText('***********'); + await click(PAGE.infoRowToggleMasked('api_key')); + await click(PAGE.infoRowToggleMasked('api_url')); + assert.dom(PAGE.infoRowValue('api_key')).hasText('partyparty', 'secret value shows after toggle'); + assert.dom(PAGE.infoRowValue('api_url')).hasText('hashicorp.com', 'secret value shows after toggle'); + }); + }); +}); diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js index a807da07ce04..ef5fc4b550a8 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js @@ -1,29 +1,53 @@ +/** + * Copyright (c) HashiCorp, Inc. + */ + import { module, test } from 'qunit'; import { v4 as uuidv4 } from 'uuid'; -import { click, currentURL, fillIn, typeIn, visit } from '@ember/test-helpers'; - import { setupApplicationTest } from 'vault/tests/helpers'; import authPage from 'vault/tests/pages/auth'; import { deleteEngineCmd, mountEngineCmd, runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands'; import { personas } from 'vault/tests/helpers/policy-generator/kv'; -import { clearRecords, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands'; -import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors'; -import { grantAccessForWrite, setupControlGroup } from 'vault/tests/helpers/control-groups'; - +import { + clearRecords, + deleteLatestCmd, + setupControlGroup, + writeVersionedSecret, +} from 'vault/tests/helpers/kv/kv-run-commands'; +import { click, currentURL, visit } from '@ember/test-helpers'; +import { PAGE } from 'vault/tests/helpers/kv/kv-selectors'; + +const ALL_DELETE_ACTIONS = ['delete', 'destroy', 'undelete']; +const assertDeleteActions = (assert, expected = ['delete', 'destroy']) => { + ALL_DELETE_ACTIONS.forEach((toolbar) => { + if (expected.includes(toolbar)) { + assert.dom(PAGE.detail[toolbar]).exists(`${toolbar} toolbar action exists`); + } else { + assert.dom(PAGE.detail[toolbar]).doesNotExist(`${toolbar} toolbar action not rendered`); + } + }); +}; /** - * This test set is for testing the flow for creating new secrets and versions. + * This test set is for testing delete, undelete, destroy flows * Letter(s) in parenthesis at the end are shorthand for the persona, * for ease of tracking down specific tests failures from CI */ -module('Acceptance | kv-v2 workflow | secret and version create', function (hooks) { +module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hooks) { setupApplicationTest(hooks); hooks.beforeEach(async function () { - this.backend = `kv-create-${uuidv4()}`; this.store = this.owner.lookup('service:store'); + this.backend = `kv-delete-${uuidv4()}`; + this.secretPath = 'bad-secret'; + this.nestedSecretPath = 'app/nested/bad-secret'; await authPage.login(); await runCmd(mountEngineCmd('kv-v2', this.backend), false); - await writeVersionedSecret(this.backend, 'app/first', 'foo', 'bar', 2); + await writeVersionedSecret(this.backend, this.secretPath, 'foo', 'bar', 4); + await writeVersionedSecret(this.backend, this.nestedSecretPath, 'foo', 'bar', 1); + await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2); + // Delete latest version for testing undelete for users that can't delete + await runCmd(deleteLatestCmd(this.backend, 'nuke')); + return; }); hooks.afterEach(async function () { @@ -38,240 +62,97 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook clearRecords(this.store); return; }); - test('cancel on create clears model (a)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list`); - assert.dom(PAGE.list.item()).exists({ count: 1 }, 'single secret exists on list'); - assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); - await click(PAGE.list.createSecret); - await fillIn(FORM.inputByAttr('path'), 'jk'); - await click(FORM.cancelBtn); - assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); - assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); - await click(PAGE.list.createSecret); - await fillIn(FORM.inputByAttr('path'), 'psych'); - await click(PAGE.breadcrumbAtIdx(1)); - assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); - assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); - }); - test('cancel on new version rolls back model (a)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`); - assert.dom(PAGE.infoRowValue('foo')).exists('key has expected value'); - await click(PAGE.detail.createNewVersion); - await fillIn(FORM.keyInput(), 'bar'); - await click(FORM.cancelBtn); - assert.dom(PAGE.infoRowValue('foo')).exists('secret is previous value'); - await click(PAGE.detail.createNewVersion); - await fillIn(FORM.keyInput(), 'bar'); - await click(PAGE.breadcrumbAtIdx(3)); - assert.dom(PAGE.infoRowValue('foo')).exists('secret is previous value'); - }); - test('create & update root secret with default metadata (a)', async function (assert) { - const backend = this.backend; - const secretPath = 'some secret'; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret form -- validations - await click(FORM.saveBtn); - assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); - assert.dom(FORM.validation('path')).hasText("Path can't be blank."); - await typeIn(FORM.inputByAttr('path'), secretPath); + test('can delete and undelete the latest secret version (a)', async function (assert) { + assert.expect(17); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); + // correct toolbar options & details show + assertDeleteActions(assert); + assert.dom(PAGE.infoRow).exists('shows secret data'); + // delete flow + await click(PAGE.detail.delete); + assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); + assert.dom(PAGE.detail.deleteOption).isNotDisabled('delete option is selectable'); + assert.dom(PAGE.detail.deleteOptionLatest).isNotDisabled('delete latest option is selectable'); + await click(PAGE.detail.deleteOptionLatest); + await click(PAGE.detail.deleteConfirm); + // details update accordingly assert - .dom(FORM.validationWarning) - .hasText( - "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests." - ); - assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); - - // Submit with API errors - await click(FORM.saveBtn); - assert.dom(FORM.messageError).hasText('Error no data provided', 'API error shows on form'); - - await fillIn(FORM.keyInput(), 'api_key'); - await fillIn(FORM.maskedValueInput(), 'partyparty'); - await click(FORM.saveBtn); - - // Details page - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=1`, - 'Goes to details page after save' - ); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); - assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); - assert.dom(PAGE.infoRowValue('api_key')).hasText('***********'); - await click(PAGE.infoRowToggleMasked('api_key')); - assert.dom(PAGE.infoRowValue('api_key')).hasText('partyparty', 'secret value shows after toggle'); - - // Metadata page - await click(PAGE.secretTab('Metadata')); - assert - .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText('No custom metadata', 'No custom metadata empty state'); - assert - .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.infoRow}`) - .exists({ count: 4 }, '4 metadata rows show'); - assert.dom(PAGE.infoRowValue('Maximum versions')).hasText('0', 'max versions shows 0'); - assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('No', 'cas not enforced'); + .dom(PAGE.emptyStateTitle) + .hasText('Version 4 of this secret has been deleted', 'Shows deleted message'); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 4 deleted'); + // updated toolbar options + assertDeleteActions(assert, ['undelete', 'destroy']); + // undelete flow + await click(PAGE.detail.undelete); + // details update accordingly + assert.dom(PAGE.infoRow).exists('shows secret data'); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 4 created'); + // correct toolbar options + assertDeleteActions(assert, ['delete', 'destroy']); + }); + test('can soft delete and undelete an older secret version (a)', async function (assert) { + assert.expect(17); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); + // correct toolbar options & details show + assertDeleteActions(assert); + assert.dom(PAGE.infoRow).exists('shows secret data'); + // delete flow + await click(PAGE.detail.delete); + assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); + assert.dom(PAGE.detail.deleteOption).isNotDisabled('delete option is selectable'); + assert.dom(PAGE.detail.deleteOptionLatest).isNotDisabled('delete latest option is selectable'); + await click(PAGE.detail.deleteOption); + await click(PAGE.detail.deleteConfirm); + // details update accordingly assert - .dom(PAGE.infoRowValue('Delete version after')) - .hasText('Never delete', 'Delete version after has default 0s'); - - // Add new version - await click(PAGE.secretTab('Secret')); - await click(PAGE.detail.createNewVersion); - assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); - assert.dom(FORM.inputByAttr('path')).hasValue(secretPath); - assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); - assert.dom(FORM.keyInput()).hasValue('api_key'); - assert.dom(FORM.maskedValueInput()).hasValue('partyparty'); - await fillIn(FORM.keyInput(1), 'api_url'); - await fillIn(FORM.maskedValueInput(1), 'hashicorp.com'); - await click(FORM.saveBtn); - - // Back to details page - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2` - ); + .dom(PAGE.emptyStateTitle) + .hasText('Version 2 of this secret has been deleted', 'Shows deleted message'); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 deleted'); + // updated toolbar options + assertDeleteActions(assert, ['undelete', 'destroy']); + // undelete flow + await click(PAGE.detail.undelete); + // details update accordingly + assert.dom(PAGE.infoRow).exists('shows secret data'); assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); - assert.dom(PAGE.infoRow).exists({ count: 2 }, '2 rows of data shows'); - assert.dom(PAGE.infoRowValue('api_key')).hasText('***********'); - assert.dom(PAGE.infoRowValue('api_url')).hasText('***********'); - await click(PAGE.infoRowToggleMasked('api_key')); - await click(PAGE.infoRowToggleMasked('api_url')); - assert.dom(PAGE.infoRowValue('api_key')).hasText('partyparty', 'secret value shows after toggle'); - assert.dom(PAGE.infoRowValue('api_url')).hasText('hashicorp.com', 'secret value shows after toggle'); - }); - test('create nested secret with metadata (a)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret - await typeIn(FORM.inputByAttr('path'), 'my/'); - assert.dom(FORM.validation('path')).hasText("Path can't end in forward slash '/'."); - await typeIn(FORM.inputByAttr('path'), 'secret'); - assert.dom(FORM.validation('path')).doesNotExist('form validation goes away'); - await fillIn(FORM.keyInput(), 'password'); - await fillIn(FORM.maskedValueInput(), 'kittens1234'); - - await click(FORM.toggleMetadata); - assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled'); - // Check initial values - assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0'); - assert.dom(FORM.inputByAttr('casRequired')).isNotChecked(); - assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked(); - // MaxVersions validation - await fillIn(FORM.inputByAttr('maxVersions'), 'seven'); - await click(FORM.saveBtn); - assert.dom(FORM.validation('maxVersions')).hasText('Maximum versions must be a number.'); - await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999'); - await click(FORM.saveBtn); - assert.dom(FORM.validation('maxVersions')).hasText('You cannot go over 16 characters.'); - await fillIn(FORM.inputByAttr('maxVersions'), '7'); - - // Fill in other metadata - await click(FORM.inputByAttr('casRequired')); - await click(FORM.toggleByLabel('Automate secret deletion')); - await fillIn(FORM.ttlValue('Automate secret deletion'), '1000'); - - // Fill in custom metadata - await fillIn(`${PAGE.create.metadataSection} ${FORM.keyInput()}`, 'team'); - await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); - // Fill in metadata - await click(FORM.saveBtn); - - // Details - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}/details?version=1` - ); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); - assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); - assert.dom(PAGE.infoRowValue('password')).hasText('***********'); - await click(PAGE.infoRowToggleMasked('password')); - assert.dom(PAGE.infoRowValue('password')).hasText('kittens1234', 'secret value shows after toggle'); - - // Metadata - await click(PAGE.secretTab('Metadata')); - assert - .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.infoRow}`) - .exists({ count: 1 }, 'One custom metadata row shows'); - assert.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.infoRowValue('team')}`).hasText('UI'); - + // correct toolbar options + assertDeleteActions(assert, ['delete', 'destroy']); + }); + test('can destroy a secret version (a)', async function (assert) { + assert.expect(9); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`); + // correct toolbar options show + assertDeleteActions(assert); + // delete flow + await click(PAGE.detail.destroy); + assert.dom(PAGE.detail.deleteModalTitle).includesText('Destroy version?', 'modal has correct title'); + await click(PAGE.detail.deleteConfirm); + // details update accordingly assert - .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.infoRow}`) - .exists({ count: 4 }, '4 metadata rows show'); - assert.dom(PAGE.infoRowValue('Maximum versions')).hasText('7', 'max versions shows 0'); - assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('Yes', 'cas enforced'); - assert - .dom(PAGE.infoRowValue('Delete version after')) - .hasText('16 minutes 40 seconds', 'Delete version after has custom value'); - }); - test('creates a secret at a sub-directory (a)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list/app/`); - assert.dom(PAGE.list.item('first')).exists('Lists first sub-secret'); - assert.dom(PAGE.list.item('new')).doesNotExist('Does not show new secret'); - await click(PAGE.list.createSecret); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/create?initialKey=app%2F`, - 'Goes to create page with initialKey' - ); - await typeIn(FORM.inputByAttr('path'), 'new'); - await fillIn(FORM.keyInput(), 'api_key'); - await fillIn(FORM.maskedValueInput(), 'partyparty'); - await click(FORM.saveBtn); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent('app/new')}/details?version=1`, - 'Redirects to detail after save' - ); - await click(PAGE.breadcrumbAtIdx(2)); - assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, 'sub-dir page'); - assert.dom(PAGE.list.item('new')).exists('Lists new secret in sub-dir'); - }); - test('create new version of secret from older version (a)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details`); - await click(PAGE.detail.versionDropdown); - await click(`${PAGE.detail.version(1)} a`); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`, - 'goes to version 1' - ); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); - await click(PAGE.detail.createNewVersion); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/app%2Ffirst/details/edit?version=1`, - 'Goes to new version page' - ); + .dom(PAGE.emptyStateTitle) + .hasText('Version 3 of this secret has been permanently destroyed', 'Shows destroyed message'); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('does not show version timestamp'); + // updated toolbar options + assertDeleteActions(assert, []); + }); + test('can permanently delete all secret versions (a)', async function (assert) { + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); + // Check metadata toolbar + await click(PAGE.secretTab('Metadata')); + assert.dom(PAGE.metadata.deleteMetadata).hasText('Permanently delete', 'shows delete metadata button'); + // delete flow + await click(PAGE.metadata.deleteMetadata); assert - .dom(FORM.versionAlert) - .hasText( - 'Warning You are creating a new version based on data from Version 1. The current version for app/first is Version 2.', - 'Shows version warning' - ); - assert.dom(FORM.keyInput()).hasValue('key-1', 'Key input has old value'); - assert.dom(FORM.maskedValueInput()).hasValue('val-1', 'Val input has old value'); + .dom(PAGE.detail.deleteModalTitle) + .includesText('Delete metadata and secret data?', 'modal has correct title'); + await click(PAGE.detail.deleteConfirm); - await fillIn(FORM.keyInput(), 'my-key'); - await fillIn(FORM.maskedValueInput(), 'my-value'); - await click(FORM.saveBtn); - - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/app%2Ffirst/details?version=3`, - 'goes to latest version 3' - ); - await click(PAGE.infoRowToggleMasked('my-key')); - assert.dom(PAGE.infoRowValue('my-key')).hasText('my-value', 'has new value'); + // redirects to list + assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/kv/list`, 'redirects to list'); }); }); @@ -282,136 +163,43 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook clearRecords(this.store); return; }); - test('cancel on create clears model (dr)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list`); - assert.dom(PAGE.list.item()).doesNotExist('list view has no items'); - await click(PAGE.list.createSecret); - await fillIn(FORM.inputByAttr('path'), 'jk'); - await click(FORM.cancelBtn); - assert.dom(PAGE.list.item()).doesNotExist('list view still has no items'); - await click(PAGE.list.createSecret); - await fillIn(FORM.inputByAttr('path'), 'psych'); - await click(PAGE.breadcrumbAtIdx(1)); - assert.dom(PAGE.list.item()).doesNotExist('list view still has no items'); - }); - test('cancel on new version rolls back model (dr)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`); - assert.dom(PAGE.infoRowValue('foo')).exists('key has expected value'); - assert.dom(PAGE.detail.createNewVersion).doesNotExist(); - }); - test('create & update root secret with default metadata (dr)', async function (assert) { - const backend = this.backend; - const secretPath = 'some secret'; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret form -- validations - await click(FORM.saveBtn); - assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); - assert.dom(FORM.validation('path')).hasText("Path can't be blank."); - await typeIn(FORM.inputByAttr('path'), secretPath); - assert - .dom(FORM.validationWarning) - .hasText( - "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests." - ); - assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); + test('cannot delete and undelete the latest secret version (dr)', async function (assert) { + assert.expect(9); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); + // correct toolbar options & details show + assertDeleteActions(assert, []); + assert.dom(PAGE.infoRow).exists('shows secret data'); - // Submit with API errors - await click(FORM.saveBtn); + // data-reader can't delete, so check undelete with already-deleted version + await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); + assertDeleteActions(assert, []); assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); - - // Since this persona can't create a new secret, test update with existing: - await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details`); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); - assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); - assert.dom(PAGE.infoRowValue('foo')).hasText('***********'); - await click(PAGE.infoRowToggleMasked('foo')); - assert.dom(PAGE.infoRowValue('foo')).hasText('bar', 'secret value shows after toggle'); - - // Metadata page + .dom(PAGE.emptyStateTitle) + .hasText('Version 2 of this secret has been deleted', 'Shows deleted message'); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 deleted'); + }); + test('cannot soft delete and undelete an older secret version (dr)', async function (assert) { + assert.expect(4); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); + // correct toolbar options & details show + assertDeleteActions(assert, []); + assert.dom(PAGE.infoRow).exists('shows secret data'); + }); + test('cannot destroy a secret version (dr)', async function (assert) { + assert.expect(3); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`); + // correct toolbar options show + assertDeleteActions(assert, []); + }); + test('cannot permanently delete all secret versions (dr)', async function (assert) { + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); + // Check metadata toolbar await click(PAGE.secretTab('Metadata')); - assert - .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText('No custom metadata', 'No custom metadata empty state'); - assert - .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText('You do not have access to secret metadata', 'shows no access state on metadata'); - - // Add new version - await click(PAGE.secretTab('Secret')); - assert.dom(PAGE.detail.createNewVersion).doesNotExist('cannot create new version'); - }); - test('create nested secret with metadata (dr)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret - await typeIn(FORM.inputByAttr('path'), 'my/'); - assert.dom(FORM.validation('path')).hasText("Path can't end in forward slash '/'."); - await typeIn(FORM.inputByAttr('path'), 'secret'); - assert.dom(FORM.validation('path')).doesNotExist('form validation goes away'); - await fillIn(FORM.keyInput(), 'password'); - await fillIn(FORM.maskedValueInput(), 'kittens1234'); - - await click(FORM.toggleMetadata); - assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled'); - // Check initial values - assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0'); - assert.dom(FORM.inputByAttr('casRequired')).isNotChecked(); - assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked(); - // MaxVersions validation - await fillIn(FORM.inputByAttr('maxVersions'), 'seven'); - await click(FORM.saveBtn); - assert.dom(FORM.validation('maxVersions')).hasText('Maximum versions must be a number.'); - await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999'); - await click(FORM.saveBtn); - assert.dom(FORM.validation('maxVersions')).hasText('You cannot go over 16 characters.'); - await fillIn(FORM.inputByAttr('maxVersions'), '7'); - - // Fill in other metadata - await click(FORM.inputByAttr('casRequired')); - await click(FORM.toggleByLabel('Automate secret deletion')); - await fillIn(FORM.ttlValue('Automate secret deletion'), '1000'); - - // Fill in custom metadata - await fillIn(`${PAGE.create.metadataSection} ${FORM.keyInput()}`, 'team'); - await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); - // Fill in metadata - await click(FORM.saveBtn); - assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); - }); - test('creates a secret at a sub-directory (dr)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list/app/`); - assert.dom(PAGE.list.item()).doesNotExist('Does not list any secrets'); - await click(PAGE.list.createSecret); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/create?initialKey=app%2F`, - 'Goes to create page with initialKey' - ); - await typeIn(FORM.inputByAttr('path'), 'new'); - await fillIn(FORM.keyInput(), 'api_key'); - await fillIn(FORM.maskedValueInput(), 'partyparty'); - await click(FORM.saveBtn); - assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); - }); - test('create new version of secret from older version (dr)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`); - assert.dom(PAGE.detail.versionDropdown).doesNotExist('version dropdown does not show'); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); - assert.dom(PAGE.detail.createNewVersion).doesNotExist('cannot create new version'); + assert.dom(PAGE.metadata.deleteMetadata).doesNotExist('does not show delete metadata button'); }); }); @@ -424,336 +212,172 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook clearRecords(this.store); return; }); - test('cancel on create clears model (dlr)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list`); - assert.dom(PAGE.list.item()).exists({ count: 1 }, 'single secret exists on list'); - assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); - await click(PAGE.list.createSecret); - await fillIn(FORM.inputByAttr('path'), 'jk'); - await click(FORM.cancelBtn); - assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); - assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); - await click(PAGE.list.createSecret); - await fillIn(FORM.inputByAttr('path'), 'psych'); - await click(PAGE.breadcrumbAtIdx(1)); - assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); - assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); - }); - test('cancel on new version rolls back model (dlr)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`); - assert.dom(PAGE.infoRowValue('foo')).exists('key has expected value'); - assert.dom(PAGE.detail.createNewVersion).doesNotExist('cannot create new version'); - }); - test('create & update root secret with default metadata (dlr)', async function (assert) { - const backend = this.backend; - const secretPath = 'some secret'; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret form -- validations - await click(FORM.saveBtn); - assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); - assert.dom(FORM.validation('path')).hasText("Path can't be blank."); - await typeIn(FORM.inputByAttr('path'), secretPath); - assert - .dom(FORM.validationWarning) - .hasText( - "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests." - ); - assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); - - // Submit with API errors - await click(FORM.saveBtn); + test('can delete and undelete the latest secret version (dlr)', async function (assert) { + assert.expect(12); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); + // correct toolbar options & details show + assertDeleteActions(assert, ['delete']); + assert.dom(PAGE.infoRow).exists('shows secret data'); + // delete flow + await click(PAGE.detail.delete); + assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); + assert.dom(PAGE.detail.deleteOption).isDisabled('delete option is disabled'); + assert.dom(PAGE.detail.deleteOptionLatest).isNotDisabled('delete latest option is selectable'); + await click(PAGE.detail.deleteOptionLatest); + await click(PAGE.detail.deleteConfirm); + // details update accordingly assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); - - // Since this persona can't create a new secret, test update with existing: - await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details`); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); - assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); - assert.dom(PAGE.infoRowValue('foo')).hasText('***********'); - await click(PAGE.infoRowToggleMasked('foo')); - assert.dom(PAGE.infoRowValue('foo')).hasText('bar', 'secret value shows after toggle'); - - // Metadata page + .dom(PAGE.emptyStateTitle) + .hasText('Version 4 of this secret has been deleted', 'Shows deleted message'); + assert.dom(PAGE.detail.versionTimestamp).includesText('Version 4 deleted'); + // updated toolbar options + assertDeleteActions(assert, []); + // user can't undelete + }); + test('can soft delete and undelete an older secret version (dlr)', async function (assert) { + assert.expect(6); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); + // correct toolbar options & details show + assertDeleteActions(assert, ['delete']); + assert.dom(PAGE.infoRow).exists('shows secret data'); + // delete flow + await click(PAGE.detail.delete); + assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); + assert.dom(PAGE.detail.deleteOption).isDisabled('delete this version is not available'); + }); + test('cannot destroy a secret version (dlr)', async function (assert) { + assert.expect(3); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`); + // correct toolbar options show + assertDeleteActions(assert, ['delete']); + }); + test('cannot permanently delete all secret versions (dr)', async function (assert) { + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); + // Check metadata toolbar await click(PAGE.secretTab('Metadata')); - assert - .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText('No custom metadata', 'No custom metadata empty state'); - assert - .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText('You do not have access to secret metadata', 'shows no access state on metadata'); - - // Add new version - await click(PAGE.secretTab('Secret')); - assert.dom(PAGE.detail.createNewVersion).doesNotExist('cannot create new version'); - }); - test('create nested secret with metadata (dlr)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret - await typeIn(FORM.inputByAttr('path'), 'my/'); - assert.dom(FORM.validation('path')).hasText("Path can't end in forward slash '/'."); - await typeIn(FORM.inputByAttr('path'), 'secret'); - assert.dom(FORM.validation('path')).doesNotExist('form validation goes away'); - await fillIn(FORM.keyInput(), 'password'); - await fillIn(FORM.maskedValueInput(), 'kittens1234'); - - await click(FORM.toggleMetadata); - assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled'); - // Check initial values - assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0'); - assert.dom(FORM.inputByAttr('casRequired')).isNotChecked(); - assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked(); - // MaxVersions validation - await fillIn(FORM.inputByAttr('maxVersions'), 'seven'); - await click(FORM.saveBtn); - assert.dom(FORM.validation('maxVersions')).hasText('Maximum versions must be a number.'); - await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999'); - await click(FORM.saveBtn); - assert.dom(FORM.validation('maxVersions')).hasText('You cannot go over 16 characters.'); - await fillIn(FORM.inputByAttr('maxVersions'), '7'); - - // Fill in other metadata - await click(FORM.inputByAttr('casRequired')); - await click(FORM.toggleByLabel('Automate secret deletion')); - await fillIn(FORM.ttlValue('Automate secret deletion'), '1000'); - - // Fill in custom metadata - await fillIn(`${PAGE.create.metadataSection} ${FORM.keyInput()}`, 'team'); - await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); - // Fill in metadata - await click(FORM.saveBtn); - assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); - }); - test('creates a secret at a sub-directory (dlr)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list/app/`); - assert.dom(PAGE.list.item()).doesNotExist('Does not list any secrets'); - await click(PAGE.list.createSecret); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/create?initialKey=app%2F`, - 'Goes to create page with initialKey' - ); - await typeIn(FORM.inputByAttr('path'), 'new'); - await fillIn(FORM.keyInput(), 'api_key'); - await fillIn(FORM.maskedValueInput(), 'partyparty'); - await click(FORM.saveBtn); - assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); - }); - test('create new version of secret from older version (dlr)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`); - assert.dom(PAGE.detail.versionDropdown).doesNotExist('version dropdown does not show'); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); - assert.dom(PAGE.detail.createNewVersion).doesNotExist('cannot create new version'); + assert.dom(PAGE.metadata.deleteMetadata).doesNotExist('does not show delete metadata button'); }); }); module('metadata-maintainer persona', function (hooks) { hooks.beforeEach(async function () { const token = await runCmd( - tokenWithPolicyCmd('data-list-reader', personas.metadataMaintainer(this.backend)) + tokenWithPolicyCmd('metadata-maintainer', personas.metadataMaintainer(this.backend)) ); await authPage.login(token); clearRecords(this.store); return; }); - test('cancel on create clears model (mm)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list`); - assert.dom(PAGE.list.item()).exists({ count: 1 }, 'single secret exists on list'); - assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); - await click(PAGE.list.createSecret); - await fillIn(FORM.inputByAttr('path'), 'jk'); - await click(FORM.cancelBtn); - assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); - assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); - await click(PAGE.list.createSecret); - await fillIn(FORM.inputByAttr('path'), 'psych'); - await click(PAGE.breadcrumbAtIdx(1)); - assert.dom(PAGE.list.item()).exists({ count: 1 }, 'same amount of secrets'); - assert.dom(PAGE.list.item('app/')).hasText('app/', 'expected list item'); - }); - test('cancel on new version rolls back model (mm)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`); + test('can delete and undelete the latest secret version (mm)', async function (assert) { + assert.expect(17); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); + // correct toolbar options & details show + assertDeleteActions(assert); assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - assert - .dom(PAGE.detail.createNewVersion) - .doesNotExist('create new version button now allowed since user cannot read existing'); - }); - test('create & update root secret with default metadata (mm)', async function (assert) { - const backend = this.backend; - const secretPath = 'some secret'; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret form -- validations - await click(FORM.saveBtn); - assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); - assert.dom(FORM.validation('path')).hasText("Path can't be blank."); - await typeIn(FORM.inputByAttr('path'), secretPath); - assert - .dom(FORM.validationWarning) - .hasText( - "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests." - ); - assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); - - // Submit with API errors - await click(FORM.saveBtn); - assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); - - // Since this persona can't create a new secret, test update with existing: - await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details`); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created tooltip does not show'); - assert.dom(PAGE.infoRow).doesNotExist('secret data not shown'); + // delete flow + await click(PAGE.detail.delete); + assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); + assert.dom(PAGE.detail.deleteOption).isNotDisabled('delete option is selectable'); + assert.dom(PAGE.detail.deleteOptionLatest).isDisabled('delete latest option is disabled'); + + // Can't delete latest, try with pre-deleted secret + await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - - // Metadata page - await click(PAGE.secretTab('Metadata')); - assert - .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText('No custom metadata', 'No custom metadata empty state'); - assert - .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.infoRow}`) - .exists({ count: 4 }, '4 metadata rows show'); - assert.dom(PAGE.infoRowValue('Maximum versions')).hasText('0', 'max versions shows 0'); - assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('No', 'cas not enforced'); - assert - .dom(PAGE.infoRowValue('Delete version after')) - .hasText('Never delete', 'Delete version after has default 0s'); - - // Add new version - await click(PAGE.secretTab('Secret')); - assert.dom(PAGE.detail.createNewVersion).doesNotExist('create new version button not rendered'); - await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details/edit?version=1`); - assert - .dom(FORM.noReadAlert) - .hasText( - 'Warning You do not have read permissions for this secret data. Saving will overwrite the existing secret.', - 'shows alert for no read permissions' - ); - - assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); - assert.dom(FORM.inputByAttr('path')).hasValue('app/first'); - assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); - assert.dom(FORM.keyInput()).hasValue('', 'first row has no key'); - assert.dom(FORM.maskedValueInput()).hasValue('', 'first row has no value'); - await fillIn(FORM.keyInput(), 'api_url'); - await fillIn(FORM.maskedValueInput(), 'hashicorp.com'); - await click(FORM.saveBtn); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); + // updated toolbar options + assertDeleteActions(assert, ['undelete', 'destroy']); + // undelete flow + await click(PAGE.detail.undelete); + // details update accordingly + assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); + // correct toolbar options + assertDeleteActions(assert, ['delete', 'destroy']); + }); + test('can soft delete and undelete an older secret version (mm)', async function (assert) { + assert.expect(18); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); + // correct toolbar options & details show + assertDeleteActions(assert); + assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); + // delete flow + await click(PAGE.detail.delete); + assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); + assert.dom(PAGE.detail.deleteOption).isNotDisabled('delete option is selectable'); + assert.dom(PAGE.detail.deleteOptionLatest).isDisabled('delete latest option is disabled'); + await click(PAGE.detail.deleteOption); + await click(PAGE.detail.deleteConfirm); + // details update accordingly + assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); + // updated toolbar options + assertDeleteActions(assert, ['undelete', 'destroy']); + // undelete flow + await click(PAGE.detail.undelete); + // details update accordingly + assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); + // correct toolbar options + assertDeleteActions(assert); + }); + test('can destroy a secret version (mm)', async function (assert) { + assert.expect(9); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`); + // correct toolbar options show + assertDeleteActions(assert); + // delete flow + await click(PAGE.detail.destroy); + assert.dom(PAGE.detail.deleteModalTitle).includesText('Destroy version?', 'modal has correct title'); + await click(PAGE.detail.deleteConfirm); + // details update accordingly assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + .dom(PAGE.emptyStateTitle) + .hasText('You do not have permission to read this secret', 'Shows permissions message'); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('does not show version timestamp'); + // updated toolbar options + assertDeleteActions(assert, []); + }); + test('cannot permanently delete all secret versions (mm)', async function (assert) { + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); + // Check metadata toolbar + await click(PAGE.secretTab('Metadata')); + assert.dom(PAGE.metadata.deleteMetadata).doesNotExist('does not show delete metadata button'); }); - test('create nested secret with metadata (mm)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret - await typeIn(FORM.inputByAttr('path'), 'my/'); - assert.dom(FORM.validation('path')).hasText("Path can't end in forward slash '/'."); - await typeIn(FORM.inputByAttr('path'), 'secret'); - assert.dom(FORM.validation('path')).doesNotExist('form validation goes away'); - await fillIn(FORM.keyInput(), 'password'); - await fillIn(FORM.maskedValueInput(), 'kittens1234'); - - await click(FORM.toggleMetadata); - assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled'); - // Check initial values - assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0'); - assert.dom(FORM.inputByAttr('casRequired')).isNotChecked(); - assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked(); - // MaxVersions validation - await fillIn(FORM.inputByAttr('maxVersions'), 'seven'); - await click(FORM.saveBtn); - assert.dom(FORM.validation('maxVersions')).hasText('Maximum versions must be a number.'); - await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999'); - await click(FORM.saveBtn); - assert.dom(FORM.validation('maxVersions')).hasText('You cannot go over 16 characters.'); - await fillIn(FORM.inputByAttr('maxVersions'), '7'); - - // Fill in other metadata - await click(FORM.inputByAttr('casRequired')); - await click(FORM.toggleByLabel('Automate secret deletion')); - await fillIn(FORM.ttlValue('Automate secret deletion'), '1000'); - - // Fill in custom metadata - await fillIn(`${PAGE.create.metadataSection} ${FORM.keyInput()}`, 'team'); - await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); + }); - await click(FORM.saveBtn); - assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); - }); - test('creates a secret at a sub-directory (mm)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list/app/`); - assert.dom(PAGE.list.item('first')).exists('Lists first sub-secret'); - assert.dom(PAGE.list.item('new')).doesNotExist('Does not show new secret'); - await click(PAGE.list.createSecret); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/create?initialKey=app%2F`, - 'Goes to create page with initialKey' + module('secret-nested-creator persona', function (hooks) { + hooks.beforeEach(async function () { + const token = await runCmd( + tokenWithPolicyCmd('secret-nested-creator', personas.secretNestedCreator(this.backend)) ); - await typeIn(FORM.inputByAttr('path'), 'new'); - await fillIn(FORM.keyInput(), 'api_key'); - await fillIn(FORM.maskedValueInput(), 'partyparty'); - await click(FORM.saveBtn); - assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + await authPage.login(token); + clearRecords(this.store); + return; }); - test('create new version of secret from older version (mm)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details`); - assert.dom(PAGE.detail.versionDropdown).hasText('Version 2'); - await click(PAGE.detail.versionDropdown); - await click(`${PAGE.detail.version(1)} a`); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`, - 'goes to version 1' - ); - assert.dom(PAGE.detail.versionDropdown).hasText('Version 1'); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('version timestamp not shown'); - assert.dom(PAGE.detail.createNewVersion).doesNotExist('create new version button not rendered'); - await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details/edit?version=1`); - assert - .dom(FORM.noReadAlert) - .hasText( - 'Warning You do not have read permissions for this secret data. Saving will overwrite the existing secret.', - 'shows alert for no read permissions' - ); - - assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); - assert.dom(FORM.inputByAttr('path')).hasValue('app/first'); - assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); - assert.dom(FORM.keyInput()).hasValue('', 'first row has no key'); - assert.dom(FORM.maskedValueInput()).hasValue('', 'first row has no value'); - await fillIn(FORM.keyInput(), 'api_url'); - await fillIn(FORM.maskedValueInput(), 'hashicorp.com'); - await click(FORM.saveBtn); - assert - .dom(FORM.messageError) - .hasText('Error 1 error occurred: * permission denied', 'API error shows on form'); + test('can delete all secret versions from the nested list view (snc)', async function (assert) { + assert.expect(1); + // go to nested secret directory list view + await visit(`/vault/secrets/${this.backend}/kv/list/app/nested`); + // 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'); + }); + test('can not delete all secret versions from root list view (snc)', async function (assert) { + assert.expect(1); + // go to root secret directory list view + await visit(`/vault/secrets/${this.backend}/kv/list`); + // shows overview card and not list view + assert.dom(PAGE.list.overviewCard).exists('renders overview card'); }); }); @@ -764,250 +388,63 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook clearRecords(this.store); return; }); - test('cancel on create clears model (sc)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list`); - assert.dom(PAGE.list.item()).doesNotExist('list view has no items'); - await click(PAGE.list.createSecret); - await fillIn(FORM.inputByAttr('path'), 'jk'); - await click(FORM.cancelBtn); - assert.dom(PAGE.list.item()).doesNotExist('list view still has no items'); - await click(PAGE.list.createSecret); - await fillIn(FORM.inputByAttr('path'), 'psych'); - await click(PAGE.breadcrumbAtIdx(1)); - assert.dom(PAGE.list.item()).doesNotExist('list view still has no items'); - }); - test('cancel on new version rolls back model (sc)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`); - assert - .dom(PAGE.emptyStateTitle) - .hasText('You do not have permission to read this secret', 'no permissions state shows'); - await click(PAGE.detail.createNewVersion); - await fillIn(FORM.keyInput(), 'bar'); - await click(FORM.cancelBtn); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`, - 'cancel goes to correct url' - ); - assert.dom(PAGE.list.item()).doesNotExist('list view has no items'); - await click(PAGE.detail.createNewVersion); - await fillIn(FORM.keyInput(), 'bar'); - await click(PAGE.breadcrumbAtIdx(3)); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`, - 'breadcrumb goes to correct url' - ); - assert.dom(PAGE.list.item()).doesNotExist('list view has no items'); - }); - test('create & update root secret with default metadata (sc)', async function (assert) { - const backend = this.backend; - const secretPath = 'some secret'; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret form -- validations - await click(FORM.saveBtn); - assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); - assert.dom(FORM.validation('path')).hasText("Path can't be blank."); - await typeIn(FORM.inputByAttr('path'), secretPath); - assert - .dom(FORM.validationWarning) - .hasText( - "Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests." - ); - assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); - - // Submit with API errors - await click(FORM.saveBtn); - assert.dom(FORM.messageError).hasText('Error no data provided', 'API error shows on form'); - - await fillIn(FORM.keyInput(), 'api_key'); - await fillIn(FORM.maskedValueInput(), 'partyparty'); - await click(FORM.saveBtn); - - // Details page - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details`, - 'Goes to details page after save' - ); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created not shown'); - assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); - assert - .dom(PAGE.emptyStateTitle) - .hasText('You do not have permission to read this secret', 'shows permissions empty state'); - - // Metadata page - await click(PAGE.secretTab('Metadata')); - assert - .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText( - 'You do not have access to read custom metadata', - 'permissions empty state for custom metadata' - ); - assert - .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText('You do not have access to secret metadata', 'permissions empty state for secret metadata'); - - // Add new version - await click(PAGE.secretTab('Secret')); - await click(PAGE.detail.createNewVersion); - assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); - assert.dom(FORM.inputByAttr('path')).hasValue(secretPath); - assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); - assert.dom(FORM.keyInput()).hasValue('', 'row 1 is empty key'); - assert.dom(FORM.maskedValueInput()).hasValue('', 'row 1 has empty value'); - await fillIn(FORM.keyInput(), 'api_url'); - await fillIn(FORM.maskedValueInput(), 'hashicorp.com'); - await click(FORM.saveBtn); + test('cannot delete and undelete the latest secret version (sc)', async function (assert) { + assert.expect(9); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); + // correct toolbar options & details show + assertDeleteActions(assert, []); + assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - // Back to details page - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2`, - 'goes back to details page' - ); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created does not show'); - assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); - assert - .dom(PAGE.emptyStateTitle) - .hasText('You do not have permission to read this secret', 'shows permissions empty state'); + // test with already deleted method + await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); + assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); + assert.dom(PAGE.detail.versionTimestamp).doesNotExist('version timestamp not rendered'); + // updated toolbar options + assertDeleteActions(assert, []); + }); + test('cannot soft delete and undelete an older secret version (sc)', async function (assert) { + assert.expect(4); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); + // correct toolbar options & details show + assertDeleteActions(assert, []); + assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); }); - test('create nested secret with metadata (sc)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret - await typeIn(FORM.inputByAttr('path'), 'my/'); - assert.dom(FORM.validation('path')).hasText("Path can't end in forward slash '/'."); - await typeIn(FORM.inputByAttr('path'), 'secret'); - assert.dom(FORM.validation('path')).doesNotExist('form validation goes away'); - await fillIn(FORM.keyInput(), 'password'); - await fillIn(FORM.maskedValueInput(), 'kittens1234'); - - await click(FORM.toggleMetadata); - assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled'); - // Check initial values - assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0'); - assert.dom(FORM.inputByAttr('casRequired')).isNotChecked(); - assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked(); - // MaxVersions validation - await fillIn(FORM.inputByAttr('maxVersions'), 'seven'); - await click(FORM.saveBtn); - assert.dom(FORM.validation('maxVersions')).hasText('Maximum versions must be a number.'); - await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999'); - await click(FORM.saveBtn); - assert.dom(FORM.validation('maxVersions')).hasText('You cannot go over 16 characters.'); - await fillIn(FORM.inputByAttr('maxVersions'), '7'); - - // Fill in other metadata - await click(FORM.inputByAttr('casRequired')); - await click(FORM.toggleByLabel('Automate secret deletion')); - await fillIn(FORM.ttlValue('Automate secret deletion'), '1000'); - - // Fill in custom metadata - await fillIn(`${PAGE.create.metadataSection} ${FORM.keyInput()}`, 'team'); - await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); - // Fill in metadata - await click(FORM.saveBtn); - - // Details - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}/details`, - 'goes back to details page' - ); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('version created not shown'); - assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); - assert - .dom(PAGE.emptyStateTitle) - .hasText('You do not have permission to read this secret', 'shows permissions empty state'); - - // Metadata + test('cannot destroy a secret version (sc)', async function (assert) { + assert.expect(3); + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`); + // correct toolbar options show + assertDeleteActions(assert, []); + }); + test('can permanently delete all secret versions (sc)', async function (assert) { + // go to secret details + await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); + // Check metadata toolbar await click(PAGE.secretTab('Metadata')); + assert.dom(PAGE.metadata.deleteMetadata).hasText('Permanently delete', 'shows delete metadata button'); + // delete flow + await click(PAGE.metadata.deleteMetadata); assert - .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText( - 'You do not have access to read custom metadata', - 'permissions empty state for custom metadata' - ); - assert - .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText('You do not have access to secret metadata', 'permissions empty state for secret metadata'); - }); - test('creates a secret at a sub-directory (sc)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/list/app/`); - assert.dom(PAGE.list.item()).doesNotExist('Does not list any secrets'); - await click(PAGE.list.createSecret); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/create?initialKey=app%2F`, - 'Goes to create page with initialKey' - ); - await typeIn(FORM.inputByAttr('path'), 'new'); - await fillIn(FORM.keyInput(), 'api_key'); - await fillIn(FORM.maskedValueInput(), 'partyparty'); - await click(FORM.saveBtn); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent('app/new')}/details`, - 'Redirects to detail after save' - ); - await click(PAGE.breadcrumbAtIdx(2)); - assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, 'sub-dir page'); - assert.dom(PAGE.list.item()).doesNotExist('Does not list any secrets'); - }); - test('create new version of secret from older version (sc)', async function (assert) { - const backend = this.backend; - await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`); - assert.dom(PAGE.detail.versionDropdown).doesNotExist('version dropdown does not show'); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created not shown'); - await click(PAGE.detail.createNewVersion); - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/app%2Ffirst/details/edit?version=1`, - 'Goes to new version page' - ); - assert - .dom(FORM.noReadAlert) - .hasText( - 'Warning You do not have read permissions for this secret data. Saving will overwrite the existing secret.', - 'shows alert for no read permissions' - ); - assert.dom(FORM.keyInput()).hasValue('', 'Key input has empty value'); - assert.dom(FORM.maskedValueInput()).hasValue('', 'Val input has empty value'); + .dom(PAGE.detail.deleteModalTitle) + .includesText('Delete metadata and secret data?', 'modal has correct title'); + await click(PAGE.detail.deleteConfirm); - await fillIn(FORM.keyInput(), 'my-key'); - await fillIn(FORM.maskedValueInput(), 'my-value'); - await click(FORM.saveBtn); - - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/app%2Ffirst/details?version=3`, - 'redirects to details page' - ); - assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); - assert - .dom(PAGE.emptyStateTitle) - .hasText('You do not have permission to read this secret', 'shows permissions empty state'); + // redirects to list + assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/kv/list`, 'redirects to list'); }); }); module('enterprise controlled access persona', function (hooks) { hooks.beforeEach(async function () { - this.controlGroup = this.owner.lookup('service:control-group'); const userPolicy = ` path "${this.backend}/data/*" { - capabilities = ["create", "read", "update"] + capabilities = ["create", "read", "update", "delete", "list"] control_group = { max_ttl = "24h" - factor "authorizer" { - controlled_capabilities = ["create", "update"] + factor "approver" { + controlled_capabilities = ["write"] identity { group_names = ["managers"] approvals = 1 @@ -1016,151 +453,26 @@ path "${this.backend}/data/*" { } } -path "${this.backend}/metadata" { - capabilities = ["list", "read"] +path "${this.backend}/*" { + capabilities = ["list"] +} + +// Can we allow this so user can self-authorize? +path "sys/control-group/authorize" { + capabilities = ["update"] } -path "${this.backend}/metadata/*" { - capabilities = ["list", "read"] + +path "sys/control-group/request" { + capabilities = ["update"] } `; + const { userToken } = await setupControlGroup({ userPolicy }); this.userToken = userToken; await authPage.login(userToken); clearRecords(this.store); return; }); - test('create & update root secret with default metadata (cg)', async function (assert) { - const backend = this.backend; - // Known issue: control groups do not work correctly in UI when encodable characters in path - const secretPath = 'some-secret'; - await visit(`/vault/secrets/${backend}/kv/list`); - await click(PAGE.list.createSecret); - - // Create secret form -- validations - await click(FORM.saveBtn); - assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.'); - assert.dom(FORM.validation('path')).hasText("Path can't be blank."); - await typeIn(FORM.inputByAttr('path'), secretPath); - assert.dom(PAGE.create.metadataSection).doesNotExist('Hides metadata section by default'); - - await fillIn(FORM.keyInput(), 'api_key'); - await fillIn(FORM.maskedValueInput(), 'partyparty'); - await click(FORM.saveBtn); - let tokenToUnwrap = this.controlGroup.tokenToUnwrap; - assert.deepEqual( - Object.keys(tokenToUnwrap), - ['accessor', 'token', 'creation_path', 'creation_time', 'ttl'], - 'stored tokenToUnwrap includes correct keys' - ); - assert.strictEqual( - tokenToUnwrap.creation_path, - `${backend}/data/${secretPath}`, - 'stored tokenToUnwrap includes correct creation path' - ); - assert - .dom(FORM.messageError) - .includesText( - `Error A Control Group was encountered at ${backend}/data/${secretPath}.`, - 'shows control group error' - ); - await grantAccessForWrite({ - accessor: tokenToUnwrap.accessor, - token: tokenToUnwrap.token, - creation_path: `${backend}/data/${secretPath}`, - originUrl: `/vault/secrets/${backend}/kv/create`, - userToken: this.userToken, - }); - // In a real scenario the user would stay on page, but in the test - // we fill in the same info and try again - await typeIn(FORM.inputByAttr('path'), secretPath); - await fillIn(FORM.keyInput(), 'this can be anything'); - await fillIn(FORM.maskedValueInput(), 'this too, gonna use the wrapped data'); - await click(FORM.saveBtn); - assert.strictEqual(this.controlGroup.tokenToUnwrap, null, 'clears tokenToUnwrap after successful save'); - // Details page - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${secretPath}/details?version=1`, - 'Goes to details page after save' - ); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); - assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); - assert.dom(PAGE.infoRowValue('api_key')).hasText('***********'); - await click(PAGE.infoRowToggleMasked('api_key')); - assert.dom(PAGE.infoRowValue('api_key')).hasText('partyparty', 'secret value shows after toggle'); - - // Metadata page - await click(PAGE.secretTab('Metadata')); - assert - .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) - .hasText('No custom metadata', 'No custom metadata empty state'); - assert - .dom(`${PAGE.metadata.secretMetadataSection} ${PAGE.infoRow}`) - .exists({ count: 4 }, '4 metadata rows show'); - assert.dom(PAGE.infoRowValue('Maximum versions')).hasText('0', 'max versions shows 0'); - assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('No', 'cas not enforced'); - assert - .dom(PAGE.infoRowValue('Delete version after')) - .hasText('Never delete', 'Delete version after has default 0s'); - - // Add new version - await click(PAGE.secretTab('Secret')); - await click(PAGE.detail.createNewVersion); - assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); - assert.dom(FORM.inputByAttr('path')).hasValue(secretPath); - assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); - assert.dom(FORM.keyInput()).hasValue('api_key'); - assert.dom(FORM.maskedValueInput()).hasValue('partyparty'); - await fillIn(FORM.keyInput(1), 'api_url'); - await fillIn(FORM.maskedValueInput(1), 'hashicorp.com'); - await click(FORM.saveBtn); - tokenToUnwrap = this.controlGroup.tokenToUnwrap; - assert.strictEqual( - tokenToUnwrap.creation_path, - `${backend}/data/${secretPath}`, - 'stored tokenToUnwrap includes correct update path' - ); - assert - .dom(FORM.messageError) - .includesText( - `Error A Control Group was encountered at ${backend}/data/${secretPath}.`, - 'shows control group error' - ); - // Normally the user stays on the page and tries again once approval is granted - // unmark on test so it doesn't use the control group on read at the same path - // when we return to the page after granting access below - this.controlGroup.unmarkTokenForUnwrap(); - await grantAccessForWrite({ - accessor: tokenToUnwrap.accessor, - token: tokenToUnwrap.token, - creation_path: `${backend}/data/${secretPath}`, - originUrl: `/vault/secrets/${backend}/kv/${secretPath}/details/edit`, - userToken: this.userToken, - }); - // Remark for unwrap as if we never left the page. - this.controlGroup.markTokenForUnwrap(tokenToUnwrap.accessor); - // No need to fill in data because we're using the stored wrapped request - // and the path already exists - await click(FORM.saveBtn); - assert.strictEqual( - this.controlGroup.tokenToUnwrap, - null, - 'clears tokenToUnwrap after successful update' - ); - - // Back to details page - assert.strictEqual( - currentURL(), - `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2` - ); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); - assert.dom(PAGE.infoRow).exists({ count: 2 }, '2 rows of data shows'); - assert.dom(PAGE.infoRowValue('api_key')).hasText('***********'); - assert.dom(PAGE.infoRowValue('api_url')).hasText('***********'); - await click(PAGE.infoRowToggleMasked('api_key')); - await click(PAGE.infoRowToggleMasked('api_url')); - assert.dom(PAGE.infoRowValue('api_key')).hasText('partyparty', 'secret value shows after toggle'); - assert.dom(PAGE.infoRowValue('api_url')).hasText('hashicorp.com', 'secret value shows after toggle'); - }); + // Copy test outline from admin persona }); }); 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 9b42364d18f7..bcd91ed0ee56 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 @@ -1,445 +1,100 @@ -import { module, test } from 'qunit'; -import { v4 as uuidv4 } from 'uuid'; -import { setupApplicationTest } from 'vault/tests/helpers'; -import authPage from 'vault/tests/pages/auth'; -import { deleteEngineCmd, mountEngineCmd, runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands'; -import { personas } from 'vault/tests/helpers/policy-generator/kv'; -import { - clearRecords, - deleteLatestCmd, - setupControlGroup, - writeVersionedSecret, -} from 'vault/tests/helpers/kv/kv-run-commands'; -import { click, currentURL, visit } from '@ember/test-helpers'; -import { PAGE } from 'vault/tests/helpers/kv/kv-selectors'; - -const ALL_DELETE_ACTIONS = ['delete', 'destroy', 'undelete']; -const assertDeleteActions = (assert, expected = ['delete', 'destroy']) => { - ALL_DELETE_ACTIONS.forEach((toolbar) => { - if (expected.includes(toolbar)) { - assert.dom(PAGE.detail[toolbar]).exists(`${toolbar} toolbar action exists`); - } else { - assert.dom(PAGE.detail[toolbar]).doesNotExist(`${toolbar} toolbar action not rendered`); - } - }); -}; /** - * This test set is for testing delete, undelete, destroy flows - * Letter(s) in parenthesis at the end are shorthand for the persona, - * for ease of tracking down specific tests failures from CI + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 */ -module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hooks) { - setupApplicationTest(hooks); - - hooks.beforeEach(async function () { - this.store = this.owner.lookup('service:store'); - this.backend = `kv-delete-${uuidv4()}`; - this.secretPath = 'bad-secret'; - await authPage.login(); - await runCmd(mountEngineCmd('kv-v2', this.backend), false); - await writeVersionedSecret(this.backend, this.secretPath, 'foo', 'bar', 4); - await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2); - // Delete latest version for testing undelete for users that can't delete - await runCmd(deleteLatestCmd(this.backend, 'nuke')); - return; - }); - - hooks.afterEach(async function () { - await authPage.login(); - return runCmd(deleteEngineCmd(this.backend)); - }); - - module('admin persona', function (hooks) { - hooks.beforeEach(async function () { - const token = await runCmd(tokenWithPolicyCmd('admin', personas.admin(this.backend))); - await authPage.login(token); - clearRecords(this.store); - return; - }); - test('can delete and undelete the latest secret version (a)', async function (assert) { - assert.expect(17); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); - // correct toolbar options & details show - assertDeleteActions(assert); - assert.dom(PAGE.infoRow).exists('shows secret data'); - // delete flow - await click(PAGE.detail.delete); - assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); - assert.dom(PAGE.detail.deleteOption).isNotDisabled('delete option is selectable'); - assert.dom(PAGE.detail.deleteOptionLatest).isNotDisabled('delete latest option is selectable'); - await click(PAGE.detail.deleteOptionLatest); - await click(PAGE.detail.deleteConfirm); - // details update accordingly - assert - .dom(PAGE.emptyStateTitle) - .hasText('Version 4 of this secret has been deleted', 'Shows deleted message'); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 4 deleted'); - // updated toolbar options - assertDeleteActions(assert, ['undelete', 'destroy']); - // undelete flow - await click(PAGE.detail.undelete); - // details update accordingly - assert.dom(PAGE.infoRow).exists('shows secret data'); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 4 created'); - // correct toolbar options - assertDeleteActions(assert, ['delete', 'destroy']); - }); - test('can soft delete and undelete an older secret version (a)', async function (assert) { - assert.expect(17); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); - // correct toolbar options & details show - assertDeleteActions(assert); - assert.dom(PAGE.infoRow).exists('shows secret data'); - // delete flow - await click(PAGE.detail.delete); - assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); - assert.dom(PAGE.detail.deleteOption).isNotDisabled('delete option is selectable'); - assert.dom(PAGE.detail.deleteOptionLatest).isNotDisabled('delete latest option is selectable'); - await click(PAGE.detail.deleteOption); - await click(PAGE.detail.deleteConfirm); - // details update accordingly - assert - .dom(PAGE.emptyStateTitle) - .hasText('Version 2 of this secret has been deleted', 'Shows deleted message'); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 deleted'); - // updated toolbar options - assertDeleteActions(assert, ['undelete', 'destroy']); - // undelete flow - await click(PAGE.detail.undelete); - // details update accordingly - assert.dom(PAGE.infoRow).exists('shows secret data'); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); - // correct toolbar options - assertDeleteActions(assert, ['delete', 'destroy']); - }); - test('can destroy a secret version (a)', async function (assert) { - assert.expect(9); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`); - // correct toolbar options show - assertDeleteActions(assert); - // delete flow - await click(PAGE.detail.destroy); - assert.dom(PAGE.detail.deleteModalTitle).includesText('Destroy version?', 'modal has correct title'); - await click(PAGE.detail.deleteConfirm); - // details update accordingly - assert - .dom(PAGE.emptyStateTitle) - .hasText('Version 3 of this secret has been permanently destroyed', 'Shows destroyed message'); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('does not show version timestamp'); - // updated toolbar options - assertDeleteActions(assert, []); - }); - test('can permanently delete all secret versions (a)', async function (assert) { - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); - // Check metadata toolbar - await click(PAGE.secretTab('Metadata')); - assert.dom(PAGE.metadata.deleteMetadata).hasText('Permanently delete', 'shows delete metadata button'); - // delete flow - await click(PAGE.metadata.deleteMetadata); - assert - .dom(PAGE.detail.deleteModalTitle) - .includesText('Delete metadata and secret data?', 'modal has correct title'); - await click(PAGE.detail.deleteConfirm); - - // redirects to list - assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/kv/list`, 'redirects to list'); - }); - }); - module('data-reader persona', function (hooks) { - hooks.beforeEach(async function () { - const token = await runCmd(tokenWithPolicyCmd('data-reader', personas.dataReader(this.backend))); - await authPage.login(token); - clearRecords(this.store); - return; - }); - test('cannot delete and undelete the latest secret version (dr)', async function (assert) { - assert.expect(9); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); - // correct toolbar options & details show - assertDeleteActions(assert, []); - assert.dom(PAGE.infoRow).exists('shows secret data'); +const root = ['create', 'read', 'update', 'delete', 'list']; - // data-reader can't delete, so check undelete with already-deleted version - await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); - assertDeleteActions(assert, []); - assert - .dom(PAGE.emptyStateTitle) - .hasText('Version 2 of this secret has been deleted', 'Shows deleted message'); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 deleted'); - }); - test('cannot soft delete and undelete an older secret version (dr)', async function (assert) { - assert.expect(4); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); - // correct toolbar options & details show - assertDeleteActions(assert, []); - assert.dom(PAGE.infoRow).exists('shows secret data'); - }); - test('cannot destroy a secret version (dr)', async function (assert) { - assert.expect(3); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`); - // correct toolbar options show - assertDeleteActions(assert, []); - }); - test('cannot permanently delete all secret versions (dr)', async function (assert) { - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); - // Check metadata toolbar - await click(PAGE.secretTab('Metadata')); - assert.dom(PAGE.metadata.deleteMetadata).doesNotExist('does not show delete metadata button'); - }); - }); +// returns a string with each capability wrapped in double quotes => ["create", "read"] +const format = (array) => array.map((c) => `"${c}"`).join(', '); - module('data-list-reader persona', function (hooks) { - hooks.beforeEach(async function () { - const token = await runCmd( - tokenWithPolicyCmd('data-list-reader', personas.dataListReader(this.backend)) - ); - await authPage.login(token); - clearRecords(this.store); - return; - }); - test('can delete and undelete the latest secret version (dlr)', async function (assert) { - assert.expect(12); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); - // correct toolbar options & details show - assertDeleteActions(assert, ['delete']); - assert.dom(PAGE.infoRow).exists('shows secret data'); - // delete flow - await click(PAGE.detail.delete); - assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); - assert.dom(PAGE.detail.deleteOption).isDisabled('delete option is disabled'); - assert.dom(PAGE.detail.deleteOptionLatest).isNotDisabled('delete latest option is selectable'); - await click(PAGE.detail.deleteOptionLatest); - await click(PAGE.detail.deleteConfirm); - // details update accordingly - assert - .dom(PAGE.emptyStateTitle) - .hasText('Version 4 of this secret has been deleted', 'Shows deleted message'); - assert.dom(PAGE.detail.versionTimestamp).includesText('Version 4 deleted'); - // updated toolbar options - assertDeleteActions(assert, []); - // user can't undelete - }); - test('can soft delete and undelete an older secret version (dlr)', async function (assert) { - assert.expect(6); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); - // correct toolbar options & details show - assertDeleteActions(assert, ['delete']); - assert.dom(PAGE.infoRow).exists('shows secret data'); - // delete flow - await click(PAGE.detail.delete); - assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); - assert.dom(PAGE.detail.deleteOption).isDisabled('delete this version is not available'); - }); - test('cannot destroy a secret version (dlr)', async function (assert) { - assert.expect(3); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`); - // correct toolbar options show - assertDeleteActions(assert, ['delete']); - }); - test('cannot permanently delete all secret versions (dr)', async function (assert) { - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); - // Check metadata toolbar - await click(PAGE.secretTab('Metadata')); - assert.dom(PAGE.metadata.deleteMetadata).doesNotExist('does not show delete metadata button'); - }); - }); - - module('metadata-maintainer persona', function (hooks) { - hooks.beforeEach(async function () { - const token = await runCmd( - tokenWithPolicyCmd('metadata-maintainer', personas.metadataMaintainer(this.backend)) - ); - await authPage.login(token); - clearRecords(this.store); - return; - }); - test('can delete and undelete the latest secret version (mm)', async function (assert) { - assert.expect(17); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); - // correct toolbar options & details show - assertDeleteActions(assert); - assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - // delete flow - await click(PAGE.detail.delete); - assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); - assert.dom(PAGE.detail.deleteOption).isNotDisabled('delete option is selectable'); - assert.dom(PAGE.detail.deleteOptionLatest).isDisabled('delete latest option is disabled'); - - // Can't delete latest, try with pre-deleted secret - await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); - assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); - // updated toolbar options - assertDeleteActions(assert, ['undelete', 'destroy']); - // undelete flow - await click(PAGE.detail.undelete); - // details update accordingly - assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); - // correct toolbar options - assertDeleteActions(assert, ['delete', 'destroy']); - }); - test('can soft delete and undelete an older secret version (mm)', async function (assert) { - assert.expect(18); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); - // correct toolbar options & details show - assertDeleteActions(assert); - assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); - // delete flow - await click(PAGE.detail.delete); - assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); - assert.dom(PAGE.detail.deleteOption).isNotDisabled('delete option is selectable'); - assert.dom(PAGE.detail.deleteOptionLatest).isDisabled('delete latest option is disabled'); - await click(PAGE.detail.deleteOption); - await click(PAGE.detail.deleteConfirm); - // details update accordingly - assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); - // updated toolbar options - assertDeleteActions(assert, ['undelete', 'destroy']); - // undelete flow - await click(PAGE.detail.undelete); - // details update accordingly - assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); - // correct toolbar options - assertDeleteActions(assert); - }); - test('can destroy a secret version (mm)', async function (assert) { - assert.expect(9); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`); - // correct toolbar options show - assertDeleteActions(assert); - // delete flow - await click(PAGE.detail.destroy); - assert.dom(PAGE.detail.deleteModalTitle).includesText('Destroy version?', 'modal has correct title'); - await click(PAGE.detail.deleteConfirm); - // details update accordingly - assert - .dom(PAGE.emptyStateTitle) - .hasText('You do not have permission to read this secret', 'Shows permissions message'); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('does not show version timestamp'); - // updated toolbar options - assertDeleteActions(assert, []); - }); - test('cannot permanently delete all secret versions (mm)', async function (assert) { - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); - // Check metadata toolbar - await click(PAGE.secretTab('Metadata')); - assert.dom(PAGE.metadata.deleteMetadata).doesNotExist('does not show delete metadata button'); - }); - }); - - module('secret-creator persona', function (hooks) { - hooks.beforeEach(async function () { - const token = await runCmd(tokenWithPolicyCmd('secret-creator', personas.secretCreator(this.backend))); - await authPage.login(token); - clearRecords(this.store); - return; - }); - test('cannot delete and undelete the latest secret version (sc)', async function (assert) { - assert.expect(9); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); - // correct toolbar options & details show - assertDeleteActions(assert, []); - assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); +export const adminPolicy = (backend) => { + return ` + path "${backend}/*" { + capabilities = [${format(root)}] + }, + `; +}; - // test with already deleted method - await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); - assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - assert.dom(PAGE.detail.versionTimestamp).doesNotExist('version timestamp not rendered'); - // updated toolbar options - assertDeleteActions(assert, []); - }); - test('cannot soft delete and undelete an older secret version (sc)', async function (assert) { - assert.expect(4); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); - // correct toolbar options & details show - assertDeleteActions(assert, []); - assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); - }); - test('cannot destroy a secret version (sc)', async function (assert) { - assert.expect(3); - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`); - // correct toolbar options show - assertDeleteActions(assert, []); - }); - test('can permanently delete all secret versions (sc)', async function (assert) { - // go to secret details - await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); - // Check metadata toolbar - await click(PAGE.secretTab('Metadata')); - assert.dom(PAGE.metadata.deleteMetadata).hasText('Permanently delete', 'shows delete metadata button'); - // delete flow - await click(PAGE.metadata.deleteMetadata); - assert - .dom(PAGE.detail.deleteModalTitle) - .includesText('Delete metadata and secret data?', 'modal has correct title'); - await click(PAGE.detail.deleteConfirm); +export const dataPolicy = ({ backend, secretPath = '*', capabilities = root }) => { + // "delete" capability on this path can delete latest version + return ` + path "${backend}/data/${secretPath}" { + capabilities = [${format(capabilities)}] + } + `; +}; - // redirects to list - assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/kv/list`, 'redirects to list'); - }); - }); +export const dataNestedPolicy = ({ backend, secretPath = '*', capabilities = root }) => { + return ` + path "${backend}/data/app/${secretPath}" { + capabilities = [${format(capabilities)}] + } + `; +}; - module('enterprise controlled access persona', function (hooks) { - hooks.beforeEach(async function () { - const userPolicy = ` -path "${this.backend}/data/*" { - capabilities = ["create", "read", "update", "delete", "list"] - control_group = { - max_ttl = "24h" - factor "approver" { - controlled_capabilities = ["write"] - identity { - group_names = ["managers"] - approvals = 1 - } +export const metadataPolicy = ({ backend, secretPath = '*', capabilities = root }) => { + // "delete" capability on this path can destroy all versions + return ` + path "${backend}/metadata/${secretPath}" { + capabilities = [${format(capabilities)}] } - } -} + `; +}; -path "${this.backend}/*" { - capabilities = ["list"] -} +export const metadataNestedPolicy = ({ backend, secretPath = '*', capabilities = root }) => { + return ` + path "${backend}/metadata/app/${secretPath}" { + capabilities = [${format(capabilities)}] + } + `; +}; -// Can we allow this so user can self-authorize? -path "sys/control-group/authorize" { - capabilities = ["update"] -} +export const metadataListPolicy = (backend) => { + return ` + path "${backend}/metadata" { + capabilities = ["list"] + } + `; +}; -path "sys/control-group/request" { - capabilities = ["update"] -} -`; +export const deleteVersionsPolicy = ({ backend, secretPath = '*' }) => { + return ` + path "${backend}/delete/${secretPath}" { + capabilities = ["update"] + } + `; +}; +export const undeleteVersionsPolicy = ({ backend, secretPath = '*' }) => { + return ` + path "${backend}/undelete/${secretPath}" { + capabilities = ["update"] + } + `; +}; +export const destroyVersionsPolicy = ({ backend, secretPath = '*' }) => { + return ` + path "${backend}/destroy/${secretPath}" { + capabilities = ["update"] + } + `; +}; - const { userToken } = await setupControlGroup({ userPolicy }); - this.userToken = userToken; - await authPage.login(userToken); - clearRecords(this.store); - return; - }); - // Copy test outline from admin persona - }); -}); +// Personas for reuse in workflow tests +export const personas = { + admin: (backend) => adminPolicy(backend), + dataReader: (backend) => dataPolicy({ backend, capabilities: ['read'] }), + dataListReader: (backend) => + dataPolicy({ backend, capabilities: ['read', 'delete'] }) + metadataListPolicy(backend), + metadataMaintainer: (backend) => + metadataPolicy({ backend, capabilities: ['create', 'read', 'update', 'list'] }) + + deleteVersionsPolicy({ backend }) + + undeleteVersionsPolicy({ backend }) + + destroyVersionsPolicy({ backend }), + secretNestedCreator: (backend) => + dataNestedPolicy({ backend, capabilities: ['create', 'update'] }) + + metadataNestedPolicy({ backend, capabilities: ['list', 'delete'] }), + secretCreator: (backend) => + dataPolicy({ backend, capabilities: ['create', 'update'] }) + + metadataPolicy({ backend, capabilities: ['delete'] }), +}; diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js index 8728af7bf4c0..8aec0c7fcc5c 100644 --- a/ui/tests/helpers/kv/kv-selectors.js +++ b/ui/tests/helpers/kv/kv-selectors.js @@ -1,126 +1,149 @@ -/** - * Copyright (c) HashiCorp, Inc. - * SPDX-License-Identifier: MPL-2.0 - */ +{{! + Copyright (c) HashiCorp, Inc. +}} -export const PAGE = { - // General selectors that are common between pages - title: '[data-test-header-title]', - breadcrumbs: '[data-test-breadcrumbs]', - breadcrumb: '[data-test-breadcrumbs] li', - breadcrumbAtIdx: (idx) => `[data-test-crumb="${idx}"] a`, - infoRow: '[data-test-component="info-table-row"]', - infoRowValue: (label) => `[data-test-value-div="${label}"]`, - infoRowToggleMasked: (label) => `[data-test-value-div="${label}"] [data-test-button="toggle-masked"]`, - secretTab: (tab) => (tab ? `[data-test-secrets-tab="${tab}"]` : '[data-test-secrets-tab]'), - emptyStateTitle: '[data-test-empty-state-title]', - emptyStateMessage: '[data-test-empty-state-message]', - emptyStateActions: '[data-test-empty-state-actions]', - popup: '[data-test-popup-menu-trigger]', - error: { - title: '[data-test-page-error] h1', - message: '[data-test-page-error] p', - }, - toolbar: 'nav.toolbar', - toolbarAction: 'nav.toolbar-actions .toolbar-link', - secretRow: '[data-test-component="info-table-row"]', // replace with infoRow - // specific page selectors - backends: { - link: (backend) => `[data-test-secrets-backend-link="${backend}"]`, - }, - metadata: { - editBtn: '[data-test-edit-metadata]', - addCustomMetadataBtn: '[data-test-add-custom-metadata]', - customMetadataSection: '[data-test-kv-custom-metadata-section]', - secretMetadataSection: '[data-test-kv-metadata-section]', - deleteMetadata: '[data-test-kv-delete="delete-metadata"]', - }, - detail: { - versionTimestamp: '[data-test-kv-version-tooltip-trigger]', - versionDropdown: '[data-test-version-dropdown]', - version: (number) => `[data-test-version="${number}"]`, - createNewVersion: '[data-test-create-new-version]', - delete: '[data-test-kv-delete="delete"]', - destroy: '[data-test-kv-delete="destroy"]', - undelete: '[data-test-kv-delete="undelete"]', - copy: '[data-test-copy-menu-trigger]', - deleteModal: '[data-test-delete-modal]', - deleteModalTitle: '[data-test-delete-modal] [data-test-modal-title]', - deleteOption: 'input#delete-version', - deleteOptionLatest: 'input#delete-latest-version', - deleteConfirm: '[data-test-delete-modal-confirm]', - }, - edit: { - toggleDiff: '[data-test-toggle-input="Show diff"', - toggleDiffDescription: '[data-test-diff-description]', - }, - list: { - createSecret: '[data-test-toolbar-create-secret]', - item: (secret) => (!secret ? '[data-test-list-item]' : `[data-test-list-item="${secret}"]`), - filter: `[data-test-kv-list-filter]`, - overviewCard: '[data-test-overview-card-container="View secret"]', - overviewInput: '[data-test-view-secret] input', - overviewButton: '[data-test-get-secret-detail]', - pagination: '[data-test-pagination]', - paginationInfo: '.hds-pagination-info', - paginationNext: '.hds-pagination-nav__arrow--direction-next', - paginationSelected: '.hds-pagination-nav__number--is-selected', - }, - versions: { - icon: (version) => `[data-test-icon-holder="${version}"]`, - linkedBlock: (version) => - version ? `[data-test-version-linked-block="${version}"]` : '[data-test-version-linked-block]', - versionMenu: (version) => `[data-test-version-linked-block="${version}"] [data-test-popup-menu-trigger]`, - createFromVersion: (version) => `[data-test-create-new-version-from="${version}"]`, - }, - diff: { - visualDiff: '[data-test-visual-diff]', - added: `.jsondiffpatch-added`, - deleted: `.jsondiffpatch-deleted`, - }, - create: { - metadataSection: '[data-test-metadata-section]', - }, - paths: { - copyButton: (label) => `${PAGE.infoRowValue(label)} button`, - codeSnippet: (section) => `[data-test-code-snippet][data-test-commands="${section}"] code`, - snippetCopy: (section) => `[data-test-code-snippet][data-test-commands="${section}"] button`, - }, -}; + + <:tabLinks> + Secrets + Configuration + -// Form/Interactive selectors that are common between pages and forms -export const FORM = { - inputByAttr: (attr) => `[data-test-input="${attr}"]`, - fieldByAttr: (attr) => `[data=test=field="${attr}"]`, // formfield - toggleJson: '[data-test-toggle-input="json"]', - toggleMasked: '[data-test-button="toggle-masked"]', - toggleMetadata: '[data-test-metadata-toggle]', - jsonEditor: '[data-test-component="code-mirror-modifier"]', - ttlValue: (name) => `[data-test-ttl-value="${name}"]`, - toggleByLabel: (label) => `[data-test-ttl-toggle="${label}"]`, - dataInputLabel: ({ isJson = false }) => - isJson ? '[data-test-component="json-editor-title"]' : '[data-test-kv-label]', - // - kvLabel: '[data-test-kv-label]', - kvRow: '[data-test-kv-row]', - keyInput: (idx = 0) => `[data-test-kv-key="${idx}"]`, - valueInput: (idx = 0) => `[data-test-kv-value="${idx}"]`, - maskedValueInput: (idx = 0) => `[data-test-kv-value="${idx}"] [data-test-textarea]`, - addRow: (idx = 0) => `[data-test-kv-add-row="${idx}"]`, - deleteRow: (idx = 0) => `[data-test-kv-delete-row="${idx}"]`, - // Alerts & validation - inlineAlert: '[data-test-inline-alert]', - validation: (attr) => `[data-test-field="${attr}"] [data-test-inline-alert]`, - messageError: '[data-test-message-error]', - validationWarning: '[data-test-validation-warning]', - invalidFormAlert: '[data-test-invalid-form-alert]', - versionAlert: '[data-test-secret-version-alert]', - noReadAlert: '[data-test-secret-no-read-alert]', - // Form btns - saveBtn: '[data-test-kv-save]', - cancelBtn: '[data-test-kv-cancel]', -}; + <:toolbarFilters> -export const parseJsonEditor = (find) => { - return JSON.parse(find(FORM.jsonEditor).innerText); -}; + {{#if (and (not-eq @secrets 403) (or @secrets @filterValue))}} + + {{/if}} + + + <:toolbarActions> + + Create secret + + + +{{#if (eq @secrets 403)}} +
+
+ +
+ + + + {{#if @failedDirectoryQuery}} + + {{/if}} +
+
+
+{{else}} + {{#if @secrets}} + {{#each @secrets as |metadata|}} + +
+
+
+ + + {{metadata.path}} + +
+
+
+
+ + + +
+
+
+
+ {{/each}} + {{! Pagination }} + + {{else}} + {{#if @filterValue}} + + {{else}} + + + Create secret + + + {{/if}} + {{/if}} +{{/if}} \ No newline at end of file diff --git a/ui/tests/helpers/policy-generator/kv.js b/ui/tests/helpers/policy-generator/kv.js index 7794e0493d03..43dd20fc9120 100644 --- a/ui/tests/helpers/policy-generator/kv.js +++ b/ui/tests/helpers/policy-generator/kv.js @@ -3,80 +3,117 @@ * SPDX-License-Identifier: MPL-2.0 */ -const root = ['create', 'read', 'update', 'delete', 'list']; +import Model, { attr } from '@ember-data/model'; +import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities'; +import { withModelValidations } from 'vault/decorators/model-validations'; +import { withFormFields } from 'vault/decorators/model-form-fields'; +import { keyIsFolder } from 'core/utils/key-utils'; +import { isDeleted } from 'kv/utils/kv-deleted'; -// returns a string with each capability wrapped in double quotes => ["create", "read"] -const format = (array) => array.map((c) => `"${c}"`).join(', '); - -export const adminPolicy = (backend) => { - return ` - path "${backend}/*" { - capabilities = [${format(root)}] - }, - `; +const validations = { + maxVersions: [ + { type: 'number', message: 'Maximum versions must be a number.' }, + { type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' }, + ], }; +const formFieldProps = ['customMetadata', 'maxVersions', 'casRequired', 'deleteVersionAfter']; -export const dataPolicy = ({ backend, secretPath = '*', capabilities = root }) => { - // "delete" capability on this path can delete latest version - return ` - path "${backend}/data/${secretPath}" { - capabilities = [${format(capabilities)}] - } - `; -}; +@withModelValidations(validations) +@withFormFields(formFieldProps) +export default class KvSecretMetadataModel extends Model { + @attr('string') backend; + @attr('string') path; + @attr('string') fullSecretPath; -export const metadataPolicy = ({ backend, secretPath = '*', capabilities = root }) => { - // "delete" capability on this path can destroy all versions - return ` - path "${backend}/metadata/${secretPath}" { - capabilities = [${format(capabilities)}] - } - `; -}; + @attr('number', { + defaultValue: 0, + label: 'Maximum number of versions', + subText: + 'The number of versions to keep per key. Once the number of keys exceeds the maximum number set here, the oldest version will be permanently deleted.', + }) + maxVersions; -export const metadataListPolicy = (backend) => { - return ` - path "${backend}/metadata" { - capabilities = ["list"] - } - `; -}; + @attr('boolean', { + defaultValue: false, + label: 'Require Check and Set', + subText: `Writes will only be allowed if the key's current version matches the version specified in the cas parameter.`, + }) + casRequired; -export const deleteVersionsPolicy = ({ backend, secretPath = '*' }) => { - return ` - path "${backend}/delete/${secretPath}" { - capabilities = ["update"] - } - `; -}; -export const undeleteVersionsPolicy = ({ backend, secretPath = '*' }) => { - return ` - path "${backend}/undelete/${secretPath}" { - capabilities = ["update"] - } - `; -}; -export const destroyVersionsPolicy = ({ backend, secretPath = '*' }) => { - return ` - path "${backend}/destroy/${secretPath}" { - capabilities = ["update"] + @attr('string', { + defaultValue: '0s', + editType: 'ttl', + label: 'Automate secret deletion', + helperTextDisabled: `A secret's version must be manually deleted.`, + helperTextEnabled: 'Delete all new versions of this secret after:', + }) + deleteVersionAfter; + + @attr('object', { + editType: 'kv', + subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.', + }) + customMetadata; + + // Additional Params only returned on the GET response. + @attr('string') createdTime; + @attr('number') currentVersion; + @attr('number') oldestVersion; + @attr('string') updatedTime; + @attr('object') versions; + + // used for KV list and list-directory view + get pathIsDirectory() { + // ex: beep/ + return keyIsFolder(this.path); + } + + // cannot use isDeleted due to ember property conflict + get isSecretDeleted() { + return isDeleted(this.deletionTime); + } + + // turns version object into an array for version dropdown menu + get sortedVersions() { + const array = []; + for (const key in this.versions) { + this.versions[key].isSecretDeleted = isDeleted(this.versions[key].deletion_time); + array.push({ version: key, ...this.versions[key] }); } - `; -}; + // version keys are in order created with 1 being the oldest, we want newest first + return array.reverse(); + } -// Personas for reuse in workflow tests -export const personas = { - admin: (backend) => adminPolicy(backend), - dataReader: (backend) => dataPolicy({ backend, capabilities: ['read'] }), - dataListReader: (backend) => - dataPolicy({ backend, capabilities: ['read', 'delete'] }) + metadataListPolicy(backend), - metadataMaintainer: (backend) => - metadataListPolicy(backend) + - metadataPolicy({ backend, capabilities: ['create', 'read', 'update', 'list'] }) + - deleteVersionsPolicy({ backend }) + - undeleteVersionsPolicy({ backend }) + - destroyVersionsPolicy({ backend }), - secretCreator: (backend) => - dataPolicy({ backend, capabilities: ['create', 'update'] }) + - metadataPolicy({ backend, capabilities: ['delete'] }), -}; + // helps in long logic statements for state of a currentVersion + get currentSecret() { + if (!this.versions || !this.currentVersion) return false; + const data = this.versions[this.currentVersion]; + const state = data.destroyed ? 'destroyed' : isDeleted(data.deletion_time) ? 'deleted' : 'created'; + return { + state, + isDeactivated: state !== 'created', + }; + } + + get permissionsPath() { + return this.fullSecretPath || this.path; + } + + // permissions needed for the list view where kv/data has not yet been called. Allows us to conditionally show action items in the LinkedBlock popups. + @lazyCapabilities(apiPath`${'backend'}/data/${'permissionsPath'}`, 'backend', 'permissionsPath') dataPath; + @lazyCapabilities(apiPath`${'backend'}/metadata/${'permissionsPath'}`, 'backend', 'permissionsPath') + metadataPath; + + get canDeleteMetadata() { + return this.metadataPath.get('canDelete') !== false; + } + get canReadMetadata() { + return this.metadataPath.get('canRead') !== false; + } + get canUpdateMetadata() { + return this.metadataPath.get('canUpdate') !== false; + } + get canCreateVersionData() { + return this.dataPath.get('canUpdate') !== false; + } +}