From fe18e6ca8793edf1a7b8ba63b1ad7fe520f28bcf Mon Sep 17 00:00:00 2001
From: claire bontempo <68122737+hellobontempo@users.noreply.github.com>
Date: Mon, 29 Jul 2024 13:12:40 -0700
Subject: [PATCH] UI: add subkey request to kv v2 adapter (#27804)
* add subkey request to ui
* WIP kv subkey display
* revert subkey changes to see view in ui
* finish subkey component
* remove reamining user facing changes
* update jsdoc
* add subtext depending on toggle
* finish tests
* organize adapter tests into modules
* add adapter tests
* woops, make beforeEach
* encode paths and add wrap secret test
* reword subkey component
* extract subkey path logic into util
* extract subkey path logic into util
* rename yielded subtext block
---
ui/app/adapters/kv/data.js | 17 +-
ui/app/models/kv/data.js | 4 +
ui/app/styles/helper-classes/spacing.scss | 15 +-
ui/app/utils/kv-path.ts | 15 +
.../core/addon/components/overview-card.hbs | 16 +-
ui/lib/kv/addon/components/kv-subkeys.hbs | 48 ++
ui/lib/kv/addon/components/kv-subkeys.js | 41 ++
ui/tests/helpers/kv/kv-selectors.js | 1 +
.../components/kv/kv-subkeys-test.js | 57 ++
.../kv/page/kv-page-secret-details-test.js | 34 ++
.../components/overview-card-test.js | 21 +-
ui/tests/unit/adapters/kv/data-test.js | 523 ++++++++++--------
ui/tests/unit/utils/kv-path-test.js | 43 +-
13 files changed, 588 insertions(+), 247 deletions(-)
create mode 100644 ui/lib/kv/addon/components/kv-subkeys.hbs
create mode 100644 ui/lib/kv/addon/components/kv-subkeys.js
create mode 100644 ui/tests/integration/components/kv/kv-subkeys-test.js
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>
+
+
+ <: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);
+ });
+ });
+ });
});