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
This commit is contained in:
claire bontempo 2024-07-29 13:12:40 -07:00 committed by GitHub
parent 7d093f4e11
commit fe18e6ca87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 588 additions and 247 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -10,7 +10,7 @@
data-test-overview-card-container={{@cardTitle}}
...attributes
>
<div class="flex row-wrap space-between has-bottom-margin-m" data-test-overview-card={{@cardTitle}}>
<div class="flex row-wrap space-between has-bottom-margin-s" data-test-overview-card={{@cardTitle}}>
<Hds::Text::Display @weight="bold" @size="300" data-test-overview-card-title={{@cardTitle}}>
{{@cardTitle}}
</Hds::Text::Display>
@ -20,9 +20,17 @@
{{/if}}
</div>
<Hds::Text::Body @color="faint" data-test-overview-card-subtitle={{@cardTitle}}>
{{@subText}}
</Hds::Text::Body>
{{! Pass @subText for text only content to use default styling. }}
{{#if @subText}}
<Hds::Text::Body @color="faint" data-test-overview-card-subtitle={{@cardTitle}}>
{{@subText}}
</Hds::Text::Body>
{{/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"}}

View file

@ -0,0 +1,48 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<OverviewCard @cardTitle="Subkeys">
<:customSubtext>
<Hds::Text::Body @color="faint" data-test-overview-card-subtitle="Subkeys">
{{#if this.showJson}}
These are the subkeys within this secret. All underlying values of leaf keys are not retrieved and are replaced with
<code>null</code>
instead. Subkey
<Hds::Link::Inline
@icon="docs-link"
@iconPosition="trailing"
@href={{doc-link "/vault/api-docs/secret/kv/kv-v2#read-secret-subkeys"}}
>API documentation</Hds::Link::Inline>.
{{else}}
The table is displaying the top level subkeys. Toggle on the JSON view to see the full depth.
{{/if}}
</Hds::Text::Body>
</:customSubtext>
<:action>
<div>
<Toggle @name="kv-subkeys" @checked={{this.showJson}} @onChange={{fn (mut this.showJson)}}>
<p class="has-text-grey">JSON</p>
</Toggle>
</div>
</:action>
<:content>
<div class="has-top-margin-s" data-test-overview-card-content="Subkeys">
{{#if this.showJson}}
<Hds::CodeBlock @value={{stringify @subkeys}} @hasLineNumbers={{false}} />
{{else}}
<Hds::Text::Display @tag="p" @size="200" @weight="semibold" @color="faint" class="has-bottom-margin-s">
Keys
</Hds::Text::Display>
<hr class="has-background-gray-100 is-marginless" />
{{#each-in @subkeys as |key|}}
<Hds::Text::Display @tag="p" @size="200" @weight="semibold" class="has-top-bottom-margin-12">
{{key}}
</Hds::Text::Display>
<hr class="has-background-gray-100 is-marginless" />
{{/each-in}}
{{/if}}
</div>
</:content>
</OverviewCard>

View file

@ -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
* <KvSubkeys @subkeys={{this.subkeys}} />
*
* @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;
}

View file

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

View file

@ -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`<KvSubkeys @subkeys={{this.subkeys}} />`, {
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));
});
});

View file

@ -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`
<Page::Secret::Details
@path={{this.model.path}}
@secret={{this.model.secret}}
@metadata={{this.model.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ 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', {

View file

@ -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`<OverviewCard @cardTitle={{this.cardTitle}}/>`);
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`<OverviewCard @cardTitle={{this.cardTitle}} @subText={{this.subText}} />`);
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`
<OverviewCard @cardTitle={{this.cardTitle}}>
@ -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`
<OverviewCard @cardTitle={{this.cardTitle}}>
<:customSubtext>
<div data-test-custom-subtext>
Fancy yielded subtext
</div>
</:customSubtext>
</OverviewCard>
`
);
assert.dom(SELECTORS.customSubtext).hasText('Fancy yielded subtext');
});
});

View file

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

View file

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