diff --git a/ui/lib/kv/addon/components/kv-patch-editor/alerts.hbs b/ui/lib/kv/addon/components/kv-patch/editor/alerts.hbs similarity index 100% rename from ui/lib/kv/addon/components/kv-patch-editor/alerts.hbs rename to ui/lib/kv/addon/components/kv-patch/editor/alerts.hbs diff --git a/ui/lib/kv/addon/components/kv-patch-editor/form.hbs b/ui/lib/kv/addon/components/kv-patch/editor/form.hbs similarity index 76% rename from ui/lib/kv/addon/components/kv-patch-editor/form.hbs rename to ui/lib/kv/addon/components/kv-patch/editor/form.hbs index 8115e8dff3..20ea9f1907 100644 --- a/ui/lib/kv/addon/components/kv-patch-editor/form.hbs +++ b/ui/lib/kv/addon/components/kv-patch/editor/form.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 ~}} -
+
Key @@ -15,7 +15,7 @@ {{! Rows for existing keys (includes new rows after user clicks "Add") }} {{#each this.patchData as |kv idx|}} -
-
- -
- - Reveal subkeys in JSON - - {{#if this.showSubkeys}} - - {{/if}} -
- +
@@ -77,7 +68,7 @@ - {{#if this.submitError}} - + {{#if (or @submitError this.validationError)}} + {{/if}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-patch-editor/form.js b/ui/lib/kv/addon/components/kv-patch/editor/form.js similarity index 92% rename from ui/lib/kv/addon/components/kv-patch-editor/form.js rename to ui/lib/kv/addon/components/kv-patch/editor/form.js index e0771d8ef5..45dd546d65 100644 --- a/ui/lib/kv/addon/components/kv-patch-editor/form.js +++ b/ui/lib/kv/addon/components/kv-patch/editor/form.js @@ -10,7 +10,7 @@ import { A } from '@ember/array'; import { hasWhitespace, isNonString, WHITESPACE_WARNING, NON_STRING_WARNING } from 'vault/utils/validators'; /** - * @module KvPatchEditor::Form + * @module KvPatch::Editor::Form * @description * This component renders one of two ways to patch a KV v2 secret (the other is using the JSON editor). * Each top-level subkey returned by the API endpoint renders in a disabled column with an empty (also disabled) value input beside it. @@ -25,14 +25,14 @@ import { hasWhitespace, isNonString, WHITESPACE_WARNING, NON_STRING_WARNING } fr * * Clicking the "Reveal subkeys in JSON" toggle displays the full, nested subkey structure returned by the API. * - * * @example - * + * * - * @param {object} subkeys - leaf keys of a kv v2 secret, all values (unless a nested object with more keys) return null. https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2#read-secret-subkeys - * @param {function} onSubmit - called when form is saved, called with with the key value object containing patch data - * @param {function} onCancel - called when form is canceled * @param {boolean} isSaving - if true, disables the save and cancel buttons. useful if the onSubmit callback is a concurrency task + * @param {function} onCancel - called when form is canceled + * @param {function} onSubmit - called when form is saved, called with with the key value object containing patch data + * @param {object} subkeys - leaf keys of a kv v2 secret, all values (unless a nested object with more keys) return null. https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2#read-secret-subkeys + * @param {string} submitError - error message string from parent if submit failed */ export class KeyValueState { @@ -72,10 +72,10 @@ export class KeyValueState { } } -export default class KvPatchEditor extends Component { +export default class KvPatchEditorForm extends Component { @tracked patchData; // key value pairs in form @tracked showSubkeys = false; - @tracked submitError; + @tracked validationError; // tracked variables for new (initially empty) row of inputs. // once a user clicks "Add" a KeyValueState class is instantiated for that row @@ -169,13 +169,13 @@ export default class KvPatchEditor extends Component { submit(event) { event.preventDefault(); if (this.newKeyError || this.patchData.any((KV) => KV.keyError)) { - this.submitError = 'This form contains validations errors, please resolve those before submitting.'; + this.validationError = 'This form contains validations errors, please resolve those before submitting.'; return; } // patchData will not include the last row if a user has not clicked "Add" // manually check for data and add it to this.patchData - if (this.newKey && this.newValue) { + if (this.newKey) { this.addRow(); } diff --git a/ui/lib/kv/addon/components/kv-patch-editor/row.hbs b/ui/lib/kv/addon/components/kv-patch/editor/row.hbs similarity index 98% rename from ui/lib/kv/addon/components/kv-patch-editor/row.hbs rename to ui/lib/kv/addon/components/kv-patch/editor/row.hbs index c5df002b10..d7b7c6b691 100644 --- a/ui/lib/kv/addon/components/kv-patch-editor/row.hbs +++ b/ui/lib/kv/addon/components/kv-patch/editor/row.hbs @@ -67,7 +67,7 @@ {{/if}} - + + {{#if this.lintingErrors}} + + {{/if}} +
+ +
+ + + + + {{#if @submitError}} + + {{/if}} + \ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-patch/json-form.js b/ui/lib/kv/addon/components/kv-patch/json-form.js new file mode 100644 index 0000000000..718c41041d --- /dev/null +++ b/ui/lib/kv/addon/components/kv-patch/json-form.js @@ -0,0 +1,50 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module KvPatchJsonForm + * @description + * This component renders one of two ways to patch a KV v2 secret (the other is using the KvPatch::Editor::Form). + * + * @example + * + * + * @param {boolean} isSaving - if true, disables the save and cancel buttons. useful if the onSubmit callback is a concurrency task + * @param {function} onCancel - called when form is canceled + * @param {function} onSubmit - called when form is saved, called with with the key value object containing patch data + * @param {object} subkeys - leaf keys of a kv v2 secret, all values (unless a nested object with more keys) return null. used for toggle that reveals codeblock of subkey structure + * @param {string} submitError - error message string from parent if submit failed + */ + +export default class KvPatchJsonForm extends Component { + @tracked jsonObject; + @tracked lintingErrors; + + constructor() { + super(...arguments); + // prefill JSON editor with an empty object + this.jsonObject = JSON.stringify({ '': '' }, null, 2); + } + + @action + handleJson(value, codemirror) { + codemirror.performLint(); + this.lintingErrors = codemirror.state.lint.marked.length > 0; + if (!this.lintingErrors) { + this.jsonObject = value; + } + } + + @action + submit(event) { + event.preventDefault(); + const patchData = JSON.parse(this.jsonObject); + this.args.onSubmit(patchData); + } +} diff --git a/ui/lib/kv/addon/components/kv-patch/subkeys-reveal.hbs b/ui/lib/kv/addon/components/kv-patch/subkeys-reveal.hbs new file mode 100644 index 0000000000..be076f246c --- /dev/null +++ b/ui/lib/kv/addon/components/kv-patch/subkeys-reveal.hbs @@ -0,0 +1,13 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + +
+ + Reveal subkeys in JSON + + {{#if this.showSubkeys}} + + {{/if}} +
\ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-patch/subkeys-reveal.js b/ui/lib/kv/addon/components/kv-patch/subkeys-reveal.js new file mode 100644 index 0000000000..d504c126d1 --- /dev/null +++ b/ui/lib/kv/addon/components/kv-patch/subkeys-reveal.js @@ -0,0 +1,20 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module SubkeysReveal + * + * @example + * + * + * @param {object} subkeys - leaf keys of a kv v2 secret, all values (unless a nested object with more keys) return null. https://developer.hashicorp.com/vault/api-docs/secret/kv/kv-v2#read-secret-subkeys + */ + +export default class SubkeysReveal extends Component { + @tracked showSubkeys = false; +} diff --git a/ui/lib/kv/addon/components/kv-subkeys.hbs b/ui/lib/kv/addon/components/kv-subkeys-card.hbs similarity index 100% rename from ui/lib/kv/addon/components/kv-subkeys.hbs rename to ui/lib/kv/addon/components/kv-subkeys-card.hbs diff --git a/ui/lib/kv/addon/components/kv-subkeys.js b/ui/lib/kv/addon/components/kv-subkeys-card.js similarity index 81% rename from ui/lib/kv/addon/components/kv-subkeys.js rename to ui/lib/kv/addon/components/kv-subkeys-card.js index 95b633f1c7..bc99755387 100644 --- a/ui/lib/kv/addon/components/kv-subkeys.js +++ b/ui/lib/kv/addon/components/kv-subkeys-card.js @@ -7,7 +7,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; /** - * @module KvSubkeys + * @module KvSubkeysCard * @description sample secret data: ``` @@ -31,11 +31,11 @@ sample subkeys: ``` * * @example - * + * * * @param {object} subkeys - leaf keys of a kv v2 secret, all values (unless a nested object with more keys) return null */ -export default class KvSubkeys extends Component { +export default class KvSubkeysCard extends Component { @tracked showJson = false; } diff --git a/ui/lib/kv/addon/components/page/secret/overview.hbs b/ui/lib/kv/addon/components/page/secret/overview.hbs index 43da56c1aa..c3c0043967 100644 --- a/ui/lib/kv/addon/components/page/secret/overview.hbs +++ b/ui/lib/kv/addon/components/page/secret/overview.hbs @@ -101,5 +101,5 @@ {{#if @subkeys.subkeys}} - + {{/if}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/secret/patch.hbs b/ui/lib/kv/addon/components/page/secret/patch.hbs new file mode 100644 index 0000000000..655abfefea --- /dev/null +++ b/ui/lib/kv/addon/components/page/secret/patch.hbs @@ -0,0 +1,65 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + + + + +
+ + + Path for this secret + + +
+ + Patch secret data + + + The + PATCH + action allows you to partially update or add a key-value pair to the current version of the secret. The values will + remain the same in the new version if no changes are made to them. + + + + + Edit via + + Choose how to patch the secret data. + Switching to another method will reset the form data. + + + {{#each (array "JSON" "UI") as |method|}} + + {{method}} + + {{/each}} + + + {{#if (eq this.patchMethod "UI")}} + + {{else}} + + {{/if}} +
\ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/secret/patch.js b/ui/lib/kv/addon/components/page/secret/patch.js new file mode 100644 index 0000000000..181b5b5d48 --- /dev/null +++ b/ui/lib/kv/addon/components/page/secret/patch.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { service } from '@ember/service'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { task } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; +import errorMessage from 'vault/utils/error-message'; + +/** + * @module KvSecretPatch + * @description + * This page template provides two methods for submitting patch data to update a KV v2 secret. + * Either using a key/value form KvPatch::Editor::Form or the json editor via KvPatch::JsonForm + * + * + * + * @param {model} path - Secret path + * @param {string} backend - Mount backend path + * @param {model} metadata - Ember data model: 'kv/metadata' + * @param {object} subkeys - subkeys (leaf keys with null values) of kv v2 secret + * @param {object} subkeysMeta - metadata object returned from the /subkeys endpoint, contains: version, created_time, custom_metadata, deletion status and time + * @param {array} breadcrumbs - breadcrumb objects to render in page header + */ + +export default class KvSecretPatch extends Component { + @service controlGroup; + @service flashMessages; + @service router; + @service store; + + @tracked errorMessage; + @tracked invalidFormAlert; + @tracked patchMethod = 'UI'; + + @action + selectPatchMethod(event) { + this.patchMethod = event.target.value; + } + + @task + @waitFor + *save(patchData) { + const isEmpty = this.isEmpty(patchData); + if (isEmpty) { + this.flashMessages.info(`No changes to submit. No updates made to "${this.args.path}".`); + return this.onCancel(); + } + + const { backend, path, metadata, subkeysMeta } = this.args; + // if no metadata permission, use subkey metadata as backup + const version = metadata.currentVersion || subkeysMeta.version; + const adapter = this.store.adapterFor('kv/data'); + try { + yield adapter.patchSecret(backend, path, patchData, version); + this.flashMessages.success(`Successfully patched new version of ${path}.`); + this.router.transitionTo('vault.cluster.secrets.backend.kv.secret'); + } catch (error) { + // TODO test...this is copy pasta'd from the edit page + let message = errorMessage(error); + if (error.message === 'Control Group encountered') { + this.controlGroup.saveTokenFromError(error); + const err = this.controlGroup.logFromError(error); + message = err.content; + } + this.errorMessage = message; + this.invalidFormAlert = 'There was an error submitting this form.'; + } + } + + @action + onCancel() { + this.router.transitionTo('vault.cluster.secrets.backend.kv.secret'); + } + + isEmpty(object) { + const emptyKeys = Object.keys(object).every((k) => k === ''); + const emptyValues = Object.values(object).every((v) => v === ''); + return emptyKeys && emptyValues; + } +} diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js index 9602728c88..6938f707b6 100644 --- a/ui/tests/helpers/kv/kv-selectors.js +++ b/ui/tests/helpers/kv/kv-selectors.js @@ -114,6 +114,7 @@ export const FORM = { addRow: (idx = 0) => `[data-test-kv-add-row="${idx}"]`, deleteRow: (idx = 0) => `[data-test-kv-delete-row="${idx}"]`, // + patchEditorForm: '[data-test-kv-patch-editor]', patchEdit: (idx = 0) => `[data-test-edit-button="${idx}"]`, patchDelete: (idx = 0) => `[data-test-delete-button="${idx}"]`, patchUndo: (idx = 0) => `[data-test-undo-button="${idx}"]`, @@ -135,3 +136,5 @@ export const FORM = { export const parseJsonEditor = (find) => { return JSON.parse(find(FORM.jsonEditor).innerText); }; + +export const parseObject = (cm) => JSON.parse(cm().getValue()); diff --git a/ui/tests/integration/components/kv/kv-patch-editor/alerts-test.js b/ui/tests/integration/components/kv/kv-patch/editor/alerts-test.js similarity index 97% rename from ui/tests/integration/components/kv/kv-patch-editor/alerts-test.js rename to ui/tests/integration/components/kv/kv-patch/editor/alerts-test.js index 12c318c219..75009bd819 100644 --- a/ui/tests/integration/components/kv/kv-patch-editor/alerts-test.js +++ b/ui/tests/integration/components/kv/kv-patch/editor/alerts-test.js @@ -11,7 +11,7 @@ import { hbs } from 'ember-cli-htmlbars'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { FORM } from 'vault/tests/helpers/kv/kv-selectors'; -module('Integration | Component | kv | kv-patch-editor/alerts', function (hooks) { +module('Integration | Component | kv | kv-patch/editor/alerts', function (hooks) { setupRenderingTest(hooks); setupEngine(hooks, 'kv'); @@ -22,7 +22,7 @@ module('Integration | Component | kv | kv-patch-editor/alerts', function (hooks) this.renderComponent = async () => { return render( hbs` - { return render( hbs` - `, { owner: this.engine } ); @@ -79,16 +86,13 @@ module('Integration | Component | kv | kv-patch-editor/form', function (hooks) { this.assertEmptyRow(assert); }); + test('it renders submit error from parent', async function (assert) { + this.submitError = 'There was a problem submitting this form.'; + await this.renderComponent(); + assert.dom(GENERAL.inlineError).hasText(this.submitError); + }); + test('it reveals subkeys', async function (assert) { - this.subkeys = { - foo: null, - bar: { - baz: null, - quux: { - hello: null, - }, - }, - }; await this.renderComponent(); assert.dom(GENERAL.toggleInput('Reveal subkeys')).isNotChecked('toggle is initially unchecked'); @@ -495,7 +499,6 @@ module('Integration | Component | kv | kv-patch-editor/form', function (hooks) { NON_STRING_VALUES.forEach((value) => { test(`for new non-string values: ${value}`, async function (assert) { await this.renderComponent(); - await fillIn(FORM.keyInput('new'), 'aKey'); await fillIn(FORM.valueInput('new'), value); await blur(FORM.valueInput('new')); // unfocus input diff --git a/ui/tests/integration/components/kv/kv-patch-editor/row-test.js b/ui/tests/integration/components/kv/kv-patch/editor/row-test.js similarity index 98% rename from ui/tests/integration/components/kv/kv-patch-editor/row-test.js rename to ui/tests/integration/components/kv/kv-patch/editor/row-test.js index 0e024d3cf3..47848a02d2 100644 --- a/ui/tests/integration/components/kv/kv-patch-editor/row-test.js +++ b/ui/tests/integration/components/kv/kv-patch/editor/row-test.js @@ -11,7 +11,7 @@ import { hbs } from 'ember-cli-htmlbars'; import { FORM } from 'vault/tests/helpers/kv/kv-selectors'; import sinon from 'sinon'; -module('Integration | Component | kv | kv-patch-editor/row', function (hooks) { +module('Integration | Component | kv | kv-patch/editor/row', function (hooks) { setupRenderingTest(hooks); setupEngine(hooks, 'kv'); @@ -32,7 +32,7 @@ module('Integration | Component | kv | kv-patch-editor/row', function (hooks) { this.renderComponent = async () => { return render( hbs` - { + return render( + hbs` + `, + { owner: this.engine } + ); + }; + }); + + test('it renders', async function (assert) { + await this.renderComponent(); + assert.propEqual(parseObject(codemirror), { '': '' }, 'json editor initializes with empty object'); + await click(FORM.saveBtn); + assert.true(this.onSubmit.calledOnce, 'clicking "Save" calls @onSubmit'); + await click(FORM.cancelBtn); + assert.true(this.onCancel.calledOnce, 'clicking "Cancel" calls @onCancel'); + }); + + test('it reveals subkeys', async function (assert) { + await this.renderComponent(); + + assert.dom(GENERAL.toggleInput('Reveal subkeys')).isNotChecked('toggle is initially unchecked'); + assert.dom('[data-test-subkeys]').doesNotExist(); + await click(GENERAL.toggleInput('Reveal subkeys')); + assert.dom(GENERAL.toggleInput('Reveal subkeys')).isChecked(); + assert.dom('[data-test-subkeys]').hasText(JSON.stringify(this.subkeys, null, 2)); + + await click(GENERAL.toggleInput('Reveal subkeys')); + assert.dom(GENERAL.toggleInput('Reveal subkeys')).isNotChecked(); + assert.dom('[data-test-subkeys]').doesNotExist('unchecking re-hides subkeys'); + }); + + test('it renders linting errors', async function (assert) { + await this.renderComponent(); + await codemirror().setValue('{ "foo3": }'); + assert + .dom(GENERAL.inlineError) + .hasText('JSON is unparsable. Fix linting errors to avoid data discrepancies.'); + await codemirror().setValue('{ "foo": "bar" }'); + assert.dom(GENERAL.inlineError).doesNotExist('error disappears when linting is fixed'); + }); + + test('it renders submit error from parent', async function (assert) { + this.submitError = 'There was a problem'; + await this.renderComponent(); + assert.dom(GENERAL.inlineError).hasText(this.submitError); + }); + + test('it submits data', async function (assert) { + this.submitError = 'There was a problem'; + await this.renderComponent(); + await codemirror().setValue('{ "foo": "bar" }'); + await click(FORM.saveBtn); + const [data] = this.onSubmit.lastCall.args; + assert.propEqual(data, { foo: 'bar' }, `onSubmit called with ${JSON.stringify(data)}`); + }); +}); diff --git a/ui/tests/integration/components/kv/kv-subkeys-test.js b/ui/tests/integration/components/kv/kv-subkeys-card-test.js similarity index 92% rename from ui/tests/integration/components/kv/kv-subkeys-test.js rename to ui/tests/integration/components/kv/kv-subkeys-card-test.js index 751b258659..6266712ee7 100644 --- a/ui/tests/integration/components/kv/kv-subkeys-test.js +++ b/ui/tests/integration/components/kv/kv-subkeys-card-test.js @@ -11,7 +11,7 @@ import { hbs } from 'ember-cli-htmlbars'; import { GENERAL } from 'vault/tests/helpers/general-selectors'; const { overviewCard } = GENERAL; -module('Integration | Component | kv | kv-subkeys', function (hooks) { +module('Integration | Component | kv | kv-subkeys-card', function (hooks) { setupRenderingTest(hooks); setupEngine(hooks, 'kv'); hooks.beforeEach(function () { @@ -22,7 +22,7 @@ module('Integration | Component | kv | kv-subkeys', function (hooks) { }, }; this.renderComponent = async () => { - return render(hbs``, { + return render(hbs``, { owner: this.engine, }); }; diff --git a/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js b/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js index 234902e751..4d065f4fda 100644 --- a/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js @@ -9,9 +9,9 @@ import { setupEngine } from 'ember-engines/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; -import { kvDataPath, kvMetadataPath } from 'vault/utils/kv-path'; -import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; +import { kvDataPath } from 'vault/utils/kv-path'; import { PAGE } from 'vault/tests/helpers/kv/kv-selectors'; +import { baseSetup, metadataModel } from 'vault/tests/helpers/kv/kv-run-commands'; module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', function (hooks) { setupRenderingTest(hooks); @@ -19,27 +19,8 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func setupMirage(hooks); hooks.beforeEach(async function () { - this.store = this.owner.lookup('service:store'); - this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); - this.backend = 'kv-engine'; - this.path = 'my-secret'; + baseSetup(this); this.dataId = kvDataPath(this.backend, this.path); - this.metadataId = kvMetadataPath(this.backend, this.path); - - this.metadataModel = (withCustom = false) => { - const metadata = withCustom - ? this.server.create('kv-metadatum', 'withCustomMetadata') - : this.server.create('kv-metadatum'); - metadata.id = this.metadataId; - this.store.pushPayload('kv/metadata', { - modelName: 'kv/metadata', - ...metadata, - }); - return this.store.peekRecord('kv/metadata', this.metadataId); - }; - - this.metadata = this.metadataModel(); - // empty secret model always exists for permissions this.store.pushPayload('kv/data', { modelName: 'kv/data', @@ -64,7 +45,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func test('it renders metadata details', async function (assert) { assert.expect(8); - this.metadata = this.metadataModel(); await render( hbs` { + return render( + hbs` + `, + { owner: this.engine } + ); + }; + }); + + test('it renders', async function (assert) { + await this.renderComponent(); + assert.dom(PAGE.breadcrumbs).hasText(`Secrets ${this.backend} ${this.path} Patch`); + assert.dom(PAGE.title).hasText('Patch Secret to New Version'); + assert.dom(GENERAL.fieldByAttr('Path')).isDisabled(); + assert.dom(GENERAL.fieldByAttr('Path')).hasValue(this.path); + assert.dom(GENERAL.inputByAttr('JSON')).isNotChecked(); + assert.dom(GENERAL.inputByAttr('UI')).isChecked(); + assert.dom(FORM.patchEditorForm).exists('it renders editor form by default'); + assert.dom(GENERAL.codemirror).doesNotExist(); + Object.keys(this.subkeys).forEach((key, idx) => { + assert.dom(FORM.keyInput(idx)).hasValue(key); + assert.dom(FORM.keyInput(idx)).isDisabled(); + }); + }); + + test('it selects JSON as an edit option', async function (assert) { + await this.renderComponent(); + assert.dom(FORM.patchEditorForm).exists(); + await click(GENERAL.inputByAttr('JSON')); + assert.dom(GENERAL.inputByAttr('JSON')).isChecked(); + assert.dom(GENERAL.inputByAttr('UI')).isNotChecked(); + assert.dom(FORM.patchEditorForm).doesNotExist(); + assert.dom(GENERAL.codemirror).exists(); + }); + + test('it transitions on cancel', async function (assert) { + await this.renderComponent(); + await click(FORM.cancelBtn); + const [route] = this.transitionStub.lastCall.args; + assert.strictEqual( + route, + 'vault.cluster.secrets.backend.kv.secret', + `it transitions on cancel to: ${route}` + ); + }); + + module('it submits', function (hooks) { + const EXAMPLE_KV_DATA_CREATE_RESPONSE = { + request_id: 'foobar', + data: { + created_time: '2023-06-21T16:18:31.479993Z', + custom_metadata: null, + deletion_time: '', + destroyed: false, + version: 1, + }, + }; + + hooks.beforeEach(async function () { + this.endpoint = `${encodePath(this.backend)}/data/${encodePath(this.path)}`; + }); + + test('patch data from kv editor form', async function (assert) { + assert.expect(3); + this.server.patch(this.endpoint, (schema, req) => { + const payload = JSON.parse(req.requestBody); + const expected = { + data: { bar: null, foo: 'foovalue', aKey: '1', bKey: 'null' }, + options: { + cas: this.metadata.currentVersion, + }, + }; + assert.true(true, `PATCH request made to ${this.endpoint}`); + assert.propEqual( + payload, + expected, + `payload: ${JSON.stringify(payload)} matches expected: ${JSON.stringify(payload)}` + ); + return EXAMPLE_KV_DATA_CREATE_RESPONSE; + }); + + await this.renderComponent(); + // patch existing, delete and create a new key key + await click(FORM.patchEdit()); + await fillIn(FORM.valueInput(), 'foovalue'); + await blur(FORM.valueInput()); + await click(FORM.patchDelete(1)); + await fillIn(FORM.keyInput('new'), 'aKey'); + await fillIn(FORM.valueInput('new'), '1'); + await click(FORM.patchAdd); + // add new key and do NOT click add + await fillIn(FORM.keyInput('new'), 'bKey'); + await fillIn(FORM.valueInput('new'), 'null'); + await click(FORM.saveBtn); + const [route] = this.transitionStub.lastCall.args; + assert.strictEqual( + route, + 'vault.cluster.secrets.backend.kv.secret', + `it transitions on save to: ${route}` + ); + }); + + test('patch data from json form', async function (assert) { + assert.expect(3); + this.server.patch(this.endpoint, (schema, req) => { + const payload = JSON.parse(req.requestBody); + const expected = { + data: { foo: 'foovalue', bar: null, number: 1 }, + options: { + cas: 4, + }, + }; + assert.true(true, `PATCH request made to ${this.endpoint}`); + assert.propEqual( + payload, + expected, + `payload: ${JSON.stringify(payload)} matches expected: ${JSON.stringify(payload)}` + ); + return EXAMPLE_KV_DATA_CREATE_RESPONSE; + }); + await this.renderComponent(); + await click(GENERAL.inputByAttr('JSON')); + await waitUntil(() => find('.CodeMirror')); + await codemirror().setValue('{ "foo": "foovalue", "bar":null, "number":1 }'); + await click(FORM.saveBtn); + const [route] = this.transitionStub.lastCall.args; + assert.strictEqual( + route, + 'vault.cluster.secrets.backend.kv.secret', + `it transitions on save to: ${route}` + ); + }); + + // this assertion confirms submit allows empty values + test('empty string values from kv editor form', async function (assert) { + assert.expect(1); + this.server.patch(this.endpoint, (schema, req) => { + const payload = JSON.parse(req.requestBody); + const expected = { + data: { foo: '', aKey: '', bKey: '' }, + options: { + cas: this.metadata.currentVersion, + }, + }; + assert.propEqual( + payload, + expected, + `payload: ${JSON.stringify(payload)} matches expected: ${JSON.stringify(payload)}` + ); + return EXAMPLE_KV_DATA_CREATE_RESPONSE; + }); + + await this.renderComponent(); + await click(FORM.patchEdit()); + // edit existing key's value + await fillIn(FORM.valueInput(), ''); + // add a new key with empty value, click add + await fillIn(FORM.keyInput('new'), 'aKey'); + await fillIn(FORM.valueInput('new'), ''); + await click(FORM.patchAdd); + // add new key and do NOT click add + await fillIn(FORM.keyInput('new'), 'bKey'); + await fillIn(FORM.valueInput('new'), ''); + await click(FORM.saveBtn); + }); + + // this assertion confirms submit allows empty values + test('empty string value from json form', async function (assert) { + assert.expect(1); + this.server.patch(this.endpoint, (schema, req) => { + const payload = JSON.parse(req.requestBody); + const expected = { + data: { foo: '' }, + options: { + cas: this.metadata.currentVersion, + }, + }; + assert.propEqual( + payload, + expected, + `payload: ${JSON.stringify(payload)} matches expected: ${JSON.stringify(payload)}` + ); + return EXAMPLE_KV_DATA_CREATE_RESPONSE; + }); + + await this.renderComponent(); + await click(GENERAL.inputByAttr('JSON')); + await waitUntil(() => find('.CodeMirror')); + await codemirror().setValue('{ "foo": "" }'); + await click(FORM.saveBtn); + }); + }); + + module('it does not submit', function (hooks) { + hooks.beforeEach(async function () { + this.endpoint = `${encodePath(this.backend)}/data/${encodePath(this.path)}`; + this.flashSpy = sinon.spy(this.owner.lookup('service:flash-messages'), 'info'); + }); + + test('if no changes from kv editor form', async function (assert) { + assert.expect(3); + this.server.patch(this.endpoint, () => + overrideResponse(500, `Request made to: ${this.endpoint}. This should not have happened!`) + ); + await this.renderComponent(); + await click(FORM.saveBtn); + assert.dom(GENERAL.messageError).doesNotExist('PATCH request is not made'); + const route = this.transitionStub.lastCall?.args[0] || ''; + const flash = this.flashSpy.lastCall?.args[0] || ''; + assert.strictEqual( + route, + 'vault.cluster.secrets.backend.kv.secret', + `it transitions to overview route: ${route}` + ); + assert.strictEqual( + flash, + `No changes to submit. No updates made to "${this.path}".`, + `flash message has message: "${flash}"` + ); + }); + + test('if no changes from json form', async function (assert) { + assert.expect(3); + this.server.patch(this.endpoint, () => + overrideResponse(500, `Request made to: ${this.endpoint}. This should not have happened!`) + ); + await this.renderComponent(); + await click(GENERAL.inputByAttr('JSON')); + await waitUntil(() => find('.CodeMirror')); + await click(FORM.saveBtn); + assert.dom(GENERAL.messageError).doesNotExist('PATCH request is not made'); + const route = this.transitionStub.lastCall?.args[0] || ''; + const flash = this.flashSpy.lastCall?.args[0] || ''; + assert.strictEqual( + route, + 'vault.cluster.secrets.backend.kv.secret', + `it transitions to overview route: ${route}` + ); + assert.strictEqual( + flash, + `No changes to submit. No updates made to "${this.path}".`, + `flash message has message: "${flash}"` + ); + }); + }); + + module('it passes error', function (hooks) { + hooks.beforeEach(async function () { + this.endpoint = `${encodePath(this.backend)}/data/${encodePath(this.path)}`; + this.server.patch(this.endpoint, () => { + return overrideResponse(403); + }); + }); + + test('to kv editor form', async function (assert) { + assert.expect(2); + + await this.renderComponent(); + // patch existing, delete and create a new key key + await click(FORM.patchEdit()); + await fillIn(FORM.valueInput(), 'foovalue'); + await blur(FORM.valueInput()); + await click(FORM.patchDelete(1)); + await fillIn(FORM.keyInput('new'), 'aKey'); + await fillIn(FORM.valueInput('new'), 'aValue'); + await click(FORM.patchAdd); + // add new key and do NOT click add + await fillIn(FORM.keyInput('new'), 'bKey'); + await fillIn(FORM.valueInput('new'), 'bValue'); + await click(FORM.saveBtn); + assert.dom(GENERAL.messageError).hasText('Error permission denied'); + assert.dom(GENERAL.inlineError).hasText('There was an error submitting this form.'); + }); + + test('to json form', async function (assert) { + assert.expect(2); + await this.renderComponent(); + await click(GENERAL.inputByAttr('JSON')); + await waitUntil(() => find('.CodeMirror')); + await codemirror().setValue('{ "foo": "foovalue", "bar":null, "number":1 }'); + await click(FORM.saveBtn); + await click(FORM.saveBtn); + assert.dom(GENERAL.messageError).hasText('Error permission denied'); + assert.dom(GENERAL.inlineError).hasText('There was an error submitting this form.'); + }); + }); +}); diff --git a/ui/tests/integration/components/kv/page/kv-page-secret-details-test.js b/ui/tests/integration/components/kv/page/kv-page-secret-details-test.js index eed6357c60..3046ab8f2f 100644 --- a/ui/tests/integration/components/kv/page/kv-page-secret-details-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-secret-details-test.js @@ -9,11 +9,11 @@ import { setupEngine } from 'ember-engines/test-support'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { click, find, render } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; -import { kvDataPath, kvMetadataPath } from 'vault/utils/kv-path'; -import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; +import { kvDataPath } from 'vault/utils/kv-path'; import { FORM, PAGE, parseJsonEditor } from 'vault/tests/helpers/kv/kv-selectors'; import { syncStatusResponse } from 'vault/mirage/handlers/sync'; import { encodePath } from 'vault/utils/path-encoding-helpers'; +import { baseSetup } from 'vault/tests/helpers/kv/kv-run-commands'; module('Integration | Component | kv-v2 | Page::Secret::Details', function (hooks) { setupRenderingTest(hooks); @@ -21,15 +21,11 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook setupMirage(hooks); hooks.beforeEach(async function () { - this.store = this.owner.lookup('service:store'); - this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub()); - this.backend = 'kv-engine'; - this.path = 'my-secret'; + baseSetup(this); this.pathComplex = 'my-secret-object'; this.version = 2; this.dataId = kvDataPath(this.backend, this.path); this.dataIdComplex = kvDataPath(this.backend, this.pathComplex); - this.metadataId = kvMetadataPath(this.backend, this.path); this.secretData = { foo: 'bar' }; this.store.pushPayload('kv/data', { @@ -60,15 +56,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook destroyed: false, version: this.version, }); - - const metadata = this.server.create('kv-metadatum'); - metadata.id = this.metadataId; - this.store.pushPayload('kv/metadata', { - modelName: 'kv/metadata', - ...metadata, - }); - - this.metadata = this.store.peekRecord('kv/metadata', this.metadataId); this.secret = this.store.peekRecord('kv/data', this.dataId); this.secretComplex = this.store.peekRecord('kv/data', this.dataIdComplex);