From 581c999f56c6de29e4be1b06e5effdd040a4c32c Mon Sep 17 00:00:00 2001
From: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
Date: Thu, 22 Aug 2024 12:47:59 -0700
Subject: [PATCH] UI: Build kv v2 patch page (#28114)
* build patch component
* pass submit error to child component
* add copyright header
* aphabetize
* rename kv-patch components
* build json editor patch form
* finish patch component and tests
* use baseSetup in other kv tests
* small styling changes
* remove linting conditional, set json editor value on change
* rename subkeys card
* add reveal component to both patch forms
* implement subkeys card
* add copyright header, add transition assertions
* assert flash spy
* add assertion for empty values
* separate tests into it does not submit module
* update flash copy
---
.../editor}/alerts.hbs | 0
.../editor}/form.hbs | 21 +-
.../editor}/form.js | 20 +-
.../editor}/row.hbs | 2 +-
.../addon/components/kv-patch/json-form.hbs | 25 ++
.../kv/addon/components/kv-patch/json-form.js | 50 +++
.../components/kv-patch/subkeys-reveal.hbs | 13 +
.../components/kv-patch/subkeys-reveal.js | 20 +
.../{kv-subkeys.hbs => kv-subkeys-card.hbs} | 0
.../{kv-subkeys.js => kv-subkeys-card.js} | 6 +-
.../addon/components/page/secret/overview.hbs | 2 +-
.../kv/addon/components/page/secret/patch.hbs | 65 ++++
.../kv/addon/components/page/secret/patch.js | 92 +++++
ui/tests/helpers/kv/kv-selectors.js | 3 +
.../editor}/alerts-test.js | 4 +-
.../editor}/form-test.js | 31 +-
.../editor}/row-test.js | 4 +-
.../components/kv/kv-patch/json-form-test.js | 96 +++++
...ubkeys-test.js => kv-subkeys-card-test.js} | 4 +-
.../kv/page/kv-page-metadata-details-test.js | 29 +-
.../kv/page/kv-page-overview-test.js | 6 +-
.../components/kv/page/kv-page-patch-test.js | 342 ++++++++++++++++++
.../kv/page/kv-page-secret-details-test.js | 19 +-
23 files changed, 760 insertions(+), 94 deletions(-)
rename ui/lib/kv/addon/components/{kv-patch-editor => kv-patch/editor}/alerts.hbs (100%)
rename ui/lib/kv/addon/components/{kv-patch-editor => kv-patch/editor}/form.hbs (76%)
rename ui/lib/kv/addon/components/{kv-patch-editor => kv-patch/editor}/form.js (92%)
rename ui/lib/kv/addon/components/{kv-patch-editor => kv-patch/editor}/row.hbs (98%)
create mode 100644 ui/lib/kv/addon/components/kv-patch/json-form.hbs
create mode 100644 ui/lib/kv/addon/components/kv-patch/json-form.js
create mode 100644 ui/lib/kv/addon/components/kv-patch/subkeys-reveal.hbs
create mode 100644 ui/lib/kv/addon/components/kv-patch/subkeys-reveal.js
rename ui/lib/kv/addon/components/{kv-subkeys.hbs => kv-subkeys-card.hbs} (100%)
rename ui/lib/kv/addon/components/{kv-subkeys.js => kv-subkeys-card.js} (81%)
create mode 100644 ui/lib/kv/addon/components/page/secret/patch.hbs
create mode 100644 ui/lib/kv/addon/components/page/secret/patch.js
rename ui/tests/integration/components/kv/{kv-patch-editor => kv-patch/editor}/alerts-test.js (97%)
rename ui/tests/integration/components/kv/{kv-patch-editor => kv-patch/editor}/form-test.js (97%)
rename ui/tests/integration/components/kv/{kv-patch-editor => kv-patch/editor}/row-test.js (98%)
create mode 100644 ui/tests/integration/components/kv/kv-patch/json-form-test.js
rename ui/tests/integration/components/kv/{kv-subkeys-test.js => kv-subkeys-card-test.js} (92%)
create mode 100644 ui/tests/integration/components/kv/page/kv-page-patch-test.js
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
~}}
-
\ 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);