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:
claire bontempo
2023-09-21 09:50:08 -07:00
committed by GitHub
parent 758de878d6
commit 8375149004
11 changed files with 342 additions and 23 deletions

3
changelog/23200.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:improvement
ui: Move access to KV V2 version diff view to toolbar in Version History
```

View File

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

View File

@@ -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}}

View File

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

View File

@@ -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}} />

View File

@@ -19,6 +19,7 @@ export default buildRoutes(function () {
this.route('metadata', function () {
this.route('edit');
this.route('versions');
this.route('diff');
});
});
this.route('configuration');

View 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);
}
}

View File

@@ -0,0 +1,6 @@
<Page::Secret::Metadata::VersionDiff
@metadata={{this.model.metadata}}
@path={{this.model.path}}
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>

View File

@@ -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]',
},

View File

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

View File

@@ -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.');
});
});