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 {
kvDataPath,
kvDeletePath,
kvDestroyPath,
kvMetadataPath,
kvSubkeysPath,
kvUndeletePath,
} from 'vault/utils/kv-path';
import { kvDataPath, kvDeletePath, kvDestroyPath, kvSubkeysPath, kvUndeletePath } from 'vault/utils/kv-path';
import { assert } from '@ember/debug';
import ControlGroupError from 'vault/lib/control-group-error';
@@ -40,9 +33,15 @@ export default class KvDataAdapter extends ApplicationAdapter {
fetchSubkeys(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
// extrapolate error handling logic from queryRecord and share between these two methods
return this.ajax(url, 'GET').then((resp) => resp.data);
return (
this.ajax(url, 'GET')
.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) {
@@ -85,39 +84,7 @@ export default class KvDataAdapter extends ApplicationAdapter {
};
})
.catch((errorOrResponse) => {
const baseResponse = { 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;
return this.parseErrorOrResponse(errorOrResponse, { id, backend, path, version });
});
}
@@ -145,12 +112,8 @@ export default class KvDataAdapter extends ApplicationAdapter {
return this.ajax(this._url(kvUndeletePath(backend, path)), 'POST', {
data: { versions: deleteVersions },
});
case 'destroy-all-versions':
return this.ajax(this._url(kvMetadataPath(backend, path)), 'DELETE');
default:
assert(
'deleteType must be one of delete-latest-version, delete-version, destroy, undelete, or destroy-all-versions.'
);
assert('deleteType must be one of delete-latest-version, delete-version, destroy, or undelete.');
}
}
@@ -162,4 +125,42 @@ export default class KvDataAdapter extends ApplicationAdapter {
}
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) {
const { backend, path, fullSecretPath } = snapshot.record;
// 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.
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: {
dependencies: {
services: [
'capabilities',
'control-group',
'download',
'flash-messages',
'namespace',
'router',
'store',
'secret-mount-path',
'flash-messages',
'control-group',
'store',
'version',
],
externalRoutes: {
secrets: 'vault.cluster.secrets.backends',
@@ -116,7 +118,7 @@ export default class App extends Application {
dependencies: {
services: ['flash-messages', 'flags', 'router', 'store', 'version'],
externalRoutes: {
kvSecretDetails: 'vault.cluster.secrets.backend.kv.secret.details',
kvSecretOverview: 'vault.cluster.secrets.backend.kv.secret.index',
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.',
buttonText: 'Read secrets',
model: 'kv/metadata',
route: 'vault.cluster.secrets.backend.kv.secret.details',
route: 'vault.cluster.secrets.backend.kv.secret.index',
nameKey: 'path',
queryObject: { pathToSecret: '', backend: this.selectedEngine.id },
objectKeys: ['path', 'id'],
@@ -149,7 +149,7 @@ export default class DashboardQuickActionsCard extends Component {
const path = this.paramValue.path || this.paramValue;
route = pathIsDirectory(path)
? 'vault.cluster.secrets.backend.kv.list-directory'
: 'vault.cluster.secrets.backend.kv.secret.details';
: 'vault.cluster.secrets.backend.kv.secret.index';
param = path;
}

View File

@@ -47,12 +47,13 @@ const computedCapability = function (capability) {
export default Model.extend({
path: attr('string'),
capabilities: attr('array'),
canSudo: computedCapability('sudo'),
canRead: computedCapability('read'),
canCreate: computedCapability('create'),
canUpdate: computedCapability('update'),
canDelete: computedCapability('delete'),
canList: computedCapability('list'),
allowedParameters: 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();
// for kv v2, redirect users from the old url to the new engine url (1.15.0 +)
if (secretEngine.type === 'kv' && secretEngine.version === 2) {
let route, params;
switch (true) {
case !secret:
// if no secret param redirect to the create route
// if secret param they are either viewing or editing secret so navigate to the details route
if (!secret) {
this.router.transitionTo('vault.cluster.secrets.backend.kv.create', secretEngine.id);
} else {
this.router.transitionTo(
'vault.cluster.secrets.backend.kv.secret.details',
secretEngine.id,
secret
);
route = 'vault.cluster.secrets.backend.kv.create';
params = [secretEngine.id];
break;
case this.routeName === 'vault.cluster.secrets.backend.show':
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;
}
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 StoreService from 'vault/services/store';
interface Query {
paths?: string[];
path?: string;
interface Capabilities {
canCreate: boolean;
canDelete: boolean;
canList: boolean;
canPatch: boolean;
canRead: boolean;
canSudo: boolean;
canUpdate: boolean;
}
interface MultipleCapabilities {
[key: string]: Capabilities;
}
export default class CapabilitiesService extends Service {
@service declare readonly store: StoreService;
async request(query: Query) {
async request(query: { paths?: string[]; path?: string }) {
if (query?.paths) {
const { paths } = query;
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);
}
/*
this method returns a capabilities model for each path in the array of paths
*/
async fetchMultiplePaths(paths: string[]): Promise<Array<CapabilitiesModel>> | AdapterError {
try {
return await this.request({ paths });
} catch (e) {
return e;
async fetchMultiplePaths(paths: string[]): MultipleCapabilities | AdapterError {
// if the request to capabilities-self fails, silently catch
// all of path capabilities default to "true"
const resp: Array<CapabilitiesModel> | [] = await this.request({ paths }).catch(() => []);
return paths.reduce((obj: MultipleCapabilities, apiPath: string) => {
// path is the model's primaryKey (id)
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
*/
async fetchPathCapabilities(path: string): Promise<CapabilitiesModel> | AdapterError {
fetchPathCapabilities(path: string): Promise<CapabilitiesModel> | AdapterError {
try {
return await this.request({ path });
return this.request({ path });
} catch (error) {
return error;
}
@@ -69,17 +95,25 @@ export default class CapabilitiesService extends Service {
}
}
async canRead(path: string) {
canRead(path: string) {
try {
return await this._fetchSpecificCapability(path, 'canRead');
return this._fetchSpecificCapability(path, 'canRead');
} catch (e) {
return e;
}
}
async canUpdate(path: string) {
canUpdate(path: string) {
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) {
return e;
}

View File

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

View File

@@ -20,7 +20,7 @@ import { assert } from '@ember/debug';
*
* @param {string} mode - delete, delete-metadata, or destroy.
* @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 {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."
/>
{{/if}}
<hr class="has-background-gray-200" />
<KvPatch::SubkeysReveal @subkeys={{@subkeys}} />
<hr class="has-background-gray-200" />
<Hds::ButtonSet>

View File

@@ -3,7 +3,7 @@
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>
<Hds::Text::Body @tag="p" @color="faint" data-test-overview-card-subtitle="Subkeys">
{{#if this.showJson}}
@@ -14,6 +14,7 @@
@icon="docs-link"
@iconPosition="trailing"
@href={{doc-link "/vault/api-docs/secret/kv/kv-v2#read-secret-subkeys"}}
@isHrefExternal={{true}}
>API documentation</Hds::Link::Inline>.
{{else}}
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>
</:customSubtext>
<:action>
<div>
<Toggle @name="kv-subkeys" @checked={{this.showJson}} @onChange={{fn (mut this.showJson)}}>
<p class="has-text-grey">JSON</p>
</Toggle>
<div class="flex column-gap-16">
<div class="top-padding-4">
<Hds::Form::Toggle::Field
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>
</:action>
<:content>

View File

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

View File

@@ -58,7 +58,7 @@
@color="secondary"
type="submit"
disabled={{not this.secretPath}}
data-test-get-secret-detail
data-test-submit-button
/>
</form>
{{#if @failedDirectoryQuery}}
@@ -77,7 +77,7 @@
<LinkedBlock
data-test-list-item={{metadata.path}}
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}}
>
<div class="level is-mobile">
@@ -106,7 +106,12 @@
/>
{{else}}
<dd.Interactive
@text="Details"
@text="Overview"
@route="secret.index"
@models={{array @backend metadata.fullSecretPath}}
/>
<dd.Interactive
@text="Secret data"
@route="secret.details"
@models={{array @backend metadata.fullSecretPath}}
/>
@@ -117,14 +122,6 @@
@models={{array @backend metadata.fullSecretPath}}
/>
{{/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}}
<dd.Interactive
@text="Permanently delete"

View File

@@ -87,6 +87,6 @@ export default class KvListPageComponent extends Component {
evt.preventDefault();
pathIsDirectory(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>
<:tabLinks>
<li>
<LinkTo
@route="secret.index"
@models={{array @secret.backend @path}}
data-test-secrets-tab="Overview"
>Overview</LinkTo>
</li>
<li>
<LinkTo @route="secret.details" @models={{array @secret.backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo>
</li>
@@ -112,6 +119,12 @@
{{#if @secret.canReadMetadata}}
<KvVersionDropdown @displayVersion={{this.version}} @metadata={{@metadata}} @onClose={{this.closeVersionMenu}} />
{{/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}}
<ToolbarLink
data-test-create-new-version
@@ -156,6 +169,7 @@
@iconPosition="trailing"
@text="KV v2 API docs"
@href={{doc-link this.emptyState.link}}
@isHrefExternal={{true}}
/>
{{/if}}
</EmptyState>

View File

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

View File

@@ -84,9 +84,7 @@ export default class KvSecretEdit extends Component {
const { secret } = this.args;
yield secret.save();
this.flashMessages.success(`Successfully created new version of ${secret.path}.`);
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details', {
queryParams: { version: secret?.version },
});
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
}
} catch (error) {
let message = errorMessage(error);
@@ -102,6 +100,6 @@ export default class KvSecretEdit extends Component {
@action
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}}>
<:tabLinks>
<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>
<LinkTo
@route="secret.metadata.index"
@models={{array @secret.backend @path}}
@models={{array @backend @path}}
data-test-secrets-tab="Metadata"
>Metadata</LinkTo>
</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>
{{#if @secret.canReadMetadata}}
{{#if @canReadMetadata}}
<li>
<LinkTo
@route="secret.metadata.versions"
@models={{array @secret.backend @path}}
@models={{array @backend @path}}
data-test-secrets-tab="Version History"
>Version History</LinkTo>
</li>
@@ -30,16 +33,11 @@
</:tabLinks>
<:toolbarActions>
{{#if @secret.canDeleteMetadata}}
<KvDeleteModal
@mode="delete-metadata"
@metadata={{@metadata}}
@onDelete={{this.onDelete}}
@text="Permanently delete"
/>
{{#if @canDeleteMetadata}}
<KvDeleteModal @mode="delete-metadata" @onDelete={{this.onDelete}} @text="Permanently delete" />
{{/if}}
{{#if @secret.canUpdateMetadata}}
<ToolbarLink @route="secret.metadata.edit" @models={{array @secret.backend @path}} data-test-edit-metadata>
{{#if @canUpdateMetadata}}
<ToolbarLink @route="secret.metadata.edit" @models={{array @backend @path}} data-test-edit-metadata>
Edit metadata
</ToolbarLink>
{{/if}}
@@ -50,8 +48,9 @@
Custom metadata
</h2>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-custom-metadata-section>
{{#if (or @metadata.canReadMetadata @secret.canReadData)}}
{{#each-in this.customMetadata as |key value|}}
{{! if the user had read permissions and there is no custom_metadata @customMetadata is an empty object, without read capabilities it's undefined }}
{{#if @customMetadata}}
{{#each-in @customMetadata as |key value|}}
<InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} />
{{else}}
<EmptyState
@@ -59,12 +58,12 @@
@bottomBorder={{true}}
@message="This data is version-agnostic and is usually used to describe the secret being stored."
>
{{#if @secret.canUpdateMetadata}}
{{#if @canUpdateMetadata}}
<Hds::Link::Standalone
@icon="plus"
@text="Add metadata"
@route="secret.metadata.edit"
@models={{array @secret.backend @path}}
@models={{array @backend @path}}
data-test-add-custom-metadata
/>
{{/if}}
@@ -81,7 +80,7 @@
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l">
Secret metadata
</h2>
{{#if @secret.canReadMetadata}}
{{#if @canReadMetadata}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-metadata-section>
<InfoTableRow @alwaysRender={{true}} @label="Last updated">
<KvTooltipTimestamp @timestamp={{@metadata.updatedTime}} />

View File

@@ -6,21 +6,31 @@
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import errorMessage from 'vault/utils/error-message';
/**
* @module KvSecretMetadataDetails renders the details view for kv/metadata.
* It also renders a button to delete metadata.
* @module KvSecretMetadataDetails renders the details view for kv/metadata and button to delete (which deletes the whole secret) or edit metadata.
* <Page::Secret::Metadata::Details
* @path={{this.model.path}}
* @secret={{this.model.secret}}
* @metadata={{this.model.metadata}}
* @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}}
* @path={{this.model.path}}
* />
*
* @param {string} path - path of kv secret 'my/secret' used as the title for the KV page header
* @param {model} [secret] - Ember data model: 'kv/data'. Param not required for delete-metadata.
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {string} backend - The name of the kv secret engine.
* @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 {
@@ -28,25 +38,20 @@ export default class KvSecretMetadataDetails extends Component {
@service router;
@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
async onDelete() {
// The only delete option from this view is delete all versions
const { secret } = this.args;
// The only delete option from this view is delete metadata and all versions
const { backend, path } = this.args;
const adapter = this.store.adapterFor('kv/metadata');
try {
await secret.destroyRecord({
adapterOptions: { deleteType: 'destroy-all-versions', deleteVersions: this.version },
});
await adapter.deleteMetadata(backend, path);
this.store.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
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');
} 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"
@text="KV v2 metadata API docs"
@href={{doc-link "/vault/api-docs/secret/kv/kv-v2#create-update-metadata"}}
@isHrefExternal={{true}}
/>
</EmptyState>
{{/if}}

View File

@@ -5,6 +5,9 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:tabLinks>
<li>
<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>

View File

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

View File

@@ -5,6 +5,7 @@
import Component from '@glimmer/component';
import { dateFormat } from 'core/helpers/date-format';
import { isDeleted } from 'kv/utils/kv-deleted';
/**
* @module KvSecretOverview
@@ -15,7 +16,6 @@ import { dateFormat } from 'core/helpers/date-format';
* @canUpdateSecret={{true}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* @secretState="created"
* @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 {model} metadata - Ember data model: 'kv/metadata'
* @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
* @param {object} subkeys - API response from subkeys endpoint, object with "subkeys" and "metadata" keys. This arg is null for community edition
*/
export default class KvSecretOverview extends Component {
get isActive() {
const state = this.args.secretState;
return state !== 'destroyed' && state !== 'deleted';
get secretState() {
if (this.args.metadata) {
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() {
const state = this.args.secretState;
const state = this.secretState;
if (state === 'destroyed') {
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 {
yield adapter.patchSecret(backend, path, patchData, version);
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) {
// TODO test...this is copy pasta'd from the edit page
let message = errorMessage(error);
@@ -81,7 +81,7 @@ export default class KvSecretPatch extends Component {
@action
onCancel() {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret');
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
}
isEmpty(object) {

View File

@@ -5,6 +5,9 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle={{@path}}>
<:tabLinks>
<li>
<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>

View File

@@ -90,7 +90,7 @@ export default class KvSecretCreate extends Component {
if (this.errorMessage) {
this.invalidFormAlert = 'There was an error submitting this form.';
} 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;
dependencies = {
services: [
'capabilities',
'control-group',
'download',
'flash-messages',
'namespace',
'router',
'store',
'secret-mount-path',
'flash-messages',
'control-group',
'store',
'version',
],
externalRoutes: ['secrets', 'syncDestination'],
};

View File

@@ -13,6 +13,7 @@ export default buildRoutes(function () {
this.route('list-directory', { path: '/list/*path_to_secret' });
this.route('create');
this.route('secret', { path: '/:name' }, function () {
this.route('patch');
this.route('paths');
this.route('details', function () {
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 {
@service secretMountPath;
@service store;
fetchSecretData(backend, path) {
// This will always return a record unless 404 not found (show error) or control group
return this.store.queryRecord('kv/data', { backend, path });
}
@service capabilities;
@service version;
fetchSecretMetadata(backend, path) {
// catch error and do nothing because kv/data model handles metadata capabilities
return this.store.queryRecord('kv/metadata', { backend, path }).catch(() => {});
// catch error and only return 404 which indicates the secret truly does not exist.
// 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() {
@@ -29,8 +57,11 @@ export default class KvSecretRoute extends Route {
return hash({
path,
backend,
secret: this.fetchSecretData(backend, path),
subkeys: this.fetchSubkeys(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,23 +18,18 @@ export default class KvSecretDetailsRoute extends Route {
model(params) {
const parentModel = this.modelFor('secret');
// Only fetch versioned data if selected version does not match parent (current) version
// and parentModel.secret has failReadErrorCode since permissions aren't version specific
if (
params.version &&
parentModel.secret.version !== params.version &&
!parentModel.secret.failReadErrorCode
) {
// 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;
const query = { backend, path };
// if a version is selected from the dropdown it triggers a model refresh
// and we fire off new request for that version's secret data
if (params.version) {
query.version = params.version;
}
return hash({
...parentModel,
secret: this.store.queryRecord('kv/data', { backend, path, version: params.version }),
secret: this.store.queryRecord('kv/data', query),
});
}
return parentModel;
}
// breadcrumbs are set in details/index.js
setupController(controller, resolvedModel) {

View File

@@ -5,11 +5,18 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
export default class SecretIndex extends Route {
@service router;
redirect() {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.details');
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, true),
];
controller.breadcrumbs = breadcrumbsArray;
}
}

View File

@@ -7,31 +7,57 @@ import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class KvSecretMetadataRoute extends Route {
@service store;
@service capabilities;
@service secretMountPath;
@service store;
fetchMetadata(backend, path) {
return this.store.queryRecord('kv/metadata', { backend, path }).catch((error) => {
if (error.message === 'Control Group encountered') {
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() {
const backend = this.secretMountPath.currentPath;
const { name: path } = this.paramsFor('secret');
const parentModel = this.modelFor('secret');
const { backend, path } = parentModel;
const permissions = await this.fetchCapabilities(backend, path);
const model = {
...parentModel,
permissions,
};
if (!parentModel.metadata) {
// metadata read on the secret root fails silently
// if there's no metadata, try again in case it's a control group
const metadata = await this.fetchMetadata(backend, path);
if (metadata) {
return {
...parentModel,
...model,
metadata,
};
}
return parentModel;
// 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 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
@breadcrumbs={{this.breadcrumbs}}
@isPatchAllowed={{this.model.isPatchAllowed}}
@path={{this.model.path}}
@secret={{this.model.secret}}
@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
@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}}
@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}}
@backend={{this.model.backend}}
@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) {
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}/`] };

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ export default class SyncEngine extends Engine {
Resolver = Resolver;
dependencies = {
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',
max_versions: 15,
oldest_version: 0,
updated_time: '2018-03-22T02:36:43.986212308Z',
updated_time: '2023-07-21T03:11:58.095971Z',
versions: {
1: {
created_time: '2023-07-20T02:12:09.11529Z',
created_time: '2018-03-22T02:24:06.945319214Z',
deletion_time: '',
destroyed: false,
},

View File

@@ -11,9 +11,10 @@ import { setupApplicationTest } from 'vault/tests/helpers';
import authPage from 'vault/tests/pages/auth';
import { deleteEngineCmd, mountEngineCmd, runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands';
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 { 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.
@@ -66,10 +67,12 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await click(PAGE.detail.createNewVersion);
await fillIn(FORM.keyInput(), 'bar');
await click(FORM.cancelBtn);
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.infoRowValue('foo')).exists('secret is previous value');
await click(PAGE.detail.createNewVersion);
await fillIn(FORM.keyInput(), 'bar');
await click(PAGE.breadcrumbAtIdx(3));
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.infoRowValue('foo')).exists('secret is previous value');
});
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 click(FORM.saveBtn);
// Details page
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=1`,
'Goes to details page after save'
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}`,
'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.infoRow).exists({ count: 1 }, '1 row of data shows');
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.maskedValueInput(1), 'hashicorp.com');
await click(FORM.saveBtn);
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasTextContaining('2', 'Overview shows updated version');
// Back to details page
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/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');
// Fill in metadata
await click(FORM.saveBtn);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}`,
'goes to overview after save'
);
// Details
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/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.maskedValueInput(), 'partyparty');
await click(FORM.saveBtn);
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasTextContaining('1', 'Overview shows current version');
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/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));
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.maskedValueInput(), 'my-value');
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(
currentURL(),
`/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'));
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) {
@@ -797,19 +858,18 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await click(FORM.cancelBtn);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`,
'cancel goes to correct url'
`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}`,
'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 fillIn(FORM.keyInput(), 'bar');
await click(PAGE.breadcrumbAtIdx(3));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}/details`,
'breadcrumb goes to correct url'
`/vault/secrets/${backend}/kv/${encodeURIComponent('app/first')}`,
'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) {
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.maskedValueInput(), 'partyparty');
await click(FORM.saveBtn);
// Details page
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details`,
'Goes to details page after save'
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}`,
'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.infoRow).doesNotExist('does not show data contents');
assert
@@ -864,20 +924,31 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
// Add new version
await click(PAGE.secretTab('Secret'));
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')).hasValue(secretPath);
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.maskedValueInput()).hasValue('', 'row 1 has empty value');
assert.dom(FORM.keyInput()).hasValue('', 'Key input has empty value');
assert.dom(FORM.maskedValueInput()).hasValue('', 'Val input has empty value');
await fillIn(FORM.keyInput(), 'api_url');
await fillIn(FORM.maskedValueInput(), 'hashicorp.com');
await click(FORM.saveBtn);
// Back to details page
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}/details?version=2`,
'goes back to details page'
`/vault/secrets/${backend}/kv/${encodeURIComponent(secretPath)}`,
'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.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');
// Fill in metadata
await click(FORM.saveBtn);
// Details
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}/details`,
'goes back to details page'
`/vault/secrets/${backend}/kv/${encodeURIComponent('my/secret')}`,
'goes to overview page'
);
// Details
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.detail.versionTimestamp).doesNotExist('version created not shown');
assert.dom(PAGE.infoRow).doesNotExist('does not show data contents');
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.maskedValueInput(), 'partyparty');
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(
currentURL(),
`/vault/secrets/${backend}/kv/${encodeURIComponent('app/new')}/details`,
'Redirects to detail after save'
'navigates to details'
);
await click(PAGE.breadcrumbAtIdx(2));
assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, 'sub-dir page');
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) {
@@ -1114,12 +1135,13 @@ path "${this.backend}/metadata/*" {
await fillIn(FORM.maskedValueInput(), 'this too, gonna use the wrapped data');
await click(FORM.saveBtn);
assert.strictEqual(this.controlGroup.tokenToUnwrap, null, 'clears tokenToUnwrap after successful save');
// Details page
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPath}/details?version=1`,
'Goes to details page after save'
`/vault/secrets/${backend}/kv/${secretPath}`,
'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.infoRow).exists({ count: 1 }, '1 row of data shows');
assert.dom(PAGE.infoRowValue('api_key')).hasText('***********');
@@ -1185,8 +1207,8 @@ path "${this.backend}/metadata/*" {
null,
'clears tokenToUnwrap after successful update'
);
// Back to details page
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/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 { clearRecords, deleteLatestCmd, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands';
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 sinon from 'sinon';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
const ALL_DELETE_ACTIONS = ['delete', 'destroy', 'undelete'];
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 writeVersionedSecret(this.backend, this.secretPath, 'foo', 'bar', 4);
await writeVersionedSecret(this.backend, this.nestedSecretPath, 'foo', 'bar', 1);
await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2);
// Delete latest version for testing undelete for users that can't delete
await runCmd(deleteLatestCmd(this.backend, 'nuke'));
// Versioned secret for testing delete is created (and deleted) by each module to avoid race condition failures
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`);
// correct toolbar options & details show
assertDeleteActions(assert);
assert.dom(PAGE.infoRow).exists('shows secret data');
assert.dom(PAGE.infoRow).exists('shows secret data on load');
// delete flow
await click(PAGE.detail.delete);
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');
// details update accordingly
await click(PAGE.secretTab('Secret'));
assert
.dom(PAGE.emptyStateTitle)
.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
await click(PAGE.detail.undelete);
// 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');
// correct toolbar options
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`);
// correct toolbar options & details show
assertDeleteActions(assert);
assert.dom(PAGE.infoRow).exists('shows secret data');
assert.dom(PAGE.infoRow).exists('shows secret data on load');
// delete flow
await click(PAGE.detail.delete);
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');
await click(PAGE.detail.deleteOption);
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
.dom(PAGE.emptyStateTitle)
.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
await click(PAGE.detail.undelete);
// 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');
// correct toolbar options
assertDeleteActions(assert, ['delete', 'destroy']);
@@ -143,6 +146,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
const [actual] = flashSuccess.lastCall.args;
assert.strictEqual(actual, expected, 'renders correct flash message');
// details update accordingly
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`);
assert
.dom(PAGE.emptyStateTitle)
.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
assertDeleteActions(assert, []);
});
test('can permanently delete all secret versions (a)', async function (assert) {
// go to secret details
await visit(`/vault/secrets/${this.backend}/kv/nuke/details`);
// Check metadata toolbar
await click(PAGE.secretTab('Metadata'));
await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2);
await visit(`/vault/secrets/${this.backend}/kv/nuke/metadata`);
assert.dom(PAGE.metadata.deleteMetadata).hasText('Permanently delete', 'shows delete metadata button');
// delete flow
await click(PAGE.metadata.deleteMetadata);
@@ -162,7 +165,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
.dom(PAGE.detail.deleteModalTitle)
.includesText('Delete metadata and secret data?', 'modal has correct title');
await click(PAGE.detail.deleteConfirm);
await waitUntil(() => currentRouteName() === 'vault.cluster.secrets.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) {
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));
await authPage.login(token);
clearRecords(this.store);
@@ -217,12 +225,17 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
module('data-list-reader persona', function (hooks) {
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));
await authPage.login(token);
clearRecords(this.store);
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);
// go to secret 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.deleteConfirm);
// details update accordingly
await click(PAGE.secretTab('Secret'));
assert
.dom(PAGE.emptyStateTitle)
.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
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
await visit(`/vault/secrets/${this.backend}/kv/nuke/details`);
// Check metadata toolbar
@@ -275,13 +289,18 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
module('metadata-maintainer persona', function (hooks) {
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));
await authPage.login(token);
clearRecords(this.store);
return;
});
test('can delete and undelete the latest secret version (mm)', async function (assert) {
assert.expect(17);
test('cannot delete but can undelete the latest secret version (mm)', async function (assert) {
assert.expect(18);
// go to secret details
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details`);
// correct toolbar options & details show
@@ -301,7 +320,12 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
assertDeleteActions(assert, ['undelete', 'destroy']);
// undelete flow
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
await click(PAGE.secretTab('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');
// 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.deleteConfirm);
// 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.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered');
// updated toolbar options
@@ -330,6 +355,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
// undelete flow
await click(PAGE.detail.undelete);
// 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.detail.versionTimestamp).doesNotExist('Version 2 timestamp not rendered');
// 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');
await click(PAGE.detail.deleteConfirm);
// details update accordingly
await visit(`/vault/secrets/${this.backend}/kv/${this.secretPath}/details?version=3`);
assert
.dom(PAGE.emptyStateTitle)
.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, []);
});
test('can permanently delete all secret versions (sc)', async function (assert) {
await writeVersionedSecret(this.backend, 'nuke', 'foo', 'bar', 2);
// go to secret details
await visit(`/vault/secrets/${this.backend}/kv/nuke/details`);
// Check metadata toolbar
@@ -438,7 +466,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
.dom(PAGE.detail.deleteModalTitle)
.includesText('Delete metadata and secret data?', 'modal has correct title');
await click(PAGE.detail.deleteConfirm);
await waitUntil(() => currentRouteName() === 'vault.cluster.secrets.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 { GENERAL } from 'vault/tests/helpers/general-selectors';
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
@@ -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) {
assert.expect(21);
assert.expect(23);
const backend = this.backend;
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}/`));
assert.dom(PAGE.list.item(secret)).exists('renders linked block for child 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
await click(PAGE.secretTab('Secret'));
assert.dom(PAGE.title).hasText(this.fullSecretPath);
assert.dom(PAGE.secretTab('Secret')).hasText('Secret');
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('Version History')).hasText('Version History');
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) {
@@ -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.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(1)).hasText(backend);
@@ -302,6 +310,7 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
await click(FORM.saveBtn);
// Details view
await click(PAGE.secretTab('Secret'));
assert.dom(FORM.toggleJson).isNotDisabled();
assert.dom(FORM.toggleJson).isChecked();
assert.strictEqual(
@@ -353,11 +362,12 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
await click(FORM.saveBtn);
// Create another version
await click(PAGE.detail.createNewVersion);
await click(GENERAL.overviewCard.actionText('Create new'));
codemirror().setValue('{ "foo2": { "name": "bar2" } }');
await click(FORM.saveBtn);
// 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.version(1)} a`);
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.maskedValueInput(), '{bar}');
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).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
@@ -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) {
assert.expect(15);
assert.expect(16);
const backend = this.backend;
const ns = this.namespace;
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.maskedValueInput(), 'woahsecret');
await click(FORM.saveBtn);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secret}/details?namespace=${ns}&version=1`,
'navigates to details'
);
assert
.dom(GENERAL.overviewCard.container('Current version'))
.hasText(`Current version Create new The current version of this secret. 1`);
// 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')).hasValue(secret);
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.maskedValueInput(1), 'supersecret');
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
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/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) {
assert.expect(34);
assert.expect(36);
const backend = this.backend;
const ns = this.namespace;
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));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secret}/details?namespace=${ns}&version=2`,
'navigates to details'
`/vault/secrets/${backend}/kv/${secret}?namespace=${ns}`,
'navigates to overview'
);
// correct toolbar options & details show
await click(PAGE.secretTab('Secret'));
assertDeleteActions(assert);
await assertVersionDropdown(assert);
// delete flow
await click(PAGE.detail.delete);
await click(PAGE.detail.deleteOption);
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
assertDeleteActions(assert, ['undelete', 'destroy']);
assert
@@ -537,7 +622,11 @@ module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks)
// undelete flow
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
await click(PAGE.secretTab('Secret'));
assertDeleteActions(assert, ['delete', 'destroy']);
assert.dom(PAGE.infoRow).exists('shows secret data');
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
await click(PAGE.detail.destroy);
await click(PAGE.detail.deleteConfirm);
await click(PAGE.secretTab('Secret'));
assertDeleteActions(assert, []);
assert
.dom(PAGE.emptyStateTitle)

View File

@@ -5,7 +5,7 @@
import { module, test } from 'qunit';
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 authPage from 'vault/tests/pages/auth';
import {
@@ -24,26 +24,27 @@ import {
writeVersionedSecret,
} from 'vault/tests/helpers/kv/kv-run-commands';
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 { humanize } from 'vault/helpers/humanize';
const secretPath = `my-#:$=?-secret`;
// This doesn't encode in a normal way, so hardcoding it here until we sort that out
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) => {
await visit(`/vault/secrets`);
return click(PAGE.backends.link(backend));
};
const assertCorrectBreadcrumbs = (assert, expected) => {
assert.dom(PAGE.breadcrumbs).hasText(expected.join(' '));
const breadcrumbs = document.querySelectorAll(PAGE.breadcrumb);
const breadcrumbs = findAll(PAGE.breadcrumb);
expected.forEach((text, idx) => {
assert.dom(breadcrumbs[idx]).hasText(text, `position ${idx} breadcrumb includes text ${text}`);
});
};
const assertDetailTabs = (assert, current, hidden = []) => {
const allTabs = ['Secret', 'Metadata', 'Paths', 'Version History'];
allTabs.forEach((tab) => {
ALL_TABS.forEach((tab) => {
if (hidden.includes(tab)) {
assert.dom(PAGE.secretTab(tab)).doesNotExist(`${tab} tab does not render`);
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 assertDetailsToolbar = (assert, expected = DETAIL_TOOLBARS) => {
assert
.dom(PAGE.toolbarAction)
.exists({ count: expected.length }, 'correct number of toolbar actions render');
DETAIL_TOOLBARS.forEach((toolbar) => {
const method = expected.includes(toolbar) ? 'exists' : 'doesNotExist';
assert.dom(PAGE.detail[toolbar])[method](`${toolbar} action ${humanize([method])}`);
expected.forEach((toolbar) => {
assert.dom(PAGE.detail[toolbar]).exists(`${toolbar} action exists`);
});
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 () {
const uid = uuidv4();
this.store = this.owner.lookup('service:store');
this.version = this.owner.lookup('service:version');
this.emptyBackend = `kv-empty-${uid}`;
this.backend = `kv-nav-${uid}`;
await authPage.login();
@@ -106,17 +125,23 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
return;
});
test('empty backend - breadcrumbs, title, tabs, emptyState (a)', async function (assert) {
assert.expect(15);
assert.expect(23);
const backend = this.emptyBackend;
await navToBackend(backend);
// URL correct
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]);
// Title correct
assert.dom(PAGE.title).hasText(`${backend} version 2`);
// Tabs correct
assert.dom(PAGE.secretTab('Secrets')).hasText('Secrets');
assert.dom(PAGE.secretTab('Secrets')).hasClass('active');
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) {
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;
await navToBackend(backend);
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'));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`,
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
`navigated to ${currentURL()}`
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'app', 'nested', 'secret']);
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));
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
// list for directory
await visit(`/vault/secrets/${backend}/list/app/`);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/list/app/`,
`navigated to ${currentURL()}`
);
assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list/app/`, `navigates to list`);
// show for secret
await visit(`/vault/secrets/${backend}/show/app/nested/secret`);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`,
`navigated to ${currentURL()}`
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
`navigates to overview`
);
// edit for secret
await visit(`/vault/secrets/${backend}/edit/app/nested/secret`);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`,
`navigated to ${currentURL()}`
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details/edit?version=1`,
`navigates to edit`
);
});
test('versioned secret nav, tabs, breadcrumbs (a)', async function (assert) {
assert.expect(45);
test('versioned secret nav, tabs (a)', async function (assert) {
assert.expect(27);
const backend = this.backend;
await navToBackend(backend);
await click(PAGE.list.item(secretPath));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'navigates to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=3`,
'Url includes version query param'
);
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.createNewVersion).hasText('Create new version', 'Create version button shows');
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);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details?version=3`,
'Goes back to detail view'
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'Goes back to overview'
);
await click(PAGE.secretTab('Secret'));
await click(PAGE.detail.versionDropdown);
await click(`${PAGE.detail.version(1)} a`);
assert.strictEqual(
@@ -294,7 +329,6 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/metadata`,
`goes to metadata page`
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata']);
assert.dom(PAGE.title).hasText(secretPath);
assert
.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`,
`goes to metadata edit page`
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata', 'Edit']);
await click(FORM.cancelBtn);
assert.strictEqual(
currentURL(),
@@ -318,44 +351,121 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
`cancel btn goes back to metadata page`
);
});
test('breadcrumbs & page titles are correct (a)', async function (assert) {
assert.expect(45);
test('breadcrumbs, tabs & page titles are correct (a)', async function (assert) {
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;
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));
// 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]);
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');
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']);
assert.dom(PAGE.title).hasText('Create New Version', 'correct page title for secret edit');
// metadata tab
await click(PAGE.breadcrumbAtIdx(2));
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']);
assertDetailTabs(assert, 'Metadata');
assertTabHrefs(assert, 'Metadata');
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for metadata');
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']);
assert.dom(PAGE.title).hasText('Edit Secret Metadata', 'correct page title for metadata edit');
// paths tab
await click(PAGE.breadcrumbAtIdx(3));
await click(PAGE.secretTab('Paths'));
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.paths',
'navs to paths'
);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Paths']);
assertDetailTabs(assert, 'Paths');
assertTabHrefs(assert, 'Paths');
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for paths');
// version history tab
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']);
assertDetailTabs(assert, 'Version History');
assertTabHrefs(assert, '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) {
@@ -437,9 +547,10 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret/details?version=1`,
`navigated to correct details view ${currentURL()}`
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
`navigated to secret overview ${currentURL()}`
);
await click(PAGE.secretTab('Secret'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, 'app', 'nested', 'secret']);
assert.dom(PAGE.title).hasText('app/nested/secret', 'title is full secret path');
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');
});
test('versioned secret nav, tabs, breadcrumbs (dr)', async function (assert) {
assert.expect(28);
assert.expect(31);
const backend = this.backend;
await navToBackend(backend);
@@ -468,6 +579,12 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
await typeIn(PAGE.list.overviewInput, secretPath);
await click(PAGE.list.overviewButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'navigates to secret overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/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');
});
patchRedirectTest(test, 'dr');
});
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) {
assert.expect(31);
assert.expect(32);
const backend = this.backend;
await navToBackend(backend);
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 click(PAGE.list.overviewButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
`navigated to overview`
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/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');
});
test('versioned secret nav, tabs, breadcrumbs (dlr)', async function (assert) {
assert.expect(28);
assert.expect(31);
const backend = this.backend;
await navToBackend(backend);
await click(PAGE.list.item(secretPath));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'navigates to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/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');
});
patchRedirectTest(test, 'dlr');
});
module('metadata-maintainer persona', function (hooks) {
@@ -732,7 +863,6 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assert.expect(15);
const backend = this.emptyBackend;
await navToBackend(backend);
// URL correct
assert.strictEqual(currentURL(), `/vault/secrets/${backend}/kv/list`, 'lands on secrets list page');
// Breadcrumbs correct
@@ -766,7 +896,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
);
});
test('can access nested secret (mm)', async function (assert) {
assert.expect(41);
assert.expect(42);
const backend = this.backend;
await navToBackend(backend);
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');
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(
currentURL(),
`/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']);
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');
});
test('versioned secret nav, tabs, breadcrumbs (mm)', async function (assert) {
assert.expect(37);
assert.expect(40);
const backend = this.backend;
await navToBackend(backend);
await click(PAGE.list.item(secretPath));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'navs to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/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');
assertDetailTabs(assert, 'Secret');
@@ -924,6 +1066,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Version History']);
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for version history');
});
patchRedirectTest(test, 'mm');
});
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) {
assert.expect(23);
assert.expect(24);
const backend = this.backend;
await navToBackend(backend);
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 click(PAGE.list.overviewButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/app%2Fnested%2Fsecret`,
'goes to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/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');
});
test('versioned secret nav, tabs, breadcrumbs (sc)', async function (assert) {
assert.expect(36);
assert.expect(39);
const backend = this.backend;
await navToBackend(backend);
await typeIn(PAGE.list.overviewInput, secretPath);
await click(PAGE.list.overviewButton);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'Goes to overview'
);
await click(PAGE.secretTab('Secret'));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details`,
@@ -1055,8 +1210,8 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
await click(FORM.cancelBtn);
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/details`,
'Goes back to detail view'
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'Goes back to overview'
);
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');
});
test('breadcrumbs & page titles are correct (sc)', async function (assert) {
assert.expect(34);
assert.expect(39);
const backend = this.backend;
await navToBackend(backend);
await click(PAGE.secretTab('Configuration'));
@@ -1106,6 +1261,10 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath]);
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);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, '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');
});
patchRedirectTest(test, 'sc');
});
module('enterprise controlled access persona', function (hooks) {
@@ -1155,7 +1315,7 @@ path "${this.backend}/*" {
return;
});
test('can access nested secret (cg)', async function (assert) {
assert.expect(42);
assert.expect(43);
const backend = this.backend;
await navToBackend(backend);
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'
);
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(
currentURL(),
`/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');
});
test('breadcrumbs & page titles are correct (cg)', async function (assert) {
assert.expect(36);
assert.expect(43);
const backend = this.backend;
await navToBackend(backend);
await click(PAGE.secretTab('Configuration'));
@@ -1261,17 +1427,19 @@ path "${this.backend}/*" {
'navigates back to list url after authorized'
);
await click(PAGE.list.item(secretPath));
assert.strictEqual(
currentURL(),
`/vault/secrets/${backend}/kv/${secretPathUrlEncoded}`,
'Goes to overview'
);
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'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, 'Metadata']);
assert.dom(PAGE.title).hasText(secretPath, 'correct page title for metadata');
assert.dom(PAGE.metadata.editBtn).doesNotExist('cannot edit metadata');
await click(PAGE.breadcrumbAtIdx(2));
await click(PAGE.secretTab('Paths'));
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, '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');
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);
assertCorrectBreadcrumbs(assert, ['Secrets', backend, secretPath, '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');
assert.strictEqual(
currentRouteName(),
'vault.cluster.secrets.backend.kv.secret.details.index',
'redirects to the show page'
'vault.cluster.secrets.backend.kv.secret.index',
'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) {
assert.expect(4);
const secretPath = `specific-version`;
await writeVersionedSecret(this.backend, secretPath, 'foo', 'bar', 4);
await click(PAGE.secretTab('Secret'));
assert
.dom(PAGE.detail.versionTimestamp)
.includesText('Version 4 created', 'shows version created time');

View File

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

View File

@@ -3,7 +3,7 @@
* 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"]
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 }) => {
return `
path "${backend}/data/app/${secretPath}" {
@@ -82,7 +90,7 @@ export const destroyVersionsPolicy = ({ backend, secretPath = '*' }) => {
// Personas for reuse in workflow tests
export const personas = {
admin: (backend) => adminPolicy(backend),
admin: (backend) => adminPolicy(backend) + subkeysPolicy({ backend }),
dataReader: (backend) => dataPolicy({ backend, capabilities: ['read'] }),
dataListReader: (backend) =>
dataPolicy({ backend, capabilities: ['read', 'delete'] }) + metadataListPolicy(backend),
@@ -97,4 +105,8 @@ export const personas = {
secretCreator: (backend) =>
dataPolicy({ backend, capabilities: ['create', 'update'] }) +
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);
setupEngine(hooks, 'kv');
hooks.beforeEach(function () {
this.isPatchAllowed = true;
this.subkeys = {
foo: null,
bar: {
@@ -22,14 +23,16 @@ module('Integration | Component | kv | kv-subkeys-card', function (hooks) {
},
};
this.renderComponent = async () => {
return render(hbs`<KvSubkeysCard @subkeys={{this.subkeys}} />`, {
return render(
hbs`<KvSubkeysCard @subkeys={{this.subkeys}} @isPatchAllowed={{this.isPatchAllowed}} />`,
{
owner: this.engine,
});
}
);
};
});
test('it renders', async function (assert) {
assert.expect(4);
await this.renderComponent();
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(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) {
assert.expect(4);
await this.renderComponent();
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 { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
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) {
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.path },
];
});
this.canDeleteMetadata = true;
this.canReadCustomMetadata = true;
this.canReadMetadata = true;
this.canUpdateMetadata = true;
test('it renders metadata details', async function (assert) {
assert.expect(8);
await render(
this.renderComponent = () => {
return render(
hbs`
<Page::Secret::Metadata::Details
@path={{this.model.path}}
@secret={{this.model.secret}}
@metadata={{this.model.metadata}}
@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) {
assert.expect(8);
await this.renderComponent();
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');
@@ -63,9 +75,10 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
assert.dom(PAGE.metadata.editBtn).exists();
// Metadata details
const expectedTime = dateFormat([this.metadata.updatedTime, 'MMM d, yyyy hh:mm aa'], {});
assert
.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('Check-and-Set required')).hasText('Yes');
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) {
assert.expect(2);
this.secret.customMetadata = { hi: 'there' };
await render(
hbs`
<Page::Secret::Metadata::Details
@path={{this.model.path}}
@secret={{this.model.secret}}
@metadata={{this.model.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent();
assert.dom(PAGE.emptyStateTitle).doesNotExist();
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) {
assert.expect(4);
this.model.metadata = metadataModel(this, { withCustom: true });
await render(
hbs`
<Page::Secret::Metadata::Details
@path={{this.model.path}}
@secret={{this.model.secret}}
@metadata={{this.model.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent();
assert.dom(PAGE.emptyStateTitle).doesNotExist();
// 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('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';
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) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
@@ -46,7 +48,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
};
this.canReadMetadata = true;
this.canUpdateSecret = true;
this.secretState = 'created';
this.format = (time) => dateFormat([time, 'MMM d yyyy, h:mm:ss aa'], {});
this.renderComponent = async () => {
@@ -59,7 +60,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
@canUpdateSecret={{this.canUpdateSecret}}
@metadata={{this.metadata}}
@path={{this.path}}
@secretState={{this.secretState}}
@subkeys={{this.subkeys}}
/>`,
{ 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) {
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'))
@@ -94,8 +94,8 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
assert
.dom(overviewCard.container('Secret age'))
.hasText(
`Secret age View metadata Time since last update at ${this.format(
this.metadata.createdTime
`Secret age View metadata Current secret version age. Last updated on ${this.format(
this.metadata.updatedTime
)}. ${fromNow}`
);
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}`);
assert
.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('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) {
this.subkeys = null;
await this.renderComponent();
const fromNow = dateFromNow([this.metadata.createdTime]); // uses date-fns so can't stub timestamp util
const expectedTime = this.format(this.metadata.createdTime);
const fromNow = dateFromNow([this.metadata.updatedTime]); // uses date-fns so can't stub timestamp util
const expectedTime = this.format(this.metadata.updatedTime);
assert
.dom(overviewCard.container('Current version'))
.hasText(
@@ -166,7 +170,9 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
);
assert
.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
.dom(overviewCard.container('Paths'))
.hasText(
@@ -192,7 +198,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
module('deleted version', function (hooks) {
hooks.beforeEach(async function () {
this.secretState = 'deleted';
// subkeys is null but metadata still has data
this.subkeys = {
subkeys: null,
@@ -256,6 +261,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
.hasText(
`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) {
@@ -268,7 +274,6 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
module('destroyed version', function (hooks) {
hooks.beforeEach(async function () {
this.secretState = 'destroyed';
// subkeys is null but metadata still has data
this.subkeys = {
subkeys: null,
@@ -329,6 +334,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
.hasText(
`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) {

View File

@@ -94,7 +94,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
const [route] = this.transitionStub.lastCall.args;
assert.strictEqual(
route,
'vault.cluster.secrets.backend.kv.secret',
'vault.cluster.secrets.backend.kv.secret.index',
`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;
assert.strictEqual(
route,
'vault.cluster.secrets.backend.kv.secret',
'vault.cluster.secrets.backend.kv.secret.index',
`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;
assert.strictEqual(
route,
'vault.cluster.secrets.backend.kv.secret',
'vault.cluster.secrets.backend.kv.secret.index',
`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] || '';
assert.strictEqual(
route,
'vault.cluster.secrets.backend.kv.secret',
'vault.cluster.secrets.backend.kv.secret.index',
`it transitions to overview route: ${route}`
);
assert.strictEqual(
@@ -288,7 +288,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
const flash = this.flashSpy.lastCall?.args[0] || '';
assert.strictEqual(
route,
'vault.cluster.secrets.backend.kv.secret',
'vault.cluster.secrets.backend.kv.secret.index',
`it transitions to overview route: ${route}`
);
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) {
assert.expect(10);
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);
assert.propEqual(payload, {
data: { foo: 'bar', foo2: 'bar2' },
options: { cas: 1 },
});
assert.propEqual(
payload,
{ data: { foo: 'bar', foo2: 'bar2' }, options: { cas: 1 } },
'request has expected payload'
);
return {
request_id: 'bd76db73-605d-fcbc-0dad-d44a008f9b95',
data: {
@@ -97,9 +98,11 @@ module('Integration | Component | kv-v2 | Page::Secret::Edit', function (hooks)
await fillIn(FORM.keyInput(1), 'foo2');
await fillIn(FORM.maskedValueInput(1), 'bar2');
await click(FORM.saveBtn);
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.kv.secret.details'),
'router transitions to secret details route on save'
const [actual] = this.transitionStub.lastCall.args;
assert.strictEqual(
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.inlineAlert).hasText('There was an error submitting this form.');
await click(FORM.cancelBtn);
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.kv.secret.details'),
'router transitions to details on cancel'
const [actual] = this.transitionStub.lastCall.args;
assert.strictEqual(
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 click(FORM.saveBtn);
assert.ok(
this.transitionStub.calledWith('vault.cluster.secrets.backend.kv.secret.details'),
'router transitions to secret details route on save'
const [actual] = this.transitionStub.lastCall.args;
assert.strictEqual(
actual,
'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';
const CAPABILITIES = {
canCreate: 'create',
canRead: 'read',
canUpdate: 'update',
canDelete: 'delete',
canList: 'list',
canPatch: 'patch',
};
module('Unit | Model | capabilities', function (hooks) {
setupTest(hooks);
@@ -17,7 +26,7 @@ module('Unit | Model | capabilities', function (hooks) {
assert.ok(!!model);
});
test('it reads capabilities', function (assert) {
test('it computes capabilities', function (assert) {
const model = run(() =>
this.owner.lookup('service:store').createRecord('capabilities', {
path: 'foo',
@@ -31,6 +40,23 @@ module('Unit | Model | capabilities', function (hooks) {
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) {
const model = run(() =>
this.owner.lookup('service:store').createRecord('capabilities', {
@@ -38,11 +64,10 @@ module('Unit | Model | capabilities', function (hooks) {
capabilities: ['root', 'deny', 'read'],
})
);
assert.ok(model.get('canRead'));
assert.ok(model.get('canCreate'));
assert.ok(model.get('canUpdate'));
assert.ok(model.get('canDelete'));
assert.ok(model.get('canList'));
Object.keys(CAPABILITIES).forEach((c) => {
assert.true(model.get(c), `${c} is true`);
});
});
test('it denies everything if deny is present', function (assert) {
@@ -52,11 +77,9 @@ module('Unit | Model | capabilities', function (hooks) {
capabilities: ['sudo', 'deny', 'read'],
})
);
assert.notOk(model.get('canRead'));
assert.notOk(model.get('canCreate'));
assert.notOk(model.get('canUpdate'));
assert.notOk(model.get('canDelete'));
assert.notOk(model.get('canList'));
Object.keys(CAPABILITIES).forEach((c) => {
assert.false(model.get(c), `${c} is false`);
});
});
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);
});
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 expectedPayload = { paths };
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)}`);
return this.generateResponse({
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 () {
@@ -102,23 +186,33 @@ module('Unit | Service | capabilities', function (hooks) {
capabilities: ['read'],
expectedRead: true, // expected computed properties based on response
expectedUpdate: false,
expectedPatch: false,
},
{
capabilities: ['update'],
expectedRead: false,
expectedUpdate: true,
expectedPatch: false,
},
{
capabilities: ['patch'],
expectedRead: false,
expectedUpdate: false,
expectedPatch: true,
},
{
capabilities: ['deny'],
expectedRead: false,
expectedUpdate: false,
expectedPatch: false,
},
{
capabilities: ['read', 'update'],
expectedRead: 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) {
this.server.post('/sys/capabilities-self', () => {
return this.generateResponse({ path, capabilities });
@@ -135,6 +229,14 @@ module('Unit | Service | capabilities', function (hooks) {
const response = await this.capabilities.canUpdate(path);
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: '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'
);
@@ -66,7 +66,7 @@ module('Unit | Utility | kv-breadcrumbs', function () {
results = breadcrumbsForSecret('kv-mount', 'beep');
assert.deepEqual(
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'
);

View File

@@ -9,12 +9,13 @@ import Model from '@ember-data/model';
interface CapabilitiesModel extends Model {
path: string;
capabilities: Array<string>;
canSudo: ComputedProperty<boolean | undefined>;
canRead: ComputedProperty<boolean | undefined>;
canCreate: ComputedProperty<boolean | undefined>;
canUpdate: ComputedProperty<boolean | undefined>;
canDelete: 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
// inferring type from key name
allowedParameters: Array<string>;