/** * Copyright (c) HashiCorp, Inc. * SPDX-License-Identifier: BUSL-1.1 */ import { module, test } from 'qunit'; import { v4 as uuidv4 } from 'uuid'; import { click, currentURL, fillIn, findAll, setupOnerror, typeIn, visit } from '@ember/test-helpers'; import { setupApplicationTest } from 'vault/tests/helpers'; import authPage from 'vault/tests/pages/auth'; import { createPolicyCmd, deleteEngineCmd, mountEngineCmd, runCmd, createTokenCmd, } from 'vault/tests/helpers/commands'; import { dataPolicy, deleteVersionsPolicy, destroyVersionsPolicy, metadataListPolicy, metadataPolicy, } from 'vault/tests/helpers/kv/policy-generator'; import { clearRecords, writeSecret, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands'; import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors'; import codemirror from 'vault/tests/helpers/codemirror'; /** * This test set is for testing edge cases, such as specific bug fixes or reported user workflows */ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) { setupApplicationTest(hooks); hooks.beforeEach(async function () { const uid = uuidv4(); this.backend = `kv-edge-${uid}`; this.rootSecret = 'root-directory'; this.fullSecretPath = `${this.rootSecret}/nested/child-secret`; await authPage.login(); await runCmd(mountEngineCmd('kv-v2', this.backend), false); await writeSecret(this.backend, this.fullSecretPath, 'foo', 'bar'); await writeSecret(this.backend, 'edge/one', 'foo', 'bar'); await writeSecret(this.backend, 'edge/two', 'foo', 'bar'); return; }); hooks.afterEach(async function () { await authPage.login(); await runCmd(deleteEngineCmd(this.backend)); return; }); module('persona with read and list access on the secret level', function (hooks) { // see github issue for more details https://github.com/hashicorp/vault/issues/5362 hooks.beforeEach(async function () { const secretPath = `${this.rootSecret}/*`; // user has LIST and READ access within this root secret directory const capabilities = ['list', 'read']; const backend = this.backend; const token = await runCmd([ createPolicyCmd( `nested-secret-list-reader-${this.backend}`, metadataPolicy({ backend, secretPath, capabilities }) + dataPolicy({ backend, secretPath, capabilities }) ), createTokenCmd(`nested-secret-list-reader-${this.backend}`), ]); await authPage.login(token); }); test('it can navigate to secrets within a secret directory', async function (assert) { assert.expect(21); const backend = this.backend; const [root, subdirectory, secret] = this.fullSecretPath.split('/'); await visit(`/vault/secrets/${backend}/kv/list`); assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list`, 'lands on secrets list page'); await typeIn(PAGE.list.overviewInput, `${root}/no-access/`); assert .dom(PAGE.list.overviewButton) .hasText('View list', 'shows list and not secret because search is a directory'); await click(PAGE.list.overviewButton); assert.dom(PAGE.emptyStateTitle).hasText(`There are no secrets matching "${root}/no-access/".`); await visit(`/vault/secrets/${backend}/kv/list`); await typeIn(PAGE.list.overviewInput, `${root}/`); // add slash because this is a directory await click(PAGE.list.overviewButton); // URL correct assert.strictEqual( currentURL(), `/vault/secrets/${backend}/kv/list/${root}/`, 'visits list-directory of root' ); // Title correct assert.dom(PAGE.title).hasText(`${backend} version 2`); // Tabs correct assert.dom(PAGE.secretTab('Secrets')).hasText('Secrets'); assert.dom(PAGE.secretTab('Secrets')).hasClass('active'); assert.dom(PAGE.secretTab('Configuration')).hasText('Configuration'); assert.dom(PAGE.secretTab('Configuration')).doesNotHaveClass('active'); // Toolbar correct assert.dom(PAGE.toolbarAction).exists({ count: 1 }, 'toolbar only renders create secret action'); assert.dom(PAGE.list.filter).hasValue(`${root}/`); // List content correct assert.dom(PAGE.list.item(`${subdirectory}/`)).exists('renders linked block for subdirectory'); await click(PAGE.list.item(`${subdirectory}/`)); assert.dom(PAGE.list.item(secret)).exists('renders linked block for child secret'); await click(PAGE.list.item(secret)); // Secret details visible assert.dom(PAGE.title).hasText(this.fullSecretPath); assert.dom(PAGE.secretTab('Secret')).hasText('Secret'); assert.dom(PAGE.secretTab('Secret')).hasClass('active'); assert.dom(PAGE.secretTab('Metadata')).hasText('Metadata'); assert.dom(PAGE.secretTab('Metadata')).doesNotHaveClass('active'); assert.dom(PAGE.secretTab('Version History')).hasText('Version History'); assert.dom(PAGE.secretTab('Version History')).doesNotHaveClass('active'); assert.dom(PAGE.toolbarAction).exists({ count: 4 }, 'toolbar renders all actions'); }); test('it navigates back to engine index route via breadcrumbs from secret details', async function (assert) { assert.expect(6); const backend = this.backend; const [root, subdirectory, secret] = this.fullSecretPath.split('/'); await visit(`vault/secrets/${backend}/kv/${encodeURIComponent(this.fullSecretPath)}/details?version=1`); // navigate back through crumbs let previousCrumb = findAll('[data-test-breadcrumbs] li').length - 2; await click(PAGE.breadcrumbAtIdx(previousCrumb)); assert.strictEqual( currentURL(), `/vault/secrets/${backend}/kv/list/${root}/${subdirectory}/`, 'goes back to subdirectory list' ); assert.dom(PAGE.list.filter).hasValue(`${root}/${subdirectory}/`); assert.dom(PAGE.list.item(secret)).exists('renders linked block for child secret'); // back again previousCrumb = findAll('[data-test-breadcrumbs] li').length - 2; await click(PAGE.breadcrumbAtIdx(previousCrumb)); assert.strictEqual( currentURL(), `/vault/secrets/${backend}/kv/list/${root}/`, 'goes back to root directory' ); assert.dom(PAGE.list.item(`${subdirectory}/`)).exists('renders linked block for subdirectory'); // and back to the engine list view previousCrumb = findAll('[data-test-breadcrumbs] li').length - 2; await click(PAGE.breadcrumbAtIdx(previousCrumb)); assert.strictEqual( currentURL(), `/vault/secrets/${backend}/kv/list`, 'navigates back to engine list from crumbs' ); }); test('it handles errors when attempting to view details of a secret that is a directory', async function (assert) { assert.expect(7); const backend = this.backend; const [root, subdirectory] = this.fullSecretPath.split('/'); setupOnerror((error) => assert.strictEqual(error.httpStatus, 404), '404 error is thrown'); // catches error so qunit test doesn't fail await visit(`/vault/secrets/${backend}/kv/list`); await typeIn(PAGE.list.overviewInput, `${root}/${subdirectory}`); // intentionally leave out trailing slash await click(PAGE.list.overviewButton); assert.dom(PAGE.error.title).hasText('404 Not Found'); assert .dom(PAGE.error.message) .hasText(`Sorry, we were unable to find any content at /v1/${backend}/data/${root}/${subdirectory}.`); assert.dom(PAGE.breadcrumbAtIdx(0)).hasText('secrets'); assert.dom(PAGE.breadcrumbAtIdx(1)).hasText(backend); assert.dom(PAGE.secretTab('Secrets')).doesNotHaveClass('is-active'); assert.dom(PAGE.secretTab('Configuration')).doesNotHaveClass('is-active'); }); }); module('destruction without read', function (hooks) { hooks.beforeEach(async function () { const backend = this.backend; const testSecrets = [ 'data-delete-only', 'delete-version-only', 'destroy-version-only', 'destroy-metadata-only', ]; // user has different permissions for each secret path const token = await runCmd([ createPolicyCmd( `destruction-no-read-${this.backend}`, dataPolicy({ backend, secretPath: 'data-delete-only', capabilities: ['delete'] }) + deleteVersionsPolicy({ backend, secretPath: 'delete-version-only' }) + destroyVersionsPolicy({ backend, secretPath: 'destroy-version-only' }) + metadataPolicy({ backend, secretPath: 'destroy-metadata-only', capabilities: ['delete'] }) + metadataListPolicy(backend) ), createTokenCmd(`destruction-no-read-${this.backend}`), ]); for (const secret of testSecrets) { await writeVersionedSecret(backend, secret, 'foo', 'bar', 2); } await authPage.login(token); }); test('it renders the delete action and disables delete this version option', async function (assert) { assert.expect(4); const testSecret = 'data-delete-only'; await visit(`/vault/secrets/${this.backend}/kv/${testSecret}/details`); assert.dom(PAGE.detail.delete).exists('renders delete button'); await click(PAGE.detail.delete); assert .dom(PAGE.detail.deleteModal) .hasTextContaining('Delete this version This deletes a specific version of the secret'); assert.dom(PAGE.detail.deleteOption).isDisabled('disables version specific option'); assert.dom(PAGE.detail.deleteOptionLatest).isEnabled('enables version specific option'); }); test('it renders the delete action and disables delete latest version option', async function (assert) { assert.expect(4); const testSecret = 'delete-version-only'; await visit(`/vault/secrets/${this.backend}/kv/${testSecret}/details`); assert.dom(PAGE.detail.delete).exists('renders delete button'); await click(PAGE.detail.delete); assert .dom(PAGE.detail.deleteModal) .hasTextContaining('Delete this version This deletes a specific version of the secret'); assert.dom(PAGE.detail.deleteOption).isEnabled('enables version specific option'); assert.dom(PAGE.detail.deleteOptionLatest).isDisabled('disables version specific option'); }); test('it hides destroy option without version number', async function (assert) { assert.expect(1); const testSecret = 'destroy-version-only'; await visit(`/vault/secrets/${this.backend}/kv/${testSecret}/details`); assert.dom(PAGE.detail.destroy).doesNotExist(); }); test('it renders the destroy metadata action and expected modal copy', async function (assert) { assert.expect(2); const testSecret = 'destroy-metadata-only'; await visit(`/vault/secrets/${this.backend}/kv/${testSecret}/metadata`); assert.dom(PAGE.metadata.deleteMetadata).exists('renders delete metadata button'); await click(PAGE.metadata.deleteMetadata); assert .dom(PAGE.detail.deleteModal) .hasText( 'Delete metadata and secret data? This will permanently delete the metadata and versions of the secret. All version history will be removed. This cannot be undone. Confirm Cancel' ); }); }); test('no ghost item after editing metadata', async function (assert) { await visit(`/vault/secrets/${this.backend}/kv/list/edge/`); assert.dom(PAGE.list.item()).exists({ count: 2 }, 'two secrets are listed'); await click(PAGE.list.item('two')); await click(PAGE.secretTab('Metadata')); await click(PAGE.metadata.editBtn); await fillIn(FORM.keyInput(), 'foo'); await fillIn(FORM.valueInput(), 'bar'); await click(FORM.saveBtn); await click(PAGE.breadcrumbAtIdx(2)); assert.dom(PAGE.list.item()).exists({ count: 2 }, 'two secrets are listed'); }); test('advanced secret values default to JSON display', async function (assert) { const obscuredData = `{ "foo3": { "name": "********" } }`; await visit(`/vault/secrets/${this.backend}/kv/create`); await fillIn(FORM.inputByAttr('path'), 'complex'); await click(FORM.toggleJson); assert.strictEqual(codemirror().getValue(), '{ "": "" }'); codemirror().setValue('{ "foo3": { "name": "bar3" } }'); await click(FORM.saveBtn); // Details view assert.dom(FORM.toggleJson).isNotDisabled(); assert.dom(FORM.toggleJson).isChecked(); assert.strictEqual( codemirror().getValue(), obscuredData, 'Value is obscured by default on details view when advanced' ); await click('[data-test-toggle-input="revealValues"]'); assert.false(codemirror().getValue().includes('*'), 'Value unobscured after toggle'); // New version view await click(PAGE.detail.createNewVersion); assert.dom(FORM.toggleJson).isNotDisabled(); assert.dom(FORM.toggleJson).isChecked(); assert.false(codemirror().getValue().includes('*'), 'Values are not obscured on edit view'); }); test('viewing advanced secret data versions displays the correct version data', async function (assert) { assert.expect(2); const obscuredDataV1 = `{ "foo1": { "name": "********" } }`; const obscuredDataV2 = `{ "foo2": { "name": "********" } }`; await visit(`/vault/secrets/${this.backend}/kv/create`); await fillIn(FORM.inputByAttr('path'), 'complex_version_test'); await click(FORM.toggleJson); codemirror().setValue('{ "foo1": { "name": "bar1" } }'); await click(FORM.saveBtn); // Create another version await click(PAGE.detail.createNewVersion); codemirror().setValue('{ "foo2": { "name": "bar2" } }'); await click(FORM.saveBtn); // View the first version and make sure the secret data is correct await click(PAGE.detail.versionDropdown); await click(`${PAGE.detail.version(1)} a`); assert.strictEqual(codemirror().getValue(), obscuredDataV1, 'Version one data is displayed'); // Navigate back the second version and make sure the secret data is correct await click(PAGE.detail.versionDropdown); await click(`${PAGE.detail.version(2)} a`); assert.strictEqual(codemirror().getValue(), obscuredDataV2, 'Version two data is displayed'); }); test('does not register as advanced when value includes {', async function (assert) { await visit(`/vault/secrets/${this.backend}/kv/create`); await fillIn(FORM.inputByAttr('path'), 'not-advanced'); await fillIn(FORM.keyInput(), 'foo'); await fillIn(FORM.maskedValueInput(), '{bar}'); await click(FORM.saveBtn); await click(PAGE.detail.createNewVersion); assert.dom(FORM.toggleJson).isNotDisabled(); assert.dom(FORM.toggleJson).isNotChecked(); }); }); // NAMESPACE TESTS module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks) { setupApplicationTest(hooks); const navToEngine = async (backend) => { await click('[data-test-sidebar-nav-link="Secrets Engines"]'); return await click(PAGE.backends.link(backend)); }; const assertDeleteActions = (assert, expected = ['delete', 'destroy']) => { ['delete', 'destroy', 'undelete'].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`); } }); }; const assertVersionDropdown = async (assert, deleted = [], versions = [2, 1]) => { assert.dom(PAGE.detail.versionDropdown).hasText(`Version ${versions[0]}`); await click(PAGE.detail.versionDropdown); versions.forEach((num) => { assert.dom(PAGE.detail.version(num)).exists(`renders version ${num} link in dropdown`); }); // also asserts destroyed icon deleted.forEach((num) => { assert.dom(`${PAGE.detail.version(num)} [data-test-icon="x-square"]`); }); }; // each test uses a different secret path hooks.beforeEach(async function () { const uid = uuidv4(); this.store = this.owner.lookup('service:store'); this.backend = `kv-enterprise-edge-${uid}`; this.namespace = `ns-${uid}`; await authPage.login(); await runCmd([`write sys/namespaces/${this.namespace} -force`]); return; }); hooks.afterEach(async function () { await authPage.login(); await runCmd([`delete /sys/auth/${this.namespace}`]); await runCmd(deleteEngineCmd(this.backend)); return; }); module('admin persona', function (hooks) { hooks.beforeEach(async function () { await authPage.loginNs(this.namespace); // mount engine within namespace await runCmd(mountEngineCmd('kv-v2', this.backend), false); clearRecords(this.store); return; }); hooks.afterEach(async function () { // visit logout with namespace query param because we're transitioning from within an engine // and navigating directly to /vault/auth caused test context routing problems :( await visit(`/vault/logout?namespace=${this.namespace}`); await authPage.namespaceInput(''); // clear login form namespace input }); test('namespace: it can create a secret and new secret version', async function (assert) { assert.expect(15); const backend = this.backend; const ns = this.namespace; const secret = 'my-create-secret'; await navToEngine(backend); assert.strictEqual( currentURL(), `/vault/secrets/${backend}/kv/list?namespace=${ns}`, 'navigates to list' ); // Create first version of secret await click(PAGE.list.createSecret); await fillIn(FORM.inputByAttr('path'), secret); assert.dom(FORM.toggleMetadata).exists('Shows metadata toggle when creating new secret'); await fillIn(FORM.keyInput(), 'foo'); await fillIn(FORM.maskedValueInput(), 'woahsecret'); await click(FORM.saveBtn); assert.strictEqual( currentURL(), `/vault/secrets/${backend}/kv/${secret}/details?namespace=${ns}&version=1`, 'navigates to details' ); // Create a new version await click(PAGE.detail.createNewVersion); assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); assert.dom(FORM.inputByAttr('path')).hasValue(secret); assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); assert.dom(FORM.keyInput()).hasValue('foo'); assert.dom(FORM.maskedValueInput()).hasValue('woahsecret'); await fillIn(FORM.keyInput(1), 'foo-two'); await fillIn(FORM.maskedValueInput(1), 'supersecret'); await click(FORM.saveBtn); // Check details assert.strictEqual( currentURL(), `/vault/secrets/${backend}/kv/${secret}/details?namespace=${ns}&version=2`, 'navigates to details' ); await assertVersionDropdown(assert); assert .dom(`${PAGE.detail.version(2)} [data-test-icon="check-circle"]`) .exists('renders current version icon'); assert.dom(PAGE.infoRowValue('foo-two')).hasText('***********'); await click(PAGE.infoRowToggleMasked('foo-two')); assert.dom(PAGE.infoRowValue('foo-two')).hasText('supersecret', 'secret value shows after toggle'); }); test('namespace: it manages state throughout delete, destroy and undelete operations', async function (assert) { assert.expect(34); const backend = this.backend; const ns = this.namespace; const secret = 'my-delete-secret'; await writeVersionedSecret(backend, secret, 'foo', 'bar', 2, ns); await navToEngine(backend); await click(PAGE.list.item(secret)); assert.strictEqual( currentURL(), `/vault/secrets/${backend}/kv/${secret}/details?namespace=${ns}&version=2`, 'navigates to details' ); // correct toolbar options & details show assertDeleteActions(assert); await assertVersionDropdown(assert); // delete flow await click(PAGE.detail.delete); await click(PAGE.detail.deleteOption); await click(PAGE.detail.deleteConfirm); // check empty state and toolbar assertDeleteActions(assert, ['undelete', 'destroy']); 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'); await assertVersionDropdown(assert, [2]); // important to test dropdown versions are accurate // navigate to sibling route to make sure empty state remains for details tab await click(PAGE.secretTab('Version History')); assert.dom(PAGE.versions.linkedBlock()).exists({ count: 2 }); // back to secret tab to confirm deleted state await click(PAGE.secretTab('Secret')); // if this assertion fails, the view is rendering a stale model assert.dom(PAGE.emptyStateTitle).exists('still renders empty state!!'); await assertVersionDropdown(assert, [2]); // undelete flow await click(PAGE.detail.undelete); // details update accordingly assertDeleteActions(assert, ['delete', 'destroy']); assert.dom(PAGE.infoRow).exists('shows secret data'); assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); // destroy flow await click(PAGE.detail.destroy); await click(PAGE.detail.deleteConfirm); assertDeleteActions(assert, []); assert .dom(PAGE.emptyStateTitle) .hasText('Version 2 of this secret has been permanently destroyed', 'Shows destroyed message'); // navigate to sibling route to make sure empty state remains for details tab await click(PAGE.secretTab('Version History')); assert.dom(PAGE.versions.linkedBlock()).exists({ count: 2 }); // back to secret tab to confirm destroyed state await click(PAGE.secretTab('Secret')); // if this assertion fails, the view is rendering a stale model assert.dom(PAGE.emptyStateTitle).exists('still renders empty state!!'); await assertVersionDropdown(assert, [2]); }); }); });