mirror of
https://github.com/optim-enterprises-bv/vault.git
synced 2025-10-29 01:32:33 +00:00
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:
3
changelog/28212.txt
Normal file
3
changelog/28212.txt
Normal 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.
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
// 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
|
||||
);
|
||||
let route, params;
|
||||
switch (true) {
|
||||
case !secret:
|
||||
// if no secret param redirect to the create route
|
||||
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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@
|
||||
padding-top: $spacing-8;
|
||||
}
|
||||
|
||||
.top-padding-4 {
|
||||
padding-top: $spacing-4;
|
||||
}
|
||||
|
||||
.has-top-padding-s {
|
||||
padding-top: $spacing-12;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}} />
|
||||
|
||||
@@ -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}}
|
||||
* @breadcrumbs={{this.breadcrumbs}}
|
||||
/>
|
||||
* @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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
@@ -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.';
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -18,22 +18,17 @@ 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;
|
||||
return hash({
|
||||
...parentModel,
|
||||
secret: this.store.queryRecord('kv/data', { backend, path, version: params.version }),
|
||||
});
|
||||
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 parentModel;
|
||||
return hash({
|
||||
...parentModel,
|
||||
secret: this.store.queryRecord('kv/data', query),
|
||||
});
|
||||
}
|
||||
|
||||
// breadcrumbs are set in details/index.js
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
return {
|
||||
...parentModel,
|
||||
metadata,
|
||||
};
|
||||
if (metadata) {
|
||||
return {
|
||||
...model,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
// only fetch secret data if metadata is unavailable and user can read endpoint
|
||||
if (permissions.data.canRead) {
|
||||
// fail silently because this request is just for custom_metadata
|
||||
const secret = await this.store.queryRecord('kv/data', { backend, path }).catch(() => {});
|
||||
return {
|
||||
...model,
|
||||
secret,
|
||||
};
|
||||
}
|
||||
}
|
||||
return parentModel;
|
||||
return model;
|
||||
}
|
||||
}
|
||||
|
||||
30
ui/lib/kv/addon/routes/secret/patch.js
Normal file
30
ui/lib/kv/addon/routes/secret/patch.js
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
/>
|
||||
15
ui/lib/kv/addon/templates/secret/index.hbs
Normal file
15
ui/lib/kv/addon/templates/secret/index.hbs
Normal 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}}
|
||||
/>
|
||||
@@ -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}}
|
||||
/>
|
||||
13
ui/lib/kv/addon/templates/secret/patch.hbs
Normal file
13
ui/lib/kv/addon/templates/secret/patch.hbs
Normal 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}}
|
||||
/>
|
||||
@@ -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}}
|
||||
/>
|
||||
@@ -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}/`] };
|
||||
|
||||
@@ -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}}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
@@ -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}} />`, {
|
||||
owner: this.engine,
|
||||
});
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
this.renderComponent = () => {
|
||||
return render(
|
||||
hbs`
|
||||
<Page::Secret::Metadata::Details
|
||||
@breadcrumbs={{this.breadcrumbs}}
|
||||
@canDeleteMetadata={{this.canDeleteMetadata}}
|
||||
@canReadMetadata={{this.canReadMetadata}}
|
||||
@canUpdateMetadata={{this.canReadMetadata}}
|
||||
@customMetadata={{or this.model.metadata.customMetadata this.model.secret.customMetadata}}
|
||||
@metadata={{this.model.metadata}}
|
||||
@path={{this.model.path}}
|
||||
/>
|
||||
`,
|
||||
{ owner: this.engine }
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders metadata details', async function (assert) {
|
||||
assert.expect(8);
|
||||
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.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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
|
||||
|
||||
7
ui/types/vault/models/capabilities.d.ts
vendored
7
ui/types/vault/models/capabilities.d.ts
vendored
@@ -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>;
|
||||
|
||||
Reference in New Issue
Block a user