mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 01:32:33 +00:00
ui: Add version diff comparison to KV v2 (#23200)
* add diff route * add version diff toolbar link * finish functionality of version diff comparison * add tests * update empty state message * update selectors * wip tests * finish test * add empty state test * switch dropdowns * add changelog * add comment
This commit is contained in:
3
changelog/23200.txt
Normal file
3
changelog/23200.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
```release-note:improvement
|
||||
ui: Move access to KV V2 version diff view to toolbar in Version History
|
||||
```
|
||||
@@ -9,17 +9,36 @@
|
||||
<ul class="menu-list">
|
||||
{{#each @metadata.sortedVersions as |versionData|}}
|
||||
<li data-test-version={{versionData.version}} class="action">
|
||||
<LinkTo @query={{hash version=versionData.version}} {{on "click" (fn @onClose D)}}>
|
||||
Version
|
||||
{{versionData.version}}
|
||||
{{#if versionData.destroyed}}
|
||||
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
|
||||
{{else if versionData.isSecretDeleted}}
|
||||
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
|
||||
{{else if (loose-equal versionData.version @metadata.currentVersion)}}
|
||||
<Icon @name="check-circle" class="has-text-success is-pulled-right" />
|
||||
{{/if}}
|
||||
</LinkTo>
|
||||
{{#if @onSelect}}
|
||||
<button
|
||||
disabled={{or versionData.destroyed versionData.isSecretDeleted}}
|
||||
{{on "click" (fn @onSelect versionData.version D.actions)}}
|
||||
type="button"
|
||||
class="link {{if (loose-equal versionData.version @displayVersion) 'is-active'}}"
|
||||
>
|
||||
Version
|
||||
{{versionData.version}}
|
||||
{{#if versionData.destroyed}}
|
||||
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
|
||||
{{else if versionData.isSecretDeleted}}
|
||||
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
|
||||
{{else if (loose-equal versionData.version @metadata.currentVersion)}}
|
||||
<Icon @name="check-circle" class="has-text-success is-pulled-right" />
|
||||
{{/if}}
|
||||
</button>
|
||||
{{else}}
|
||||
<LinkTo @query={{hash version=versionData.version}} {{on "click" (fn @onClose D)}}>
|
||||
Version
|
||||
{{versionData.version}}
|
||||
{{#if versionData.destroyed}}
|
||||
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
|
||||
{{else if versionData.isSecretDeleted}}
|
||||
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
|
||||
{{else if (loose-equal versionData.version @metadata.currentVersion)}}
|
||||
<Icon @name="check-circle" class="has-text-success is-pulled-right" />
|
||||
{{/if}}
|
||||
</LinkTo>
|
||||
{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle="Version Diff">
|
||||
<:toolbarFilters>
|
||||
<span class="has-text-grey has-text-weight-semibold is-size-8">FROM:</span>
|
||||
<KvVersionDropdown
|
||||
@displayVersion={{this.leftVersion}}
|
||||
@metadata={{@metadata}}
|
||||
@onSelect={{fn this.handleSelect "leftVersion"}}
|
||||
/>
|
||||
<span class="has-text-grey has-text-weight-semibold is-size-8">TO:</span>
|
||||
<KvVersionDropdown
|
||||
@displayVersion={{this.rightVersion}}
|
||||
@metadata={{@metadata}}
|
||||
@onSelect={{fn this.handleSelect "rightVersion"}}
|
||||
/>
|
||||
{{#if this.statesMatch}}
|
||||
<div class="has-left-padding-s">
|
||||
<Icon @name="check-circle-fill" class="has-text-success" />
|
||||
<span>States match</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
</:toolbarFilters>
|
||||
</KvPageHeader>
|
||||
|
||||
{{#if this.deactivatedState}}
|
||||
<EmptyState
|
||||
@title="Version {{this.rightVersion}} has been {{this.deactivatedState}}"
|
||||
@message="The current version of this secret has been {{this.deactivatedState}}. Select another version to compare."
|
||||
/>
|
||||
{{else}}
|
||||
<div class="form-section visual-diff text-grey-lightest background-color-black has-top-margin-s">
|
||||
<pre data-test-visual-diff>{{sanitized-html this.visualDiff}}</pre>
|
||||
</div>
|
||||
{{/if}}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { kvDataPath } from 'vault/utils/kv-path';
|
||||
|
||||
/**
|
||||
* @module KvSecretMetadataVersionDiff renders the version diff comparison
|
||||
* <Page::Secret::Metadata::VersionDiff
|
||||
* @metadata={{this.model.metadata}}
|
||||
* @path={{this.model.path}}
|
||||
* @backend={{this.model.backend}}
|
||||
* @breadcrumbs={{this.breadcrumbs}}
|
||||
* />
|
||||
*
|
||||
* @param {model} metadata - Ember data model: 'kv/metadata'
|
||||
* @param {string} path - path to request secret data for selected version
|
||||
* @param {string} backend - kv secret mount to make network request
|
||||
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
|
||||
*/
|
||||
|
||||
/* eslint-disable no-undef */
|
||||
export default class KvSecretMetadataVersionDiff extends Component {
|
||||
@service store;
|
||||
@tracked leftVersion;
|
||||
@tracked rightVersion;
|
||||
@tracked visualDiff;
|
||||
@tracked statesMatch = false;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
|
||||
// initialize with most recently (before current), active version on left
|
||||
const olderVersions = this.args.metadata.sortedVersions.slice(1);
|
||||
const recentlyActive = olderVersions.find((v) => !v.destroyed && !v.isSecretDeleted);
|
||||
this.leftVersion = Number(recentlyActive?.version);
|
||||
this.rightVersion = this.args.metadata.currentVersion;
|
||||
|
||||
// this diff is from older to newer (current) secret data
|
||||
this.createVisualDiff();
|
||||
}
|
||||
|
||||
// this can only be true on initialization if the current version is inactive
|
||||
// selecting a deleted/destroyed version is otherwise disabled
|
||||
get deactivatedState() {
|
||||
const { currentVersion, currentSecret } = this.args.metadata;
|
||||
return this.rightVersion === currentVersion && currentSecret.isDeactivated ? currentSecret.state : '';
|
||||
}
|
||||
|
||||
@action
|
||||
handleSelect(side, version, actions) {
|
||||
this[side] = Number(version);
|
||||
actions.close();
|
||||
this.createVisualDiff();
|
||||
}
|
||||
|
||||
async createVisualDiff() {
|
||||
const leftSecretData = await this.fetchSecretData(this.leftVersion);
|
||||
const rightSecretData = await this.fetchSecretData(this.rightVersion);
|
||||
const diffpatcher = jsondiffpatch.create({});
|
||||
const delta = diffpatcher.diff(leftSecretData, rightSecretData);
|
||||
|
||||
this.statesMatch = !delta;
|
||||
this.visualDiff = delta
|
||||
? jsondiffpatch.formatters.html.format(delta, leftSecretData)
|
||||
: JSON.stringify(rightSecretData, undefined, 2);
|
||||
}
|
||||
|
||||
async fetchSecretData(version) {
|
||||
const { backend, path } = this.args;
|
||||
// check the store first, avoiding an extra network request if possible.
|
||||
const storeData = await this.store.peekRecord('kv/data', kvDataPath(backend, path, version));
|
||||
const data = storeData ? storeData : await this.store.queryRecord('kv/data', { backend, path, version });
|
||||
|
||||
return data?.secretData;
|
||||
}
|
||||
}
|
||||
@@ -5,9 +5,14 @@
|
||||
<LinkTo @route="secret.paths" data-test-secrets-tab="Paths">Paths</LinkTo>
|
||||
<LinkTo @route="secret.metadata.versions" data-test-secrets-tab="Version History">Version History</LinkTo>
|
||||
</:tabLinks>
|
||||
|
||||
<:toolbarActions>
|
||||
{{#if @metadata.canReadMetadata}}
|
||||
<ToolbarLink @route="secret.metadata.diff">Version diff</ToolbarLink>
|
||||
{{/if}}
|
||||
</:toolbarActions>
|
||||
</KvPageHeader>
|
||||
|
||||
<Toolbar />
|
||||
{{#if @metadata.canReadMetadata}}
|
||||
<div class="sub-text has-text-weight-semibold is-flex-end has-short-padding">
|
||||
<KvTooltipTimestamp @text="Secret last updated" @timestamp={{@metadata.updatedTime}} />
|
||||
|
||||
@@ -19,6 +19,7 @@ export default buildRoutes(function () {
|
||||
this.route('metadata', function () {
|
||||
this.route('edit');
|
||||
this.route('versions');
|
||||
this.route('diff');
|
||||
});
|
||||
});
|
||||
this.route('configuration');
|
||||
|
||||
23
ui/lib/kv/addon/routes/secret/metadata/diff.js
Normal file
23
ui/lib/kv/addon/routes/secret/metadata/diff.js
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
|
||||
|
||||
export default class KvSecretMetadataDiffRoute extends Route {
|
||||
// model passed from parent secret route, if we need to access or intercept
|
||||
// it can retrieved via `this.modelFor('secret'), which includes the metadata model.
|
||||
setupController(controller, resolvedModel) {
|
||||
super.setupController(controller, resolvedModel);
|
||||
const breadcrumbsArray = [
|
||||
{ label: 'secrets', route: 'secrets', linkExternal: true },
|
||||
{ label: resolvedModel.backend, route: 'list' },
|
||||
...breadcrumbsForSecret(resolvedModel.path),
|
||||
{ label: 'version history', route: 'secret.metadata.versions' },
|
||||
{ label: 'diff' },
|
||||
];
|
||||
controller.set('breadcrumbs', breadcrumbsArray);
|
||||
}
|
||||
}
|
||||
6
ui/lib/kv/addon/templates/secret/metadata/diff.hbs
Normal file
6
ui/lib/kv/addon/templates/secret/metadata/diff.hbs
Normal file
@@ -0,0 +1,6 @@
|
||||
<Page::Secret::Metadata::VersionDiff
|
||||
@metadata={{this.model.metadata}}
|
||||
@path={{this.model.path}}
|
||||
@backend={{this.model.backend}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
/>
|
||||
@@ -53,9 +53,6 @@ export const PAGE = {
|
||||
edit: {
|
||||
toggleDiff: '[data-test-toggle-input="Show diff"',
|
||||
toggleDiffDescription: '[data-test-diff-description]',
|
||||
visualDiff: '[data-test-visual-diff]',
|
||||
added: `.jsondiffpatch-added`,
|
||||
deleted: `.jsondiffpatch-deleted`,
|
||||
},
|
||||
list: {
|
||||
createSecret: '[data-test-toolbar-create-secret]',
|
||||
@@ -73,10 +70,14 @@ export const PAGE = {
|
||||
icon: (version) => `[data-test-icon-holder="${version}"]`,
|
||||
linkedBlock: (version) =>
|
||||
version ? `[data-test-version-linked-block="${version}"]` : '[data-test-version-linked-block]',
|
||||
button: (version) => `[data-test-version-button="${version}"]`,
|
||||
versionMenu: (version) => `[data-test-version-linked-block="${version}"] [data-test-popup-menu-trigger]`,
|
||||
createFromVersion: (version) => `[data-test-create-new-version-from="${version}"]`,
|
||||
},
|
||||
diff: {
|
||||
visualDiff: '[data-test-visual-diff]',
|
||||
added: `.jsondiffpatch-added`,
|
||||
deleted: `.jsondiffpatch-deleted`,
|
||||
},
|
||||
create: {
|
||||
metadataSection: '[data-test-metadata-section]',
|
||||
},
|
||||
|
||||
@@ -108,24 +108,24 @@ module('Integration | Component | kv-v2 | Page::Secret::Edit', function (hooks)
|
||||
|
||||
assert.dom(PAGE.edit.toggleDiff).isDisabled('Diff toggle is disabled');
|
||||
assert.dom(PAGE.edit.toggleDiffDescription).hasText('No changes to show. Update secret to view diff');
|
||||
assert.dom(PAGE.edit.visualDiff).doesNotExist('Does not show visual diff');
|
||||
assert.dom(PAGE.diff.visualDiff).doesNotExist('Does not show visual diff');
|
||||
|
||||
await fillIn(FORM.keyInput(1), 'foo2');
|
||||
await fillIn(FORM.maskedValueInput(1), 'bar2');
|
||||
|
||||
assert.dom(PAGE.edit.toggleDiff).isNotDisabled('Diff toggle is not disabled');
|
||||
assert.dom(PAGE.edit.toggleDiffDescription).hasText('Showing the diff will reveal secret values');
|
||||
assert.dom(PAGE.edit.visualDiff).doesNotExist('Does not show visual diff');
|
||||
assert.dom(PAGE.diff.visualDiff).doesNotExist('Does not show visual diff');
|
||||
await click(PAGE.edit.toggleDiff);
|
||||
assert.dom(PAGE.edit.visualDiff).exists('Shows visual diff');
|
||||
assert.dom(PAGE.edit.added).hasText(`foo2"bar2"`);
|
||||
assert.dom(PAGE.diff.visualDiff).exists('Shows visual diff');
|
||||
assert.dom(PAGE.diff.added).hasText(`foo2"bar2"`);
|
||||
|
||||
await click(FORM.toggleJson);
|
||||
codemirror().setValue('{ "foo3": "bar3" }');
|
||||
|
||||
assert.dom(PAGE.edit.visualDiff).exists('Visual diff updates');
|
||||
assert.dom(PAGE.edit.deleted).hasText(`foo"bar"`);
|
||||
assert.dom(PAGE.edit.added).hasText(`foo3"bar3"`);
|
||||
assert.dom(PAGE.diff.visualDiff).exists('Visual diff updates');
|
||||
assert.dom(PAGE.diff.deleted).hasText(`foo"bar"`);
|
||||
assert.dom(PAGE.diff.added).hasText(`foo3"bar3"`);
|
||||
});
|
||||
|
||||
test('it saves nested secrets', async function (assert) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: MPL-2.0
|
||||
*/
|
||||
|
||||
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 { click, findAll, render } from '@ember/test-helpers';
|
||||
import { hbs } from 'ember-cli-htmlbars';
|
||||
import { kvMetadataPath, kvDataPath } from 'vault/utils/kv-path';
|
||||
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
|
||||
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
|
||||
|
||||
module('Integration | Component | kv | Page::Secret::Metadata::VersionDiff', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupEngine(hooks, 'kv');
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(async function () {
|
||||
this.backend = 'kv-engine';
|
||||
this.path = 'my-secret';
|
||||
this.breadcrumbs = [{ label: 'version history', route: 'secret.metadata.versions' }, { label: 'diff' }];
|
||||
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
|
||||
|
||||
const metadata = this.server.create('kv-metadatum');
|
||||
metadata.id = kvMetadataPath(this.backend, this.path);
|
||||
this.store.pushPayload('kv/metadata', { modelName: 'kv/metadata', ...metadata });
|
||||
this.metadata = this.store.peekRecord('kv/metadata', metadata.id);
|
||||
// push current secret version record into the store to assert only one request is made
|
||||
const dataId = kvDataPath(this.backend, this.path, 4);
|
||||
this.store.pushPayload('kv/data', {
|
||||
modelName: 'kv/data',
|
||||
id: dataId,
|
||||
secret_data: { foo: 'bar' },
|
||||
version: this.metadata.currentVersion,
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders empty states when current version is deleted or destroyed', async function (assert) {
|
||||
assert.expect(4);
|
||||
this.server.get(`/${this.backend}/data/${this.path}`, () => {});
|
||||
const { currentVersion } = this.metadata;
|
||||
|
||||
// destroyed
|
||||
this.metadata.versions[currentVersion].destroyed = true;
|
||||
await render(
|
||||
hbs`
|
||||
<Page::Secret::Metadata::VersionDiff
|
||||
@metadata={{this.metadata}}
|
||||
@path={{this.path}}
|
||||
@backend={{this.backend}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
assert.dom(PAGE.emptyStateTitle).hasText(`Version ${currentVersion} has been destroyed`);
|
||||
assert
|
||||
.dom(PAGE.emptyStateMessage)
|
||||
.hasText('The current version of this secret has been destroyed. Select another version to compare.');
|
||||
|
||||
// deleted
|
||||
this.metadata.versions[currentVersion].destroyed = false;
|
||||
this.metadata.versions[currentVersion].deletion_time = '2023-07-25T00:36:19.950545Z';
|
||||
await render(
|
||||
hbs`
|
||||
<Page::Secret::Metadata::VersionDiff
|
||||
@metadata={{this.metadata}}
|
||||
@path={{this.path}}
|
||||
@backend={{this.backend}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
|
||||
assert.dom(PAGE.emptyStateTitle).hasText(`Version ${currentVersion} has been deleted`);
|
||||
assert
|
||||
.dom(PAGE.emptyStateMessage)
|
||||
.hasText('The current version of this secret has been deleted. Select another version to compare.');
|
||||
});
|
||||
|
||||
test('it renders compared data of the two versions and shows icons for deleted, destroyed and current', async function (assert) {
|
||||
assert.expect(14);
|
||||
this.server.get(`/${this.backend}/data/${this.path}`, (schema, req) => {
|
||||
assert.ok('request made to the fetch version 1 data.');
|
||||
// request should not be made for version 4 (current version) because that record already exists in the store
|
||||
assert.strictEqual(req.queryParams.version, '1', 'request includes version param');
|
||||
return {
|
||||
request_id: 'foobar',
|
||||
data: {
|
||||
data: { hello: 'world' },
|
||||
metadata: {
|
||||
created_time: '2023-06-20T21:26:47.592306Z',
|
||||
custom_metadata: null,
|
||||
deletion_time: '',
|
||||
destroyed: false,
|
||||
version: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await render(
|
||||
hbs`
|
||||
<Page::Secret::Metadata::VersionDiff
|
||||
@metadata={{this.metadata}}
|
||||
@path={{this.path}}
|
||||
@backend={{this.backend}}
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
|
||||
const [left, right] = findAll(PAGE.detail.versionDropdown);
|
||||
assert.dom(PAGE.diff.visualDiff).hasText(
|
||||
`foo\"bar\"hello\"world\"`, // eslint-disable-line no-useless-escape
|
||||
'correctly pull in the data from version 4 and compared to version 1.'
|
||||
);
|
||||
assert.dom(PAGE.diff.deleted).hasText(`hello"world"`);
|
||||
assert.dom(PAGE.diff.added).hasText(`foo"bar"`);
|
||||
assert.dom(right).hasText('Version 4', 'shows the current version for the left side default version.');
|
||||
assert.dom(left).hasText('Version 1', 'shows the latest active version on init.');
|
||||
|
||||
await click(left);
|
||||
|
||||
for (const num in this.metadata.versions) {
|
||||
const data = this.metadata.versions[num];
|
||||
assert.dom(PAGE.detail.version(num)).exists('renders the button for each version.');
|
||||
|
||||
if (data.destroyed || data.deletion_time) {
|
||||
assert
|
||||
.dom(`${PAGE.detail.version(num)} [data-test-icon="x-square-fill"]`)
|
||||
.hasClass(`${data.destroyed ? 'has-text-danger' : 'has-text-grey'}`);
|
||||
}
|
||||
}
|
||||
assert
|
||||
.dom(`${PAGE.detail.version('1')} button`)
|
||||
.hasClass('is-active', 'correctly shows the selected version 1 as active.');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user