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
This commit is contained in:
claire bontempo 2024-08-22 12:47:59 -07:00 committed by GitHub
parent 89aecf4f93
commit 581c999f56
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 760 additions and 94 deletions

View File

@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1
~}}
<form {{on "submit" this.submit}} ...attributes>
<form {{on "submit" this.submit}} data-test-kv-patch-editor>
<div class="flex column-gap-16 has-top-padding-s">
<Hds::Form::Label @controlId="newKey" class="one-fourth-width">
Key
@ -15,7 +15,7 @@
{{! Rows for existing keys (includes new rows after user clicks "Add") }}
{{#each this.patchData as |kv idx|}}
<KvPatchEditor::Row
<KvPatch::Editor::Row
@idx={{idx}}
@kvClass={{kv}}
@isOriginalSubkey={{this.isOriginalSubkey}}
@ -52,7 +52,7 @@
</div>
</div>
<KvPatchEditor::Alerts
<KvPatch::Editor::Alerts
@idx="new"
@keyError={{this.newKeyError}}
@keyWarning={{this.newKeyWarning}}
@ -60,16 +60,7 @@
/>
<hr class="has-background-gray-200" />
<div class="has-top-margin-m">
<Toggle @onChange={{fn (mut this.showSubkeys)}} @checked={{this.showSubkeys}} @name="Reveal subkeys">
<Hds::Text::Body @tag="p" @weight="semibold">Reveal subkeys in JSON</Hds::Text::Body>
</Toggle>
{{#if this.showSubkeys}}
<Hds::CodeBlock @value={{stringify @subkeys}} @hasLineNumbers={{false}} data-test-subkeys />
{{/if}}
</div>
<KvPatch::SubkeysReveal @subkeys={{@subkeys}} />
<hr class="has-background-gray-200" />
<Hds::ButtonSet>
@ -77,7 +68,7 @@
<Hds::Button @text="Cancel" {{on "click" @onCancel}} @color="secondary" disabled={{@isSaving}} data-test-kv-cancel />
</Hds::ButtonSet>
{{#if this.submitError}}
<AlertInline @type="danger" @message={{this.submitError}} class="has-top-padding-s" />
{{#if (or @submitError this.validationError)}}
<AlertInline @type="danger" @message={{or @submitError this.validationError}} class="has-top-padding-s" />
{{/if}}
</form>

View File

@ -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
* <KvPatchEditor::Form @subkeys={{@subkeys}} @onSubmit={{perform this.save}} @onCancel={{this.onCancel}} @isSaving={{this.save.isRunning}} />
* <KvPatch::Editor::Form @subkeys={{@subkeys}} @onSubmit={{perform this.save}} @onCancel={{this.onCancel}} @isSaving={{this.save.isRunning}} />
*
* @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();
}

View File

@ -67,7 +67,7 @@
</Hds::Alert>
{{/if}}
<KvPatchEditor::Alerts
<KvPatch::Editor::Alerts
@idx={{@idx}}
@keyError={{@kvClass.keyError}}
@keyWarning={{@kvClass.keyWarning}}

View File

@ -0,0 +1,25 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<form {{on "submit" this.submit}}>
<JsonEditor class="has-top-margin-l" @title="Patch data" @valueUpdated={{this.handleJson}} @value={{this.jsonObject}} />
{{#if this.lintingErrors}}
<AlertInline
@color="warning"
class="has-top-padding-s"
@message="JSON is unparsable. Fix linting errors to avoid data discrepancies."
/>
{{/if}}
<hr class="has-background-gray-200" />
<KvPatch::SubkeysReveal @subkeys={{@subkeys}} />
<hr class="has-background-gray-200" />
<Hds::ButtonSet>
<Hds::Button @text="Save" type="submit" @icon={{if @isSaving "loading"}} disabled={{@isSaving}} data-test-kv-save />
<Hds::Button @text="Cancel" {{on "click" @onCancel}} @color="secondary" disabled={{@isSaving}} data-test-kv-cancel />
</Hds::ButtonSet>
{{#if @submitError}}
<AlertInline @type="danger" @message={{@submitError}} class="has-top-padding-s" />
{{/if}}
</form>

View File

@ -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
* <KvPatch::JsonForm @onSubmit={{perform this.save}} @onCancel={{this.onCancel}} @isSaving={{this.save.isRunning}} />
*
* @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);
}
}

View File

@ -0,0 +1,13 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<div class="has-top-margin-m">
<Toggle @onChange={{fn (mut this.showSubkeys)}} @checked={{this.showSubkeys}} @name="Reveal subkeys">
<Hds::Text::Body @tag="p" @weight="semibold">Reveal subkeys in JSON</Hds::Text::Body>
</Toggle>
{{#if this.showSubkeys}}
<Hds::CodeBlock @value={{stringify @subkeys}} @hasLineNumbers={{false}} data-test-subkeys />
{{/if}}
</div>

View File

@ -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
* <SubkeysReveal @subkeys={{this.subkeys}} />
*
* @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;
}

View File

@ -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
* <KvSubkeys @subkeys={{this.subkeys}} />
* <KvSubkeysCard @subkeys={{this.subkeys}} />
*
* @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;
}

View File

@ -101,5 +101,5 @@
</Hds::Card::Container>
{{#if @subkeys.subkeys}}
<KvSubkeys @subkeys={{@subkeys.subkeys}} />
<KvSubkeysCard @subkeys={{@subkeys.subkeys}} />
{{/if}}

View File

@ -0,0 +1,65 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle="Patch Secret to New Version" />
<MessageError @errorMessage={{this.errorMessage}} />
<div class="box is-sideless is-fullwidth is-bottomless">
<NamespaceReminder @mode="patch" @noun="secret" />
<Hds::Form::TextInput::Field name="secret path" @value={{@path}} disabled data-test-field="Path" as |F|>
<F.Label>Path for this secret</F.Label>
</Hds::Form::TextInput::Field>
<hr class="has-background-gray-200" />
<Hds::Text::Display @tag="h2" @size="300" class="has-bottom-padding-s">Patch secret data</Hds::Text::Display>
<Hds::Alert @type="compact" @icon="alert-triangle-fill" @color="warning" class="has-bottom-padding-m" as |A|>
<A.Description>
The
<code>PATCH</code>
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.
</A.Description>
</Hds::Alert>
<Hds::Form::Radio::Group @name="patch-method" class="has-bottom-margin-m" as |G|>
<G.Legend>Edit via</G.Legend>
<G.HelperText>
Choose how to patch the secret data.
<strong>Switching to another method will reset the form data.</strong>
</G.HelperText>
{{#each (array "JSON" "UI") as |method|}}
<G.RadioField
@value={{method}}
{{on "change" this.selectPatchMethod}}
checked={{eq this.patchMethod method}}
data-test-input={{method}}
as |F|
>
<F.Label>{{method}}</F.Label>
</G.RadioField>
{{/each}}
</Hds::Form::Radio::Group>
{{#if (eq this.patchMethod "UI")}}
<KvPatch::Editor::Form
@isSaving={{this.save.isRunning}}
@onCancel={{this.onCancel}}
@onSubmit={{perform this.save}}
@subkeys={{@subkeys}}
@submitError={{this.invalidFormAlert}}
/>
{{else}}
<KvPatch::JsonForm
@isSaving={{this.save.isRunning}}
@onCancel={{this.onCancel}}
@onSubmit={{perform this.save}}
@subkeys={{@subkeys}}
@submitError={{this.invalidFormAlert}}
/>
{{/if}}
</div>

View File

@ -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
*
* <Page::Secret::Patch
* @backend="my-kv-engine"
* @breadcrumbs={{this.breadcrumbs}
* @metadata={{this.model.metadata}}
* @path="my-secret"
* @subkeys={{this.subkeys}
* @subkeysMeta={{this.subkeysMeta}
* />
*
* @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;
}
}

View File

@ -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}"]`,
// <KvPatchEditor>
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());

View File

@ -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`
<KvPatchEditor::Alerts
<KvPatch::Editor::Alerts
@idx={{1}}
@keyError={{this.keyError}}
@keyWarning={{this.keyWarning}}

View File

@ -8,32 +8,39 @@ import { setupRenderingTest } from 'vault/tests/helpers';
import { setupEngine } from 'ember-engines/test-support';
import { blur, click, fillIn, typeIn, render, focus } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { FORM } from 'vault/tests/helpers/kv/kv-selectors';
import { NON_STRING_WARNING, WHITESPACE_WARNING } from 'vault/utils/validators';
module('Integration | Component | kv | kv-patch-editor/form', function (hooks) {
module('Integration | Component | kv | kv-patch/editor/form', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
hooks.beforeEach(function () {
this.subkeys = {
foo: null,
baz: null,
baz: {
nested: null,
bar: {
hello: null,
},
},
};
this.onSubmit = sinon.spy();
this.onCancel = sinon.spy();
this.isSaving = false;
this.submitError = '';
this.renderComponent = async () => {
return render(
hbs`
<KvPatchEditor::Form
<KvPatch::Editor::Form
@subkeys={{this.subkeys}}
@onSubmit={{this.onSubmit}}
@onCancel={{this.onCancel}}
@isSaving={{this.isSaving}}
@submitError={{this.submitError}}
/>`,
{ 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

View File

@ -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`
<KvPatchEditor::Row
<KvPatch::Editor::Row
@idx={{0}}
@kvClass={{this.kvClass}}
@isOriginalSubkey={{this.isOriginalSubkey}}

View File

@ -0,0 +1,96 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupEngine } from 'ember-engines/test-support';
import { click, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { FORM, parseObject } from 'vault/tests/helpers/kv/kv-selectors';
import codemirror from 'vault/tests/helpers/codemirror';
module('Integration | Component | kv | kv-patch/editor/json-form', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
hooks.beforeEach(function () {
this.onSubmit = sinon.spy();
this.onCancel = sinon.spy();
this.isSaving = false;
this.submitError = '';
this.subkeys = {
foo: null,
bar: {
baz: null,
quux: {
hello: null,
},
},
};
this.renderComponent = async () => {
return render(
hbs`
<KvPatch::JsonForm
@onSubmit={{this.onSubmit}}
@onCancel={{this.onCancel}}
@isSaving={{this.isSaving}}
@subkeys={{this.subkeys}}
@submitError={{this.submitError}}
/>`,
{ 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)}`);
});
});

View File

@ -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`<KvSubkeys @subkeys={{this.subkeys}} />`, {
return render(hbs`<KvSubkeysCard @subkeys={{this.subkeys}} />`, {
owner: this.engine,
});
};

View File

@ -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`
<Page::Secret::Metadata::Details
@ -95,7 +75,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
test('it renders custom metadata from secret model', async function (assert) {
assert.expect(2);
this.metadata = this.metadataModel();
this.secret.customMetadata = { hi: 'there' };
await render(
hbs`
@ -115,7 +94,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
test('it renders custom metadata from metadata model', async function (assert) {
assert.expect(4);
this.metadata = this.metadataModel({ withCustom: true });
this.model.metadata = metadataModel(this, { withCustom: true });
await render(
hbs`
<Page::Secret::Metadata::Details

View File

@ -67,7 +67,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
};
});
module('it renders when version is not deleted nor destroyed', function () {
module('active secret (version not deleted or destroyed)', function () {
test('it renders tabs', async function (assert) {
await this.renderComponent();
const tabs = ['Overview', 'Secret', 'Metadata', 'Paths', 'Version History'];
@ -190,7 +190,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
});
});
module('it renders when version is deleted', function (hooks) {
module('deleted version', function (hooks) {
hooks.beforeEach(async function () {
this.secretState = 'deleted';
// subkeys is null but metadata still has data
@ -266,7 +266,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
});
});
module('it renders when version is destroyed', function (hooks) {
module('destroyed version', function (hooks) {
hooks.beforeEach(async function () {
this.secretState = 'destroyed';
// subkeys is null but metadata still has data

View File

@ -0,0 +1,342 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { blur, click, fillIn, find, render, waitUntil } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import sinon from 'sinon';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { baseSetup } from 'vault/tests/helpers/kv/kv-run-commands';
import codemirror from 'vault/tests/helpers/codemirror';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { overrideResponse } from 'vault/tests/helpers/stubs';
module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(async function () {
baseSetup(this);
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.backend, route: 'list' },
{ label: this.path, route: 'index' },
{ label: 'Patch' },
];
this.subkeys = {
foo: null,
bar: {
baz: null,
},
quux: null,
};
this.subkeyMeta = {
created_time: '2021-12-14T20:28:00.773477Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
};
this.renderComponent = async () => {
return render(
hbs`
<Page::Secret::Patch
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
@metadata={{this.metadata}}
@path={{this.path}}
@subkeys={{this.subkeys}}
@subkeysMeta={{this.subkeysMeta}}
/>`,
{ 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.');
});
});
});

View File

@ -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);