From f634808ed4b881af97df02b19eb15dd12c5051d2 Mon Sep 17 00:00:00 2001 From: claire bontempo <68122737+hellobontempo@users.noreply.github.com> Date: Thu, 29 Aug 2024 16:38:39 -0700 Subject: [PATCH] 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 --- changelog/28212.txt | 3 + ui/app/adapters/kv/data.js | 99 ++--- ui/app/adapters/kv/metadata.js | 8 +- ui/app/app.js | 10 +- .../dashboard/quick-actions-card.js | 4 +- ui/app/models/capabilities.js | 13 +- .../cluster/secrets/backend/secret-edit.js | 30 +- ui/app/services/capabilities.ts | 72 +++- ui/app/styles/helper-classes/spacing.scss | 4 + ui/lib/kv/addon/components/kv-delete-modal.js | 2 +- .../addon/components/kv-patch/json-form.hbs | 2 +- .../kv/addon/components/kv-subkeys-card.hbs | 29 +- ui/lib/kv/addon/components/kv-subkeys-card.js | 6 + ui/lib/kv/addon/components/page/list.hbs | 19 +- ui/lib/kv/addon/components/page/list.js | 2 +- .../addon/components/page/secret/details.hbs | 14 + .../addon/components/page/secret/details.js | 12 +- .../kv/addon/components/page/secret/edit.js | 6 +- .../page/secret/metadata/details.hbs | 37 +- .../page/secret/metadata/details.js | 47 ++- .../components/page/secret/metadata/edit.hbs | 1 + .../page/secret/metadata/version-history.hbs | 3 + .../addon/components/page/secret/overview.hbs | 24 +- .../addon/components/page/secret/overview.js | 24 +- .../kv/addon/components/page/secret/patch.js | 4 +- .../kv/addon/components/page/secret/paths.hbs | 3 + .../addon/components/page/secrets/create.js | 2 +- ui/lib/kv/addon/engine.js | 8 +- ui/lib/kv/addon/routes.js | 1 + ui/lib/kv/addon/routes/secret.js | 47 ++- ui/lib/kv/addon/routes/secret/details.js | 25 +- ui/lib/kv/addon/routes/secret/index.js | 11 +- ui/lib/kv/addon/routes/secret/metadata.js | 44 ++- ui/lib/kv/addon/routes/secret/patch.js | 30 ++ .../addon/templates/secret/details/index.hbs | 3 +- ui/lib/kv/addon/templates/secret/index.hbs | 15 + .../addon/templates/secret/metadata/index.hbs | 8 +- ui/lib/kv/addon/templates/secret/patch.hbs | 13 + ui/lib/kv/addon/templates/secret/paths.hbs | 2 +- ui/lib/kv/addon/utils/kv-breadcrumbs.js | 2 +- .../page/destinations/destination/secrets.hbs | 4 +- .../page/destinations/destination/sync.hbs | 2 +- ui/lib/sync/addon/engine.js | 2 +- ui/mirage/factories/kv-metadatum.js | 4 +- .../backend/kv/kv-v2-workflow-create-test.js | 196 +++++---- .../backend/kv/kv-v2-workflow-delete-test.js | 66 +++- .../kv/kv-v2-workflow-edge-cases-test.js | 120 +++++- .../kv/kv-v2-workflow-navigation-test.js | 372 +++++++++++++++--- .../secrets/backend/kv/secret-test.js | 6 +- ui/tests/helpers/kv/kv-selectors.js | 4 +- ui/tests/helpers/kv/policy-generator.js | 16 +- .../components/kv/kv-subkeys-card-test.js | 20 +- .../kv/page/kv-page-metadata-details-test.js | 84 ++-- .../kv/page/kv-page-overview-test.js | 28 +- .../components/kv/page/kv-page-patch-test.js | 10 +- .../kv/page/kv-page-secret-edit-test.js | 27 +- .../kv/page/kv-page-secrets-create-test.js | 9 +- ui/tests/unit/models/capabilities-test.js | 45 ++- ui/tests/unit/services/capabilities-test.js | 110 +++++- ui/tests/unit/utils/kv-breadcrumbs-test.js | 4 +- ui/types/vault/models/capabilities.d.ts | 7 +- 61 files changed, 1315 insertions(+), 510 deletions(-) create mode 100644 changelog/28212.txt create mode 100644 ui/lib/kv/addon/routes/secret/patch.js create mode 100644 ui/lib/kv/addon/templates/secret/index.hbs create mode 100644 ui/lib/kv/addon/templates/secret/patch.hbs diff --git a/changelog/28212.txt b/changelog/28212.txt new file mode 100644 index 0000000000..a5e1a2b19a --- /dev/null +++ b/changelog/28212.txt @@ -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. +``` diff --git a/ui/app/adapters/kv/data.js b/ui/app/adapters/kv/data.js index b43beefd41..9ed2039a52 100644 --- a/ui/app/adapters/kv/data.js +++ b/ui/app/adapters/kv/data.js @@ -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; + } } diff --git a/ui/app/adapters/kv/metadata.js b/ui/app/adapters/kv/metadata.js index ac61daf016..f87d341600 100644 --- a/ui/app/adapters/kv/metadata.js +++ b/ui/app/adapters/kv/metadata.js @@ -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'); + } } diff --git a/ui/app/app.js b/ui/app/app.js index 779ba59370..e5484bf392 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -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', }, }, diff --git a/ui/app/components/dashboard/quick-actions-card.js b/ui/app/components/dashboard/quick-actions-card.js index 6c2b8bca05..eccc519ab3 100644 --- a/ui/app/components/dashboard/quick-actions-card.js +++ b/ui/app/components/dashboard/quick-actions-card.js @@ -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; } diff --git a/ui/app/models/capabilities.js b/ui/app/models/capabilities.js index 139d4f194e..80e9e3ccd3 100644 --- a/ui/app/models/capabilities.js +++ b/ui/app/models/capabilities.js @@ -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'), }); diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index 70358006fb..767dc10dfd 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -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)) { diff --git a/ui/app/services/capabilities.ts b/ui/app/services/capabilities.ts index 1fb3def512..f33e45285b 100644 --- a/ui/app/services/capabilities.ts +++ b/ui/app/services/capabilities.ts @@ -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> | 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 | [] = 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 | AdapterError { + fetchPathCapabilities(path: string): Promise | 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; } diff --git a/ui/app/styles/helper-classes/spacing.scss b/ui/app/styles/helper-classes/spacing.scss index e62a63dcd4..47e4064c61 100644 --- a/ui/app/styles/helper-classes/spacing.scss +++ b/ui/app/styles/helper-classes/spacing.scss @@ -58,6 +58,10 @@ padding-top: $spacing-8; } +.top-padding-4 { + padding-top: $spacing-4; +} + .has-top-padding-s { padding-top: $spacing-12; } diff --git a/ui/lib/kv/addon/components/kv-delete-modal.js b/ui/lib/kv/addon/components/kv-delete-modal.js index f1ed665fde..ac34bf90c2 100644 --- a/ui/lib/kv/addon/components/kv-delete-modal.js +++ b/ui/lib/kv/addon/components/kv-delete-modal.js @@ -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. */ diff --git a/ui/lib/kv/addon/components/kv-patch/json-form.hbs b/ui/lib/kv/addon/components/kv-patch/json-form.hbs index dcf852ac61..3096e85358 100644 --- a/ui/lib/kv/addon/components/kv-patch/json-form.hbs +++ b/ui/lib/kv/addon/components/kv-patch/json-form.hbs @@ -12,7 +12,7 @@ @message="JSON is unparsable. Fix linting errors to avoid data discrepancies." /> {{/if}} -
+
diff --git a/ui/lib/kv/addon/components/kv-subkeys-card.hbs b/ui/lib/kv/addon/components/kv-subkeys-card.hbs index 62451b7368..66a0b7c995 100644 --- a/ui/lib/kv/addon/components/kv-subkeys-card.hbs +++ b/ui/lib/kv/addon/components/kv-subkeys-card.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 ~}} - + <:customSubtext> {{#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. {{else}} The table is displaying the top level subkeys. Toggle on the JSON view to see the full depth. @@ -21,10 +22,28 @@ <:action> -
- -

JSON

-
+
+
+ + JSON + +
+ {{#if @isPatchAllowed}} + + {{/if}}
<:content> diff --git a/ui/lib/kv/addon/components/kv-subkeys-card.js b/ui/lib/kv/addon/components/kv-subkeys-card.js index bc99755387..ed17ad9f8a 100644 --- a/ui/lib/kv/addon/components/kv-subkeys-card.js +++ b/ui/lib/kv/addon/components/kv-subkeys-card.js @@ -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; + } } diff --git a/ui/lib/kv/addon/components/page/list.hbs b/ui/lib/kv/addon/components/page/list.hbs index 224e77f05f..abc35fa329 100644 --- a/ui/lib/kv/addon/components/page/list.hbs +++ b/ui/lib/kv/addon/components/page/list.hbs @@ -58,7 +58,7 @@ @color="secondary" type="submit" disabled={{not this.secretPath}} - data-test-get-secret-detail + data-test-submit-button /> {{#if @failedDirectoryQuery}} @@ -77,7 +77,7 @@
@@ -106,7 +106,12 @@ /> {{else}} + @@ -117,14 +122,6 @@ @models={{array @backend metadata.fullSecretPath}} /> {{/if}} - {{#if metadata.canCreateVersionData}} - - {{/if}} {{#if metadata.canDeleteMetadata}} <:tabLinks> +
  • + Overview +
  • Secret
  • @@ -112,6 +119,12 @@ {{#if @secret.canReadMetadata}} {{/if}} + {{! @isPatchAllowed is true if the version is enterprise AND a user has "patch" secret + "read" subkeys capabilities }} + {{#if @isPatchAllowed}} + + Patch latest version + + {{/if}} {{#if @secret.canEditData}} {{/if}} diff --git a/ui/lib/kv/addon/components/page/secret/details.js b/ui/lib/kv/addon/components/page/secret/details.js index 75de88eda2..2c13cb82bc 100644 --- a/ui/lib/kv/addon/components/page/secret/details.js +++ b/ui/lib/kv/addon/components/page/secret/details.js @@ -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() { diff --git a/ui/lib/kv/addon/components/page/secret/edit.js b/ui/lib/kv/addon/components/page/secret/edit.js index cced8f6ba6..c1549f9b45 100644 --- a/ui/lib/kv/addon/components/page/secret/edit.js +++ b/ui/lib/kv/addon/components/page/secret/edit.js @@ -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'); } } diff --git a/ui/lib/kv/addon/components/page/secret/metadata/details.hbs b/ui/lib/kv/addon/components/page/secret/metadata/details.hbs index 41e993c51c..2c5efa6e21 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/details.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/details.hbs @@ -6,23 +6,26 @@ <:tabLinks>
  • - Secret + Overview +
  • +
  • + Secret
  • Metadata
  • - Paths + Paths
  • - {{#if @secret.canReadMetadata}} + {{#if @canReadMetadata}}
  • Version History
  • @@ -30,16 +33,11 @@ <:toolbarActions> - {{#if @secret.canDeleteMetadata}} - + {{#if @canDeleteMetadata}} + {{/if}} - {{#if @secret.canUpdateMetadata}} - + {{#if @canUpdateMetadata}} + Edit metadata {{/if}} @@ -50,8 +48,9 @@ Custom metadata
    - {{#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|}} {{else}} - {{#if @secret.canUpdateMetadata}} + {{#if @canUpdateMetadata}} {{/if}} @@ -81,7 +80,7 @@

    Secret metadata

    - {{#if @secret.canReadMetadata}} + {{#if @canReadMetadata}}
    diff --git a/ui/lib/kv/addon/components/page/secret/metadata/details.js b/ui/lib/kv/addon/components/page/secret/metadata/details.js index 1f758ee488..85a37fdebc 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/details.js +++ b/ui/lib/kv/addon/components/page/secret/metadata/details.js @@ -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. * + * @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)}`); } } } diff --git a/ui/lib/kv/addon/components/page/secret/metadata/edit.hbs b/ui/lib/kv/addon/components/page/secret/metadata/edit.hbs index 0c0dc4147a..bfbc673b9c 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/edit.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/edit.hbs @@ -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}} /> {{/if}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs index 16b1dcde85..db914d3345 100644 --- a/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs +++ b/ui/lib/kv/addon/components/page/secret/metadata/version-history.hbs @@ -5,6 +5,9 @@ <:tabLinks> +
  • + Overview +
  • Secret
  • diff --git a/ui/lib/kv/addon/components/page/secret/overview.hbs b/ui/lib/kv/addon/components/page/secret/overview.hbs index c3c0043967..4f3f003b95 100644 --- a/ui/lib/kv/addon/components/page/secret/overview.hbs +++ b/ui/lib/kv/addon/components/page/secret/overview.hbs @@ -31,22 +31,24 @@ {{/if}} + <:toolbarActions> +
    -{{#if (or @metadata @subkeys)}} +{{#if (or @metadata @subkeys.metadata)}}
    <:customTitle> Current version - {{#unless this.isActive}} + {{#if (not-eq this.secretState "created")}} - {{/unless}} + {{/if}} <:action> @@ -57,6 +59,7 @@ @models={{array @backend @path}} @icon="plus" @iconPosition="trailing" + data-test-action-text="Create new" /> {{/if}} @@ -67,11 +70,11 @@ - {{#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|}} <:action> @@ -100,6 +103,7 @@ +{{! @subkeys is null for community edition or if a user does not have read permissions }} {{#if @subkeys.subkeys}} - + {{/if}} \ No newline at end of file diff --git a/ui/lib/kv/addon/components/page/secret/overview.js b/ui/lib/kv/addon/components/page/secret/overview.js index 0c41df40dc..8129551f2a 100644 --- a/ui/lib/kv/addon/components/page/secret/overview.js +++ b/ui/lib/kv/addon/components/page/secret/overview.js @@ -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.'; } diff --git a/ui/lib/kv/addon/components/page/secret/patch.js b/ui/lib/kv/addon/components/page/secret/patch.js index 181b5b5d48..b49ae842ef 100644 --- a/ui/lib/kv/addon/components/page/secret/patch.js +++ b/ui/lib/kv/addon/components/page/secret/patch.js @@ -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) { diff --git a/ui/lib/kv/addon/components/page/secret/paths.hbs b/ui/lib/kv/addon/components/page/secret/paths.hbs index f7e7e0c15d..bb866b4985 100644 --- a/ui/lib/kv/addon/components/page/secret/paths.hbs +++ b/ui/lib/kv/addon/components/page/secret/paths.hbs @@ -5,6 +5,9 @@ <:tabLinks> +
  • + Overview +
  • Secret
  • diff --git a/ui/lib/kv/addon/components/page/secrets/create.js b/ui/lib/kv/addon/components/page/secrets/create.js index 0cd733b24c..c4375f300a 100644 --- a/ui/lib/kv/addon/components/page/secrets/create.js +++ b/ui/lib/kv/addon/components/page/secrets/create.js @@ -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); } } } diff --git a/ui/lib/kv/addon/engine.js b/ui/lib/kv/addon/engine.js index 120e293ab9..bfd19ebefe 100644 --- a/ui/lib/kv/addon/engine.js +++ b/ui/lib/kv/addon/engine.js @@ -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'], }; diff --git a/ui/lib/kv/addon/routes.js b/ui/lib/kv/addon/routes.js index 7eae3309f4..90efc9b4bf 100644 --- a/ui/lib/kv/addon/routes.js +++ b/ui/lib/kv/addon/routes.js @@ -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 diff --git a/ui/lib/kv/addon/routes/secret.js b/ui/lib/kv/addon/routes/secret.js index a15e96f85d..16dd9410da 100644 --- a/ui/lib/kv/addon/routes/secret.js +++ b/ui/lib/kv/addon/routes/secret.js @@ -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}`), }); } diff --git a/ui/lib/kv/addon/routes/secret/details.js b/ui/lib/kv/addon/routes/secret/details.js index 52cfd3f0d4..e808143cce 100644 --- a/ui/lib/kv/addon/routes/secret/details.js +++ b/ui/lib/kv/addon/routes/secret/details.js @@ -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 diff --git a/ui/lib/kv/addon/routes/secret/index.js b/ui/lib/kv/addon/routes/secret/index.js index 0c7d434534..f048281b2d 100644 --- a/ui/lib/kv/addon/routes/secret/index.js +++ b/ui/lib/kv/addon/routes/secret/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; } } diff --git a/ui/lib/kv/addon/routes/secret/metadata.js b/ui/lib/kv/addon/routes/secret/metadata.js index 2e94e9f87f..1eb77bc269 100644 --- a/ui/lib/kv/addon/routes/secret/metadata.js +++ b/ui/lib/kv/addon/routes/secret/metadata.js @@ -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; } } diff --git a/ui/lib/kv/addon/routes/secret/patch.js b/ui/lib/kv/addon/routes/secret/patch.js new file mode 100644 index 0000000000..2e0a77c64d --- /dev/null +++ b/ui/lib/kv/addon/routes/secret/patch.js @@ -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); + } + } +} diff --git a/ui/lib/kv/addon/templates/secret/details/index.hbs b/ui/lib/kv/addon/templates/secret/details/index.hbs index 2744537886..0529c91697 100644 --- a/ui/lib/kv/addon/templates/secret/details/index.hbs +++ b/ui/lib/kv/addon/templates/secret/details/index.hbs @@ -4,8 +4,9 @@ ~}} \ No newline at end of file diff --git a/ui/lib/kv/addon/templates/secret/index.hbs b/ui/lib/kv/addon/templates/secret/index.hbs new file mode 100644 index 0000000000..e915cda8fb --- /dev/null +++ b/ui/lib/kv/addon/templates/secret/index.hbs @@ -0,0 +1,15 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + \ No newline at end of file diff --git a/ui/lib/kv/addon/templates/secret/metadata/index.hbs b/ui/lib/kv/addon/templates/secret/metadata/index.hbs index 147f25d93d..90a626c2bf 100644 --- a/ui/lib/kv/addon/templates/secret/metadata/index.hbs +++ b/ui/lib/kv/addon/templates/secret/metadata/index.hbs @@ -4,8 +4,12 @@ ~}} \ No newline at end of file diff --git a/ui/lib/kv/addon/templates/secret/patch.hbs b/ui/lib/kv/addon/templates/secret/patch.hbs new file mode 100644 index 0000000000..a3542faec8 --- /dev/null +++ b/ui/lib/kv/addon/templates/secret/patch.hbs @@ -0,0 +1,13 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: BUSL-1.1 +~}} + + \ No newline at end of file diff --git a/ui/lib/kv/addon/templates/secret/paths.hbs b/ui/lib/kv/addon/templates/secret/paths.hbs index d27c698d53..20190681e6 100644 --- a/ui/lib/kv/addon/templates/secret/paths.hbs +++ b/ui/lib/kv/addon/templates/secret/paths.hbs @@ -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}} /> \ No newline at end of file diff --git a/ui/lib/kv/addon/utils/kv-breadcrumbs.js b/ui/lib/kv/addon/utils/kv-breadcrumbs.js index d6c1f56be9..2296f51f3d 100644 --- a/ui/lib/kv/addon/utils/kv-breadcrumbs.js +++ b/ui/lib/kv/addon/utils/kv-breadcrumbs.js @@ -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}/`] }; diff --git a/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.hbs b/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.hbs index d568873725..a950cfb359 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.hbs +++ b/ui/lib/sync/addon/components/secrets/page/destinations/destination/secrets.hbs @@ -17,7 +17,7 @@ {{association.secretName}} {{#if association.subKey}} @@ -62,7 +62,7 @@ diff --git a/ui/lib/sync/addon/components/secrets/page/destinations/destination/sync.hbs b/ui/lib/sync/addon/components/secrets/page/destinations/destination/sync.hbs index f2e24efb45..2fa9ad7a7f 100644 --- a/ui/lib/sync/addon/components/secrets/page/destinations/destination/sync.hbs +++ b/ui/lib/sync/addon/components/secrets/page/destinations/destination/sync.hbs @@ -24,7 +24,7 @@ Sync operation successfully initiated for {{this.syncedSecret}}. You can continue on this page to sync more secrets. diff --git a/ui/lib/sync/addon/engine.js b/ui/lib/sync/addon/engine.js index 020014a462..f6cfda3760 100644 --- a/ui/lib/sync/addon/engine.js +++ b/ui/lib/sync/addon/engine.js @@ -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'], }; } diff --git a/ui/mirage/factories/kv-metadatum.js b/ui/mirage/factories/kv-metadatum.js index b7e89da425..b4ea2d56d7 100644 --- a/ui/mirage/factories/kv-metadatum.js +++ b/ui/mirage/factories/kv-metadatum.js @@ -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, }, diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js index 1b84ce0bce..0bc16b1016 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-create-test.js @@ -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` diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-delete-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-delete-test.js index 04a723fb60..643d243c15 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-delete-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-delete-test.js @@ -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'); }); diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js index a62722fa0c..c56551b1c8 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-edge-cases-test.js @@ -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) diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js index a64c23953f..edf9986a64 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js @@ -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'); + }); + }); }); diff --git a/ui/tests/acceptance/secrets/backend/kv/secret-test.js b/ui/tests/acceptance/secrets/backend/kv/secret-test.js index ab224d0aa8..987a84a43b 100644 --- a/ui/tests/acceptance/secrets/backend/kv/secret-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/secret-test.js @@ -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'); diff --git a/ui/tests/helpers/kv/kv-selectors.js b/ui/tests/helpers/kv/kv-selectors.js index 6938f707b6..1e0fddbf4e 100644 --- a/ui/tests/helpers/kv/kv-selectors.js +++ b/ui/tests/helpers/kv/kv-selectors.js @@ -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', diff --git a/ui/tests/helpers/kv/policy-generator.js b/ui/tests/helpers/kv/policy-generator.js index d032a9f21f..7b680344f0 100644 --- a/ui/tests/helpers/kv/policy-generator.js +++ b/ui/tests/helpers/kv/policy-generator.js @@ -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 }), }; diff --git a/ui/tests/integration/components/kv/kv-subkeys-card-test.js b/ui/tests/integration/components/kv/kv-subkeys-card-test.js index 6266712ee7..c629702c33 100644 --- a/ui/tests/integration/components/kv/kv-subkeys-card-test.js +++ b/ui/tests/integration/components/kv/kv-subkeys-card-test.js @@ -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``, { - owner: this.engine, - }); + return render( + hbs``, + { + 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(); diff --git a/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js b/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js index 4d065f4fda..9a946c283f 100644 --- a/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-metadata-details-test.js @@ -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` + + `, + { owner: this.engine } + ); + }; }); test('it renders metadata details', async function (assert) { assert.expect(8); - await render( - hbs` - - `, - { 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` - - `, - { 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` - - `, - { 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(); + }); }); diff --git a/ui/tests/integration/components/kv/page/kv-page-overview-test.js b/ui/tests/integration/components/kv/page/kv-page-overview-test.js index 0bb699fee3..9342d621fe 100644 --- a/ui/tests/integration/components/kv/page/kv-page-overview-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-overview-test.js @@ -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) { diff --git a/ui/tests/integration/components/kv/page/kv-page-patch-test.js b/ui/tests/integration/components/kv/page/kv-page-patch-test.js index d6855c011b..06da592043 100644 --- a/ui/tests/integration/components/kv/page/kv-page-patch-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-patch-test.js @@ -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( diff --git a/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js b/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js index a86ef59361..bd8ebfca82 100644 --- a/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-secret-edit-test.js @@ -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' ); }); diff --git a/ui/tests/integration/components/kv/page/kv-page-secrets-create-test.js b/ui/tests/integration/components/kv/page/kv-page-secrets-create-test.js index 79f229c58c..c48b544f24 100644 --- a/ui/tests/integration/components/kv/page/kv-page-secrets-create-test.js +++ b/ui/tests/integration/components/kv/page/kv-page-secrets-create-test.js @@ -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' ); }); diff --git a/ui/tests/unit/models/capabilities-test.js b/ui/tests/unit/models/capabilities-test.js index 99a5654be8..399cca6b2b 100644 --- a/ui/tests/unit/models/capabilities-test.js +++ b/ui/tests/unit/models/capabilities-test.js @@ -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) { diff --git a/ui/tests/unit/services/capabilities-test.js b/ui/tests/unit/services/capabilities-test.js index 7225d232b2..1e884aed8c 100644 --- a/ui/tests/unit/services/capabilities-test.js +++ b/ui/tests/unit/services/capabilities-test.js @@ -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}`); + }); }); }); }); diff --git a/ui/tests/unit/utils/kv-breadcrumbs-test.js b/ui/tests/unit/utils/kv-breadcrumbs-test.js index 85664c181e..09a0faf48f 100644 --- a/ui/tests/unit/utils/kv-breadcrumbs-test.js +++ b/ui/tests/unit/utils/kv-breadcrumbs-test.js @@ -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' ); diff --git a/ui/types/vault/models/capabilities.d.ts b/ui/types/vault/models/capabilities.d.ts index c56eb228ac..9ef39f7e2d 100644 --- a/ui/types/vault/models/capabilities.d.ts +++ b/ui/types/vault/models/capabilities.d.ts @@ -9,12 +9,13 @@ import Model from '@ember-data/model'; interface CapabilitiesModel extends Model { path: string; capabilities: Array; - canSudo: ComputedProperty; - canRead: ComputedProperty; canCreate: ComputedProperty; - canUpdate: ComputedProperty; canDelete: ComputedProperty; canList: ComputedProperty; + canPatch: ComputedProperty; + canRead: ComputedProperty; + canSudo: ComputedProperty; + canUpdate: ComputedProperty; // these don't seem to be used anywhere // inferring type from key name allowedParameters: Array;