diff --git a/ui/app/adapters/kv/data.js b/ui/app/adapters/kv/data.js index 2b68acd5a7..13e37ae778 100644 --- a/ui/app/adapters/kv/data.js +++ b/ui/app/adapters/kv/data.js @@ -4,7 +4,14 @@ */ import ApplicationAdapter from '../application'; -import { kvDataPath, kvDeletePath, kvDestroyPath, kvMetadataPath, kvUndeletePath } from 'vault/utils/kv-path'; +import { + kvDataPath, + kvDeletePath, + kvDestroyPath, + kvMetadataPath, + kvSubkeysPath, + kvUndeletePath, +} from 'vault/utils/kv-path'; import { assert } from '@ember/debug'; import ControlGroupError from 'vault/lib/control-group-error'; @@ -31,6 +38,14 @@ export default class KvDataAdapter extends ApplicationAdapter { }); } + fetchSubkeys(query) { + const { backend, path, version, depth } = query; + const url = this._url(kvSubkeysPath(backend, path, depth, version)); + // TODO subkeys response handles deleted records the same as queryRecord and returns a 404 + // extrapolate error handling logic from queryRecord and share between these two methods + return this.ajax(url, 'GET').then((resp) => resp.data); + } + fetchWrapInfo(query) { const { backend, path, version, wrapTTL } = query; const id = kvDataPath(backend, path, version); diff --git a/ui/app/models/kv/data.js b/ui/app/models/kv/data.js index 58392bf6b3..f0f30651f8 100644 --- a/ui/app/models/kv/data.js +++ b/ui/app/models/kv/data.js @@ -88,6 +88,7 @@ export default class KvSecretDataModel extends Model { @lazyCapabilities(apiPath`${'backend'}/delete/${'path'}`, 'backend', 'path') deletePath; @lazyCapabilities(apiPath`${'backend'}/destroy/${'path'}`, 'backend', 'path') destroyPath; @lazyCapabilities(apiPath`${'backend'}/undelete/${'path'}`, 'backend', 'path') undeletePath; + @lazyCapabilities(apiPath`${'backend'}/subkeys/${'path'}`, 'backend', 'path') subkeysPath; get canDeleteLatestVersion() { return this.dataPath.get('canDelete') !== false; @@ -119,4 +120,7 @@ export default class KvSecretDataModel extends Model { get canDeleteMetadata() { return this.metadataPath.get('canDelete') !== false; } + get canReadSubkeys() { + return this.subkeysPath.get('canRead') !== false; + } } diff --git a/ui/app/styles/helper-classes/spacing.scss b/ui/app/styles/helper-classes/spacing.scss index 8d95939e53..e62a63dcd4 100644 --- a/ui/app/styles/helper-classes/spacing.scss +++ b/ui/app/styles/helper-classes/spacing.scss @@ -3,7 +3,11 @@ * SPDX-License-Identifier: BUSL-1.1 */ -/* Helpers that define margin and padding in pixels */ +/* + Helpers that define margin and padding in pixels + *New pattern* Use the numerical value in the name for class selectors + i.e. "has-padding-24" instead of "has-padding-l" + */ .is-paddingless { padding: 0 !important; @@ -33,9 +37,6 @@ .has-padding-m { padding: $spacing-16; } -.has-padding-l { - padding: $spacing-24; -} .has-padding-l { padding: $spacing-24; @@ -90,6 +91,7 @@ margin: 0 !important; } +// spacing-18 is between medium + large .has-top-bottom-margin { margin: $spacing-18 0rem; } @@ -98,6 +100,11 @@ margin: $spacing-4 0; } +// moving towards numerical class names (i.e. -12) and away from s/m/l etc. +.has-top-bottom-margin-12 { + margin: $spacing-12 0; +} + .has-top-margin-negative-m { margin-top: -$spacing-16; } diff --git a/ui/app/utils/kv-path.ts b/ui/app/utils/kv-path.ts index 0ee8479747..dc801d147a 100644 --- a/ui/app/utils/kv-path.ts +++ b/ui/app/utils/kv-path.ts @@ -33,3 +33,18 @@ export function kvDestroyPath(backend: string, path: string) { export function kvUndeletePath(backend: string, path: string) { return buildKvPath(backend, path, 'undelete'); } +// TODO use query-param-string util when https://github.com/hashicorp/vault/pull/27455 is merged +export function kvSubkeysPath( + backend: string, + path: string, + depth?: number | string, + version?: number | string +) { + const apiPath = buildKvPath(backend, path, 'subkeys'); + // if no version, defaults to latest + const versionParam = version ? `&version=${version}` : ''; + // depth specifies the deepest nesting level the API should return + // depth=0 returns all subkeys (no limit), depth=1 returns only top-level keys + const queryParams = `?depth=${depth || '0'}${versionParam}`; + return `${apiPath}${queryParams}`; +} diff --git a/ui/lib/core/addon/components/overview-card.hbs b/ui/lib/core/addon/components/overview-card.hbs index f133788463..7bf6d32d17 100644 --- a/ui/lib/core/addon/components/overview-card.hbs +++ b/ui/lib/core/addon/components/overview-card.hbs @@ -10,7 +10,7 @@ data-test-overview-card-container={{@cardTitle}} ...attributes > -
+
{{@cardTitle}} @@ -20,9 +20,17 @@ {{/if}}
- - {{@subText}} - + {{! Pass @subText for text only content to use default styling. }} + {{#if @subText}} + + {{@subText}} + + {{/if}} + + {{! Use the "customSubtext" yield for stylized subtext or including elements like doc links. }} + {{#if (has-block "customSubtext")}} + {{yield to="customSubtext"}} + {{/if}} {{#if (has-block "content")}} {{yield to="content"}} diff --git a/ui/lib/kv/addon/components/kv-subkeys.hbs b/ui/lib/kv/addon/components/kv-subkeys.hbs new file mode 100644 index 0000000000..a36f18be69 --- /dev/null +++ b/ui/lib/kv/addon/components/kv-subkeys.hbs @@ -0,0 +1,48 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + + <:customSubtext> + + {{#if this.showJson}} + These are the subkeys within this secret. All underlying values of leaf keys are not retrieved and are replaced with + null + instead. Subkey + API documentation. + {{else}} + The table is displaying the top level subkeys. Toggle on the JSON view to see the full depth. + {{/if}} + + + <:action> +
+ +

JSON

+
+
+ + <:content> +
+ {{#if this.showJson}} + + {{else}} + + Keys + +
+ {{#each-in @subkeys as |key|}} + + {{key}} + +
+ {{/each-in}} + {{/if}} +
+ +
\ No newline at end of file diff --git a/ui/lib/kv/addon/components/kv-subkeys.js b/ui/lib/kv/addon/components/kv-subkeys.js new file mode 100644 index 0000000000..95b633f1c7 --- /dev/null +++ b/ui/lib/kv/addon/components/kv-subkeys.js @@ -0,0 +1,41 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Component from '@glimmer/component'; +import { tracked } from '@glimmer/tracking'; + +/** + * @module KvSubkeys + * @description +sample secret data: +``` + { + "foo": "abc", + "bar": { + "baz": "def" + }, + "quux": {} +} +``` +sample subkeys: +``` + this.subkeys = { + "bar": { + "baz": null + }, + "foo": null, + "quux": null +} +``` + * + * @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 { + @tracked showJson = false; +} diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js index 247e873034..23a50553f1 100644 --- a/ui/tests/helpers/kv/kv-selectors.js +++ b/ui/tests/helpers/kv/kv-selectors.js @@ -44,6 +44,7 @@ export const PAGE = { destroy: '[data-test-kv-delete="destroy"]', undelete: '[data-test-kv-delete="undelete"]', copy: '[data-test-copy-menu-trigger]', + wrap: '[data-test-wrap-button]', deleteModal: '[data-test-delete-modal]', deleteModalTitle: '[data-test-delete-modal] [data-test-modal-title]', deleteOption: 'input#delete-version', diff --git a/ui/tests/integration/components/kv/kv-subkeys-test.js b/ui/tests/integration/components/kv/kv-subkeys-test.js new file mode 100644 index 0000000000..751b258659 --- /dev/null +++ b/ui/tests/integration/components/kv/kv-subkeys-test.js @@ -0,0 +1,57 @@ +/** + * 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 { render, click } from '@ember/test-helpers'; +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) { + setupRenderingTest(hooks); + setupEngine(hooks, 'kv'); + hooks.beforeEach(function () { + this.subkeys = { + foo: null, + bar: { + baz: null, + }, + }; + this.renderComponent = async () => { + return render(hbs``, { + owner: this.engine, + }); + }; + }); + + test('it renders', async function (assert) { + assert.expect(4); + await this.renderComponent(); + + assert.dom(overviewCard.title('Subkeys')).exists(); + assert + .dom(overviewCard.description('Subkeys')) + .hasText( + 'The table is displaying the top level subkeys. Toggle on the JSON view to see the full depth.' + ); + assert.dom(overviewCard.content('Subkeys')).hasText('Keys foo bar'); + assert.dom(GENERAL.toggleInput('kv-subkeys')).isNotChecked('JSON toggle is not checked by default'); + }); + + test('it toggles to JSON', async function (assert) { + assert.expect(4); + await this.renderComponent(); + + assert.dom(GENERAL.toggleInput('kv-subkeys')).isNotChecked(); + await click(GENERAL.toggleInput('kv-subkeys')); + assert.dom(GENERAL.toggleInput('kv-subkeys')).isChecked('JSON toggle is checked'); + assert.dom(overviewCard.description('Subkeys')).hasText( + 'These are the subkeys within this secret. All underlying values of leaf keys are not retrieved and are replaced with null instead. Subkey API documentation .' // space is intentional because a trailing icon renders after the inline link + ); + assert.dom(overviewCard.content('Subkeys')).hasText(JSON.stringify(this.subkeys, null, 2)); + }); +}); 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 2a85876f3b..eed6357c60 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 @@ -13,6 +13,7 @@ import { kvDataPath, kvMetadataPath } from 'vault/utils/kv-path'; import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs'; 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'; module('Integration | Component | kv-v2 | Page::Secret::Details', function (hooks) { setupRenderingTest(hooks); @@ -286,6 +287,39 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook await click(`${PAGE.detail.syncAlert()} button`); }); + test('it makes request to wrap a secret', async function (assert) { + assert.expect(2); + const url = `${encodePath(this.backend)}/data/${encodePath(this.path)}`; + + this.server.get(url, (schema, { requestHeaders }) => { + assert.true(true, `GET request made to url: ${url}`); + assert.strictEqual(requestHeaders['X-Vault-Wrap-TTL'], '1800', 'request header includes wrap ttl'); + return { + data: null, + token: 'hvs.token', + accessor: 'nTgqnw3S4GMz8NKHsOhTBhlk', + ttl: 1800, + creation_time: '2024-07-26T10:20:32.359107-07:00', + creation_path: `${this.backend}/data/${this.path}}`, + }; + }); + + await render( + hbs` + + `, + { owner: this.engine } + ); + + await click(PAGE.detail.copy); + await click(PAGE.detail.wrap); + }); + test('it renders sync status page alert for multiple destinations', async function (assert) { assert.expect(3); // assert count important because confirms request made to fetch sync status twice this.server.create('sync-association', { diff --git a/ui/tests/integration/components/overview-card-test.js b/ui/tests/integration/components/overview-card-test.js index f38a6e89b1..38383808ce 100644 --- a/ui/tests/integration/components/overview-card-test.js +++ b/ui/tests/integration/components/overview-card-test.js @@ -16,9 +16,10 @@ const SELECTORS = { title: '[data-test-overview-card-title]', subtitle: '[data-test-overview-card-subtitle]', action: '[data-test-action-text]', + customSubtext: '[data-test-custom-subtext]', }; -module('Integration | Component overview-card', function (hooks) { +module('Integration | Component | overview-card', function (hooks) { setupRenderingTest(hooks); hooks.beforeEach(function () { @@ -31,11 +32,11 @@ module('Integration | Component overview-card', function (hooks) { await render(hbs``); assert.dom(SELECTORS.title).hasText('Card title'); }); - test('it returns card subtext, ', async function (assert) { + test('it renders card @subText arg, ', async function (assert) { await render(hbs``); assert.dom(SELECTORS.subtitle).hasText('This is subtext for card'); }); - test('it returns card action text', async function (assert) { + test('it renders card action text', async function (assert) { await render( hbs` @@ -49,4 +50,18 @@ module('Integration | Component overview-card', function (hooks) { ); assert.dom(SELECTORS.action).hasText('View card'); }); + test('it renders custom subtext text', async function (assert) { + await render( + hbs` + + <:customSubtext> +
+ Fancy yielded subtext +
+ +
+ ` + ); + assert.dom(SELECTORS.customSubtext).hasText('Fancy yielded subtext'); + }); }); diff --git a/ui/tests/unit/adapters/kv/data-test.js b/ui/tests/unit/adapters/kv/data-test.js index 20a0ed55d8..8fdb6d1adf 100644 --- a/ui/tests/unit/adapters/kv/data-test.js +++ b/ui/tests/unit/adapters/kv/data-test.js @@ -35,6 +35,14 @@ const EXAMPLE_KV_DATA_GET_RESPONSE = { }, }; +const EXAMPLE_KV_SUBKEYS_RESPONSE = { + request_id: 'foobar', + data: { + ...EXAMPLE_KV_DATA_GET_RESPONSE, + data: { foo: null }, + }, +}; + const EXAMPLE_CONTROL_GROUP_RESPONSE = { data: null, wrap_info: { @@ -78,8 +86,6 @@ module('Unit | Adapter | kv/data', function (hooks) { hooks.beforeEach(function () { this.store = this.owner.lookup('service:store'); - this.version = this.owner.lookup('service:version'); - this.version.type = 'enterprise'; // Required for testing control-group flow this.secretMountPath = this.owner.lookup('service:secret-mount-path'); this.backend = 'my/kv-back&end'; this.secretMountPath.currentPath = this.backend; @@ -102,266 +108,315 @@ module('Unit | Adapter | kv/data', function (hooks) { this.endpoint = (noun) => `${encodePath(this.backend)}/${noun}/${encodePath(this.path)}`; }); - test('it should make request to correct endpoint on createRecord', async function (assert) { - assert.expect(8); - this.server.post(this.endpoint('data'), (schema, req) => { - assert.ok('POST request made to correct endpoint when creating new record'); - const body = JSON.parse(req.requestBody); - assert.deepEqual(body, { - data: { - foo: 'bar', - }, - options: { - cas: 0, - }, + module('createRecord', function () { + test('it should make request to correct endpoint on createRecord', async function (assert) { + assert.expect(8); + this.server.post(this.endpoint('data'), (schema, req) => { + assert.ok('POST request made to correct endpoint when creating new record'); + const body = JSON.parse(req.requestBody); + assert.deepEqual(body, { + data: { + foo: 'bar', + }, + options: { + cas: 0, + }, + }); + return EXAMPLE_KV_DATA_CREATE_RESPONSE; }); - return EXAMPLE_KV_DATA_CREATE_RESPONSE; - }); - const record = this.store.createRecord('kv/data', { - backend: this.backend, - path: this.path, - secretData: { foo: 'bar' }, - casVersion: 0, - }); - await record.save(); - assert.strictEqual(record.path, this.path, 'record has correct path'); - assert.strictEqual(record.backend, this.backend, 'record has correct backend'); - assert.strictEqual(record.version, 1, 'record has correct version'); - assert.deepEqual(record.secretData, { foo: 'bar' }, 'record has correct data'); - assert.strictEqual(record.createdTime, '2023-06-21T16:18:31.479993Z', 'record has correct createdTime'); - assert.strictEqual( - record.id, - `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=1`, - 'record has correct id' - ); - }); - - test('it should not send cas if casVersion is not a number', async function (assert) { - assert.expect(8); - this.server.post(this.endpoint('data'), (schema, req) => { - assert.ok('POST request made to correct endpoint when creating new record'); - const body = JSON.parse(req.requestBody); - assert.deepEqual(body, { - data: { - foo: 'bar', - }, + const record = this.store.createRecord('kv/data', { + backend: this.backend, + path: this.path, + secretData: { foo: 'bar' }, + casVersion: 0, }); - return EXAMPLE_KV_DATA_CREATE_RESPONSE; - }); - const record = this.store.createRecord('kv/data', { - backend: this.backend, - path: this.path, - secretData: { foo: 'bar' }, - }); - await record.save(); - assert.strictEqual(record.path, this.path, 'record has correct path'); - assert.strictEqual(record.backend, this.backend, 'record has correct backend'); - assert.strictEqual(record.version, 1, 'record has correct version'); - assert.deepEqual(record.secretData, { foo: 'bar' }, 'record has correct data'); - assert.strictEqual(record.createdTime, '2023-06-21T16:18:31.479993Z', 'record has correct createdTime'); - assert.strictEqual( - record.id, - `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=1`, - 'record has correct id' - ); - }); - - test('it should make request to correct endpoint on queryRecord', async function (assert) { - assert.expect(8); - this.server.get(this.endpoint('data'), (schema, req) => { - assert.ok(true, 'request is made to correct url on queryRecord.'); + await record.save(); + assert.strictEqual(record.path, this.path, 'record has correct path'); + assert.strictEqual(record.backend, this.backend, 'record has correct backend'); + assert.strictEqual(record.version, 1, 'record has correct version'); + assert.deepEqual(record.secretData, { foo: 'bar' }, 'record has correct data'); + assert.strictEqual(record.createdTime, '2023-06-21T16:18:31.479993Z', 'record has correct createdTime'); assert.strictEqual( - req.queryParams.version, - this.version, - 'request includes the version flag on queryRecord.' + record.id, + `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=1`, + 'record has correct id' ); - return EXAMPLE_KV_DATA_GET_RESPONSE; }); - const record = await this.store.queryRecord('kv/data', this.payload); - assert.strictEqual(record.path, this.path, 'record has correct path'); - assert.strictEqual(record.backend, this.backend, 'record has correct backend'); - assert.strictEqual(record.version, 2, 'record has correct version'); - assert.deepEqual(record.secretData, { foo: 'bar' }, 'record has correct data'); - assert.strictEqual(record.createdTime, '2023-06-20T21:26:47.592306Z', 'record has correct createdTime'); - assert.strictEqual( - record.id, - `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`, - 'record has correct id' - ); - }); - - test('it should handle a 404 not found response properly', async function (assert) { - assert.expect(1); - this.server.get(this.endpoint('data'), () => { - // This is what the API currently returns for not found - return new Response(404, {}, { errors: [] }); - }); - - try { - await this.store.queryRecord('kv/data', this.payload); - } catch (e) { - assert.ok('throws the error'); - } - }); - - test('it should handle a 403 permission denied properly', async function (assert) { - assert.expect(8); - this.server.get(this.endpoint('data'), (schema, req) => { - assert.ok(true, 'request is made to correct url on queryRecord.'); + test('it should not send cas if casVersion is not a number', async function (assert) { + assert.expect(8); + this.server.post(this.endpoint('data'), (schema, req) => { + assert.ok('POST request made to correct endpoint when creating new record'); + const body = JSON.parse(req.requestBody); + assert.deepEqual(body, { + data: { + foo: 'bar', + }, + }); + return EXAMPLE_KV_DATA_CREATE_RESPONSE; + }); + const record = this.store.createRecord('kv/data', { + backend: this.backend, + path: this.path, + secretData: { foo: 'bar' }, + }); + await record.save(); + assert.strictEqual(record.path, this.path, 'record has correct path'); + assert.strictEqual(record.backend, this.backend, 'record has correct backend'); + assert.strictEqual(record.version, 1, 'record has correct version'); + assert.deepEqual(record.secretData, { foo: 'bar' }, 'record has correct data'); + assert.strictEqual(record.createdTime, '2023-06-21T16:18:31.479993Z', 'record has correct createdTime'); assert.strictEqual( - req.queryParams.version, - this.version, - 'request includes the version flag on queryRecord.' + record.id, + `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=1`, + 'record has correct id' ); - return new Response(403, {}, { errors: ['1 error occurred:\n\t* permission denied\n\n'] }); }); - - const record = await this.store.queryRecord('kv/data', this.payload); - assert.strictEqual(record.path, this.path, 'record has correct path'); - assert.strictEqual(record.backend, this.backend, 'record has correct backend'); - assert.strictEqual(record.version, 2, 'record has version based on request'); - assert.strictEqual(record.secretData, undefined, 'record does not include data'); - assert.strictEqual(record.failReadErrorCode, 403, 'record has error response recorded'); - assert.strictEqual( - record.id, - `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`, - 'record has correct id' - ); }); - test('it should handle a soft-deleted version properly', async function (assert) { - this.server.get(this.endpoint('data'), () => { - return new Response(404, {}, EXAMPLE_KV_DATA_DELETED); + module('queryRecord', function () { + test('it should make request to correct endpoint on queryRecord', async function (assert) { + assert.expect(8); + this.server.get(this.endpoint('data'), (schema, req) => { + assert.ok(true, 'request is made to correct url on queryRecord.'); + assert.strictEqual( + req.queryParams.version, + this.version, + 'request includes the version flag on queryRecord.' + ); + return EXAMPLE_KV_DATA_GET_RESPONSE; + }); + + const record = await this.store.queryRecord('kv/data', this.payload); + assert.strictEqual(record.path, this.path, 'record has correct path'); + assert.strictEqual(record.backend, this.backend, 'record has correct backend'); + assert.strictEqual(record.version, 2, 'record has correct version'); + assert.deepEqual(record.secretData, { foo: 'bar' }, 'record has correct data'); + assert.strictEqual(record.createdTime, '2023-06-20T21:26:47.592306Z', 'record has correct createdTime'); + assert.strictEqual( + record.id, + `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`, + 'record has correct id' + ); }); - const record = await this.store.queryRecord('kv/data', this.payload); - assert.strictEqual(record.path, this.path, 'record has correct path'); - assert.strictEqual(record.backend, this.backend, 'record has correct backend'); - assert.strictEqual(record.version, 2, 'record has version based on request'); - assert.strictEqual(record.deletionTime, '2023-08-09T20:10:24.70176Z', 'record includes deletion time'); - assert.strictEqual(record.failReadErrorCode, undefined, 'record does not have failed error code'); - assert.strictEqual( - record.id, - `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`, - 'record has correct id' - ); + test('it should handle a 404 not found response properly', async function (assert) { + assert.expect(1); + this.server.get(this.endpoint('data'), () => { + // This is what the API currently returns for not found + return new Response(404, {}, { errors: [] }); + }); + + try { + await this.store.queryRecord('kv/data', this.payload); + } catch (e) { + assert.ok('throws the error'); + } + }); + + test('it should handle 404 for a soft-deleted version properly', async function (assert) { + this.server.get(this.endpoint('data'), () => { + return new Response(404, {}, EXAMPLE_KV_DATA_DELETED); + }); + + const record = await this.store.queryRecord('kv/data', this.payload); + assert.strictEqual(record.path, this.path, 'record has correct path'); + assert.strictEqual(record.backend, this.backend, 'record has correct backend'); + assert.strictEqual(record.version, 2, 'record has version based on request'); + assert.strictEqual(record.deletionTime, '2023-08-09T20:10:24.70176Z', 'record includes deletion time'); + assert.strictEqual(record.failReadErrorCode, undefined, 'record does not have failed error code'); + assert.strictEqual( + record.id, + `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`, + 'record has correct id' + ); + }); + + test('it should handle 404 for a destroyed version properly', async function (assert) { + this.server.get(this.endpoint('data'), () => { + return new Response(404, {}, EXAMPLE_KV_DATA_DESTROYED); + }); + + const record = await this.store.queryRecord('kv/data', this.payload); + assert.strictEqual(record.path, this.path, 'record has correct path'); + assert.strictEqual(record.backend, this.backend, 'record has correct backend'); + assert.strictEqual(record.version, 2, 'record has version based on request'); + assert.true(record.destroyed, 'record has destroyed value'); + assert.strictEqual(record.failReadErrorCode, undefined, 'record does not have error code'); + assert.strictEqual( + record.id, + `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`, + 'record has correct id' + ); + }); + + test('it should handle a 403 permission denied properly', async function (assert) { + assert.expect(8); + this.server.get(this.endpoint('data'), (schema, req) => { + assert.ok(true, 'request is made to correct url on queryRecord.'); + assert.strictEqual( + req.queryParams.version, + this.version, + 'request includes the version flag on queryRecord.' + ); + return new Response(403, {}, { errors: ['1 error occurred:\n\t* permission denied\n\n'] }); + }); + + const record = await this.store.queryRecord('kv/data', this.payload); + assert.strictEqual(record.path, this.path, 'record has correct path'); + assert.strictEqual(record.backend, this.backend, 'record has correct backend'); + assert.strictEqual(record.version, 2, 'record has version based on request'); + assert.strictEqual(record.secretData, undefined, 'record does not include data'); + assert.strictEqual(record.failReadErrorCode, 403, 'record has error response recorded'); + assert.strictEqual( + record.id, + `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`, + 'record has correct id' + ); + }); + + test('it should handle a control group response properly', async function (assert) { + assert.expect(1); + this.owner.lookup('service:version').type = 'enterprise'; // Required for testing control-group flow + this.server.get(this.endpoint('data'), () => { + return EXAMPLE_CONTROL_GROUP_RESPONSE; + }); + + try { + await this.store.queryRecord('kv/data', this.payload); + } catch (e) { + assert.ok('throws the error'); + } + }); }); - test('it should handle a destroyed version properly', async function (assert) { - this.server.get(this.endpoint('data'), () => { - return new Response(404, {}, EXAMPLE_KV_DATA_DESTROYED); + module('destroyRecord', function () { + test('it should make request to correct endpoint on delete latest version', async function (assert) { + assert.expect(3); + this.server.delete(this.endpoint('data'), () => { + assert.ok(true, 'request made to correct endpoint on delete latest version.'); + return new Response(204); + }); + + this.store.pushPayload('kv/data', { + modelName: 'kv/data', + id: this.id, + ...this.payload, + }); + let record = await this.store.peekRecord('kv/data', this.id); + await record.destroyRecord({ adapterOptions: { deleteType: 'delete-latest-version' } }); + assert.true(record.isDeleted, 'record is deleted'); + record = await this.store.peekRecord('kv/data', this.id); + assert.strictEqual(record, null, 'record is no longer in store'); }); - const record = await this.store.queryRecord('kv/data', this.payload); - assert.strictEqual(record.path, this.path, 'record has correct path'); - assert.strictEqual(record.backend, this.backend, 'record has correct backend'); - assert.strictEqual(record.version, 2, 'record has version based on request'); - assert.true(record.destroyed, 'record has destroyed value'); - assert.strictEqual(record.failReadErrorCode, undefined, 'record does not have error code'); - assert.strictEqual( - record.id, - `${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`, - 'record has correct id' - ); + test('it should make request to correct endpoint on delete specific versions', async function (assert) { + assert.expect(4); + this.server.post(this.endpoint('delete'), (schema, req) => { + const { versions } = JSON.parse(req.requestBody); + assert.strictEqual(versions, 2, 'version array is sent in the payload.'); + assert.ok(true, 'request made to correct endpoint on delete specific version.'); + }); + + this.store.pushPayload('kv/data', { + modelName: 'kv/data', + id: this.id, + ...this.payload, + }); + let record = await this.store.peekRecord('kv/data', this.id); + await record.destroyRecord({ + adapterOptions: { deleteType: 'delete-version', deleteVersions: 2 }, + }); + assert.true(record.isDeleted, 'record is deleted'); + record = await this.store.peekRecord('kv/data', this.id); + assert.strictEqual(record, null, 'record is no longer in store'); + }); + + test('it should make request to correct endpoint on undelete', async function (assert) { + assert.expect(4); + this.server.post(`${this.backend}/undelete/${this.path}`, (schema, req) => { + const { versions } = JSON.parse(req.requestBody); + assert.strictEqual(versions, 2, 'version array is sent in the payload.'); + assert.ok(true, 'request made to correct endpoint on undelete specific version.'); + }); + + this.store.pushPayload('kv/data', { + modelName: 'kv/data', + id: this.id, + ...this.payload, + }); + let record = await this.store.peekRecord('kv/data', this.id); + + await record.destroyRecord({ + adapterOptions: { deleteType: 'undelete', deleteVersions: 2 }, + }); + assert.true(record.isDeleted, 'record is deleted'); + record = await this.store.peekRecord('kv/data', this.id); + assert.strictEqual(record, null, 'record is no longer in store'); + }); + + test('it should make request to correct endpoint on destroy specific versions', async function (assert) { + assert.expect(4); + this.server.put(`${encodePath(this.backend)}/destroy/${encodePath(this.path)}`, (schema, req) => { + const { versions } = JSON.parse(req.requestBody); + assert.strictEqual(versions, 2, 'version array is sent in the payload.'); + assert.ok(true, 'request made to correct endpoint on destroy specific version.'); + }); + + this.store.pushPayload('kv/data', { + modelName: 'kv/data', + id: this.id, + ...this.payload, + }); + let record = await this.store.peekRecord('kv/data', this.id); + await record.destroyRecord({ + adapterOptions: { deleteType: 'destroy', deleteVersions: 2 }, + }); + assert.true(record.isDeleted, 'record is deleted'); + record = await this.store.peekRecord('kv/data', this.id); + assert.strictEqual(record, null, 'record is no longer in store'); + }); }); - test('it should handle a control group response properly', async function (assert) { - assert.expect(1); - this.server.get(this.endpoint('data'), () => { - return EXAMPLE_CONTROL_GROUP_RESPONSE; + module('fetchSubkeys', function (hooks) { + hooks.beforeEach(function () { + this.adapter = this.store.adapterFor('kv/data'); + this.subkeysUrl = `${encodePath(this.backend)}/subkeys/${encodePath(this.path)}`; }); - try { - await this.store.queryRecord('kv/data', this.payload); - } catch (e) { - assert.ok('throws the error'); - } - }); + test('it should make request with default query', async function (assert) { + assert.expect(2); + const expectedQuery = { depth: '0' }; - test('it should make request to correct endpoint on delete latest version', async function (assert) { - assert.expect(3); - this.server.delete(this.endpoint('data'), () => { - assert.ok(true, 'request made to correct endpoint on delete latest version.'); - return new Response(204); + this.server.get(this.subkeysUrl, (schema, { queryParams }) => { + assert.true(true, `GET request made to ${this.subkeysUrl}`); + assert.propEqual(queryParams, expectedQuery, `queryParams contain: ${JSON.stringify(queryParams)}`); + return EXAMPLE_KV_SUBKEYS_RESPONSE; + }); + + this.adapter.fetchSubkeys({ backend: this.backend, path: this.path }); }); - this.store.pushPayload('kv/data', { - modelName: 'kv/data', - id: this.id, - ...this.payload, - }); - let record = await this.store.peekRecord('kv/data', this.id); - await record.destroyRecord({ adapterOptions: { deleteType: 'delete-latest-version' } }); - assert.true(record.isDeleted, 'record is deleted'); - record = await this.store.peekRecord('kv/data', this.id); - assert.strictEqual(record, null, 'record is no longer in store'); - }); + test('it should make request with version query', async function (assert) { + assert.expect(1); + const expectedQuery = { depth: '0', version: '2' }; + this.server.get(this.subkeysUrl, (schema, { queryParams }) => { + assert.propEqual(queryParams, expectedQuery, `queryParams contain: ${JSON.stringify(queryParams)}`); + return EXAMPLE_KV_SUBKEYS_RESPONSE; + }); - test('it should make request to correct endpoint on delete specific versions', async function (assert) { - assert.expect(4); - this.server.post(this.endpoint('delete'), (schema, req) => { - const { versions } = JSON.parse(req.requestBody); - assert.strictEqual(versions, 2, 'version array is sent in the payload.'); - assert.ok(true, 'request made to correct endpoint on delete specific version.'); + this.adapter.fetchSubkeys({ backend: this.backend, path: this.path, version: '2' }); }); - this.store.pushPayload('kv/data', { - modelName: 'kv/data', - id: this.id, - ...this.payload, - }); - let record = await this.store.peekRecord('kv/data', this.id); - await record.destroyRecord({ - adapterOptions: { deleteType: 'delete-version', deleteVersions: 2 }, - }); - assert.true(record.isDeleted, 'record is deleted'); - record = await this.store.peekRecord('kv/data', this.id); - assert.strictEqual(record, null, 'record is no longer in store'); - }); + test('it should make request with just depth query', async function (assert) { + assert.expect(1); + const expectedQuery = { depth: '1' }; + this.server.get(this.subkeysUrl, (schema, { queryParams }) => { + assert.propEqual(queryParams, expectedQuery, `queryParams contain: ${JSON.stringify(queryParams)}`); + return EXAMPLE_KV_SUBKEYS_RESPONSE; + }); - test('it should make request to correct endpoint on undelete', async function (assert) { - assert.expect(4); - this.server.post(`${this.backend}/undelete/${this.path}`, (schema, req) => { - const { versions } = JSON.parse(req.requestBody); - assert.strictEqual(versions, 2, 'version array is sent in the payload.'); - assert.ok(true, 'request made to correct endpoint on undelete specific version.'); + this.adapter.fetchSubkeys({ backend: this.backend, path: this.path, depth: '1' }); }); - - this.store.pushPayload('kv/data', { - modelName: 'kv/data', - id: this.id, - ...this.payload, - }); - let record = await this.store.peekRecord('kv/data', this.id); - - await record.destroyRecord({ - adapterOptions: { deleteType: 'undelete', deleteVersions: 2 }, - }); - assert.true(record.isDeleted, 'record is deleted'); - record = await this.store.peekRecord('kv/data', this.id); - assert.strictEqual(record, null, 'record is no longer in store'); - }); - - test('it should make request to correct endpoint on destroy specific versions', async function (assert) { - assert.expect(4); - this.server.put(`${encodePath(this.backend)}/destroy/${encodePath(this.path)}`, (schema, req) => { - const { versions } = JSON.parse(req.requestBody); - assert.strictEqual(versions, 2, 'version array is sent in the payload.'); - assert.ok(true, 'request made to correct endpoint on destroy specific version.'); - }); - - this.store.pushPayload('kv/data', { - modelName: 'kv/data', - id: this.id, - ...this.payload, - }); - let record = await this.store.peekRecord('kv/data', this.id); - await record.destroyRecord({ - adapterOptions: { deleteType: 'destroy', deleteVersions: 2 }, - }); - assert.true(record.isDeleted, 'record is deleted'); - record = await this.store.peekRecord('kv/data', this.id); - assert.strictEqual(record, null, 'record is no longer in store'); }); }); diff --git a/ui/tests/unit/utils/kv-path-test.js b/ui/tests/unit/utils/kv-path-test.js index b40d0b7b2e..4dfbb2bdfd 100644 --- a/ui/tests/unit/utils/kv-path-test.js +++ b/ui/tests/unit/utils/kv-path-test.js @@ -3,7 +3,14 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { buildKvPath, kvDataPath, kvDestroyPath, kvMetadataPath, kvUndeletePath } from 'vault/utils/kv-path'; +import { + buildKvPath, + kvDataPath, + kvDestroyPath, + kvMetadataPath, + kvUndeletePath, + kvSubkeysPath, +} from 'vault/utils/kv-path'; import { module, test } from 'qunit'; module('Unit | Utility | kv-path utils', function () { @@ -110,4 +117,38 @@ module('Unit | Utility | kv-path utils', function () { }); }); }); + + module('kvSubkeysPath', function () { + [ + { + backend: 'some/back end', + path: 'my/secret/path', + expected: 'some/back%20end/subkeys/my/secret/path?depth=0', + }, + { + backend: 'some/back end', + path: 'my/secret/path', + version: 3, + expected: 'some/back%20end/subkeys/my/secret/path?depth=0&version=3', + }, + { + backend: 'some/back end', + path: 'my/secret/path', + depth: 4, + version: 3, + expected: 'some/back%20end/subkeys/my/secret/path?depth=4&version=3', + }, + { + backend: 'some/back end', + path: 'my/secret/path', + depth: 4, + expected: 'some/back%20end/subkeys/my/secret/path?depth=4', + }, + ].forEach((t, idx) => { + test(`kvSubkeysPath ${idx}`, function (assert) { + const result = kvSubkeysPath(t.backend, t.path, t.depth, t.version); + assert.strictEqual(result, t.expected); + }); + }); + }); });