UI: Implement KV patch+subkey [enterprise] (#28212)

* UI: Implement overview page for KV v2 (#28162)

* build json editor patch form

* finish patch component and tests

* add tab to each route

* and path route

* add overview tab to tests

* update overview to use updated_time instead of created_time

* redirect relevant secret.details to secret.index

* compute secretState in component instead of pass as arg

* add capabilities service

* add error handling to fetchSubkeys adapter request

* add overview tabs to test

* add subtext to overview card

* remaining redirects in secret edit

* remove create new version from popup menu

* fix breadcrumbs for overview

* separate adding capabilities service

* add service to kv engine

* Revert "separate adding capabilities service"

This reverts commit bb70b12ab7dbcde0fbd2d4d81768e5c8b1c420cc.

* Revert "add service to kv engine"

This reverts commit bfa880535ef7d529d7610936b2c1aae55673d23f.

* update navigation test

* consistently navigate to secret.index route to be explicit

* finish overview navigation tests

* add copyright header

* update delete tests

* fix nav testrs

* cleanup secret edit redirects

* remove redundant async/awaits

* fix create test

* edge case tests

* secret acceptance tests

* final component tests

* rename kvSecretDetails external route to kvSecretOverview

* add comment

* UI: Add patch route and implement Page::Secret::Patch page component (sidebranch) (#28192)

* add tab to each route

* and path route

* add overview tab to tests

* update overview to use updated_time instead of created_time

* redirect relevant secret.details to secret.index

* compute secretState in component instead of pass as arg

* add capabilities service

* add error handling to fetchSubkeys adapter request

* add patch route and put in page component

* add patch secret action to subkeys card

* fix component name

* add patch capability

* alphabetize computed capabilities

* update links, cleanup selectors

* fix more merge conflict stuff

* add capabilities test

* add models to patch link

* add test for patch route

* rename external route

* add error templates

* make notes about enterprise tests, filter one

* remove errors, transition (redirect) instead

* redirect patch routes

* UI: Move fetching secret data to child route (#28198)

* remove @secret from metadata details

* use metadata model instead of secret in paths page

* put delete back into kv/data adapter

* grant access in control group test

* update metadata route and permissions

* remove secret from parent route, only fetch in details route

* change more permissions to route perms, add tests

* revert overview redirect from list view

* wrap model in conditional for perms

* remove redundant canReadCustomMetadata check

* rename adapter method

* handle overview 404

* remove comment

* add customMetadata as an arg

* update grantAccess in test

* make version param easier to follow

* VAULT-30494 handle 404 jira

* refactor capabilities to return an object

* update create tests

* add test for default truthy capabilities

* remove destroy-all-versions from kv/data adapter

* UI: Add enterprise checks (#28215)

* add enterprise check for subkey card

* add max height and scroll to subkey card

* only fetch subkeys if enterprise

* remove check in overview

* add test

* Update ui/tests/integration/components/kv/page/kv-page-overview-test.js

* fix test failures (#28222)

* add assertion

* add optional chaining

* create/delete versioned secret in each module

* wait for transition

* add another waitUntil

* UI: Add patch latest version to toolbar (#28223)

* add patch latest version action to toolbar

* make isPatchAllowed arg all encompassing

* no longer need model check

* use hash so both promises fire at the same time

* add subkeys to policy

* Update ui/lib/kv/addon/routes/secret.js

* add changelog

* small cleanup items! (#28229)

* add conditional for enterprise checking tabs

* cleanup fetchMultiplePaths method

* add test

* remove todo comment, ticket created and design wants to hold off

* keep transition, update comments

* cleanup tests, add index to breadcrumbs

* add some test coverage

* toggle so value is readable
This commit is contained in:
claire bontempo
2024-08-29 16:38:39 -07:00
committed by GitHub
parent 4de1c697a2
commit f634808ed4
61 changed files with 1315 additions and 510 deletions

3
changelog/28212.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:feature
**KV v2 Patch/Subkey (enterprise)**: Adds GUI support to read the subkeys of a KV v2 secret and patch (partially update) secret data.
```

View File

@@ -4,14 +4,7 @@
*/ */
import ApplicationAdapter from '../application'; import ApplicationAdapter from '../application';
import { import { kvDataPath, kvDeletePath, kvDestroyPath, kvSubkeysPath, kvUndeletePath } from 'vault/utils/kv-path';
kvDataPath,
kvDeletePath,
kvDestroyPath,
kvMetadataPath,
kvSubkeysPath,
kvUndeletePath,
} from 'vault/utils/kv-path';
import { assert } from '@ember/debug'; import { assert } from '@ember/debug';
import ControlGroupError from 'vault/lib/control-group-error'; import ControlGroupError from 'vault/lib/control-group-error';
@@ -40,9 +33,15 @@ export default class KvDataAdapter extends ApplicationAdapter {
fetchSubkeys(backend, path, query) { fetchSubkeys(backend, path, query) {
const url = this._url(kvSubkeysPath(backend, path, query)); const url = this._url(kvSubkeysPath(backend, path, query));
// TODO subkeys response handles deleted records the same as queryRecord and returns a 404 return (
// extrapolate error handling logic from queryRecord and share between these two methods this.ajax(url, 'GET')
return this.ajax(url, 'GET').then((resp) => resp.data); .then((resp) => resp.data)
// deleted/destroyed secret versions throw an error
// but still have metadata that we want to return
.catch((errorOrResponse) => {
return this.parseErrorOrResponse(errorOrResponse, { backend, path }, true);
})
);
} }
fetchWrapInfo(query) { fetchWrapInfo(query) {
@@ -85,39 +84,7 @@ export default class KvDataAdapter extends ApplicationAdapter {
}; };
}) })
.catch((errorOrResponse) => { .catch((errorOrResponse) => {
const baseResponse = { id, backend, path, version }; return this.parseErrorOrResponse(errorOrResponse, { id, backend, path, version });
const errorCode = errorOrResponse.httpStatus;
// if it's a legitimate error - throw it!
if (errorOrResponse instanceof ControlGroupError) {
throw errorOrResponse;
}
if (errorCode === 403) {
return {
data: {
...baseResponse,
fail_read_error_code: errorCode,
},
};
}
if (errorOrResponse.data) {
// in the case of a deleted/destroyed secret the API returns a 404 because { data: null }
// however, there could be a metadata block with important information like deletion_time
// handleResponse below checks 404 status codes for metadata and updates the code to 200 if it exists.
// we still end up in the good ol' catch() block, but instead of a 404 adapter error we've "caught"
// the metadata that sneakily tried to hide from us
return {
...errorOrResponse,
data: {
...baseResponse,
...errorOrResponse.data, // includes the { metadata } key we want
},
};
}
// If we get here, it's probably a 404 because it doesn't exist
throw errorOrResponse;
}); });
} }
@@ -145,12 +112,8 @@ export default class KvDataAdapter extends ApplicationAdapter {
return this.ajax(this._url(kvUndeletePath(backend, path)), 'POST', { return this.ajax(this._url(kvUndeletePath(backend, path)), 'POST', {
data: { versions: deleteVersions }, data: { versions: deleteVersions },
}); });
case 'destroy-all-versions':
return this.ajax(this._url(kvMetadataPath(backend, path)), 'DELETE');
default: default:
assert( assert('deleteType must be one of delete-latest-version, delete-version, destroy, or undelete.');
'deleteType must be one of delete-latest-version, delete-version, destroy, undelete, or destroy-all-versions.'
);
} }
} }
@@ -162,4 +125,42 @@ export default class KvDataAdapter extends ApplicationAdapter {
} }
return super.handleResponse(...arguments); return super.handleResponse(...arguments);
} }
parseErrorOrResponse(errorOrResponse, secretDataBaseResponse, isSubkeys = false) {
// if it's a legitimate error - throw it!
if (errorOrResponse instanceof ControlGroupError) {
throw errorOrResponse;
}
const errorCode = errorOrResponse.httpStatus;
if (errorCode === 403) {
return {
data: {
...secretDataBaseResponse,
fail_read_error_code: errorCode,
},
};
}
// in the case of a deleted/destroyed secret the API returns a 404 because { data: null }
// however, there could be a metadata block with important information like deletion_time
// handleResponse below checks 404 status codes for metadata and updates the code to 200 if it exists.
// we still end up in the good ol' catch() block, but instead of a 404 adapter error we've "caught"
// the metadata that sneakily tried to hide from us
if (errorOrResponse.data) {
// subkeys response doesn't correspond to a model, no need to include base response
if (isSubkeys) return errorOrResponse.data;
return {
...errorOrResponse,
data: {
...secretDataBaseResponse,
...errorOrResponse.data, // includes the { metadata } key we want
},
};
}
// If we get here, it's probably a 404 because it doesn't exist
throw errorOrResponse;
}
} }

View File

@@ -68,11 +68,17 @@ export default class KvMetadataAdapter extends ApplicationAdapter {
}); });
} }
// This method is only called when deleting from the LIST view. Otherwise, delete on kv/data // This method is called when deleting from the list or metadata details view.
// Otherwise, delete happens in kv/data adapter
deleteRecord(store, type, snapshot) { deleteRecord(store, type, snapshot) {
const { backend, path, fullSecretPath } = snapshot.record; const { backend, path, fullSecretPath } = snapshot.record;
// fullSecretPath is used when deleting from the LIST view and is defined via the serializer // fullSecretPath is used when deleting from the LIST view and is defined via the serializer
// path is used when deleting from the metadata details view. // path is used when deleting from the metadata details view.
return this.ajax(this._url(kvMetadataPath(backend, fullSecretPath || path)), 'DELETE'); return this.ajax(this._url(kvMetadataPath(backend, fullSecretPath || path)), 'DELETE');
} }
// custom method used if users do not have "read" permissions to fetch record
deleteMetadata(backend, path) {
return this.ajax(this._url(kvMetadataPath(backend, path)), 'DELETE');
}
} }

View File

@@ -78,13 +78,15 @@ export default class App extends Application {
kv: { kv: {
dependencies: { dependencies: {
services: [ services: [
'capabilities',
'control-group',
'download', 'download',
'flash-messages',
'namespace', 'namespace',
'router', 'router',
'store',
'secret-mount-path', 'secret-mount-path',
'flash-messages', 'store',
'control-group', 'version',
], ],
externalRoutes: { externalRoutes: {
secrets: 'vault.cluster.secrets.backends', secrets: 'vault.cluster.secrets.backends',
@@ -116,7 +118,7 @@ export default class App extends Application {
dependencies: { dependencies: {
services: ['flash-messages', 'flags', 'router', 'store', 'version'], services: ['flash-messages', 'flags', 'router', 'store', 'version'],
externalRoutes: { externalRoutes: {
kvSecretDetails: 'vault.cluster.secrets.backend.kv.secret.details', kvSecretOverview: 'vault.cluster.secrets.backend.kv.secret.index',
clientCountOverview: 'vault.cluster.clients', clientCountOverview: 'vault.cluster.clients',
}, },
}, },

View File

@@ -49,7 +49,7 @@ export default class DashboardQuickActionsCard extends Component {
subText: 'Path of the secret you want to read.', subText: 'Path of the secret you want to read.',
buttonText: 'Read secrets', buttonText: 'Read secrets',
model: 'kv/metadata', model: 'kv/metadata',
route: 'vault.cluster.secrets.backend.kv.secret.details', route: 'vault.cluster.secrets.backend.kv.secret.index',
nameKey: 'path', nameKey: 'path',
queryObject: { pathToSecret: '', backend: this.selectedEngine.id }, queryObject: { pathToSecret: '', backend: this.selectedEngine.id },
objectKeys: ['path', 'id'], objectKeys: ['path', 'id'],
@@ -149,7 +149,7 @@ export default class DashboardQuickActionsCard extends Component {
const path = this.paramValue.path || this.paramValue; const path = this.paramValue.path || this.paramValue;
route = pathIsDirectory(path) route = pathIsDirectory(path)
? 'vault.cluster.secrets.backend.kv.list-directory' ? 'vault.cluster.secrets.backend.kv.list-directory'
: 'vault.cluster.secrets.backend.kv.secret.details'; : 'vault.cluster.secrets.backend.kv.secret.index';
param = path; param = path;
} }

View File

@@ -47,12 +47,13 @@ const computedCapability = function (capability) {
export default Model.extend({ export default Model.extend({
path: attr('string'), path: attr('string'),
capabilities: attr('array'), capabilities: attr('array'),
canSudo: computedCapability('sudo'),
canRead: computedCapability('read'),
canCreate: computedCapability('create'),
canUpdate: computedCapability('update'),
canDelete: computedCapability('delete'),
canList: computedCapability('list'),
allowedParameters: attr(), allowedParameters: attr(),
deniedParameters: attr(), deniedParameters: attr(),
canCreate: computedCapability('create'),
canDelete: computedCapability('delete'),
canList: computedCapability('list'),
canPatch: computedCapability('patch'),
canRead: computedCapability('read'),
canSudo: computedCapability('sudo'),
canUpdate: computedCapability('update'),
}); });

View File

@@ -81,17 +81,27 @@ export default Route.extend({
const mode = this.routeName.split('.').pop(); const mode = this.routeName.split('.').pop();
// for kv v2, redirect users from the old url to the new engine url (1.15.0 +) // for kv v2, redirect users from the old url to the new engine url (1.15.0 +)
if (secretEngine.type === 'kv' && secretEngine.version === 2) { if (secretEngine.type === 'kv' && secretEngine.version === 2) {
// if no secret param redirect to the create route let route, params;
// if secret param they are either viewing or editing secret so navigate to the details route switch (true) {
if (!secret) { case !secret:
this.router.transitionTo('vault.cluster.secrets.backend.kv.create', secretEngine.id); // if no secret param redirect to the create route
} else { route = 'vault.cluster.secrets.backend.kv.create';
this.router.transitionTo( params = [secretEngine.id];
'vault.cluster.secrets.backend.kv.secret.details', break;
secretEngine.id, case this.routeName === 'vault.cluster.secrets.backend.show':
secret route = 'vault.cluster.secrets.backend.kv.secret.index';
); params = [secretEngine.id, secret];
break;
case this.routeName === 'vault.cluster.secrets.backend.edit':
route = 'vault.cluster.secrets.backend.kv.secret.details.edit';
params = [secretEngine.id, secret];
break;
default:
route = 'vault.cluster.secrets.backend.kv.secret.index';
params = [secretEngine.id, secret];
break;
} }
this.router.transitionTo(route, ...params);
return; return;
} }
if (mode === 'edit' && keyIsFolder(secret)) { if (mode === 'edit' && keyIsFolder(secret)) {

View File

@@ -10,15 +10,24 @@ import type AdapterError from '@ember-data/adapter/error';
import type CapabilitiesModel from 'vault/vault/models/capabilities'; import type CapabilitiesModel from 'vault/vault/models/capabilities';
import type StoreService from 'vault/services/store'; import type StoreService from 'vault/services/store';
interface Query { interface Capabilities {
paths?: string[]; canCreate: boolean;
path?: string; canDelete: boolean;
canList: boolean;
canPatch: boolean;
canRead: boolean;
canSudo: boolean;
canUpdate: boolean;
}
interface MultipleCapabilities {
[key: string]: Capabilities;
} }
export default class CapabilitiesService extends Service { export default class CapabilitiesService extends Service {
@service declare readonly store: StoreService; @service declare readonly store: StoreService;
async request(query: Query) { async request(query: { paths?: string[]; path?: string }) {
if (query?.paths) { if (query?.paths) {
const { paths } = query; const { paths } = query;
return this.store.query('capabilities', { paths }); return this.store.query('capabilities', { paths });
@@ -31,23 +40,40 @@ export default class CapabilitiesService extends Service {
return assert('query object must contain "paths" or "path" key', false); return assert('query object must contain "paths" or "path" key', false);
} }
/* async fetchMultiplePaths(paths: string[]): MultipleCapabilities | AdapterError {
this method returns a capabilities model for each path in the array of paths // if the request to capabilities-self fails, silently catch
*/ // all of path capabilities default to "true"
async fetchMultiplePaths(paths: string[]): Promise<Array<CapabilitiesModel>> | AdapterError { const resp: Array<CapabilitiesModel> | [] = await this.request({ paths }).catch(() => []);
try {
return await this.request({ paths }); return paths.reduce((obj: MultipleCapabilities, apiPath: string) => {
} catch (e) { // path is the model's primaryKey (id)
return e; const model: CapabilitiesModel | undefined = resp.find((m) => m.path === apiPath);
} if (model) {
const { canCreate, canDelete, canList, canPatch, canRead, canSudo, canUpdate } = model;
obj[apiPath] = { canCreate, canDelete, canList, canPatch, canRead, canSudo, canUpdate };
} else {
// default to true if there is a problem fetching the model
// since we can rely on the API to gate as a fallback
obj[apiPath] = {
canCreate: true,
canDelete: true,
canList: true,
canPatch: true,
canRead: true,
canSudo: true,
canUpdate: true,
};
}
return obj;
}, {});
} }
/* /*
this method returns all of the capabilities for a singular path this method returns all of the capabilities for a singular path
*/ */
async fetchPathCapabilities(path: string): Promise<CapabilitiesModel> | AdapterError { fetchPathCapabilities(path: string): Promise<CapabilitiesModel> | AdapterError {
try { try {
return await this.request({ path }); return this.request({ path });
} catch (error) { } catch (error) {
return error; return error;
} }
@@ -69,17 +95,25 @@ export default class CapabilitiesService extends Service {
} }
} }
async canRead(path: string) { canRead(path: string) {
try { try {
return await this._fetchSpecificCapability(path, 'canRead'); return this._fetchSpecificCapability(path, 'canRead');
} catch (e) { } catch (e) {
return e; return e;
} }
} }
async canUpdate(path: string) { canUpdate(path: string) {
try { try {
return await this._fetchSpecificCapability(path, 'canUpdate'); return this._fetchSpecificCapability(path, 'canUpdate');
} catch (e) {
return e;
}
}
canPatch(path: string) {
try {
return this._fetchSpecificCapability(path, 'canPatch');
} catch (e) { } catch (e) {
return e; return e;
} }

View File

@@ -58,6 +58,10 @@
padding-top: $spacing-8; padding-top: $spacing-8;
} }
.top-padding-4 {
padding-top: $spacing-4;
}
.has-top-padding-s { .has-top-padding-s {
padding-top: $spacing-12; padding-top: $spacing-12;
} }

View File

@@ -20,7 +20,7 @@ import { assert } from '@ember/debug';
* *
* @param {string} mode - delete, delete-metadata, or destroy. * @param {string} mode - delete, delete-metadata, or destroy.
* @param {object} secret - The kv/data model. * @param {object} secret - The kv/data model.
* @param {object} [metadata] - The kv/metadata model. It is only required when mode is "delete" or "metadata-delete". * @param {object} [metadata] - The kv/metadata model. It is only required when mode is "delete".
* @param {string} [text] - Button text that renders in KV v2 toolbar, defaults to capitalize @mode * @param {string} [text] - Button text that renders in KV v2 toolbar, defaults to capitalize @mode
* @param {callback} onDelete - callback function fired to handle delete event. * @param {callback} onDelete - callback function fired to handle delete event.
*/ */

View File

@@ -12,7 +12,7 @@
@message="JSON is unparsable. Fix linting errors to avoid data discrepancies." @message="JSON is unparsable. Fix linting errors to avoid data discrepancies."
/> />
{{/if}} {{/if}}
<hr class="has-background-gray-200" />
<KvPatch::SubkeysReveal @subkeys={{@subkeys}} /> <KvPatch::SubkeysReveal @subkeys={{@subkeys}} />
<hr class="has-background-gray-200" /> <hr class="has-background-gray-200" />
<Hds::ButtonSet> <Hds::ButtonSet>

View File

@@ -3,7 +3,7 @@
SPDX-License-Identifier: BUSL-1.1 SPDX-License-Identifier: BUSL-1.1
~}} ~}}
<OverviewCard @cardTitle="Subkeys" class="has-top-margin-l"> <OverviewCard @cardTitle="Subkeys" class="has-top-margin-l" {{style max-height="325px" overflow-y="auto"}}>
<:customSubtext> <:customSubtext>
<Hds::Text::Body @tag="p" @color="faint" data-test-overview-card-subtitle="Subkeys"> <Hds::Text::Body @tag="p" @color="faint" data-test-overview-card-subtitle="Subkeys">
{{#if this.showJson}} {{#if this.showJson}}
@@ -14,6 +14,7 @@
@icon="docs-link" @icon="docs-link"
@iconPosition="trailing" @iconPosition="trailing"
@href={{doc-link "/vault/api-docs/secret/kv/kv-v2#read-secret-subkeys"}} @href={{doc-link "/vault/api-docs/secret/kv/kv-v2#read-secret-subkeys"}}
@isHrefExternal={{true}}
>API documentation</Hds::Link::Inline>. >API documentation</Hds::Link::Inline>.
{{else}} {{else}}
The table is displaying the top level subkeys. Toggle on the JSON view to see the full depth. The table is displaying the top level subkeys. Toggle on the JSON view to see the full depth.
@@ -21,10 +22,28 @@
</Hds::Text::Body> </Hds::Text::Body>
</:customSubtext> </:customSubtext>
<:action> <:action>
<div> <div class="flex column-gap-16">
<Toggle @name="kv-subkeys" @checked={{this.showJson}} @onChange={{fn (mut this.showJson)}}> <div class="top-padding-4">
<p class="has-text-grey">JSON</p> <Hds::Form::Toggle::Field
</Toggle> checked={{this.showJson}}
{{on "change" this.toggleJson}}
data-test-toggle-input="kv-subkeys"
as |F|
>
<F.Label>JSON</F.Label>
</Hds::Form::Toggle::Field>
</div>
{{#if @isPatchAllowed}}
<Hds::Link::Standalone
@text="Patch secret"
@route="secret.patch"
@models={{array @backend @path}}
@icon="arrow-right"
@iconPosition="trailing"
@isFullWidth={{true}}
data-test-action-text="Patch secret"
/>
{{/if}}
</div> </div>
</:action> </:action>
<:content> <:content>

View File

@@ -4,6 +4,7 @@
*/ */
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
/** /**
@@ -38,4 +39,9 @@ sample subkeys:
export default class KvSubkeysCard extends Component { export default class KvSubkeysCard extends Component {
@tracked showJson = false; @tracked showJson = false;
@action
toggleJson(event) {
this.showJson = event.target.checked;
}
} }

View File

@@ -58,7 +58,7 @@
@color="secondary" @color="secondary"
type="submit" type="submit"
disabled={{not this.secretPath}} disabled={{not this.secretPath}}
data-test-get-secret-detail data-test-submit-button
/> />
</form> </form>
{{#if @failedDirectoryQuery}} {{#if @failedDirectoryQuery}}
@@ -77,7 +77,7 @@
<LinkedBlock <LinkedBlock
data-test-list-item={{metadata.path}} data-test-list-item={{metadata.path}}
class="list-item-row" class="list-item-row"
@params={{array (if metadata.pathIsDirectory "list-directory" "secret.details") @backend metadata.fullSecretPath}} @params={{array (if metadata.pathIsDirectory "list-directory" "secret.index") @backend metadata.fullSecretPath}}
@linkPrefix={{this.mountPoint}} @linkPrefix={{this.mountPoint}}
> >
<div class="level is-mobile"> <div class="level is-mobile">
@@ -106,7 +106,12 @@
/> />
{{else}} {{else}}
<dd.Interactive <dd.Interactive
@text="Details" @text="Overview"
@route="secret.index"
@models={{array @backend metadata.fullSecretPath}}
/>
<dd.Interactive
@text="Secret data"
@route="secret.details" @route="secret.details"
@models={{array @backend metadata.fullSecretPath}} @models={{array @backend metadata.fullSecretPath}}
/> />
@@ -117,14 +122,6 @@
@models={{array @backend metadata.fullSecretPath}} @models={{array @backend metadata.fullSecretPath}}
/> />
{{/if}} {{/if}}
{{#if metadata.canCreateVersionData}}
<dd.Interactive
@text="Create new version"
@route="secret.details.edit"
@models={{array @backend metadata.fullSecretPath}}
data-test-popup-create-new-version
/>
{{/if}}
{{#if metadata.canDeleteMetadata}} {{#if metadata.canDeleteMetadata}}
<dd.Interactive <dd.Interactive
@text="Permanently delete" @text="Permanently delete"

View File

@@ -87,6 +87,6 @@ export default class KvListPageComponent extends Component {
evt.preventDefault(); evt.preventDefault();
pathIsDirectory(this.secretPath) pathIsDirectory(this.secretPath)
? this.router.transitionTo('vault.cluster.secrets.backend.kv.list-directory', this.secretPath) ? this.router.transitionTo('vault.cluster.secrets.backend.kv.list-directory', this.secretPath)
: this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details', this.secretPath); : this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index', this.secretPath);
} }
} }

View File

@@ -44,6 +44,13 @@
</:syncDetails> </:syncDetails>
<:tabLinks> <:tabLinks>
<li>
<LinkTo
@route="secret.index"
@models={{array @secret.backend @path}}
data-test-secrets-tab="Overview"
>Overview</LinkTo>
</li>
<li> <li>
<LinkTo @route="secret.details" @models={{array @secret.backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo> <LinkTo @route="secret.details" @models={{array @secret.backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo>
</li> </li>
@@ -112,6 +119,12 @@
{{#if @secret.canReadMetadata}} {{#if @secret.canReadMetadata}}
<KvVersionDropdown @displayVersion={{this.version}} @metadata={{@metadata}} @onClose={{this.closeVersionMenu}} /> <KvVersionDropdown @displayVersion={{this.version}} @metadata={{@metadata}} @onClose={{this.closeVersionMenu}} />
{{/if}} {{/if}}
{{! @isPatchAllowed is true if the version is enterprise AND a user has "patch" secret + "read" subkeys capabilities }}
{{#if @isPatchAllowed}}
<ToolbarLink data-test-patch-latest-version @route="secret.patch" @models={{array @secret.backend @path}} @type="add">
Patch latest version
</ToolbarLink>
{{/if}}
{{#if @secret.canEditData}} {{#if @secret.canEditData}}
<ToolbarLink <ToolbarLink
data-test-create-new-version data-test-create-new-version
@@ -156,6 +169,7 @@
@iconPosition="trailing" @iconPosition="trailing"
@text="KV v2 API docs" @text="KV v2 API docs"
@href={{doc-link this.emptyState.link}} @href={{doc-link this.emptyState.link}}
@isHrefExternal={{true}}
/> />
{{/if}} {{/if}}
</EmptyState> </EmptyState>

View File

@@ -95,7 +95,7 @@ export default class KvSecretDetails extends Component {
adapterOptions: { deleteType: 'undelete', deleteVersions: this.version }, adapterOptions: { deleteType: 'undelete', deleteVersions: this.version },
}); });
this.flashMessages.success(`Successfully undeleted ${secret.path}.`); this.flashMessages.success(`Successfully undeleted ${secret.path}.`);
this.refreshRoute(); this.transition();
} catch (err) { } catch (err) {
this.flashMessages.danger( this.flashMessages.danger(
`There was a problem undeleting ${secret.path}. Error: ${err.errors?.join(' ')}.` `There was a problem undeleting ${secret.path}. Error: ${err.errors?.join(' ')}.`
@@ -110,7 +110,7 @@ export default class KvSecretDetails extends Component {
await secret.destroyRecord({ adapterOptions: { deleteType: type, deleteVersions: this.version } }); await secret.destroyRecord({ adapterOptions: { deleteType: type, deleteVersions: this.version } });
const verb = type.includes('delete') ? 'deleted' : 'destroyed'; const verb = type.includes('delete') ? 'deleted' : 'destroyed';
this.flashMessages.success(`Successfully ${verb} Version ${this.version} of ${secret.path}.`); this.flashMessages.success(`Successfully ${verb} Version ${this.version} of ${secret.path}.`);
this.refreshRoute(); this.transition();
} catch (err) { } catch (err) {
const verb = type.includes('delete') ? 'deleting' : 'destroying'; const verb = type.includes('delete') ? 'deleting' : 'destroying';
this.flashMessages.danger( this.flashMessages.danger(
@@ -121,11 +121,9 @@ export default class KvSecretDetails extends Component {
} }
} }
refreshRoute() { transition() {
// transition to the parent secret route to refresh both metadata and data models // transition to the overview to prevent automatically reading sensitive secret data
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret', { this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
queryParams: { version: this.version },
});
} }
get version() { get version() {

View File

@@ -84,9 +84,7 @@ export default class KvSecretEdit extends Component {
const { secret } = this.args; const { secret } = this.args;
yield secret.save(); yield secret.save();
this.flashMessages.success(`Successfully created new version of ${secret.path}.`); this.flashMessages.success(`Successfully created new version of ${secret.path}.`);
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details', { this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
queryParams: { version: secret?.version },
});
} }
} catch (error) { } catch (error) {
let message = errorMessage(error); let message = errorMessage(error);
@@ -102,6 +100,6 @@ export default class KvSecretEdit extends Component {
@action @action
onCancel() { onCancel() {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details'); this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
} }
} }

View File

@@ -6,23 +6,26 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}> <KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:tabLinks> <:tabLinks>
<li> <li>
<LinkTo @route="secret.details" @models={{array @secret.backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo> <LinkTo @route="secret.index" @models={{array @backend @path}} data-test-secrets-tab="Overview">Overview</LinkTo>
</li>
<li>
<LinkTo @route="secret.details" @models={{array @backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo>
</li> </li>
<li> <li>
<LinkTo <LinkTo
@route="secret.metadata.index" @route="secret.metadata.index"
@models={{array @secret.backend @path}} @models={{array @backend @path}}
data-test-secrets-tab="Metadata" data-test-secrets-tab="Metadata"
>Metadata</LinkTo> >Metadata</LinkTo>
</li> </li>
<li> <li>
<LinkTo @route="secret.paths" @models={{array @secret.backend @path}} data-test-secrets-tab="Paths">Paths</LinkTo> <LinkTo @route="secret.paths" @models={{array @backend @path}} data-test-secrets-tab="Paths">Paths</LinkTo>
</li> </li>
{{#if @secret.canReadMetadata}} {{#if @canReadMetadata}}
<li> <li>
<LinkTo <LinkTo
@route="secret.metadata.versions" @route="secret.metadata.versions"
@models={{array @secret.backend @path}} @models={{array @backend @path}}
data-test-secrets-tab="Version History" data-test-secrets-tab="Version History"
>Version History</LinkTo> >Version History</LinkTo>
</li> </li>
@@ -30,16 +33,11 @@
</:tabLinks> </:tabLinks>
<:toolbarActions> <:toolbarActions>
{{#if @secret.canDeleteMetadata}} {{#if @canDeleteMetadata}}
<KvDeleteModal <KvDeleteModal @mode="delete-metadata" @onDelete={{this.onDelete}} @text="Permanently delete" />
@mode="delete-metadata"
@metadata={{@metadata}}
@onDelete={{this.onDelete}}
@text="Permanently delete"
/>
{{/if}} {{/if}}
{{#if @secret.canUpdateMetadata}} {{#if @canUpdateMetadata}}
<ToolbarLink @route="secret.metadata.edit" @models={{array @secret.backend @path}} data-test-edit-metadata> <ToolbarLink @route="secret.metadata.edit" @models={{array @backend @path}} data-test-edit-metadata>
Edit metadata Edit metadata
</ToolbarLink> </ToolbarLink>
{{/if}} {{/if}}
@@ -50,8 +48,9 @@
Custom metadata Custom metadata
</h2> </h2>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-custom-metadata-section> <div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-custom-metadata-section>
{{#if (or @metadata.canReadMetadata @secret.canReadData)}} {{! if the user had read permissions and there is no custom_metadata @customMetadata is an empty object, without read capabilities it's undefined }}
{{#each-in this.customMetadata as |key value|}} {{#if @customMetadata}}
{{#each-in @customMetadata as |key value|}}
<InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} /> <InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} />
{{else}} {{else}}
<EmptyState <EmptyState
@@ -59,12 +58,12 @@
@bottomBorder={{true}} @bottomBorder={{true}}
@message="This data is version-agnostic and is usually used to describe the secret being stored." @message="This data is version-agnostic and is usually used to describe the secret being stored."
> >
{{#if @secret.canUpdateMetadata}} {{#if @canUpdateMetadata}}
<Hds::Link::Standalone <Hds::Link::Standalone
@icon="plus" @icon="plus"
@text="Add metadata" @text="Add metadata"
@route="secret.metadata.edit" @route="secret.metadata.edit"
@models={{array @secret.backend @path}} @models={{array @backend @path}}
data-test-add-custom-metadata data-test-add-custom-metadata
/> />
{{/if}} {{/if}}
@@ -81,7 +80,7 @@
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l"> <h2 class="title is-5 has-bottom-padding-s has-top-margin-l">
Secret metadata Secret metadata
</h2> </h2>
{{#if @secret.canReadMetadata}} {{#if @canReadMetadata}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-metadata-section> <div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-metadata-section>
<InfoTableRow @alwaysRender={{true}} @label="Last updated"> <InfoTableRow @alwaysRender={{true}} @label="Last updated">
<KvTooltipTimestamp @timestamp={{@metadata.updatedTime}} /> <KvTooltipTimestamp @timestamp={{@metadata.updatedTime}} />

View File

@@ -6,21 +6,31 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { service } from '@ember/service'; import { service } from '@ember/service';
import errorMessage from 'vault/utils/error-message';
/** /**
* @module KvSecretMetadataDetails renders the details view for kv/metadata. * @module KvSecretMetadataDetails renders the details view for kv/metadata and button to delete (which deletes the whole secret) or edit metadata.
* It also renders a button to delete metadata.
* <Page::Secret::Metadata::Details * <Page::Secret::Metadata::Details
* @path={{this.model.path}} * @backend={{this.model.backend}}
* @secret={{this.model.secret}} * @breadcrumbs={{this.breadcrumbs}}
* @metadata={{this.model.metadata}} * @canDeleteMetadata={{this.model.permissions.metadata.canDelete}}
* @breadcrumbs={{this.breadcrumbs}} * @canReadMetadata={{this.model.permissions.metadata.canRead}}
/> * @canUpdateMetadata={{this.model.permissions.metadata.canUpdate}}
* @customMetadata={{or this.model.metadata.customMetadata this.model.secret.customMetadata}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* />
* *
* @param {string} path - path of kv secret 'my/secret' used as the title for the KV page header * @param {string} backend - The name of the kv secret engine.
* @param {model} [secret] - Ember data model: 'kv/data'. Param not required for delete-metadata.
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component * @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
* @param {boolean} canDeleteMetadata - if true, "Permanently delete" action renders in the toolbar
* @param {boolean} canReadMetadata - if true, secret metadata renders below custom_metadata
* @param {boolean} canUpdateMetadata - if true, "Edit" action renders in the toolbar
* @param {object} customMetadata - comes from secret metadata or data endpoint. if undefined, user does not have "read" access, if an empty object then there is none
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {string} path - path of kv secret 'my/secret' used as the title for the KV page header
*
*
*/ */
export default class KvSecretMetadataDetails extends Component { export default class KvSecretMetadataDetails extends Component {
@@ -28,25 +38,20 @@ export default class KvSecretMetadataDetails extends Component {
@service router; @service router;
@service store; @service store;
get customMetadata() {
// metadata tab is available even if user only has access to kv/data path
return this.args.metadata?.customMetadata || this.args.secret?.customMetadata;
}
@action @action
async onDelete() { async onDelete() {
// The only delete option from this view is delete all versions // The only delete option from this view is delete metadata and all versions
const { secret } = this.args; const { backend, path } = this.args;
const adapter = this.store.adapterFor('kv/metadata');
try { try {
await secret.destroyRecord({ await adapter.deleteMetadata(backend, path);
adapterOptions: { deleteType: 'destroy-all-versions', deleteVersions: this.version },
});
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated. this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
this.flashMessages.success( this.flashMessages.success(
`Successfully deleted the metadata and all version data for the secret ${secret.path}.` `Successfully deleted the metadata and all version data for the secret ${path}.`
); );
this.router.transitionTo('vault.cluster.secrets.backend.kv.list'); this.router.transitionTo('vault.cluster.secrets.backend.kv.list');
} catch (err) { } catch (err) {
this.flashMessages.danger(`There was an issue deleting ${secret.path} metadata.`); this.flashMessages.danger(`There was an issue deleting ${path} metadata. \n ${errorMessage(err)}`);
} }
} }
} }

View File

@@ -57,6 +57,7 @@
@iconPosition="trailing" @iconPosition="trailing"
@text="KV v2 metadata API docs" @text="KV v2 metadata API docs"
@href={{doc-link "/vault/api-docs/secret/kv/kv-v2#create-update-metadata"}} @href={{doc-link "/vault/api-docs/secret/kv/kv-v2#create-update-metadata"}}
@isHrefExternal={{true}}
/> />
</EmptyState> </EmptyState>
{{/if}} {{/if}}

View File

@@ -5,6 +5,9 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}> <KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:tabLinks> <:tabLinks>
<li>
<LinkTo @route="secret.index" @models={{array @backend @path}} data-test-secrets-tab="Overview">Overview</LinkTo>
</li>
<li> <li>
<LinkTo @route="secret.details" @models={{array @backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo> <LinkTo @route="secret.details" @models={{array @backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo>
</li> </li>

View File

@@ -31,22 +31,24 @@
</li> </li>
{{/if}} {{/if}}
</:tabLinks> </:tabLinks>
<:toolbarActions>
</:toolbarActions>
</KvPageHeader> </KvPageHeader>
{{#if (or @metadata @subkeys)}} {{#if (or @metadata @subkeys.metadata)}}
<div class="flex row-wrap gap-24 has-top-margin-l"> <div class="flex row-wrap gap-24 has-top-margin-l">
<OverviewCard @cardTitle="Current version" @subText={{this.versionSubtext}} class="is-flex-1"> <OverviewCard @cardTitle="Current version" @subText={{this.versionSubtext}} class="is-flex-1">
<:customTitle> <:customTitle>
<Hds::Text::Display @weight="semibold" @size="300"> <Hds::Text::Display @weight="semibold" @size="300">
Current version Current version
{{#unless this.isActive}} {{#if (not-eq this.secretState "created")}}
<Hds::Badge <Hds::Badge
@text={{capitalize @secretState}} @text={{capitalize this.secretState}}
@type={{if (eq @secretState "destroyed") "outlined" "inverted"}} @type={{if (eq this.secretState "destroyed") "outlined" "inverted"}}
@color={{if (eq @secretState "destroyed") "critical"}} @color={{if (eq this.secretState "destroyed") "critical"}}
@icon="x" @icon="x"
/> />
{{/unless}} {{/if}}
</Hds::Text::Display> </Hds::Text::Display>
</:customTitle> </:customTitle>
<:action> <:action>
@@ -57,6 +59,7 @@
@models={{array @backend @path}} @models={{array @backend @path}}
@icon="plus" @icon="plus"
@iconPosition="trailing" @iconPosition="trailing"
data-test-action-text="Create new"
/> />
{{/if}} {{/if}}
</:action> </:action>
@@ -67,11 +70,11 @@
</:content> </:content>
</OverviewCard> </OverviewCard>
{{#if this.isActive}} {{#if (eq this.secretState "created")}}
{{#let (or @metadata.createdTime @subkeys.metadata.created_time) as |timestamp|}} {{#let (or @metadata.updatedTime @subkeys.metadata.created_time) as |timestamp|}}
<OverviewCard <OverviewCard
@cardTitle="Secret age" @cardTitle="Secret age"
@subText="Time since last update at {{date-format timestamp}}." @subText="Current secret version age. Last updated on {{date-format timestamp}}."
class="is-flex-1" class="is-flex-1"
> >
<:action> <:action>
@@ -100,6 +103,7 @@
<KvPathsCard @backend={{@backend}} @path={{@path}} @isCondensed={{true}} /> <KvPathsCard @backend={{@backend}} @path={{@path}} @isCondensed={{true}} />
</Hds::Card::Container> </Hds::Card::Container>
{{! @subkeys is null for community edition or if a user does not have read permissions }}
{{#if @subkeys.subkeys}} {{#if @subkeys.subkeys}}
<KvSubkeysCard @subkeys={{@subkeys.subkeys}} /> <KvSubkeysCard @subkeys={{@subkeys.subkeys}} @isPatchAllowed={{@isPatchAllowed}} @backend={{@backend}} @path={{@path}} />
{{/if}} {{/if}}

View File

@@ -5,6 +5,7 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { dateFormat } from 'core/helpers/date-format'; import { dateFormat } from 'core/helpers/date-format';
import { isDeleted } from 'kv/utils/kv-deleted';
/** /**
* @module KvSecretOverview * @module KvSecretOverview
@@ -15,7 +16,6 @@ import { dateFormat } from 'core/helpers/date-format';
* @canUpdateSecret={{true}} * @canUpdateSecret={{true}}
* @metadata={{this.model.metadata}} * @metadata={{this.model.metadata}}
* @path={{this.model.path}} * @path={{this.model.path}}
* @secretState="created"
* @subkeys={{this.model.subkeys}} * @subkeys={{this.model.subkeys}}
* /> * />
* *
@@ -25,18 +25,28 @@ import { dateFormat } from 'core/helpers/date-format';
* @param {boolean} canUpdateSecret - permissions to create a new version of a secret * @param {boolean} canUpdateSecret - permissions to create a new version of a secret
* @param {model} metadata - Ember data model: 'kv/metadata' * @param {model} metadata - Ember data model: 'kv/metadata'
* @param {string} path - path to request secret data for selected version * @param {string} path - path to request secret data for selected version
* @param {string} secretState - if a secret has been "destroyed", "deleted" or "created" (still active) * @param {object} subkeys - API response from subkeys endpoint, object with "subkeys" and "metadata" keys. This arg is null for community edition
* @param {object} subkeys - API response from subkeys endpoint, object with "subkeys" and "metadata" keys
*/ */
export default class KvSecretOverview extends Component { export default class KvSecretOverview extends Component {
get isActive() { get secretState() {
const state = this.args.secretState; if (this.args.metadata) {
return state !== 'destroyed' && state !== 'deleted'; return this.args.metadata.currentSecret.state;
}
if (this.args.subkeys?.metadata) {
const { metadata } = this.args.subkeys;
const state = metadata.destroyed
? 'destroyed'
: isDeleted(metadata.deletion_time)
? 'deleted'
: 'created';
return state;
}
return 'created';
} }
get versionSubtext() { get versionSubtext() {
const state = this.args.secretState; const state = this.secretState;
if (state === 'destroyed') { if (state === 'destroyed') {
return 'The current version of this secret has been permanently deleted and cannot be restored.'; return 'The current version of this secret has been permanently deleted and cannot be restored.';
} }

View File

@@ -65,7 +65,7 @@ export default class KvSecretPatch extends Component {
try { try {
yield adapter.patchSecret(backend, path, patchData, version); yield adapter.patchSecret(backend, path, patchData, version);
this.flashMessages.success(`Successfully patched new version of ${path}.`); this.flashMessages.success(`Successfully patched new version of ${path}.`);
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret'); this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
} catch (error) { } catch (error) {
// TODO test...this is copy pasta'd from the edit page // TODO test...this is copy pasta'd from the edit page
let message = errorMessage(error); let message = errorMessage(error);
@@ -81,7 +81,7 @@ export default class KvSecretPatch extends Component {
@action @action
onCancel() { onCancel() {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret'); this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
} }
isEmpty(object) { isEmpty(object) {

View File

@@ -5,6 +5,9 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}> <KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:tabLinks> <:tabLinks>
<li>
<LinkTo @route="secret.index" @models={{array @backend @path}} data-test-secrets-tab="Overview">Overview</LinkTo>
</li>
<li> <li>
<LinkTo @route="secret.details" @models={{array @backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo> <LinkTo @route="secret.details" @models={{array @backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo>
</li> </li>

View File

@@ -90,7 +90,7 @@ export default class KvSecretCreate extends Component {
if (this.errorMessage) { if (this.errorMessage) {
this.invalidFormAlert = 'There was an error submitting this form.'; this.invalidFormAlert = 'There was an error submitting this form.';
} else { } else {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details', secret.path); this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index', secret.path);
} }
} }
} }

View File

@@ -17,13 +17,15 @@ export default class KvEngine extends Engine {
Resolver = Resolver; Resolver = Resolver;
dependencies = { dependencies = {
services: [ services: [
'capabilities',
'control-group',
'download', 'download',
'flash-messages',
'namespace', 'namespace',
'router', 'router',
'store',
'secret-mount-path', 'secret-mount-path',
'flash-messages', 'store',
'control-group', 'version',
], ],
externalRoutes: ['secrets', 'syncDestination'], externalRoutes: ['secrets', 'syncDestination'],
}; };

View File

@@ -13,6 +13,7 @@ export default buildRoutes(function () {
this.route('list-directory', { path: '/list/*path_to_secret' }); this.route('list-directory', { path: '/list/*path_to_secret' });
this.route('create'); this.route('create');
this.route('secret', { path: '/:name' }, function () { this.route('secret', { path: '/:name' }, function () {
this.route('patch');
this.route('paths'); this.route('paths');
this.route('details', function () { this.route('details', function () {
this.route('edit'); // route to create new version of a secret this.route('edit'); // route to create new version of a secret

View File

@@ -11,15 +11,43 @@ import { action } from '@ember/object';
export default class KvSecretRoute extends Route { export default class KvSecretRoute extends Route {
@service secretMountPath; @service secretMountPath;
@service store; @service store;
@service capabilities;
fetchSecretData(backend, path) { @service version;
// This will always return a record unless 404 not found (show error) or control group
return this.store.queryRecord('kv/data', { backend, path });
}
fetchSecretMetadata(backend, path) { fetchSecretMetadata(backend, path) {
// catch error and do nothing because kv/data model handles metadata capabilities // catch error and only return 404 which indicates the secret truly does not exist.
return this.store.queryRecord('kv/metadata', { backend, path }).catch(() => {}); // control group error is handled by the metadata route
return this.store.queryRecord('kv/metadata', { backend, path }).catch((e) => {
if (e.httpStatus === 404) {
throw e;
}
return null;
});
}
fetchSubkeys(backend, path) {
if (this.version.isEnterprise) {
const adapter = this.store.adapterFor('kv/data');
// metadata will throw if the secret does not exist
// always return here so we get deletion state and relevant metadata
return adapter.fetchSubkeys(backend, path);
}
return null;
}
isPatchAllowed(backend, path) {
if (!this.version.isEnterprise) return false;
const capabilities = {
canPatch: this.capabilities.canPatch(`${backend}/data/${path}`),
canReadSubkeys: this.capabilities.canRead(`${backend}/subkeys/${path}`),
};
return hash(capabilities).then(
({ canPatch, canReadSubkeys }) => canPatch && canReadSubkeys,
// this callback fires if either promise is rejected
// since this feature is only client-side gated we return false (instead of default to true)
// for debugging you can pass an arg to log the failure reason
() => false
);
} }
model() { model() {
@@ -29,8 +57,11 @@ export default class KvSecretRoute extends Route {
return hash({ return hash({
path, path,
backend, backend,
secret: this.fetchSecretData(backend, path), subkeys: this.fetchSubkeys(backend, path),
metadata: this.fetchSecretMetadata(backend, path), metadata: this.fetchSecretMetadata(backend, path),
isPatchAllowed: this.isPatchAllowed(backend, path),
// for creating a new secret version
canUpdateSecret: this.capabilities.canUpdate(`${backend}/data/${path}`),
}); });
} }

View File

@@ -18,22 +18,17 @@ export default class KvSecretDetailsRoute extends Route {
model(params) { model(params) {
const parentModel = this.modelFor('secret'); const parentModel = this.modelFor('secret');
// Only fetch versioned data if selected version does not match parent (current) version const { backend, path } = parentModel;
// and parentModel.secret has failReadErrorCode since permissions aren't version specific const query = { backend, path };
if ( // if a version is selected from the dropdown it triggers a model refresh
params.version && // and we fire off new request for that version's secret data
parentModel.secret.version !== params.version && if (params.version) {
!parentModel.secret.failReadErrorCode query.version = params.version;
) {
// query params have changed by selecting a different version from the dropdown
// fire off new request for that version's secret data
const { backend, path } = parentModel;
return hash({
...parentModel,
secret: this.store.queryRecord('kv/data', { backend, path, version: params.version }),
});
} }
return parentModel; return hash({
...parentModel,
secret: this.store.queryRecord('kv/data', query),
});
} }
// breadcrumbs are set in details/index.js // breadcrumbs are set in details/index.js

View File

@@ -5,11 +5,18 @@
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
export default class SecretIndex extends Route { export default class SecretIndex extends Route {
@service router; @service router;
redirect() { setupController(controller, resolvedModel) {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details'); super.setupController(controller, resolvedModel);
const breadcrumbsArray = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'list', model: resolvedModel.backend },
...breadcrumbsForSecret(resolvedModel.backend, resolvedModel.path, true),
];
controller.breadcrumbs = breadcrumbsArray;
} }
} }

View File

@@ -7,31 +7,57 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service'; import { service } from '@ember/service';
export default class KvSecretMetadataRoute extends Route { export default class KvSecretMetadataRoute extends Route {
@service store; @service capabilities;
@service secretMountPath; @service secretMountPath;
@service store;
fetchMetadata(backend, path) { fetchMetadata(backend, path) {
return this.store.queryRecord('kv/metadata', { backend, path }).catch((error) => { return this.store.queryRecord('kv/metadata', { backend, path }).catch((error) => {
if (error.message === 'Control Group encountered') { if (error.message === 'Control Group encountered') {
throw error; throw error;
} }
return {}; return null;
}); });
} }
async fetchCapabilities(backend, path) {
const metadataPath = `${backend}/metadata/${path}`;
const dataPath = `${backend}/data/${path}`;
const capabilities = await this.capabilities.fetchMultiplePaths([metadataPath, dataPath]);
return {
metadata: capabilities[metadataPath],
data: capabilities[dataPath],
};
}
async model() { async model() {
const backend = this.secretMountPath.currentPath;
const { name: path } = this.paramsFor('secret');
const parentModel = this.modelFor('secret'); const parentModel = this.modelFor('secret');
const { backend, path } = parentModel;
const permissions = await this.fetchCapabilities(backend, path);
const model = {
...parentModel,
permissions,
};
if (!parentModel.metadata) { if (!parentModel.metadata) {
// metadata read on the secret root fails silently // metadata read on the secret root fails silently
// if there's no metadata, try again in case it's a control group // if there's no metadata, try again in case it's a control group
const metadata = await this.fetchMetadata(backend, path); const metadata = await this.fetchMetadata(backend, path);
return { if (metadata) {
...parentModel, return {
metadata, ...model,
}; metadata,
};
}
// only fetch secret data if metadata is unavailable and user can read endpoint
if (permissions.data.canRead) {
// fail silently because this request is just for custom_metadata
const secret = await this.store.queryRecord('kv/data', { backend, path }).catch(() => {});
return {
...model,
secret,
};
}
} }
return parentModel; return model;
} }
} }

View File

@@ -0,0 +1,30 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Route from '@ember/routing/route';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
import { service } from '@ember/service';
export default class SecretPatch extends Route {
@service router;
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);
const breadcrumbsArray = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: resolvedModel.backend, route: 'list', model: resolvedModel.backend },
...breadcrumbsForSecret(resolvedModel.backend, resolvedModel.path),
{ label: 'Patch' },
];
controller.breadcrumbs = breadcrumbsArray;
}
// isPatchAllowed is true if the version is enterprise AND a user has "patch" secret + "read" subkeys capabilities
redirect(model) {
if (!model.isPatchAllowed) {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index', model.path);
}
}
}

View File

@@ -4,8 +4,9 @@
~}} ~}}
<Page::Secret::Details <Page::Secret::Details
@breadcrumbs={{this.breadcrumbs}}
@isPatchAllowed={{this.model.isPatchAllowed}}
@path={{this.model.path}} @path={{this.model.path}}
@secret={{this.model.secret}} @secret={{this.model.secret}}
@metadata={{this.model.metadata}} @metadata={{this.model.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/> />

View File

@@ -0,0 +1,15 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Page::Secret::Overview
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadMetadata={{this.model.metadata.canReadMetadata}}
@isPatchAllowed={{this.model.isPatchAllowed}}
@canUpdateSecret={{this.model.canUpdateSecret}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
@subkeys={{this.model.subkeys}}
/>

View File

@@ -4,8 +4,12 @@
~}} ~}}
<Page::Secret::Metadata::Details <Page::Secret::Metadata::Details
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canDeleteMetadata={{this.model.permissions.metadata.canDelete}}
@canReadMetadata={{this.model.permissions.metadata.canRead}}
@canUpdateMetadata={{this.model.permissions.metadata.canUpdate}}
@customMetadata={{or this.model.metadata.customMetadata this.model.secret.customMetadata}}
@metadata={{this.model.metadata}} @metadata={{this.model.metadata}}
@path={{this.model.path}} @path={{this.model.path}}
@breadcrumbs={{this.breadcrumbs}}
@secret={{this.model.secret}}
/> />

View File

@@ -0,0 +1,13 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
~}}
<Page::Secret::Patch
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
@subkeys={{this.model.subkeys.subkeys}}
@subkeysMeta={{this.model.subkeys.metadata}}
/>

View File

@@ -7,5 +7,5 @@
@path={{this.model.path}} @path={{this.model.path}}
@backend={{this.model.backend}} @backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}} @breadcrumbs={{this.breadcrumbs}}
@canReadMetadata={{this.model.secret.canReadMetadata}} @canReadMetadata={{this.model.metadata.canReadMetadata}}
/> />

View File

@@ -45,7 +45,7 @@ export function breadcrumbsForSecret(backend, secretPath, lastItemCurrent = fals
}; };
} }
if (!isDir) { if (!isDir) {
return { label: segment.label, route: 'secret.details', models: [backend, segment.model] }; return { label: segment.label, route: 'secret.index', models: [backend, segment.model] };
} }
} }
return { label: segment.label, route: 'list-directory', models: [backend, `${segment.model}/`] }; return { label: segment.label, route: 'list-directory', models: [backend, `${segment.model}/`] };

View File

@@ -17,7 +17,7 @@
<LinkToExternal <LinkToExternal
data-test-association-name={{index}} data-test-association-name={{index}}
class="has-text-black has-text-weight-semibold" class="has-text-black has-text-weight-semibold"
@route="kvSecretDetails" @route="kvSecretOverview"
@models={{array association.mount association.secretName}} @models={{array association.mount association.secretName}}
>{{association.secretName}}</LinkToExternal> >{{association.secretName}}</LinkToExternal>
{{#if association.subKey}} {{#if association.subKey}}
@@ -62,7 +62,7 @@
<dd.Interactive <dd.Interactive
@text="View secret" @text="View secret"
data-test-association-action="view" data-test-association-action="view"
@route="kvSecretDetails" @route="kvSecretOverview"
@isRouteExternal={{true}} @isRouteExternal={{true}}
@models={{array association.mount association.secretName}} @models={{array association.mount association.secretName}}
/> />

View File

@@ -24,7 +24,7 @@
Sync operation successfully initiated for Sync operation successfully initiated for
<Hds::Link::Inline <Hds::Link::Inline
@isRouteExternal={{true}} @isRouteExternal={{true}}
@route="kvSecretDetails" @route="kvSecretOverview"
@models={{array this.mountPath this.syncedSecret}} @models={{array this.mountPath this.syncedSecret}}
>{{this.syncedSecret}}</Hds::Link::Inline>. You can continue on this page to sync more secrets. >{{this.syncedSecret}}</Hds::Link::Inline>. You can continue on this page to sync more secrets.
</A.Description> </A.Description>

View File

@@ -15,7 +15,7 @@ export default class SyncEngine extends Engine {
Resolver = Resolver; Resolver = Resolver;
dependencies = { dependencies = {
services: ['flash-messages', 'flags', 'router', 'store', 'version'], services: ['flash-messages', 'flags', 'router', 'store', 'version'],
externalRoutes: ['kvSecretDetails', 'clientCountOverview'], externalRoutes: ['kvSecretOverview', 'clientCountOverview'],
}; };
} }

View File

@@ -16,10 +16,10 @@ const data = {
delete_version_after: '3h25m19s', delete_version_after: '3h25m19s',
max_versions: 15, max_versions: 15,
oldest_version: 0, oldest_version: 0,
updated_time: '2018-03-22T02:36:43.986212308Z', updated_time: '2023-07-21T03:11:58.095971Z',
versions: { versions: {
1: { 1: {
created_time: '2023-07-20T02:12:09.11529Z', created_time: '2018-03-22T02:24:06.945319214Z',
deletion_time: '', deletion_time: '',
destroyed: false, destroyed: false,
}, },

View File

@@ -11,9 +11,10 @@ import { setupApplicationTest } from 'vault/tests/helpers';
import authPage from 'vault/tests/pages/auth'; import authPage from 'vault/tests/pages/auth';
import { deleteEngineCmd, mountEngineCmd, runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands'; import { deleteEngineCmd, mountEngineCmd, runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands';
import { personas } from 'vault/tests/helpers/kv/policy-generator'; import { personas } from 'vault/tests/helpers/kv/policy-generator';
import { clearRecords, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands'; import { clearRecords, writeSecret, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors'; import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { grantAccessForWrite, setupControlGroup } from 'vault/tests/helpers/control-groups'; import { grantAccessForWrite, setupControlGroup } from 'vault/tests/helpers/control-groups';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
/** /**
* This test set is for testing the flow for creating new secrets and versions. * This test set is for testing the flow for creating new secrets and versions.
@@ -66,10 +67,12 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await click(PAGE.detail.createNewVersion); await click(PAGE.detail.createNewVersion);
await fillIn(FORM.keyInput(), 'bar'); await fillIn(FORM.keyInput(), 'bar');
await click(FORM.cancelBtn); await click(FORM.cancelBtn);
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.infoRowValue('foo')).exists('secret is previous value'); assert.dom(PAGE.infoRowValue('foo')).exists('secret is previous value');
await click(PAGE.detail.createNewVersion); await click(PAGE.detail.createNewVersion);
await fillIn(FORM.keyInput(), 'bar'); await fillIn(FORM.keyInput(), 'bar');
await click(PAGE.breadcrumbAtIdx(3)); await click(PAGE.breadcrumbAtIdx(3));
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.infoRowValue('foo')).exists('secret is previous value'); assert.dom(PAGE.infoRowValue('foo')).exists('secret is previous value');
}); });
test('create & update root secret with default metadata (a)', async function (assert) { test('create & update root secret with default metadata (a)', async function (assert) {
@@ -98,12 +101,15 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await fillIn(FORM.maskedValueInput(), 'partyparty'); await fillIn(FORM.maskedValueInput(), 'partyparty');
await click(FORM.saveBtn); await click(FORM.saveBtn);
// Details page
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=1`, `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}`,
'Goes to details page after save' 'Goes to overview after save'
); );
// Details page
await click(PAGE.secretTab('Secret'));
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=1`,
'details has version 1 param';
assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created');
assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows');
assert.dom(PAGE.infoRowValue('api_key')).hasText('***********'); assert.dom(PAGE.infoRowValue('api_key')).hasText('***********');
@@ -135,8 +141,12 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await fillIn(FORM.keyInput(1), 'api_url'); await fillIn(FORM.keyInput(1), 'api_url');
await fillIn(FORM.maskedValueInput(1), 'hashicorp.com'); await fillIn(FORM.maskedValueInput(1), 'hashicorp.com');
await click(FORM.saveBtn); await click(FORM.saveBtn);
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasTextContaining('2', 'Overview shows updated version');
// Back to details page // Back to details page
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2` `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2`
@@ -188,8 +198,14 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI');
// Fill in metadata // Fill in metadata
await click(FORM.saveBtn); await click(FORM.saveBtn);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}`,
'goes to overview after save'
);
// Details // Details
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}/details?version=1` `/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}/details?version=1`
@@ -231,10 +247,14 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await fillIn(FORM.keyInput(), 'api_key'); await fillIn(FORM.keyInput(), 'api_key');
await fillIn(FORM.maskedValueInput(), 'partyparty'); await fillIn(FORM.maskedValueInput(), 'partyparty');
await click(FORM.saveBtn); await click(FORM.saveBtn);
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasTextContaining('1', 'Overview shows current version');
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('app/new')}/details?version=1`, `/vault/secrets/${backend}/kv/${encodeURIComponent('app/new')}/details?version=1`,
'Redirects to detail after save' 'Details url has version param'
); );
await click(PAGE.breadcrumbAtIdx(2)); await click(PAGE.breadcrumbAtIdx(2));
assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, 'sub-dir page'); assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, 'sub-dir page');
@@ -269,7 +289,13 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await fillIn(FORM.keyInput(), 'my-key'); await fillIn(FORM.keyInput(), 'my-key');
await fillIn(FORM.maskedValueInput(), 'my-value'); await fillIn(FORM.maskedValueInput(), 'my-value');
await click(FORM.saveBtn); await click(FORM.saveBtn);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Ffirst`,
'goes to overview after save'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/app%2Ffirst/details?version=3`, `/vault/secrets/${backend}/kv/app%2Ffirst/details?version=3`,
@@ -278,6 +304,41 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await click(PAGE.infoRowToggleMasked('my-key')); await click(PAGE.infoRowToggleMasked('my-key'));
assert.dom(PAGE.infoRowValue('my-key')).hasText('my-value', 'has new value'); assert.dom(PAGE.infoRowValue('my-key')).hasText('my-value', 'has new value');
}); });
// patch is technically enterprise only but stubbing the version so these run on both CE and enterprise
test('it patches a secret', async function (assert) {
this.owner.lookup('service:version').type = 'enterprise';
const patchSecret = 'patch-secret';
await writeSecret(this.backend, patchSecret, 'foo', 'bar');
assert.dom(GENERAL.overviewCard.content('Subkeys')).hasText('Keys foo');
await click(GENERAL.overviewCard.actionText('Patch secret'));
// edit existing key
await click(FORM.patchEdit(0));
await fillIn(FORM.valueInput(0), 'newfoo');
// add new key
await fillIn(FORM.keyInput('new'), 'newkey');
await fillIn(FORM.valueInput('new'), 'newvalue');
await click(FORM.saveBtn);
assert.dom(GENERAL.overviewCard.content('Subkeys')).hasText('Keys foo newkey');
// check patch updated secret
await click(PAGE.secretTab('Secret'));
await click(PAGE.infoRowToggleMasked('foo'));
assert.dom(PAGE.infoRowValue('foo')).hasText('newfoo', 'has updated value');
await click(PAGE.infoRowToggleMasked('newkey'));
assert.dom(PAGE.infoRowValue('newkey')).hasText('newvalue', 'has new key/value pair');
await click(PAGE.detail.patchLatest);
await click(FORM.patchDelete());
await click(FORM.saveBtn);
assert.dom(GENERAL.overviewCard.content('Subkeys')).hasText('Keys newkey');
// check patch updated secret
await click(PAGE.secretTab('Secret'));
await click(PAGE.infoRowToggleMasked('newkey'));
assert.dom(PAGE.infoRowValue('foo')).doesNotExist();
assert.dom(PAGE.infoRowValue('newkey')).hasText('newvalue', 'has new key/value pair');
});
}); });
module('data-reader persona', function (hooks) { module('data-reader persona', function (hooks) {
@@ -797,19 +858,18 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await click(FORM.cancelBtn); await click(FORM.cancelBtn);
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`, `/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}`,
'cancel goes to correct url' 'cancel goes to overview'
); );
assert.dom(PAGE.list.item()).doesNotExist('list view has no items'); await click(PAGE.secretTab('Secret'));
await click(PAGE.detail.createNewVersion); await click(PAGE.detail.createNewVersion);
await fillIn(FORM.keyInput(), 'bar'); await fillIn(FORM.keyInput(), 'bar');
await click(PAGE.breadcrumbAtIdx(3)); await click(PAGE.breadcrumbAtIdx(3));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`, `/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}`,
'breadcrumb goes to correct url' 'breadcrumb goes to overview'
); );
assert.dom(PAGE.list.item()).doesNotExist('list view has no items');
}); });
test('create & update root secret with default metadata (sc)', async function (assert) { test('create & update root secret with default metadata (sc)', async function (assert) {
const backend = this.backend; const backend = this.backend;
@@ -836,13 +896,13 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await fillIn(FORM.keyInput(), 'api_key'); await fillIn(FORM.keyInput(), 'api_key');
await fillIn(FORM.maskedValueInput(), 'partyparty'); await fillIn(FORM.maskedValueInput(), 'partyparty');
await click(FORM.saveBtn); await click(FORM.saveBtn);
// Details page
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details`, `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}`,
'Goes to details page after save' 'Goes to overview page after save'
); );
// Details page
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created not shown'); assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created not shown');
assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); assert.dom(PAGE.infoRow).doesNotExist('does not show data contents');
assert assert
@@ -864,20 +924,31 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
// Add new version // Add new version
await click(PAGE.secretTab('Secret')); await click(PAGE.secretTab('Secret'));
await click(PAGE.detail.createNewVersion); await click(PAGE.detail.createNewVersion);
assert
.dom(FORM.noReadAlert)
.hasText(
'Warning You do not have read permissions for this secret data. Saving will overwrite the existing secret.',
'shows alert for no read permissions'
);
assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled');
assert.dom(FORM.inputByAttr('path')).hasValue(secretPath); assert.dom(FORM.inputByAttr('path')).hasValue(secretPath);
assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version');
assert.dom(FORM.keyInput()).hasValue('', 'row 1 is empty key'); assert.dom(FORM.keyInput()).hasValue('', 'Key input has empty value');
assert.dom(FORM.maskedValueInput()).hasValue('', 'row 1 has empty value'); assert.dom(FORM.maskedValueInput()).hasValue('', 'Val input has empty value');
await fillIn(FORM.keyInput(), 'api_url'); await fillIn(FORM.keyInput(), 'api_url');
await fillIn(FORM.maskedValueInput(), 'hashicorp.com'); await fillIn(FORM.maskedValueInput(), 'hashicorp.com');
await click(FORM.saveBtn); await click(FORM.saveBtn);
// Back to details page
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2`, `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}`,
'goes back to details page' 'goes to overview page'
);
// Back to details page
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details`,
'goes to details page'
); );
assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created does not show'); assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created does not show');
assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); assert.dom(PAGE.infoRow).doesNotExist('does not show data contents');
@@ -923,13 +994,14 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI'); await fillIn(`${PAGE.create.metadataSection} ${FORM.valueInput()}`, 'UI');
// Fill in metadata // Fill in metadata
await click(FORM.saveBtn); await click(FORM.saveBtn);
// Details
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}/details`, `/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}`,
'goes back to details page' 'goes to overview page'
); );
// Details
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.detail.versionTimestamp).doesNotExist('version created not shown'); assert.dom(PAGE.detail.versionTimestamp).doesNotExist('version created not shown');
assert.dom(PAGE.infoRow).doesNotExist('does not show data contents'); assert.dom(PAGE.infoRow).doesNotExist('does not show data contents');
assert assert
@@ -962,72 +1034,21 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await fillIn(FORM.keyInput(), 'api_key'); await fillIn(FORM.keyInput(), 'api_key');
await fillIn(FORM.maskedValueInput(), 'partyparty'); await fillIn(FORM.maskedValueInput(), 'partyparty');
await click(FORM.saveBtn); await click(FORM.saveBtn);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('app/new')}`,
'Redirects to overview after save'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('app/new')}/details`, `/vault/secrets/${backend}/kv/${encodeURIComponent('app/new')}/details`,
'Redirects to detail after save' 'navigates to details'
); );
await click(PAGE.breadcrumbAtIdx(2)); await click(PAGE.breadcrumbAtIdx(2));
assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, 'sub-dir page'); assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, 'sub-dir page');
assert.dom(PAGE.list.item()).doesNotExist('Does not list any secrets'); assert.dom(PAGE.list.item()).doesNotExist('Does not list any secrets');
}); });
test('create new version of secret from older version (sc)', async function (assert) {
const backend = this.backend;
await visit(`/vault/secrets/${backend}/kv/app%2Ffirst/details?version=1`);
assert.dom(PAGE.detail.versionDropdown).doesNotExist('version dropdown does not show');
assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version created not shown');
await click(PAGE.detail.createNewVersion);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Ffirst/details/edit?version=1`,
'Goes to new version page'
);
assert
.dom(FORM.noReadAlert)
.hasText(
'Warning You do not have read permissions for this secret data. Saving will overwrite the existing secret.',
'shows alert for no read permissions'
);
assert.dom(FORM.keyInput()).hasValue('', 'Key input has empty value');
assert.dom(FORM.maskedValueInput()).hasValue('', 'Val input has empty value');
await fillIn(FORM.keyInput(), 'my-key');
await fillIn(FORM.maskedValueInput(), 'my-value');
await click(FORM.saveBtn);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Ffirst/details?version=3`,
'redirects to details page'
);
assert.dom(PAGE.infoRow).doesNotExist('does not show data contents');
assert
.dom(PAGE.emptyStateTitle)
.hasText('You do not have permission to read this secret', 'shows permissions empty state');
});
});
module('secret-nested-creator persona', function (hooks) {
hooks.beforeEach(async function () {
const token = await runCmd(
tokenWithPolicyCmd(
`secret-nested-creator-${this.backend}`,
personas.secretNestedCreator(this.backend)
)
);
await authPage.login(token);
clearRecords(this.store);
return;
});
test('can create a secret from the nested list view (snc)', async function (assert) {
assert.expect(1);
// go to nested secret directory list view
await visit(`/vault/secrets/${this.backend}/kv/list/app/`);
// correct popup menu items appear on list view
const popupSelector = `${PAGE.list.item('first')} ${PAGE.popup}`;
await click(popupSelector);
assert.dom(PAGE.list.listMenuCreate).exists('shows the option to create new version');
});
}); });
module('enterprise controlled access persona', function (hooks) { module('enterprise controlled access persona', function (hooks) {
@@ -1114,12 +1135,13 @@ path "${this.backend}/metadata/*" {
await fillIn(FORM.maskedValueInput(), 'this too, gonna use the wrapped data'); await fillIn(FORM.maskedValueInput(), 'this too, gonna use the wrapped data');
await click(FORM.saveBtn); await click(FORM.saveBtn);
assert.strictEqual(this.controlGroup.tokenToUnwrap, null, 'clears tokenToUnwrap after successful save'); assert.strictEqual(this.controlGroup.tokenToUnwrap, null, 'clears tokenToUnwrap after successful save');
// Details page
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${secretPath}/details?version=1`, `/vault/secrets/${backend}/kv/${secretPath}`,
'Goes to details page after save' 'Goes to overview page after save'
); );
// Details page
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created'); assert.dom(PAGE.detail.versionTimestamp).includesText('Version 1 created');
assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows'); assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows');
assert.dom(PAGE.infoRowValue('api_key')).hasText('***********'); assert.dom(PAGE.infoRowValue('api_key')).hasText('***********');
@@ -1185,8 +1207,8 @@ path "${this.backend}/metadata/*" {
null, null,
'clears tokenToUnwrap after successful update' 'clears tokenToUnwrap after successful update'
); );
// Back to details page // Back to details page
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2` `/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2`

View File

@@ -11,9 +11,10 @@ import { deleteEngineCmd, mountEngineCmd, runCmd, tokenWithPolicyCmd } from 'vau
import { personas } from 'vault/tests/helpers/kv/policy-generator'; import { personas } from 'vault/tests/helpers/kv/policy-generator';
import { clearRecords, deleteLatestCmd, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands'; import { clearRecords, deleteLatestCmd, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands';
import { setupControlGroup } from 'vault/tests/helpers/control-groups'; import { setupControlGroup } from 'vault/tests/helpers/control-groups';
import { click, currentURL, visit } from '@ember/test-helpers'; import { click, currentRouteName, currentURL, waitUntil, visit } from '@ember/test-helpers';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors'; import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import sinon from 'sinon'; import sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const ALL_DELETE_ACTIONS = ['delete', 'destroy', 'undelete']; const ALL_DELETE_ACTIONS = ['delete', 'destroy', 'undelete'];
const assertDeleteActions = (assert, expected = ['delete', 'destroy']) => { const assertDeleteActions = (assert, expected = ['delete', 'destroy']) => {
@@ -47,9 +48,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
await runCmd(mountEngineCmd('kv-v2', this.backend), false); await runCmd(mountEngineCmd('kv-v2', this.backend), false);
await writeVersionedSecret(this.backend, this.secretPath, 'foo', 'bar', 4); await writeVersionedSecret(this.backend, this.secretPath, 'foo', 'bar', 4);
await writeVersionedSecret(this.backend, this.nestedSecretPath, 'foo', 'bar', 1); await writeVersionedSecret(this.backend, this.nestedSecretPath, 'foo', 'bar', 1);
await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2); // Versioned secret for testing delete is created (and deleted) by each module to avoid race condition failures
// Delete latest version for testing undelete for users that can't delete
await runCmd(deleteLatestCmd(this.backend, 'nuke'));
return; return;
}); });
@@ -72,7 +71,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`);
// correct toolbar options & details show // correct toolbar options & details show
assertDeleteActions(assert); assertDeleteActions(assert);
assert.dom(PAGE.infoRow).exists('shows secret data'); assert.dom(PAGE.infoRow).exists('shows secret data on load');
// delete flow // delete flow
await click(PAGE.detail.delete); await click(PAGE.detail.delete);
assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title');
@@ -85,6 +84,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
assert.strictEqual(actual, expected, 'renders correct flash message'); assert.strictEqual(actual, expected, 'renders correct flash message');
// details update accordingly // details update accordingly
await click(PAGE.secretTab('Secret'));
assert assert
.dom(PAGE.emptyStateTitle) .dom(PAGE.emptyStateTitle)
.hasText('Version 4 of this secret has been deleted', 'Shows deleted message'); .hasText('Version 4 of this secret has been deleted', 'Shows deleted message');
@@ -94,7 +94,8 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
// undelete flow // undelete flow
await click(PAGE.detail.undelete); await click(PAGE.detail.undelete);
// details update accordingly // details update accordingly
assert.dom(PAGE.infoRow).exists('shows secret data'); await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.infoRow).exists('shows secret data after undeleting');
assert.dom(PAGE.detail.versionTimestamp).includesText('Version 4 created'); assert.dom(PAGE.detail.versionTimestamp).includesText('Version 4 created');
// correct toolbar options // correct toolbar options
assertDeleteActions(assert, ['delete', 'destroy']); assertDeleteActions(assert, ['delete', 'destroy']);
@@ -105,7 +106,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`); await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`);
// correct toolbar options & details show // correct toolbar options & details show
assertDeleteActions(assert); assertDeleteActions(assert);
assert.dom(PAGE.infoRow).exists('shows secret data'); assert.dom(PAGE.infoRow).exists('shows secret data on load');
// delete flow // delete flow
await click(PAGE.detail.delete); await click(PAGE.detail.delete);
assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title'); assert.dom(PAGE.detail.deleteModalTitle).includesText('Delete version?', 'shows correct modal title');
@@ -113,7 +114,8 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
assert.dom(PAGE.detail.deleteOptionLatest).isNotDisabled('delete latest option is selectable'); assert.dom(PAGE.detail.deleteOptionLatest).isNotDisabled('delete latest option is selectable');
await click(PAGE.detail.deleteOption); await click(PAGE.detail.deleteOption);
await click(PAGE.detail.deleteConfirm); await click(PAGE.detail.deleteConfirm);
// details update accordingly // we get navigated back to the overview page, so manually go back to deleted version
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`);
assert assert
.dom(PAGE.emptyStateTitle) .dom(PAGE.emptyStateTitle)
.hasText('Version 2 of this secret has been deleted', 'Shows deleted message'); .hasText('Version 2 of this secret has been deleted', 'Shows deleted message');
@@ -123,7 +125,8 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
// undelete flow // undelete flow
await click(PAGE.detail.undelete); await click(PAGE.detail.undelete);
// details update accordingly // details update accordingly
assert.dom(PAGE.infoRow).exists('shows secret data'); await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`);
assert.dom(PAGE.infoRow).exists('shows secret data after undeleting');
assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created');
// correct toolbar options // correct toolbar options
assertDeleteActions(assert, ['delete', 'destroy']); assertDeleteActions(assert, ['delete', 'destroy']);
@@ -143,6 +146,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
const [actual] = flashSuccess.lastCall.args; const [actual] = flashSuccess.lastCall.args;
assert.strictEqual(actual, expected, 'renders correct flash message'); assert.strictEqual(actual, expected, 'renders correct flash message');
// details update accordingly // details update accordingly
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`);
assert assert
.dom(PAGE.emptyStateTitle) .dom(PAGE.emptyStateTitle)
.hasText('Version 3 of this secret has been permanently destroyed', 'Shows destroyed message'); .hasText('Version 3 of this secret has been permanently destroyed', 'Shows destroyed message');
@@ -150,11 +154,10 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
// updated toolbar options // updated toolbar options
assertDeleteActions(assert, []); assertDeleteActions(assert, []);
}); });
test('can permanently delete all secret versions (a)', async function (assert) { test('can permanently delete all secret versions (a)', async function (assert) {
// go to secret details await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2);
await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); await visit(`/vault/secrets/${this.backend}/kv/nuke/metadata`);
// Check metadata toolbar
await click(PAGE.secretTab('Metadata'));
assert.dom(PAGE.metadata.deleteMetadata).hasText('Permanently delete', 'shows delete metadata button'); assert.dom(PAGE.metadata.deleteMetadata).hasText('Permanently delete', 'shows delete metadata button');
// delete flow // delete flow
await click(PAGE.metadata.deleteMetadata); await click(PAGE.metadata.deleteMetadata);
@@ -162,7 +165,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
.dom(PAGE.detail.deleteModalTitle) .dom(PAGE.detail.deleteModalTitle)
.includesText('Delete metadata and secret data?', 'modal has correct title'); .includesText('Delete metadata and secret data?', 'modal has correct title');
await click(PAGE.detail.deleteConfirm); await click(PAGE.detail.deleteConfirm);
await waitUntil(() => currentRouteName() === 'vault.cluster.secrets.backend.kv.list');
// redirects to list // redirects to list
assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/kv/list`, 'redirects to list'); assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/kv/list`, 'redirects to list');
}); });
@@ -170,6 +173,11 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
module('data-reader persona', function (hooks) { module('data-reader persona', function (hooks) {
hooks.beforeEach(async function () { hooks.beforeEach(async function () {
// create and delete a secret as root user
await authPage.login();
await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2);
await runCmd(deleteLatestCmd(this.backend, 'nuke'));
// login as data-reader persona
const token = await runCmd(makeToken('data-reader', this.backend, personas.dataReader)); const token = await runCmd(makeToken('data-reader', this.backend, personas.dataReader));
await authPage.login(token); await authPage.login(token);
clearRecords(this.store); clearRecords(this.store);
@@ -217,12 +225,17 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
module('data-list-reader persona', function (hooks) { module('data-list-reader persona', function (hooks) {
hooks.beforeEach(async function () { hooks.beforeEach(async function () {
// create and delete a secret as root user
await authPage.login();
await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2);
await runCmd(deleteLatestCmd(this.backend, 'nuke'));
// login as data-list-reader persona
const token = await runCmd(makeToken('data-list-reader', this.backend, personas.dataListReader)); const token = await runCmd(makeToken('data-list-reader', this.backend, personas.dataListReader));
await authPage.login(token); await authPage.login(token);
clearRecords(this.store); clearRecords(this.store);
return; return;
}); });
test('can delete and undelete the latest secret version (dlr)', async function (assert) { test('can delete and cannot undelete the latest secret version (dlr)', async function (assert) {
assert.expect(12); assert.expect(12);
// go to secret details // go to secret details
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`);
@@ -237,6 +250,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
await click(PAGE.detail.deleteOptionLatest); await click(PAGE.detail.deleteOptionLatest);
await click(PAGE.detail.deleteConfirm); await click(PAGE.detail.deleteConfirm);
// details update accordingly // details update accordingly
await click(PAGE.secretTab('Secret'));
assert assert
.dom(PAGE.emptyStateTitle) .dom(PAGE.emptyStateTitle)
.hasText('Version 4 of this secret has been deleted', 'Shows deleted message'); .hasText('Version 4 of this secret has been deleted', 'Shows deleted message');
@@ -264,7 +278,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
// correct toolbar options show // correct toolbar options show
assertDeleteActions(assert, ['delete']); assertDeleteActions(assert, ['delete']);
}); });
test('cannot permanently delete all secret versions (dr)', async function (assert) { test('cannot permanently delete all secret versions (dlr)', async function (assert) {
// go to secret details // go to secret details
await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); await visit(`/vault/secrets/${this.backend}/kv/nuke/details`);
// Check metadata toolbar // Check metadata toolbar
@@ -275,13 +289,18 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
module('metadata-maintainer persona', function (hooks) { module('metadata-maintainer persona', function (hooks) {
hooks.beforeEach(async function () { hooks.beforeEach(async function () {
// create and delete a secret as root user
await authPage.login();
await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2);
await runCmd(deleteLatestCmd(this.backend, 'nuke'));
// login as metadata-maintainer persona
const token = await runCmd(makeToken('metadata-maintainer', this.backend, personas.metadataMaintainer)); const token = await runCmd(makeToken('metadata-maintainer', this.backend, personas.metadataMaintainer));
await authPage.login(token); await authPage.login(token);
clearRecords(this.store); clearRecords(this.store);
return; return;
}); });
test('can delete and undelete the latest secret version (mm)', async function (assert) { test('cannot delete but can undelete the latest secret version (mm)', async function (assert) {
assert.expect(17); assert.expect(18);
// go to secret details // go to secret details
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`); await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`);
// correct toolbar options & details show // correct toolbar options & details show
@@ -301,7 +320,12 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
assertDeleteActions(assert, ['undelete', 'destroy']); assertDeleteActions(assert, ['undelete', 'destroy']);
// undelete flow // undelete flow
await click(PAGE.detail.undelete); await click(PAGE.detail.undelete);
await waitUntil(() => currentRouteName() === 'vault.cluster.secrets.backend.kv.secret.index');
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasText(`Current version The current version of this secret. 2`);
// details update accordingly // details update accordingly
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret');
assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered');
// correct toolbar options // correct toolbar options
@@ -323,6 +347,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
await click(PAGE.detail.deleteOption); await click(PAGE.detail.deleteOption);
await click(PAGE.detail.deleteConfirm); await click(PAGE.detail.deleteConfirm);
// details update accordingly // details update accordingly
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`);
assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret');
assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered');
// updated toolbar options // updated toolbar options
@@ -330,6 +355,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
// undelete flow // undelete flow
await click(PAGE.detail.undelete); await click(PAGE.detail.undelete);
// details update accordingly // details update accordingly
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=2`);
assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret'); assert.dom(PAGE.emptyStateTitle).hasText('You do not have permission to read this secret');
assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered'); assert.dom(PAGE.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered');
// correct toolbar options // correct toolbar options
@@ -346,6 +372,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
assert.dom(PAGE.detail.deleteModalTitle).includesText('Destroy version?', 'modal has correct title'); assert.dom(PAGE.detail.deleteModalTitle).includesText('Destroy version?', 'modal has correct title');
await click(PAGE.detail.deleteConfirm); await click(PAGE.detail.deleteConfirm);
// details update accordingly // details update accordingly
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`);
assert assert
.dom(PAGE.emptyStateTitle) .dom(PAGE.emptyStateTitle)
.hasText('You do not have permission to read this secret', 'Shows permissions message'); .hasText('You do not have permission to read this secret', 'Shows permissions message');
@@ -427,6 +454,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
assertDeleteActions(assert, []); assertDeleteActions(assert, []);
}); });
test('can permanently delete all secret versions (sc)', async function (assert) { test('can permanently delete all secret versions (sc)', async function (assert) {
await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2);
// go to secret details // go to secret details
await visit(`/vault/secrets/${this.backend}/kv/nuke/details`); await visit(`/vault/secrets/${this.backend}/kv/nuke/details`);
// Check metadata toolbar // Check metadata toolbar
@@ -438,7 +466,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
.dom(PAGE.detail.deleteModalTitle) .dom(PAGE.detail.deleteModalTitle)
.includesText('Delete metadata and secret data?', 'modal has correct title'); .includesText('Delete metadata and secret data?', 'modal has correct title');
await click(PAGE.detail.deleteConfirm); await click(PAGE.detail.deleteConfirm);
await waitUntil(() => currentRouteName() === 'vault.cluster.secrets.backend.kv.list');
// redirects to list // redirects to list
assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/kv/list`, 'redirects to list'); assert.strictEqual(currentURL(), `/vault/secrets/${this.backend}/kv/list`, 'redirects to list');
}); });

View File

@@ -35,6 +35,7 @@ import { clearRecords, writeSecret, writeVersionedSecret } from 'vault/tests/hel
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors'; import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors'; import { GENERAL } from 'vault/tests/helpers/general-selectors';
import codemirror from 'vault/tests/helpers/codemirror'; import codemirror from 'vault/tests/helpers/codemirror';
import { personas } from 'vault/tests/helpers/kv/policy-generator';
/** /**
* This test set is for testing edge cases, such as specific bug fixes or reported user workflows * This test set is for testing edge cases, such as specific bug fixes or reported user workflows
@@ -79,7 +80,7 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
}); });
test('it can navigate to secrets within a secret directory', async function (assert) { test('it can navigate to secrets within a secret directory', async function (assert) {
assert.expect(21); assert.expect(23);
const backend = this.backend; const backend = this.backend;
const [root, subdirectory, secret] = this.fullSecretPath.split('/'); const [root, subdirectory, secret] = this.fullSecretPath.split('/');
@@ -119,7 +120,11 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
await click(PAGE.list.item(`${subdirectory}/`)); await click(PAGE.list.item(`${subdirectory}/`));
assert.dom(PAGE.list.item(secret)).exists('renders linked block for child secret'); assert.dom(PAGE.list.item(secret)).exists('renders linked block for child secret');
await click(PAGE.list.item(secret)); await click(PAGE.list.item(secret));
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasText(`Current version Create new The current version of this secret. 1`);
// Secret details visible // Secret details visible
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.title).hasText(this.fullSecretPath); assert.dom(PAGE.title).hasText(this.fullSecretPath);
assert.dom(PAGE.secretTab('Secret')).hasText('Secret'); assert.dom(PAGE.secretTab('Secret')).hasText('Secret');
assert.dom(PAGE.secretTab('Secret')).hasClass('active'); assert.dom(PAGE.secretTab('Secret')).hasClass('active');
@@ -127,7 +132,8 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
assert.dom(PAGE.secretTab('Metadata')).doesNotHaveClass('active'); assert.dom(PAGE.secretTab('Metadata')).doesNotHaveClass('active');
assert.dom(PAGE.secretTab('Version History')).hasText('Version History'); assert.dom(PAGE.secretTab('Version History')).hasText('Version History');
assert.dom(PAGE.secretTab('Version History')).doesNotHaveClass('active'); assert.dom(PAGE.secretTab('Version History')).doesNotHaveClass('active');
assert.dom(PAGE.toolbarAction).exists({ count: 4 }, 'toolbar renders all actions'); assert.dom(PAGE.detail.copy).exists();
assert.dom(PAGE.detail.versionDropdown).exists();
}); });
test('it navigates back to engine index route via breadcrumbs from secret details', async function (assert) { test('it navigates back to engine index route via breadcrumbs from secret details', async function (assert) {
@@ -179,7 +185,9 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
assert.dom(PAGE.error.title).hasText('404 Not Found'); assert.dom(PAGE.error.title).hasText('404 Not Found');
assert assert
.dom(PAGE.error.message) .dom(PAGE.error.message)
.hasText(`Sorry, we were unable to find any content at /v1/${backend}/data/${root}/${subdirectory}.`); .hasText(
`Sorry, we were unable to find any content at /v1/${backend}/metadata/${root}/${subdirectory}.`
);
assert.dom(PAGE.breadcrumbAtIdx(0)).hasText('Secrets'); assert.dom(PAGE.breadcrumbAtIdx(0)).hasText('Secrets');
assert.dom(PAGE.breadcrumbAtIdx(1)).hasText(backend); assert.dom(PAGE.breadcrumbAtIdx(1)).hasText(backend);
@@ -302,6 +310,7 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
await click(FORM.saveBtn); await click(FORM.saveBtn);
// Details view // Details view
await click(PAGE.secretTab('Secret'));
assert.dom(FORM.toggleJson).isNotDisabled(); assert.dom(FORM.toggleJson).isNotDisabled();
assert.dom(FORM.toggleJson).isChecked(); assert.dom(FORM.toggleJson).isChecked();
assert.strictEqual( assert.strictEqual(
@@ -353,11 +362,12 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
await click(FORM.saveBtn); await click(FORM.saveBtn);
// Create another version // Create another version
await click(PAGE.detail.createNewVersion); await click(GENERAL.overviewCard.actionText('Create new'));
codemirror().setValue('{ "foo2": { "name": "bar2" } }'); codemirror().setValue('{ "foo2": { "name": "bar2" } }');
await click(FORM.saveBtn); await click(FORM.saveBtn);
// View the first version and make sure the secret data is correct // View the first version and make sure the secret data is correct
await click(PAGE.secretTab('Secret'));
await click(PAGE.detail.versionDropdown); await click(PAGE.detail.versionDropdown);
await click(`${PAGE.detail.version(1)} a`); await click(`${PAGE.detail.version(1)} a`);
assert.strictEqual(codemirror().getValue(), obscuredDataV1, 'Version one data is displayed'); assert.strictEqual(codemirror().getValue(), obscuredDataV1, 'Version one data is displayed');
@@ -375,10 +385,75 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
await fillIn(FORM.keyInput(), 'foo'); await fillIn(FORM.keyInput(), 'foo');
await fillIn(FORM.maskedValueInput(), '{bar}'); await fillIn(FORM.maskedValueInput(), '{bar}');
await click(FORM.saveBtn); await click(FORM.saveBtn);
await click(PAGE.detail.createNewVersion); await click(GENERAL.overviewCard.actionText('Create new'));
assert.dom(FORM.toggleJson).isNotDisabled(); assert.dom(FORM.toggleJson).isNotDisabled();
assert.dom(FORM.toggleJson).isNotChecked(); assert.dom(FORM.toggleJson).isNotChecked();
}); });
// patch is technically enterprise only but stubbing the version so these run on both CE and enterprise
module('patch-persona', function (hooks) {
hooks.beforeEach(async function () {
this.patchSecret = 'patch-secret';
this.version = this.owner.lookup('service:version');
this.version.type = 'enterprise';
this.store = this.owner.lookup('service:store');
await writeSecret(this.backend, this.patchSecret, 'foo', 'bar');
const token = await runCmd([
createPolicyCmd(
`secret-patcher-${this.backend}`,
personas.secretPatcher(this.backend) + personas.secretPatcher(this.emptyBackend)
),
createTokenCmd(`secret-patcher-${this.backend}`),
]);
await authPage.login(token);
clearRecords(this.store);
return;
});
test('it patches a secret from the overview page', async function (assert) {
await visit(`/vault/secrets/${this.backend}/kv/${this.patchSecret}`);
assert.dom(GENERAL.overviewCard.content('Subkeys')).hasText('Keys foo');
await click(GENERAL.overviewCard.actionText('Patch secret'));
await click(FORM.patchEdit(0));
await fillIn(FORM.valueInput(0), 'newvalue');
await fillIn(FORM.keyInput('new'), 'newkey');
await fillIn(FORM.valueInput('new'), 'newvalue');
await click(FORM.saveBtn);
assert.dom(GENERAL.overviewCard.content('Subkeys')).hasText('Keys foo newkey');
});
test('it patches a secret from the secret details', async function (assert) {
await visit(`/vault/secrets/${this.backend}/kv/${this.patchSecret}`);
assert.dom(GENERAL.overviewCard.content('Subkeys')).hasText('Keys foo');
await click(PAGE.secretTab('Secret'));
await click(PAGE.detail.patchLatest);
await click(FORM.patchEdit(0));
await fillIn(FORM.valueInput(0), 'newvalue');
await fillIn(FORM.keyInput('new'), 'newkey');
await fillIn(FORM.valueInput('new'), 'newvalue');
await click(FORM.saveBtn);
assert.dom(GENERAL.overviewCard.content('Subkeys')).hasText('Keys foo newkey');
});
// in the same test because the writeSecret helper only creates a single key/value pair
test('it adds and deletes a key', async function (assert) {
await visit(`/vault/secrets/${this.backend}/kv/${this.patchSecret}`);
// add a new key
assert.dom(GENERAL.overviewCard.content('Subkeys')).hasText('Keys foo');
await click(GENERAL.overviewCard.actionText('Patch secret'));
await fillIn(FORM.keyInput('new'), 'newkey');
await fillIn(FORM.valueInput('new'), 'newvalue');
await click(FORM.saveBtn);
assert.dom(GENERAL.overviewCard.content('Subkeys')).hasText('Keys foo newkey');
// deletes a key
await click(GENERAL.overviewCard.actionText('Patch secret'));
await click(FORM.patchDelete());
await click(FORM.saveBtn);
assert.dom(GENERAL.overviewCard.content('Subkeys')).hasText('Keys newkey');
});
});
}); });
// NAMESPACE TESTS // NAMESPACE TESTS
@@ -446,7 +521,7 @@ module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks)
}); });
test('namespace: it can create a secret and new secret version', async function (assert) { test('namespace: it can create a secret and new secret version', async function (assert) {
assert.expect(15); assert.expect(16);
const backend = this.backend; const backend = this.backend;
const ns = this.namespace; const ns = this.namespace;
const secret = 'my-create-secret'; const secret = 'my-create-secret';
@@ -463,14 +538,12 @@ module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks)
await fillIn(FORM.keyInput(), 'foo'); await fillIn(FORM.keyInput(), 'foo');
await fillIn(FORM.maskedValueInput(), 'woahsecret'); await fillIn(FORM.maskedValueInput(), 'woahsecret');
await click(FORM.saveBtn); await click(FORM.saveBtn);
assert.strictEqual( assert
currentURL(), .dom(GENERAL.overviewCard.container('Current version'))
`/vault/secrets/${backend}/kv/${secret}/details?namespace=${ns}&version=1`, .hasText(`Current version Create new The current version of this secret. 1`);
'navigates to details'
);
// Create a new version // Create a new version
await click(PAGE.detail.createNewVersion); await click(GENERAL.overviewCard.actionText('Create new'));
assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled'); assert.dom(FORM.inputByAttr('path')).isDisabled('path input is disabled');
assert.dom(FORM.inputByAttr('path')).hasValue(secret); assert.dom(FORM.inputByAttr('path')).hasValue(secret);
assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version'); assert.dom(FORM.toggleMetadata).doesNotExist('Does not show metadata toggle when creating new version');
@@ -479,8 +552,12 @@ module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks)
await fillIn(FORM.keyInput(1), 'foo-two'); await fillIn(FORM.keyInput(1), 'foo-two');
await fillIn(FORM.maskedValueInput(1), 'supersecret'); await fillIn(FORM.maskedValueInput(1), 'supersecret');
await click(FORM.saveBtn); await click(FORM.saveBtn);
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasText(`Current version Create new The current version of this secret. 2`);
// Check details // Check details
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${secret}/details?namespace=${ns}&version=2`, `/vault/secrets/${backend}/kv/${secret}/details?namespace=${ns}&version=2`,
@@ -496,7 +573,7 @@ module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks)
}); });
test('namespace: it manages state throughout delete, destroy and undelete operations', async function (assert) { test('namespace: it manages state throughout delete, destroy and undelete operations', async function (assert) {
assert.expect(34); assert.expect(36);
const backend = this.backend; const backend = this.backend;
const ns = this.namespace; const ns = this.namespace;
const secret = 'my-delete-secret'; const secret = 'my-delete-secret';
@@ -506,17 +583,25 @@ module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks)
await click(PAGE.list.item(secret)); await click(PAGE.list.item(secret));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${secret}/details?namespace=${ns}&version=2`, `/vault/secrets/${backend}/kv/${secret}?namespace=${ns}`,
'navigates to details' 'navigates to overview'
); );
// correct toolbar options & details show // correct toolbar options & details show
await click(PAGE.secretTab('Secret'));
assertDeleteActions(assert); assertDeleteActions(assert);
await assertVersionDropdown(assert); await assertVersionDropdown(assert);
// delete flow // delete flow
await click(PAGE.detail.delete); await click(PAGE.detail.delete);
await click(PAGE.detail.deleteOption); await click(PAGE.detail.deleteOption);
await click(PAGE.detail.deleteConfirm); await click(PAGE.detail.deleteConfirm);
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasTextContaining(
'Current version Deleted Create new The current version of this secret was deleted'
);
await click(PAGE.secretTab('Secret'));
// check empty state and toolbar // check empty state and toolbar
assertDeleteActions(assert, ['undelete', 'destroy']); assertDeleteActions(assert, ['undelete', 'destroy']);
assert assert
@@ -537,7 +622,11 @@ module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks)
// undelete flow // undelete flow
await click(PAGE.detail.undelete); await click(PAGE.detail.undelete);
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasTextContaining('Current version Create new The current version of this secret.');
// details update accordingly // details update accordingly
await click(PAGE.secretTab('Secret'));
assertDeleteActions(assert, ['delete', 'destroy']); assertDeleteActions(assert, ['delete', 'destroy']);
assert.dom(PAGE.infoRow).exists('shows secret data'); assert.dom(PAGE.infoRow).exists('shows secret data');
assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created'); assert.dom(PAGE.detail.versionTimestamp).includesText('Version 2 created');
@@ -545,6 +634,7 @@ module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks)
// destroy flow // destroy flow
await click(PAGE.detail.destroy); await click(PAGE.detail.destroy);
await click(PAGE.detail.deleteConfirm); await click(PAGE.detail.deleteConfirm);
await click(PAGE.secretTab('Secret'));
assertDeleteActions(assert, []); assertDeleteActions(assert, []);
assert assert
.dom(PAGE.emptyStateTitle) .dom(PAGE.emptyStateTitle)

View File

@@ -5,7 +5,7 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { click, currentRouteName, currentURL, typeIn, visit, waitUntil } from '@ember/test-helpers'; import { click, currentRouteName, currentURL, findAll, typeIn, visit, waitUntil } from '@ember/test-helpers';
import { setupApplicationTest } from 'vault/tests/helpers'; import { setupApplicationTest } from 'vault/tests/helpers';
import authPage from 'vault/tests/pages/auth'; import authPage from 'vault/tests/pages/auth';
import { import {
@@ -24,26 +24,27 @@ import {
writeVersionedSecret, writeVersionedSecret,
} from 'vault/tests/helpers/kv/kv-run-commands'; } from 'vault/tests/helpers/kv/kv-run-commands';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors'; import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { setupControlGroup, grantAccess } from 'vault/tests/helpers/control-groups'; import { setupControlGroup, grantAccess } from 'vault/tests/helpers/control-groups';
import { humanize } from 'vault/helpers/humanize';
const secretPath = `my-#:$=?-secret`; const secretPath = `my-#:$=?-secret`;
// This doesn't encode in a normal way, so hardcoding it here until we sort that out // This doesn't encode in a normal way, so hardcoding it here until we sort that out
const secretPathUrlEncoded = `my-%23:$=%3F-secret`; const secretPathUrlEncoded = `my-%23:$=%3F-secret`;
// these are rendered individually by each page component, assigning a const here for consistency
const ALL_TABS = ['Overview', 'Secret', 'Metadata', 'Paths', 'Version History'];
const navToBackend = async (backend) => { const navToBackend = async (backend) => {
await visit(`/vault/secrets`); await visit(`/vault/secrets`);
return click(PAGE.backends.link(backend)); return click(PAGE.backends.link(backend));
}; };
const assertCorrectBreadcrumbs = (assert, expected) => { const assertCorrectBreadcrumbs = (assert, expected) => {
assert.dom(PAGE.breadcrumbs).hasText(expected.join(' ')); assert.dom(PAGE.breadcrumbs).hasText(expected.join(' '));
const breadcrumbs = document.querySelectorAll(PAGE.breadcrumb); const breadcrumbs = findAll(PAGE.breadcrumb);
expected.forEach((text, idx) => { expected.forEach((text, idx) => {
assert.dom(breadcrumbs[idx]).hasText(text, `position ${idx} breadcrumb includes text ${text}`); assert.dom(breadcrumbs[idx]).hasText(text, `position ${idx} breadcrumb includes text ${text}`);
}); });
}; };
const assertDetailTabs = (assert, current, hidden = []) => { const assertDetailTabs = (assert, current, hidden = []) => {
const allTabs = ['Secret', 'Metadata', 'Paths', 'Version History']; ALL_TABS.forEach((tab) => {
allTabs.forEach((tab) => {
if (hidden.includes(tab)) { if (hidden.includes(tab)) {
assert.dom(PAGE.secretTab(tab)).doesNotExist(`${tab} tab does not render`); assert.dom(PAGE.secretTab(tab)).doesNotExist(`${tab} tab does not render`);
return; return;
@@ -56,14 +57,31 @@ const assertDetailTabs = (assert, current, hidden = []) => {
} }
}); });
}; };
// patchLatest is only available for enterprise so it's not included here
const DETAIL_TOOLBARS = ['delete', 'destroy', 'copy', 'versionDropdown', 'createNewVersion']; const DETAIL_TOOLBARS = ['delete', 'destroy', 'copy', 'versionDropdown', 'createNewVersion'];
const assertDetailsToolbar = (assert, expected = DETAIL_TOOLBARS) => { const assertDetailsToolbar = (assert, expected = DETAIL_TOOLBARS) => {
assert assert
.dom(PAGE.toolbarAction) .dom(PAGE.toolbarAction)
.exists({ count: expected.length }, 'correct number of toolbar actions render'); .exists({ count: expected.length }, 'correct number of toolbar actions render');
DETAIL_TOOLBARS.forEach((toolbar) => { expected.forEach((toolbar) => {
const method = expected.includes(toolbar) ? 'exists' : 'doesNotExist'; assert.dom(PAGE.detail[toolbar]).exists(`${toolbar} action exists`);
assert.dom(PAGE.detail[toolbar])[method](`${toolbar} action ${humanize([method])}`); });
const unexpected = DETAIL_TOOLBARS.filter((t) => !expected.includes(t));
unexpected.forEach((toolbar) => {
assert.dom(PAGE.detail[toolbar]).doesNotExist(`${toolbar} action doesNotExist`);
});
};
const patchRedirectTest = (test, testCase) => {
// only run this test on enterprise so we are testing permissions specifically and not enterprise vs CE (which also redirects)
test(`enterprise: patch route redirects for users without permissions (${testCase})`, async function (assert) {
await visit(`/vault/secrets/${this.backend}/kv/app%2Fnested%2Fsecret/patch`);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/kv/app%2Fnested%2Fsecret`,
'redirects to index'
);
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.kv.secret.index');
}); });
}; };
@@ -78,6 +96,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
hooks.beforeEach(async function () { hooks.beforeEach(async function () {
const uid = uuidv4(); const uid = uuidv4();
this.store = this.owner.lookup('service:store'); this.store = this.owner.lookup('service:store');
this.version = this.owner.lookup('service:version');
this.emptyBackend = `kv-empty-${uid}`; this.emptyBackend = `kv-empty-${uid}`;
this.backend = `kv-nav-${uid}`; this.backend = `kv-nav-${uid}`;
await authPage.login(); await authPage.login();
@@ -106,17 +125,23 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
return; return;
}); });
test('empty backend - breadcrumbs, title, tabs, emptyState (a)', async function (assert) { test('empty backend - breadcrumbs, title, tabs, emptyState (a)', async function (assert) {
assert.expect(15); assert.expect(23);
const backend = this.emptyBackend; const backend = this.emptyBackend;
await navToBackend(backend); await navToBackend(backend);
// URL correct // URL correct
assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list`, 'lands on secrets list page'); assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list`, 'lands on secrets list page');
// Breadcrumbs correct // CONFIGURATION TAB
await click(PAGE.secretTab('Configuration'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'Configuration']);
assert.dom(PAGE.secretTab('Configuration')).hasClass('active');
assert.dom(PAGE.secretTab('Configuration')).hasText('Configuration');
assert.dom(PAGE.secretTab('Secrets')).hasText('Secrets');
assert.dom(PAGE.secretTab('Secrets')).doesNotHaveClass('active');
// SECRETS TAB
await click(PAGE.secretTab('Secrets'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend]); assertCorrectBreadcrumbs(assert, ['Secrets', backend]);
// Title correct
assert.dom(PAGE.title).hasText(`${backend} version 2`); assert.dom(PAGE.title).hasText(`${backend} version 2`);
// Tabs correct
assert.dom(PAGE.secretTab('Secrets')).hasText('Secrets'); assert.dom(PAGE.secretTab('Secrets')).hasText('Secrets');
assert.dom(PAGE.secretTab('Secrets')).hasClass('active'); assert.dom(PAGE.secretTab('Secrets')).hasClass('active');
assert.dom(PAGE.secretTab('Configuration')).hasText('Configuration'); assert.dom(PAGE.secretTab('Configuration')).hasText('Configuration');
@@ -143,7 +168,9 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
); );
}); });
test('can access nested secret (a)', async function (assert) { test('can access nested secret (a)', async function (assert) {
assert.expect(40); // enterprise has "Patch latest version" in the toolbar which adds an assertion
const count = this.version.isEnterprise ? 47 : 46;
assert.expect(count);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct'); assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct');
@@ -177,12 +204,18 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
await click(PAGE.list.item('secret')); await click(PAGE.list.item('secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`, `/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
`navigated to ${currentURL()}` `navigated to ${currentURL()}`
); );
assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'app', 'nested', 'secret']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'app', 'nested', 'secret']);
assert.dom(PAGE.title).hasText('app/nested/secret', 'title is full secret path'); assert.dom(PAGE.title).hasText('app/nested/secret', 'title is full secret path');
assertDetailsToolbar(assert);
await click(PAGE.secretTab('Secret'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'app', 'nested', 'secret']);
const expectedToolbar = this.version.isEnterprise
? [...DETAIL_TOOLBARS, 'patchLatest']
: DETAIL_TOOLBARS;
assertDetailsToolbar(assert, expectedToolbar);
await click(PAGE.breadcrumbAtIdx(3)); await click(PAGE.breadcrumbAtIdx(3));
assert.true( assert.true(
@@ -212,38 +245,40 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
// Reported bug, backported fix https://github.com/hashicorp/vault/pull/24281 // Reported bug, backported fix https://github.com/hashicorp/vault/pull/24281
// list for directory // list for directory
await visit(`/vault/secrets/${backend}/list/app/`); await visit(`/vault/secrets/${backend}/list/app/`);
assert.strictEqual( assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, `navigates to list`);
currentURL(),
`/vault/secrets/${backend}/kv/list/app/`,
`navigated to ${currentURL()}`
);
// show for secret // show for secret
await visit(`/vault/secrets/${backend}/show/app/nested/secret`); await visit(`/vault/secrets/${backend}/show/app/nested/secret`);
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`, `/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
`navigated to ${currentURL()}` `navigates to overview`
); );
// edit for secret // edit for secret
await visit(`/vault/secrets/${backend}/edit/app/nested/secret`); await visit(`/vault/secrets/${backend}/edit/app/nested/secret`);
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`, `/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details/edit?version=1`,
`navigated to ${currentURL()}` `navigates to edit`
); );
}); });
test('versioned secret nav, tabs, breadcrumbs (a)', async function (assert) { test('versioned secret nav, tabs (a)', async function (assert) {
assert.expect(45); assert.expect(27);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
await click(PAGE.list.item(secretPath)); await click(PAGE.list.item(secretPath));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'navigates to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=3`, `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=3`,
'Url includes version query param' 'Url includes version query param'
); );
assert.dom(PAGE.title).hasText(secretPath, 'title is correct on detail view'); assert.dom(PAGE.title).hasText(secretPath, 'title is correct on detail view');
assertDetailTabs(assert, 'Secret');
assert.dom(PAGE.detail.versionDropdown).hasText('Version 3', 'Version dropdown shows current version'); assert.dom(PAGE.detail.versionDropdown).hasText('Version 3', 'Version dropdown shows current version');
assert.dom(PAGE.detail.createNewVersion).hasText('Create new version', 'Create version button shows'); assert.dom(PAGE.detail.createNewVersion).hasText('Create new version', 'Create version button shows');
assert.dom(PAGE.detail.versionTimestamp).containsText('Version 3 created'); assert.dom(PAGE.detail.versionTimestamp).containsText('Version 3 created');
@@ -261,10 +296,10 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
await click(FORM.cancelBtn); await click(FORM.cancelBtn);
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=3`, `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'Goes back to detail view' 'Goes back to overview'
); );
await click(PAGE.secretTab('Secret'));
await click(PAGE.detail.versionDropdown); await click(PAGE.detail.versionDropdown);
await click(`${PAGE.detail.version(1)} a`); await click(`${PAGE.detail.version(1)} a`);
assert.strictEqual( assert.strictEqual(
@@ -294,7 +329,6 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/metadata`, `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/metadata`,
`goes to metadata page` `goes to metadata page`
); );
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata']);
assert.dom(PAGE.title).hasText(secretPath); assert.dom(PAGE.title).hasText(secretPath);
assert assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`) .dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
@@ -310,7 +344,6 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/metadata/edit`, `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/metadata/edit`,
`goes to metadata edit page` `goes to metadata edit page`
); );
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata', 'Edit']);
await click(FORM.cancelBtn); await click(FORM.cancelBtn);
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
@@ -318,44 +351,121 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
`cancel btn goes back to metadata page` `cancel btn goes back to metadata page`
); );
}); });
test('breadcrumbs & page titles are correct (a)', async function (assert) { test('breadcrumbs, tabs & page titles are correct (a)', async function (assert) {
assert.expect(45); assert.expect(123);
// only need to assert hrefs one test, no need for this function to be global
const assertTabHrefs = (assert, page) => {
ALL_TABS.forEach((tab) => {
const baseUrl = `/ui/vault/secrets/${backend}/kv`;
const hrefs = {
Overview: `${baseUrl}/${secretPathUrlEncoded}`,
Secret:
page === 'Secret'
? `${baseUrl}/${secretPathUrlEncoded}/details?version=3`
: `${baseUrl}/${secretPathUrlEncoded}/details`,
Metadata: `${baseUrl}/${secretPathUrlEncoded}/metadata`,
Paths: `${baseUrl}/${secretPathUrlEncoded}/paths`,
'Version History': `${baseUrl}/${secretPathUrlEncoded}/metadata/versions`,
};
assert
.dom(PAGE.secretTab(tab))
.hasAttribute('href', hrefs[tab], `${tab} tab for page: ${page} has expected href`);
});
};
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
await click(PAGE.secretTab('Configuration'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'Configuration']);
assert.dom(PAGE.title).hasText(`${backend} version 2`, 'correct page title for configuration');
await click(PAGE.secretTab('Secrets'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend]);
assert.dom(PAGE.title).hasText(`${backend} version 2`, 'correct page title for secret list');
await click(PAGE.list.item(secretPath)); await click(PAGE.list.item(secretPath));
// PAGE COMPONENTS RENDER THEIR OWN TABS, ASSERT EACH HREF ON EACH PAGE
// overview tab
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.index',
'navs to overview'
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]);
assertDetailTabs(assert, 'Overview');
assertTabHrefs(assert, 'Overview');
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for secret overview');
// secret tab
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.details.index',
'navs to details'
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]);
assertDetailTabs(assert, 'Secret');
assertTabHrefs(assert, 'Secret');
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for secret detail'); assert.dom(PAGE.title).hasText(secretPath, 'correct page title for secret detail');
await click(PAGE.detail.createNewVersion); await click(PAGE.detail.createNewVersion);
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.details.edit',
'navs to create'
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Edit']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Edit']);
assert.dom(PAGE.title).hasText('Create New Version', 'correct page title for secret edit'); assert.dom(PAGE.title).hasText('Create New Version', 'correct page title for secret edit');
// metadata tab
await click(PAGE.breadcrumbAtIdx(2)); await click(PAGE.breadcrumbAtIdx(2));
await click(PAGE.secretTab('Metadata')); await click(PAGE.secretTab('Metadata'));
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.metadata.index',
'navs to metadata'
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata']);
assertDetailTabs(assert, 'Metadata');
assertTabHrefs(assert, 'Metadata');
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for metadata'); assert.dom(PAGE.title).hasText(secretPath, 'correct page title for metadata');
await click(PAGE.metadata.editBtn); await click(PAGE.metadata.editBtn);
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.metadata.edit',
'navs to metadata.edit'
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata', 'Edit']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata', 'Edit']);
assert.dom(PAGE.title).hasText('Edit Secret Metadata', 'correct page title for metadata edit'); assert.dom(PAGE.title).hasText('Edit Secret Metadata', 'correct page title for metadata edit');
// paths tab
await click(PAGE.breadcrumbAtIdx(3)); await click(PAGE.breadcrumbAtIdx(3));
await click(PAGE.secretTab('Paths')); await click(PAGE.secretTab('Paths'));
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.paths',
'navs to paths'
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Paths']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Paths']);
assertDetailTabs(assert, 'Paths');
assertTabHrefs(assert, 'Paths');
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for paths'); assert.dom(PAGE.title).hasText(secretPath, 'correct page title for paths');
// version history tab
await click(PAGE.secretTab('Version History')); await click(PAGE.secretTab('Version History'));
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.metadata.versions',
'navs to version history'
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Version History']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Version History']);
assertDetailTabs(assert, 'Version History');
assertTabHrefs(assert, 'Version History');
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for version history'); assert.dom(PAGE.title).hasText(secretPath, 'correct page title for version history');
}); });
// only run this test on enterprise so we are testing permissions specifically and not enterprise vs CE (which also redirects)
test('enterprise: patch route does not redirect for users with permissions (a)', async function (assert) {
await visit(`/vault/secrets/${this.backend}/kv/app%2Fnested%2Fsecret/patch`);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/kv/app%2Fnested%2Fsecret/patch`,
'redirects to index'
);
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.kv.secret.patch');
});
}); });
module('data-reader persona', function (hooks) { module('data-reader persona', function (hooks) {
@@ -437,9 +547,10 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`, `/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
`navigated to correct details view ${currentURL()}` `navigated to secret overview ${currentURL()}`
); );
await click(PAGE.secretTab('Secret'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'app', 'nested', 'secret']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'app', 'nested', 'secret']);
assert.dom(PAGE.title).hasText('app/nested/secret', 'title is full secret path'); assert.dom(PAGE.title).hasText('app/nested/secret', 'title is full secret path');
assertDetailsToolbar(assert, ['copy']); assertDetailsToolbar(assert, ['copy']);
@@ -460,7 +571,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root'); assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root');
}); });
test('versioned secret nav, tabs, breadcrumbs (dr)', async function (assert) { test('versioned secret nav, tabs, breadcrumbs (dr)', async function (assert) {
assert.expect(28); assert.expect(31);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
@@ -468,6 +579,12 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
await typeIn(PAGE.list.overviewInput, secretPath); await typeIn(PAGE.list.overviewInput, secretPath);
await click(PAGE.list.overviewButton); await click(PAGE.list.overviewButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'navigates to secret overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=3`, `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=3`,
@@ -536,6 +653,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.dom(PAGE.secretTab('Version History')).doesNotExist('Version History tab not shown'); assert.dom(PAGE.secretTab('Version History')).doesNotExist('Version History tab not shown');
}); });
patchRedirectTest(test, 'dr');
}); });
module('data-list-reader persona', function (hooks) { module('data-list-reader persona', function (hooks) {
@@ -590,7 +708,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
); );
}); });
test('can access nested secret (dlr)', async function (assert) { test('can access nested secret (dlr)', async function (assert) {
assert.expect(31); assert.expect(32);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct'); assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct');
@@ -615,6 +733,12 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
await typeIn(PAGE.list.overviewInput, 'nested/secret'); await typeIn(PAGE.list.overviewInput, 'nested/secret');
await click(PAGE.list.overviewButton); await click(PAGE.list.overviewButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
`navigated to overview`
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`, `/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`,
@@ -640,10 +764,16 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root'); assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root');
}); });
test('versioned secret nav, tabs, breadcrumbs (dlr)', async function (assert) { test('versioned secret nav, tabs, breadcrumbs (dlr)', async function (assert) {
assert.expect(28); assert.expect(31);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
await click(PAGE.list.item(secretPath)); await click(PAGE.list.item(secretPath));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'navigates to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=3`, `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=3`,
@@ -713,6 +843,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.dom(PAGE.secretTab('Version History')).doesNotExist('Version History tab not shown'); assert.dom(PAGE.secretTab('Version History')).doesNotExist('Version History tab not shown');
}); });
patchRedirectTest(test, 'dlr');
}); });
module('metadata-maintainer persona', function (hooks) { module('metadata-maintainer persona', function (hooks) {
@@ -732,7 +863,6 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.expect(15); assert.expect(15);
const backend = this.emptyBackend; const backend = this.emptyBackend;
await navToBackend(backend); await navToBackend(backend);
// URL correct // URL correct
assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list`, 'lands on secrets list page'); assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list`, 'lands on secrets list page');
// Breadcrumbs correct // Breadcrumbs correct
@@ -766,7 +896,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
); );
}); });
test('can access nested secret (mm)', async function (assert) { test('can access nested secret (mm)', async function (assert) {
assert.expect(41); assert.expect(42);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct'); assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct');
@@ -798,10 +928,16 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.dom(PAGE.list.item('secret')).exists('Shows deeply nested secret'); assert.dom(PAGE.list.item('secret')).exists('Shows deeply nested secret');
await click(PAGE.list.item('secret')); await click(PAGE.list.item('secret'));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
`goes to overview`
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details`, `/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details`,
`Goes to URL with version` `Goes to URL without version`
); );
assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'app', 'nested', 'secret']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'app', 'nested', 'secret']);
assert.dom(PAGE.title).hasText('app/nested/secret', 'title is full secret path'); assert.dom(PAGE.title).hasText('app/nested/secret', 'title is full secret path');
@@ -824,15 +960,21 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root'); assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root');
}); });
test('versioned secret nav, tabs, breadcrumbs (mm)', async function (assert) { test('versioned secret nav, tabs, breadcrumbs (mm)', async function (assert) {
assert.expect(37); assert.expect(40);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
await click(PAGE.list.item(secretPath)); await click(PAGE.list.item(secretPath));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'navs to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details`, `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details`,
'Url includes version query param' 'Url does not include version query param'
); );
assert.dom(PAGE.title).hasText(secretPath, 'Goes to secret detail view'); assert.dom(PAGE.title).hasText(secretPath, 'Goes to secret detail view');
assertDetailTabs(assert, 'Secret'); assertDetailTabs(assert, 'Secret');
@@ -924,6 +1066,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Version History']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Version History']);
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for version history'); assert.dom(PAGE.title).hasText(secretPath, 'correct page title for version history');
}); });
patchRedirectTest(test, 'mm');
}); });
module('secret-creator persona', function (hooks) { module('secret-creator persona', function (hooks) {
@@ -979,7 +1122,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
); );
}); });
test('can access nested secret (sc)', async function (assert) { test('can access nested secret (sc)', async function (assert) {
assert.expect(23); assert.expect(24);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct'); assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct');
@@ -991,6 +1134,12 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
await typeIn(PAGE.list.overviewInput, 'app/nested/secret'); await typeIn(PAGE.list.overviewInput, 'app/nested/secret');
await click(PAGE.list.overviewButton); await click(PAGE.list.overviewButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
'goes to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details`, `/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details`,
@@ -1016,12 +1165,18 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root'); assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root');
}); });
test('versioned secret nav, tabs, breadcrumbs (sc)', async function (assert) { test('versioned secret nav, tabs, breadcrumbs (sc)', async function (assert) {
assert.expect(36); assert.expect(39);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
await typeIn(PAGE.list.overviewInput, secretPath); await typeIn(PAGE.list.overviewInput, secretPath);
await click(PAGE.list.overviewButton); await click(PAGE.list.overviewButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'Goes to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details`, `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details`,
@@ -1055,8 +1210,8 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
await click(FORM.cancelBtn); await click(FORM.cancelBtn);
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details`, `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'Goes back to detail view' 'Goes back to overview'
); );
await visit(`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=1`); await visit(`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=1`);
@@ -1090,7 +1245,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.dom(PAGE.metadata.editBtn).doesNotExist('edit metadata button does not render'); assert.dom(PAGE.metadata.editBtn).doesNotExist('edit metadata button does not render');
}); });
test('breadcrumbs & page titles are correct (sc)', async function (assert) { test('breadcrumbs & page titles are correct (sc)', async function (assert) {
assert.expect(34); assert.expect(39);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
await click(PAGE.secretTab('Configuration')); await click(PAGE.secretTab('Configuration'));
@@ -1106,6 +1261,10 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]);
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for secret detail'); assert.dom(PAGE.title).hasText(secretPath, 'correct page title for secret detail');
await click(PAGE.secretTab('Secret'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]);
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for secret detail');
await click(PAGE.detail.createNewVersion); await click(PAGE.detail.createNewVersion);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Edit']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Edit']);
assert.dom(PAGE.title).hasText('Create New Version', 'correct page title for secret edit'); assert.dom(PAGE.title).hasText('Create New Version', 'correct page title for secret edit');
@@ -1124,6 +1283,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.dom(PAGE.secretTab('Version History')).doesNotExist('Version History tab not shown'); assert.dom(PAGE.secretTab('Version History')).doesNotExist('Version History tab not shown');
}); });
patchRedirectTest(test, 'sc');
}); });
module('enterprise controlled access persona', function (hooks) { module('enterprise controlled access persona', function (hooks) {
@@ -1155,7 +1315,7 @@ path "${this.backend}/*" {
return; return;
}); });
test('can access nested secret (cg)', async function (assert) { test('can access nested secret (cg)', async function (assert) {
assert.expect(42); assert.expect(43);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct'); assert.dom(PAGE.title).hasText(`${backend} version 2`, 'title text correct');
@@ -1205,7 +1365,13 @@ path "${this.backend}/*" {
'navigates to list url where secret is' 'navigates to list url where secret is'
); );
await click(PAGE.list.item('secret')); await click(PAGE.list.item('secret'));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
'goes to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual( assert.strictEqual(
currentURL(), currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`, `/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`,
@@ -1231,7 +1397,7 @@ path "${this.backend}/*" {
assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root'); assert.true(currentURL().startsWith(`/vault/secrets/${backend}/kv/list`), 'links back to list root');
}); });
test('breadcrumbs & page titles are correct (cg)', async function (assert) { test('breadcrumbs & page titles are correct (cg)', async function (assert) {
assert.expect(36); assert.expect(43);
const backend = this.backend; const backend = this.backend;
await navToBackend(backend); await navToBackend(backend);
await click(PAGE.secretTab('Configuration')); await click(PAGE.secretTab('Configuration'));
@@ -1261,17 +1427,19 @@ path "${this.backend}/*" {
'navigates back to list url after authorized' 'navigates back to list url after authorized'
); );
await click(PAGE.list.item(secretPath)); await click(PAGE.list.item(secretPath));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'Goes to overview'
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]);
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for secret detail'); assert.dom(PAGE.title).hasText(secretPath, 'correct page title for secret overview');
await click(PAGE.secretTab('Metadata')); await click(PAGE.secretTab('Metadata'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata']);
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for metadata'); assert.dom(PAGE.title).hasText(secretPath, 'correct page title for metadata');
assert.dom(PAGE.metadata.editBtn).doesNotExist('cannot edit metadata'); assert.dom(PAGE.metadata.editBtn).doesNotExist('cannot edit metadata');
await click(PAGE.breadcrumbAtIdx(2));
await click(PAGE.secretTab('Paths')); await click(PAGE.secretTab('Paths'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Paths']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Paths']);
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for paths'); assert.dom(PAGE.title).hasText(secretPath, 'correct page title for paths');
@@ -1279,9 +1447,87 @@ path "${this.backend}/*" {
assert.dom(PAGE.secretTab('Version History')).doesNotExist('Version History tab not shown'); assert.dom(PAGE.secretTab('Version History')).doesNotExist('Version History tab not shown');
await click(PAGE.secretTab('Secret')); await click(PAGE.secretTab('Secret'));
assert.true(
await waitUntil(() => currentRouteName() === 'vault.cluster.access.control-group-accessor'),
'redirects to access control group route'
);
await grantAccess({
apiPath: `${backend}/data/${encodeURIComponent(secretPath)}`,
originUrl: `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/paths`,
userToken: this.userToken,
backend: this.backend,
});
await click(PAGE.secretTab('Secret'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]);
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for secret details');
await click(PAGE.detail.createNewVersion); await click(PAGE.detail.createNewVersion);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Edit']); assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Edit']);
assert.dom(PAGE.title).hasText('Create New Version', 'correct page title for secret edit'); assert.dom(PAGE.title).hasText('Create New Version', 'correct page title for secret edit');
}); });
}); });
// patch is technically enterprise only but stubbing the version so they can run on both CE and enterprise
module('patch-persona', function (hooks) {
hooks.beforeEach(async function () {
const token = await runCmd([
createPolicyCmd(
`secret-patcher-${this.backend}`,
personas.secretPatcher(this.backend) + personas.secretPatcher(this.emptyBackend)
),
createTokenCmd(`secret-patcher-${this.backend}`),
]);
await authPage.login(token);
clearRecords(this.store);
return;
});
test('it navigates to patch a secret from overview', async function (assert) {
this.version.type = 'enterprise';
await navToBackend(this.backend);
await click(PAGE.list.item(secretPath));
await click(GENERAL.overviewCard.actionText('Patch secret'));
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.patch',
'navs to patch'
);
assertCorrectBreadcrumbs(assert, ['Secrets', this.backend, secretPath, 'Patch']);
assert.dom(PAGE.title).hasText('Patch Secret to New Version');
await click(FORM.cancelBtn);
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.index',
'navs back to overview'
);
});
test('overview subkeys card is hidden for community edition', async function (assert) {
this.version.type = 'community';
await navToBackend(this.backend);
await click(PAGE.list.item(secretPath));
assert.dom(GENERAL.overviewCard.container('Subkeys')).doesNotExist();
});
test('it does not redirect for ent', async function (assert) {
this.version.type = 'enterprise';
await visit(`/vault/secrets/${this.backend}/kv/app%2Fnested%2Fsecret/patch`);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/kv/app%2Fnested%2Fsecret/patch`,
'redirects to index'
);
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.kv.secret.patch');
});
test('it redirects for community edition', async function (assert) {
this.version.type = 'community';
await visit(`/vault/secrets/${this.backend}/kv/app%2Fnested%2Fsecret/patch`);
assert.strictEqual(
currentURL(),
`/vault/secrets/${this.backend}/kv/app%2Fnested%2Fsecret`,
'redirects to index'
);
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.backend.kv.secret.index');
});
});
}); });

View File

@@ -106,15 +106,15 @@ module('Acceptance | secrets/secret/create, read, delete', function (hooks) {
await writeSecret(this.backend, secretPath, 'foo', 'bar'); await writeSecret(this.backend, secretPath, 'foo', 'bar');
assert.strictEqual( assert.strictEqual(
currentRouteName(), currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.details.index', 'vault.cluster.secrets.backend.kv.secret.index',
'redirects to the show page' 'redirects to the overview page'
); );
assert.dom(PAGE.detail.createNewVersion).exists('shows the edit button');
}); });
test('it navigates to version history and to a specific version', async function (assert) { test('it navigates to version history and to a specific version', async function (assert) {
assert.expect(4); assert.expect(4);
const secretPath = `specific-version`; const secretPath = `specific-version`;
await writeVersionedSecret(this.backend, secretPath, 'foo', 'bar', 4); await writeVersionedSecret(this.backend, secretPath, 'foo', 'bar', 4);
await click(PAGE.secretTab('Secret'));
assert assert
.dom(PAGE.detail.versionTimestamp) .dom(PAGE.detail.versionTimestamp)
.includesText('Version 4 created', 'shows version created time'); .includesText('Version 4 created', 'shows version created time');

View File

@@ -40,6 +40,7 @@ export const PAGE = {
versionDropdown: '[data-test-version-dropdown]', versionDropdown: '[data-test-version-dropdown]',
version: (number) => `[data-test-version="${number}"]`, version: (number) => `[data-test-version="${number}"]`,
createNewVersion: '[data-test-create-new-version]', createNewVersion: '[data-test-create-new-version]',
patchLatest: '[data-test-patch-latest-version]',
delete: '[data-test-kv-delete="delete"]', delete: '[data-test-kv-delete="delete"]',
destroy: '[data-test-kv-delete="destroy"]', destroy: '[data-test-kv-delete="destroy"]',
undelete: '[data-test-kv-delete="undelete"]', undelete: '[data-test-kv-delete="undelete"]',
@@ -61,10 +62,9 @@ export const PAGE = {
item: (secret) => (!secret ? '[data-test-list-item]' : `[data-test-list-item="${secret}"]`), item: (secret) => (!secret ? '[data-test-list-item]' : `[data-test-list-item="${secret}"]`),
filter: `[data-test-kv-list-filter]`, filter: `[data-test-kv-list-filter]`,
listMenuDelete: `[data-test-popup-metadata-delete]`, listMenuDelete: `[data-test-popup-metadata-delete]`,
listMenuCreate: `[data-test-popup-create-new-version]`,
overviewCard: '[data-test-overview-card-container="View secret"]', overviewCard: '[data-test-overview-card-container="View secret"]',
overviewInput: '[data-test-view-secret] input', overviewInput: '[data-test-view-secret] input',
overviewButton: '[data-test-get-secret-detail]', overviewButton: '[data-test-submit-button]',
pagination: '[data-test-pagination]', pagination: '[data-test-pagination]',
paginationInfo: '.hds-pagination-info', paginationInfo: '.hds-pagination-info',
paginationNext: '.hds-pagination-nav__arrow--direction-next', paginationNext: '.hds-pagination-nav__arrow--direction-next',

View File

@@ -3,7 +3,7 @@
* SPDX-License-Identifier: BUSL-1.1 * SPDX-License-Identifier: BUSL-1.1
*/ */
const root = ['create', 'read', 'update', 'delete', 'list']; const root = ['create', 'read', 'update', 'delete', 'list', 'patch'];
// returns a string with each capability wrapped in double quotes => ["create", "read"] // returns a string with each capability wrapped in double quotes => ["create", "read"]
const format = (array) => array.map((c) => `"${c}"`).join(', '); const format = (array) => array.map((c) => `"${c}"`).join(', ');
@@ -25,6 +25,14 @@ export const dataPolicy = ({ backend, secretPath = '*', capabilities = root }) =
`; `;
}; };
export const subkeysPolicy = ({ backend, secretPath = '*' }) => {
return `
path "${backend}/subkeys/${secretPath}" {
capabilities = ["read"]
}
`;
};
export const dataNestedPolicy = ({ backend, secretPath = '*', capabilities = root }) => { export const dataNestedPolicy = ({ backend, secretPath = '*', capabilities = root }) => {
return ` return `
path "${backend}/data/app/${secretPath}" { path "${backend}/data/app/${secretPath}" {
@@ -82,7 +90,7 @@ export const destroyVersionsPolicy = ({ backend, secretPath = '*' }) => {
// Personas for reuse in workflow tests // Personas for reuse in workflow tests
export const personas = { export const personas = {
admin: (backend) => adminPolicy(backend), admin: (backend) => adminPolicy(backend) + subkeysPolicy({ backend }),
dataReader: (backend) => dataPolicy({ backend, capabilities: ['read'] }), dataReader: (backend) => dataPolicy({ backend, capabilities: ['read'] }),
dataListReader: (backend) => dataListReader: (backend) =>
dataPolicy({ backend, capabilities: ['read', 'delete'] }) + metadataListPolicy(backend), dataPolicy({ backend, capabilities: ['read', 'delete'] }) + metadataListPolicy(backend),
@@ -97,4 +105,8 @@ export const personas = {
secretCreator: (backend) => secretCreator: (backend) =>
dataPolicy({ backend, capabilities: ['create', 'update'] }) + dataPolicy({ backend, capabilities: ['create', 'update'] }) +
metadataPolicy({ backend, capabilities: ['delete'] }), metadataPolicy({ backend, capabilities: ['delete'] }),
secretPatcher: (backend) =>
dataPolicy({ backend, capabilities: ['patch'] }) +
metadataPolicy({ backend, capabilities: ['list', 'read'] }) +
subkeysPolicy({ backend }),
}; };

View File

@@ -15,6 +15,7 @@ module('Integration | Component | kv | kv-subkeys-card', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
setupEngine(hooks, 'kv'); setupEngine(hooks, 'kv');
hooks.beforeEach(function () { hooks.beforeEach(function () {
this.isPatchAllowed = true;
this.subkeys = { this.subkeys = {
foo: null, foo: null,
bar: { bar: {
@@ -22,14 +23,16 @@ module('Integration | Component | kv | kv-subkeys-card', function (hooks) {
}, },
}; };
this.renderComponent = async () => { this.renderComponent = async () => {
return render(hbs`<KvSubkeysCard @subkeys={{this.subkeys}} />`, { return render(
owner: this.engine, hbs`<KvSubkeysCard @subkeys={{this.subkeys}} @isPatchAllowed={{this.isPatchAllowed}} />`,
}); {
owner: this.engine,
}
);
}; };
}); });
test('it renders', async function (assert) { test('it renders', async function (assert) {
assert.expect(4);
await this.renderComponent(); await this.renderComponent();
assert.dom(overviewCard.title('Subkeys')).exists(); assert.dom(overviewCard.title('Subkeys')).exists();
@@ -40,10 +43,17 @@ module('Integration | Component | kv | kv-subkeys-card', function (hooks) {
); );
assert.dom(overviewCard.content('Subkeys')).hasText('Keys foo bar'); assert.dom(overviewCard.content('Subkeys')).hasText('Keys foo bar');
assert.dom(GENERAL.toggleInput('kv-subkeys')).isNotChecked('JSON toggle is not checked by default'); assert.dom(GENERAL.toggleInput('kv-subkeys')).isNotChecked('JSON toggle is not checked by default');
assert.dom(overviewCard.actionText('Patch secret')).exists();
});
test('it hides patch action when isPatchAllowed is false', async function (assert) {
this.isPatchAllowed = false;
await this.renderComponent();
assert.dom(overviewCard.title('Subkeys')).exists();
assert.dom(overviewCard.actionText('Patch secret')).doesNotExist();
}); });
test('it toggles to JSON', async function (assert) { test('it toggles to JSON', async function (assert) {
assert.expect(4);
await this.renderComponent(); await this.renderComponent();
assert.dom(GENERAL.toggleInput('kv-subkeys')).isNotChecked(); assert.dom(GENERAL.toggleInput('kv-subkeys')).isNotChecked();

View File

@@ -12,6 +12,7 @@ import { hbs } from 'ember-cli-htmlbars';
import { kvDataPath } from 'vault/utils/kv-path'; import { kvDataPath } from 'vault/utils/kv-path';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors'; import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { baseSetup, metadataModel } from 'vault/tests/helpers/kv/kv-run-commands'; import { baseSetup, metadataModel } from 'vault/tests/helpers/kv/kv-run-commands';
import { dateFormat } from 'core/helpers/date-format';
module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', function (hooks) { module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
@@ -41,21 +42,32 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
{ label: this.model.backend, route: 'list' }, { label: this.model.backend, route: 'list' },
{ label: this.model.path }, { label: this.model.path },
]; ];
this.canDeleteMetadata = true;
this.canReadCustomMetadata = true;
this.canReadMetadata = true;
this.canUpdateMetadata = true;
this.renderComponent = () => {
return render(
hbs`
<Page::Secret::Metadata::Details
@breadcrumbs={{this.breadcrumbs}}
@canDeleteMetadata={{this.canDeleteMetadata}}
@canReadMetadata={{this.canReadMetadata}}
@canUpdateMetadata={{this.canReadMetadata}}
@customMetadata={{or this.model.metadata.customMetadata this.model.secret.customMetadata}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
/>
`,
{ owner: this.engine }
);
};
}); });
test('it renders metadata details', async function (assert) { test('it renders metadata details', async function (assert) {
assert.expect(8); assert.expect(8);
await render( await this.renderComponent();
hbs`
<Page::Secret::Metadata::Details
@path={{this.model.path}}
@secret={{this.model.secret}}
@metadata={{this.model.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
assert.dom(PAGE.title).includesText(this.model.path, 'renders secret path as page title'); assert.dom(PAGE.title).includesText(this.model.path, 'renders secret path as page title');
assert.dom(PAGE.emptyStateTitle).hasText('No custom metadata', 'renders the correct empty state'); assert.dom(PAGE.emptyStateTitle).hasText('No custom metadata', 'renders the correct empty state');
@@ -63,9 +75,10 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
assert.dom(PAGE.metadata.editBtn).exists(); assert.dom(PAGE.metadata.editBtn).exists();
// Metadata details // Metadata details
const expectedTime = dateFormat([this.metadata.updatedTime, 'MMM d, yyyy hh:mm aa'], {});
assert assert
.dom(PAGE.infoRowValue('Last updated')) .dom(PAGE.infoRowValue('Last updated'))
.hasTextContaining('Mar', 'Displays updated date with formatting'); .hasTextContaining(expectedTime, 'Displays updated date with formatting');
assert.dom(PAGE.infoRowValue('Maximum versions')).hasText('15'); assert.dom(PAGE.infoRowValue('Maximum versions')).hasText('15');
assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('Yes'); assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('Yes');
assert assert
@@ -76,17 +89,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
test('it renders custom metadata from secret model', async function (assert) { test('it renders custom metadata from secret model', async function (assert) {
assert.expect(2); assert.expect(2);
this.secret.customMetadata = { hi: 'there' }; this.secret.customMetadata = { hi: 'there' };
await render( await this.renderComponent();
hbs`
<Page::Secret::Metadata::Details
@path={{this.model.path}}
@secret={{this.model.secret}}
@metadata={{this.model.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
assert.dom(PAGE.emptyStateTitle).doesNotExist(); assert.dom(PAGE.emptyStateTitle).doesNotExist();
assert.dom(PAGE.infoRowValue('hi')).hasText('there', 'renders custom metadata from secret'); assert.dom(PAGE.infoRowValue('hi')).hasText('there', 'renders custom metadata from secret');
@@ -95,17 +98,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
test('it renders custom metadata from metadata model', async function (assert) { test('it renders custom metadata from metadata model', async function (assert) {
assert.expect(4); assert.expect(4);
this.model.metadata = metadataModel(this, { withCustom: true }); this.model.metadata = metadataModel(this, { withCustom: true });
await render( await this.renderComponent();
hbs`
<Page::Secret::Metadata::Details
@path={{this.model.path}}
@secret={{this.model.secret}}
@metadata={{this.model.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
assert.dom(PAGE.emptyStateTitle).doesNotExist(); assert.dom(PAGE.emptyStateTitle).doesNotExist();
// Metadata details // Metadata details
@@ -113,4 +106,27 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
assert.dom(PAGE.infoRowValue('bar')).hasText('123'); assert.dom(PAGE.infoRowValue('bar')).hasText('123');
assert.dom(PAGE.infoRowValue('baz')).hasText('5c07d823-3810-48f6-a147-4c06b5219e84'); assert.dom(PAGE.infoRowValue('baz')).hasText('5c07d823-3810-48f6-a147-4c06b5219e84');
}); });
test('it renders custom metadata from metadata if secret data exists', async function (assert) {
assert.expect(4);
this.secret.customMetadata = { hi: 'there' };
this.model.metadata = metadataModel(this, { withCustom: true });
await this.renderComponent();
assert.dom(PAGE.emptyStateTitle).doesNotExist();
// Metadata details
assert.dom(PAGE.infoRowValue('foo')).hasText('abc');
assert.dom(PAGE.infoRowValue('bar')).hasText('123');
assert.dom(PAGE.infoRowValue('baz')).hasText('5c07d823-3810-48f6-a147-4c06b5219e84');
});
test('it hides delete modal when no permissions', async function (assert) {
this.canDeleteMetadata = false;
assert.dom(PAGE.metadata.deleteMetadata).doesNotExist();
});
test('it hides edit action when no permissions', async function (assert) {
this.canUpdateMetadata = false;
assert.dom(PAGE.metadata.editBtn).doesNotExist();
});
}); });

View File

@@ -16,6 +16,8 @@ import { dateFromNow } from 'core/helpers/date-from-now';
import { baseSetup } from 'vault/tests/helpers/kv/kv-run-commands'; import { baseSetup } from 'vault/tests/helpers/kv/kv-run-commands';
const { overviewCard } = GENERAL; const { overviewCard } = GENERAL;
// subkeys access is enterprise only (in the GUI) but we don't have any version testing here because the @subkeys arg is null for non-enterprise versions
module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hooks) { module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
setupEngine(hooks, 'kv'); setupEngine(hooks, 'kv');
@@ -46,7 +48,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
}; };
this.canReadMetadata = true; this.canReadMetadata = true;
this.canUpdateSecret = true; this.canUpdateSecret = true;
this.secretState = 'created';
this.format = (time) => dateFormat([time, 'MMM d yyyy, h:mm:ss aa'], {}); this.format = (time) => dateFormat([time, 'MMM d yyyy, h:mm:ss aa'], {});
this.renderComponent = async () => { this.renderComponent = async () => {
@@ -59,7 +60,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
@canUpdateSecret={{this.canUpdateSecret}} @canUpdateSecret={{this.canUpdateSecret}}
@metadata={{this.metadata}} @metadata={{this.metadata}}
@path={{this.path}} @path={{this.path}}
@secretState={{this.secretState}}
@subkeys={{this.subkeys}} @subkeys={{this.subkeys}}
/>`, />`,
{ owner: this.engine } { owner: this.engine }
@@ -84,7 +84,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
test('it renders with full permissions', async function (assert) { test('it renders with full permissions', async function (assert) {
await this.renderComponent(); await this.renderComponent();
const fromNow = dateFromNow([this.metadata.createdTime]); // uses date-fns so can't stub timestamp util const fromNow = dateFromNow([this.metadata.updatedTime]); // uses date-fns so can't stub timestamp util
assert.dom(`${overviewCard.container('Current version')} .hds-badge`).doesNotExist(); assert.dom(`${overviewCard.container('Current version')} .hds-badge`).doesNotExist();
assert assert
.dom(overviewCard.container('Current version')) .dom(overviewCard.container('Current version'))
@@ -94,8 +94,8 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
assert assert
.dom(overviewCard.container('Secret age')) .dom(overviewCard.container('Secret age'))
.hasText( .hasText(
`Secret age View metadata Time since last update at ${this.format( `Secret age View metadata Current secret version age. Last updated on ${this.format(
this.metadata.createdTime this.metadata.updatedTime
)}. ${fromNow}` )}. ${fromNow}`
); );
assert assert
@@ -138,7 +138,11 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
.hasText(`Current version Create new The current version of this secret. ${subkeyMeta.version}`); .hasText(`Current version Create new The current version of this secret. ${subkeyMeta.version}`);
assert assert
.dom(overviewCard.container('Secret age')) .dom(overviewCard.container('Secret age'))
.hasText(`Secret age Time since last update at ${this.format(subkeyMeta.created_time)}. ${fromNow}`); .hasText(
`Secret age Current secret version age. Last updated on ${this.format(
subkeyMeta.created_time
)}. ${fromNow}`
);
assert.dom(`${overviewCard.container('Secret age')} a`).doesNotExist('metadata link does not render'); assert.dom(`${overviewCard.container('Secret age')} a`).doesNotExist('metadata link does not render');
assert assert
.dom(overviewCard.container('Paths')) .dom(overviewCard.container('Paths'))
@@ -157,8 +161,8 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
test('it renders with no subkeys permissions', async function (assert) { test('it renders with no subkeys permissions', async function (assert) {
this.subkeys = null; this.subkeys = null;
await this.renderComponent(); await this.renderComponent();
const fromNow = dateFromNow([this.metadata.createdTime]); // uses date-fns so can't stub timestamp util const fromNow = dateFromNow([this.metadata.updatedTime]); // uses date-fns so can't stub timestamp util
const expectedTime = this.format(this.metadata.createdTime); const expectedTime = this.format(this.metadata.updatedTime);
assert assert
.dom(overviewCard.container('Current version')) .dom(overviewCard.container('Current version'))
.hasText( .hasText(
@@ -166,7 +170,9 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
); );
assert assert
.dom(overviewCard.container('Secret age')) .dom(overviewCard.container('Secret age'))
.hasText(`Secret age View metadata Time since last update at ${expectedTime}. ${fromNow}`); .hasText(
`Secret age View metadata Current secret version age. Last updated on ${expectedTime}. ${fromNow}`
);
assert assert
.dom(overviewCard.container('Paths')) .dom(overviewCard.container('Paths'))
.hasText( .hasText(
@@ -192,7 +198,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
module('deleted version', function (hooks) { module('deleted version', function (hooks) {
hooks.beforeEach(async function () { hooks.beforeEach(async function () {
this.secretState = 'deleted';
// subkeys is null but metadata still has data // subkeys is null but metadata still has data
this.subkeys = { this.subkeys = {
subkeys: null, subkeys: null,
@@ -256,6 +261,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
.hasText( .hasText(
`Current version Deleted Create new The current version of this secret was deleted ${expectedTime}. ${this.metadata.currentVersion}` `Current version Deleted Create new The current version of this secret was deleted ${expectedTime}. ${this.metadata.currentVersion}`
); );
assert.dom(overviewCard.container('Subkeys')).doesNotExist();
}); });
test('with no permissions', async function (assert) { test('with no permissions', async function (assert) {
@@ -268,7 +274,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
module('destroyed version', function (hooks) { module('destroyed version', function (hooks) {
hooks.beforeEach(async function () { hooks.beforeEach(async function () {
this.secretState = 'destroyed';
// subkeys is null but metadata still has data // subkeys is null but metadata still has data
this.subkeys = { this.subkeys = {
subkeys: null, subkeys: null,
@@ -329,6 +334,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
.hasText( .hasText(
`Current version Destroyed Create new The current version of this secret has been permanently deleted and cannot be restored. ${this.metadata.currentVersion}` `Current version Destroyed Create new The current version of this secret has been permanently deleted and cannot be restored. ${this.metadata.currentVersion}`
); );
assert.dom(overviewCard.container('Subkeys')).doesNotExist();
}); });
test('with no permissions', async function (assert) { test('with no permissions', async function (assert) {

View File

@@ -94,7 +94,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
const [route] = this.transitionStub.lastCall.args; const [route] = this.transitionStub.lastCall.args;
assert.strictEqual( assert.strictEqual(
route, route,
'vault.cluster.secrets.backend.kv.secret', 'vault.cluster.secrets.backend.kv.secret.index',
`it transitions on cancel to: ${route}` `it transitions on cancel to: ${route}`
); );
}); });
@@ -150,7 +150,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
const [route] = this.transitionStub.lastCall.args; const [route] = this.transitionStub.lastCall.args;
assert.strictEqual( assert.strictEqual(
route, route,
'vault.cluster.secrets.backend.kv.secret', 'vault.cluster.secrets.backend.kv.secret.index',
`it transitions on save to: ${route}` `it transitions on save to: ${route}`
); );
}); });
@@ -181,7 +181,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
const [route] = this.transitionStub.lastCall.args; const [route] = this.transitionStub.lastCall.args;
assert.strictEqual( assert.strictEqual(
route, route,
'vault.cluster.secrets.backend.kv.secret', 'vault.cluster.secrets.backend.kv.secret.index',
`it transitions on save to: ${route}` `it transitions on save to: ${route}`
); );
}); });
@@ -264,7 +264,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
const flash = this.flashSpy.lastCall?.args[0] || ''; const flash = this.flashSpy.lastCall?.args[0] || '';
assert.strictEqual( assert.strictEqual(
route, route,
'vault.cluster.secrets.backend.kv.secret', 'vault.cluster.secrets.backend.kv.secret.index',
`it transitions to overview route: ${route}` `it transitions to overview route: ${route}`
); );
assert.strictEqual( assert.strictEqual(
@@ -288,7 +288,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
const flash = this.flashSpy.lastCall?.args[0] || ''; const flash = this.flashSpy.lastCall?.args[0] || '';
assert.strictEqual( assert.strictEqual(
route, route,
'vault.cluster.secrets.backend.kv.secret', 'vault.cluster.secrets.backend.kv.secret.index',
`it transitions to overview route: ${route}` `it transitions to overview route: ${route}`
); );
assert.strictEqual( assert.strictEqual(

View File

@@ -53,12 +53,13 @@ module('Integration | Component | kv-v2 | Page::Secret::Edit', function (hooks)
test('it saves a new secret version', async function (assert) { test('it saves a new secret version', async function (assert) {
assert.expect(10); assert.expect(10);
this.server.post(`${this.backend}/data/${this.path}`, (schema, req) => { this.server.post(`${this.backend}/data/${this.path}`, (schema, req) => {
assert.ok(true, 'Request made to save secret'); assert.true(true, 'Request made to save secret');
const payload = JSON.parse(req.requestBody); const payload = JSON.parse(req.requestBody);
assert.propEqual(payload, { assert.propEqual(
data: { foo: 'bar', foo2: 'bar2' }, payload,
options: { cas: 1 }, { data: { foo: 'bar', foo2: 'bar2' }, options: { cas: 1 } },
}); 'request has expected payload'
);
return { return {
request_id: 'bd76db73-605d-fcbc-0dad-d44a008f9b95', request_id: 'bd76db73-605d-fcbc-0dad-d44a008f9b95',
data: { data: {
@@ -97,9 +98,11 @@ module('Integration | Component | kv-v2 | Page::Secret::Edit', function (hooks)
await fillIn(FORM.keyInput(1), 'foo2'); await fillIn(FORM.keyInput(1), 'foo2');
await fillIn(FORM.maskedValueInput(1), 'bar2'); await fillIn(FORM.maskedValueInput(1), 'bar2');
await click(FORM.saveBtn); await click(FORM.saveBtn);
assert.ok( const [actual] = this.transitionStub.lastCall.args;
this.transitionStub.calledWith('vault.cluster.secrets.backend.kv.secret.details'), assert.strictEqual(
'router transitions to secret details route on save' actual,
'vault.cluster.secrets.backend.kv.secret.index',
'router transitions to secret overview route on save'
); );
}); });
@@ -193,9 +196,11 @@ module('Integration | Component | kv-v2 | Page::Secret::Edit', function (hooks)
assert.dom(FORM.messageError).hasText('Error nope', 'it renders API error'); assert.dom(FORM.messageError).hasText('Error nope', 'it renders API error');
assert.dom(FORM.inlineAlert).hasText('There was an error submitting this form.'); assert.dom(FORM.inlineAlert).hasText('There was an error submitting this form.');
await click(FORM.cancelBtn); await click(FORM.cancelBtn);
assert.ok( const [actual] = this.transitionStub.lastCall.args;
this.transitionStub.calledWith('vault.cluster.secrets.backend.kv.secret.details'), assert.strictEqual(
'router transitions to details on cancel' actual,
'vault.cluster.secrets.backend.kv.secret.index',
'router transitions to secret overview route on cancel'
); );
}); });

View File

@@ -100,10 +100,11 @@ module('Integration | Component | kv-v2 | Page::Secrets::Create', function (hook
await fillIn(FORM.inputByAttr('maxVersions'), this.maxVersions); await fillIn(FORM.inputByAttr('maxVersions'), this.maxVersions);
await click(FORM.saveBtn); await click(FORM.saveBtn);
const [actual] = this.transitionStub.lastCall.args;
assert.ok( assert.strictEqual(
this.transitionStub.calledWith('vault.cluster.secrets.backend.kv.secret.details'), actual,
'router transitions to secret details route on save' 'vault.cluster.secrets.backend.kv.secret.index',
'router transitions to secret overview route on save'
); );
}); });

View File

@@ -9,6 +9,15 @@ import { SUDO_PATHS, SUDO_PATH_PREFIXES } from 'vault/models/capabilities';
import { run } from '@ember/runloop'; import { run } from '@ember/runloop';
const CAPABILITIES = {
canCreate: 'create',
canRead: 'read',
canUpdate: 'update',
canDelete: 'delete',
canList: 'list',
canPatch: 'patch',
};
module('Unit | Model | capabilities', function (hooks) { module('Unit | Model | capabilities', function (hooks) {
setupTest(hooks); setupTest(hooks);
@@ -17,7 +26,7 @@ module('Unit | Model | capabilities', function (hooks) {
assert.ok(!!model); assert.ok(!!model);
}); });
test('it reads capabilities', function (assert) { test('it computes capabilities', function (assert) {
const model = run(() => const model = run(() =>
this.owner.lookup('service:store').createRecord('capabilities', { this.owner.lookup('service:store').createRecord('capabilities', {
path: 'foo', path: 'foo',
@@ -31,6 +40,23 @@ module('Unit | Model | capabilities', function (hooks) {
assert.notOk(model.get('canDelete')); assert.notOk(model.get('canDelete'));
}); });
for (const capability in CAPABILITIES) {
test(`it computes capability: ${capability}`, function (assert) {
const permission = CAPABILITIES[capability];
const model = run(() =>
this.owner.lookup('service:store').createRecord('capabilities', {
path: 'foo',
capabilities: [permission],
})
);
assert.true(model.get(capability), `${capability} is true`);
const falsyCapabilities = Object.keys(CAPABILITIES).filter((c) => c !== capability);
falsyCapabilities.forEach((c) => {
assert.false(model.get(c), `${c} is false`);
});
});
}
test('it allows everything if root is present', function (assert) { test('it allows everything if root is present', function (assert) {
const model = run(() => const model = run(() =>
this.owner.lookup('service:store').createRecord('capabilities', { this.owner.lookup('service:store').createRecord('capabilities', {
@@ -38,11 +64,10 @@ module('Unit | Model | capabilities', function (hooks) {
capabilities: ['root', 'deny', 'read'], capabilities: ['root', 'deny', 'read'],
}) })
); );
assert.ok(model.get('canRead'));
assert.ok(model.get('canCreate')); Object.keys(CAPABILITIES).forEach((c) => {
assert.ok(model.get('canUpdate')); assert.true(model.get(c), `${c} is true`);
assert.ok(model.get('canDelete')); });
assert.ok(model.get('canList'));
}); });
test('it denies everything if deny is present', function (assert) { test('it denies everything if deny is present', function (assert) {
@@ -52,11 +77,9 @@ module('Unit | Model | capabilities', function (hooks) {
capabilities: ['sudo', 'deny', 'read'], capabilities: ['sudo', 'deny', 'read'],
}) })
); );
assert.notOk(model.get('canRead')); Object.keys(CAPABILITIES).forEach((c) => {
assert.notOk(model.get('canCreate')); assert.false(model.get(c), `${c} is false`);
assert.notOk(model.get('canUpdate')); });
assert.notOk(model.get('canDelete'));
assert.notOk(model.get('canList'));
}); });
test('it requires sudo on sudo paths', function (assert) { test('it requires sudo on sudo paths', function (assert) {

View File

@@ -80,7 +80,7 @@ module('Unit | Service | capabilities', function (hooks) {
this.capabilities.fetchPathCapabilities(path); this.capabilities.fetchPathCapabilities(path);
}); });
test('fetchMultiplePaths: it makes request to capabilities-self with paths param', function (assert) { test('fetchMultiplePaths: it makes request to capabilities-self with paths param', async function (assert) {
const paths = ['/my/api/path', 'another/api/path']; const paths = ['/my/api/path', 'another/api/path'];
const expectedPayload = { paths }; const expectedPayload = { paths };
this.server.post('/sys/capabilities-self', (schema, req) => { this.server.post('/sys/capabilities-self', (schema, req) => {
@@ -89,10 +89,94 @@ module('Unit | Service | capabilities', function (hooks) {
assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`); assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`);
return this.generateResponse({ return this.generateResponse({
paths, paths,
capabilities: { '/my/api/path': ['read'], 'another/api/path': ['read'] }, capabilities: { '/my/api/path': ['read', 'list'], 'another/api/path': ['read', 'delete'] },
}); });
}); });
this.capabilities.fetchMultiplePaths(paths); const actual = await this.capabilities.fetchMultiplePaths(paths);
const expected = {
'/my/api/path': {
canCreate: false,
canDelete: false,
canList: true,
canPatch: false,
canRead: true,
canSudo: false,
canUpdate: false,
},
'another/api/path': {
canCreate: false,
canDelete: true,
canList: false,
canPatch: false,
canRead: true,
canSudo: false,
canUpdate: false,
},
};
assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`);
});
test('fetchMultiplePaths: it defaults to true if the capabilities request fails', async function (assert) {
// don't stub endpoint which causes request to fail
const paths = ['/my/api/path', 'another/api/path'];
const actual = await this.capabilities.fetchMultiplePaths(paths);
const expected = {
'/my/api/path': {
canCreate: true,
canDelete: true,
canList: true,
canPatch: true,
canRead: true,
canSudo: true,
canUpdate: true,
},
'another/api/path': {
canCreate: true,
canDelete: true,
canList: true,
canPatch: true,
canRead: true,
canSudo: true,
canUpdate: true,
},
};
assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`);
});
test('fetchMultiplePaths: it defaults to true if no model is found', async function (assert) {
const paths = ['/my/api/path', 'another/api/path'];
const expectedPayload = { paths };
this.server.post('/sys/capabilities-self', (schema, req) => {
const actual = JSON.parse(req.requestBody);
assert.true(true, 'request made to capabilities-self');
assert.propEqual(actual, expectedPayload, `request made with path: ${JSON.stringify(actual)}`);
return this.generateResponse({
paths: ['/my/api/path'],
capabilities: { '/my/api/path': ['read', 'list'] },
});
});
const actual = await this.capabilities.fetchMultiplePaths(paths);
const expected = {
'/my/api/path': {
canCreate: false,
canDelete: false,
canList: true,
canPatch: false,
canRead: true,
canSudo: false,
canUpdate: false,
},
'another/api/path': {
canCreate: true,
canDelete: true,
canList: true,
canPatch: true,
canRead: true,
canSudo: true,
canUpdate: true,
},
};
assert.propEqual(actual, expected, `it returns expected response: ${JSON.stringify(actual)}`);
}); });
module('specific methods', function () { module('specific methods', function () {
@@ -102,23 +186,33 @@ module('Unit | Service | capabilities', function (hooks) {
capabilities: ['read'], capabilities: ['read'],
expectedRead: true, // expected computed properties based on response expectedRead: true, // expected computed properties based on response
expectedUpdate: false, expectedUpdate: false,
expectedPatch: false,
}, },
{ {
capabilities: ['update'], capabilities: ['update'],
expectedRead: false, expectedRead: false,
expectedUpdate: true, expectedUpdate: true,
expectedPatch: false,
},
{
capabilities: ['patch'],
expectedRead: false,
expectedUpdate: false,
expectedPatch: true,
}, },
{ {
capabilities: ['deny'], capabilities: ['deny'],
expectedRead: false, expectedRead: false,
expectedUpdate: false, expectedUpdate: false,
expectedPatch: false,
}, },
{ {
capabilities: ['read', 'update'], capabilities: ['read', 'update'],
expectedRead: true, expectedRead: true,
expectedUpdate: true, expectedUpdate: true,
expectedPatch: false,
}, },
].forEach(({ capabilities, expectedRead, expectedUpdate }) => { ].forEach(({ capabilities, expectedRead, expectedUpdate, expectedPatch }) => {
test(`canRead returns expected value for "${capabilities.join(', ')}"`, async function (assert) { test(`canRead returns expected value for "${capabilities.join(', ')}"`, async function (assert) {
this.server.post('/sys/capabilities-self', () => { this.server.post('/sys/capabilities-self', () => {
return this.generateResponse({ path, capabilities }); return this.generateResponse({ path, capabilities });
@@ -135,6 +229,14 @@ module('Unit | Service | capabilities', function (hooks) {
const response = await this.capabilities.canUpdate(path); const response = await this.capabilities.canUpdate(path);
assert[expectedUpdate](response, `canUpdate returns ${expectedUpdate}`); assert[expectedUpdate](response, `canUpdate returns ${expectedUpdate}`);
}); });
test(`canPatch returns expected value for "${capabilities.join(', ')}"`, async function (assert) {
this.server.post('/sys/capabilities-self', () => {
return this.generateResponse({ path, capabilities });
});
const response = await this.capabilities.canPatch(path);
assert[expectedPatch](response, `canPatch returns ${expectedPatch}`);
});
}); });
}); });
}); });

View File

@@ -47,7 +47,7 @@ module('Unit | Utility | kv-breadcrumbs', function () {
[ [
{ label: 'beep', route: 'list-directory', models: ['kv-mount', 'beep/'] }, { label: 'beep', route: 'list-directory', models: ['kv-mount', 'beep/'] },
{ label: 'bop', route: 'list-directory', models: ['kv-mount', 'beep/bop/'] }, { label: 'bop', route: 'list-directory', models: ['kv-mount', 'beep/bop/'] },
{ label: 'boop', route: 'secret.details', models: ['kv-mount', 'beep/bop/boop'] }, { label: 'boop', route: 'secret.index', models: ['kv-mount', 'beep/bop/boop'] },
], ],
'correct when full nested path to secret' 'correct when full nested path to secret'
); );
@@ -66,7 +66,7 @@ module('Unit | Utility | kv-breadcrumbs', function () {
results = breadcrumbsForSecret('kv-mount', 'beep'); results = breadcrumbsForSecret('kv-mount', 'beep');
assert.deepEqual( assert.deepEqual(
results, results,
[{ label: 'beep', route: 'secret.details', models: ['kv-mount', 'beep'] }], [{ label: 'beep', route: 'secret.index', models: ['kv-mount', 'beep'] }],
'correct when non-nested secret path' 'correct when non-nested secret path'
); );

View File

@@ -9,12 +9,13 @@ import Model from '@ember-data/model';
interface CapabilitiesModel extends Model { interface CapabilitiesModel extends Model {
path: string; path: string;
capabilities: Array<string>; capabilities: Array<string>;
canSudo: ComputedProperty<boolean | undefined>;
canRead: ComputedProperty<boolean | undefined>;
canCreate: ComputedProperty<boolean | undefined>; canCreate: ComputedProperty<boolean | undefined>;
canUpdate: ComputedProperty<boolean | undefined>;
canDelete: ComputedProperty<boolean | undefined>; canDelete: ComputedProperty<boolean | undefined>;
canList: ComputedProperty<boolean | undefined>; canList: ComputedProperty<boolean | undefined>;
canPatch: ComputedProperty<boolean | undefined>;
canRead: ComputedProperty<boolean | undefined>;
canSudo: ComputedProperty<boolean | undefined>;
canUpdate: ComputedProperty<boolean | undefined>;
// these don't seem to be used anywhere // these don't seem to be used anywhere
// inferring type from key name // inferring type from key name
allowedParameters: Array<string>; allowedParameters: Array<string>;