diff --git a/changelog/23200.txt b/changelog/23200.txt new file mode 100644 index 000000000000..245cc694afbb --- /dev/null +++ b/changelog/23200.txt @@ -0,0 +1,3 @@ +```release-note:improvement +ui: Move access to KV V2 version diff view to toolbar in Version History +``` \ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-version-dropdown.hbs b/ui/lib/kv/addon/components/kv-version-dropdown.hbs index 8f006da972a5..00494bc43b20 100644 --- a/ui/lib/kv/addon/components/kv-version-dropdown.hbs +++ b/ui/lib/kv/addon/components/kv-version-dropdown.hbs @@ -9,17 +9,36 @@ diff --git a/ui/lib/kv/addon/components/page/secret/metadata/version-diff.hbs b/ui/lib/kv/addon/components/page/secret/metadata/version-diff.hbs new file mode 100644 index 000000000000..d90924f2f0a4 --- /dev/null +++ b/ui/lib/kv/addon/components/page/secret/metadata/version-diff.hbs @@ -0,0 +1,33 @@ + + <:toolbarFilters> + FROM: + + TO: + + {{#if this.statesMatch}} +
+ + States match +
+ {{/if}} + +
+ +{{#if this.deactivatedState}} + +{{else}} +
+
{{sanitized-html this.visualDiff}}
+
+{{/if}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/secret/metadata/version-diff.js b/ui/lib/kv/addon/components/page/secret/metadata/version-diff.js new file mode 100644 index 000000000000..486dfd058df3 --- /dev/null +++ b/ui/lib/kv/addon/components/page/secret/metadata/version-diff.js @@ -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 + * + * + * @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; + } +} diff --git a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs index a8e50999dbe6..7ac174fe1f59 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs @@ -5,9 +5,14 @@ Paths Version History + + <:toolbarActions> + {{#if @metadata.canReadMetadata}} + Version diff + {{/if}} + - {{#if @metadata.canReadMetadata}}
diff --git a/ui/lib/kv/addon/routes.js b/ui/lib/kv/addon/routes.js index efd759b17fc2..529ecbe8d5b4 100644 --- a/ui/lib/kv/addon/routes.js +++ b/ui/lib/kv/addon/routes.js @@ -19,6 +19,7 @@ export default buildRoutes(function () { this.route('metadata', function () { this.route('edit'); this.route('versions'); + this.route('diff'); }); }); this.route('configuration'); diff --git a/ui/lib/kv/addon/routes/secret/metadata/diff.js b/ui/lib/kv/addon/routes/secret/metadata/diff.js new file mode 100644 index 000000000000..6f0e52080e91 --- /dev/null +++ b/ui/lib/kv/addon/routes/secret/metadata/diff.js @@ -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); + } +} diff --git a/ui/lib/kv/addon/templates/secret/metadata/diff.hbs b/ui/lib/kv/addon/templates/secret/metadata/diff.hbs new file mode 100644 index 000000000000..97b37f1261c0 --- /dev/null +++ b/ui/lib/kv/addon/templates/secret/metadata/diff.hbs @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js index 3c9e5efb69f7..8728af7bf4c0 100644 --- a/ui/tests/helpers/kv/kv-selectors.js +++ b/ui/tests/helpers/kv/kv-selectors.js @@ -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]', }, diff --git a/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js b/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js index 050cce23f5e3..3c06689c813c 100644 --- a/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js @@ -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) { diff --git a/ui/tests/integration/components/kv/page/kv-page-version-diff-test.js b/ui/tests/integration/components/kv/page/kv-page-version-diff-test.js new file mode 100644 index 000000000000..322d166e3769 --- /dev/null +++ b/ui/tests/integration/components/kv/page/kv-page-version-diff-test.js @@ -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` + + `, + { 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` + + `, + { 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` + + `, + { 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.'); + }); +});