[UI] Ember Data Migration - KV Secrets/Metadata (#9508) (#9762)

* adds error handling for control groups to api service as post request middleware

* updates kv list route to use api service

* updates kv config route to use api service

* updates kv secrets overview route to use api service

* updates kv secret details route to use api service

* adds kv form

* updates kv metadata details route to use api service

* updates kv paths and version history routes to use api service

* refactors kv-data-fields component to form component

* updates kv secret create route to use api service

* updates kv secret edit route to use api service

* updates kv metadata edit route to use api service

* adds waitFor to async middleware in api service to attempt to fix race conditions in tests

* adds kvMetadata path to capabilities path map

* fixes kv list item delete test selector

* fixes kv v2 workflow create tests

* fixes issue returning metadata for secret when latest version is deleted

* decodes uri in path returned by api service parseError method

* fixes kv v2 edge cases tests

* fixes issue deleteing control group token in api service

* decodes url for control group token lookup in api service

* fixes version history linked block link

* defaults cas to 0 when creating new secret

* removes log

* adds ember-template-lint to kv engine

* more test fixes

* updates kv helpers from classic format

* updates kv helpers imports

* reverts to use secret.version in details edit route

* fixes isDeleted import in kv version history test

* adds waitFor to api service parseError method

* reverts removing async from addQueryParams api method

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
This commit is contained in:
Vault Automation 2025-10-02 12:44:22 -04:00 committed by GitHub
parent eadd2bde15
commit 0b939eaaf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 1845 additions and 2031 deletions

View file

@ -27,7 +27,6 @@
*/
import Component from '@glimmer/component';
import ControlGroupError from 'vault/lib/control-group-error';
import Ember from 'ember';
import keys from 'core/utils/keys';
import { action, set } from '@ember/object';
@ -124,10 +123,10 @@ export default class SecretCreateOrUpdate extends Component {
}
})
.catch((error) => {
if (error instanceof ControlGroupError) {
if (error.isControlGroupError) {
this.controlGroup.saveTokenFromError(error);
const errorMessage = this.controlGroup.logFromError(error);
this.error = errorMessage.content;
this.controlGroup.saveTokenFromError(error);
}
throw error;
});

View file

@ -0,0 +1,84 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Form from 'vault/forms/form';
import FormField from 'vault/utils/forms/field';
import { WHITESPACE_WARNING } from 'vault/utils/forms/validators';
import type { Validations } from 'vault/app-types';
type KvFormData = {
path: string;
secretData: { [key: string]: string };
custom_metadata?: { [key: string]: string };
max_versions?: number;
cas_required?: boolean;
delete_version_after?: string;
// readonly options when editing an existing secret
options?: {
cas: number;
};
};
export default class KvForm extends Form<KvFormData> {
fieldProps = ['secretFields', 'metadataFields'];
validations: Validations = {
path: [
{ type: 'presence', message: `Path can't be blank.` },
{ type: 'endsInSlash', message: `Path can't end in forward slash '/'.` },
{
type: 'containsWhiteSpace',
message: WHITESPACE_WARNING('path'),
level: 'warn',
},
],
secretData: [
{
validator: ({ secretData }: KvForm['data']) =>
secretData !== undefined && typeof secretData !== 'object' ? false : true,
message: 'Vault expects data to be formatted as an JSON object.',
},
],
max_versions: [
{ type: 'number', message: 'Maximum versions must be a number.' },
{ type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' },
],
};
secretFields = [
new FormField('path', 'string', {
label: 'Path for this secret',
subText: 'Names with forward slashes define hierarchical path structures.',
}),
];
metadataFields = [
new FormField('custom_metadata', 'object', {
editType: 'kv',
isSectionHeader: true,
subText:
'An optional set of informational key-value pairs that will be stored with all secret versions.',
}),
new FormField('max_versions', 'number', {
defaultValue: 0,
label: 'Maximum number of versions',
subText:
'The number of versions to keep per key. Once the number of keys exceeds the maximum number set here, the oldest version will be permanently deleted.',
}),
new FormField('cas_required', 'boolean', {
defaultValue: false,
label: 'Require Check and Set',
subText: `Writes will only be allowed if the key's current version matches the version specified in the cas parameter.`,
}),
new FormField('delete_version_after', 'boolean', {
defaultValue: '0s',
editType: 'ttl',
label: 'Automate secret deletion',
helperTextDisabled: `A secret's version must be manually deleted.`,
helperTextEnabled: 'Delete all new versions of this secret after:',
}),
];
}

View file

@ -7,7 +7,7 @@ import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withModelValidations } from 'vault/decorators/model-validations';
import { withFormFields } from 'vault/decorators/model-form-fields';
import { isDeleted } from 'kv/utils/kv-deleted';
import { isDeleted } from 'kv/helpers/is-deleted';
import { WHITESPACE_WARNING } from 'vault/utils/forms/validators';
/* sample response

View file

@ -8,7 +8,7 @@ import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withModelValidations } from 'vault/decorators/model-validations';
import { withFormFields } from 'vault/decorators/model-form-fields';
import { keyIsFolder } from 'core/utils/key-utils';
import { isDeleted } from 'kv/utils/kv-deleted';
import { isDeleted } from 'kv/helpers/is-deleted';
const validations = {
maxVersions: [

View file

@ -99,13 +99,18 @@ export default class ApiService extends Service {
});
checkControlGroup = waitFor(async (context: ResponseContext) => {
const { url } = context;
const response = context.response.clone();
const { headers } = response;
const controlGroupToken = this.controlGroup.tokenForUrl(url);
if (controlGroupToken) {
this.controlGroup.deleteControlGroupToken(controlGroupToken.accessor);
// since control group requests are forwarded to /v1/sys/wrapping/unwrap we cannot use controlGroup.tokenForUrl here
// instead, we can check if tokenToUnwrap exists on the service and compare the token value with the request header value
if (this.controlGroup.tokenToUnwrap) {
const { token, accessor } = this.controlGroup.tokenToUnwrap || {};
const requestHeaders = context.init.headers as Headers;
if (requestHeaders.get('X-Vault-Token') === token) {
this.controlGroup.deleteControlGroupToken(accessor);
}
}
// if the requested path is locked by a control group we need to create a new error response
if (headers.get('Content-Length')) {
@ -182,7 +187,7 @@ export default class ApiService extends Service {
// accepts an error response and returns { status, message, response, path }
// message is built as error.errors joined with a comma, error.message or a fallback message
// path is the url of the request, minus the origin -> /v1/sys/wrapping/unwrap
async parseError(e: unknown, fallbackMessage = 'An error occurred, please try again') {
parseError = waitFor(async (e: unknown, fallbackMessage = 'An error occurred, please try again') => {
if (e instanceof ResponseError) {
const { status, url } = e.response;
// instances where an error is thrown multiple times could result in the body already being read
@ -197,7 +202,7 @@ export default class ApiService extends Service {
return {
message: message || fallbackMessage,
status,
path: url.replace(document.location.origin, ''),
path: decodeURIComponent(url.replace(document.location.origin, '')),
response: error,
};
}
@ -210,7 +215,7 @@ export default class ApiService extends Service {
return {
message: (e as Error)?.message || fallbackMessage,
};
}
});
// accepts a list response as { keyInfo, keys } and returns a flat array of the keyInfo datum
// to preserve the keys (unique identifiers) the value will be set on the datum as id

View file

@ -77,7 +77,7 @@ export default Service.extend({
return null;
}
let pathForUrl = parseURL(url).pathname;
pathForUrl = pathForUrl.replace('/v1/', '');
pathForUrl = decodeURIComponent(pathForUrl.replace('/v1/', ''));
const tokenInfo = this.tokenToUnwrap;
if (tokenInfo && tokenInfo.creation_path === pathForUrl) {
const { token, accessor, creation_time } = tokenInfo;

View file

@ -23,6 +23,7 @@ export interface FieldOptions {
helperTextEnabled?: string;
placeholder?: string;
noDefault?: boolean;
isSectionHeader?: boolean;
}
export default class FormField {

View file

@ -0,0 +1,83 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-bottomless">
<NamespaceReminder @mode="create" @noun="secret" />
<MessageError @errorMessage={{this.errorMessage}} />
{{#each @form.secretFields as |field|}}
{{! path is readonly when editing a secret }}
{{#if (and (not @form.isNew) (eq field.name "path"))}}
<ReadonlyFormField @attr={{field}} @value={{@form.data.path}} />
{{else}}
<FormField
@attr={{field}}
@model={{@form}}
@modelValidations={{this.modelValidations}}
@onKeyUp={{this.pathValidations}}
/>
{{/if}}
{{/each}}
<hr class="is-marginless has-background-gray-200" />
{{#if @showJson}}
<JsonEditor
@title="{{if @form.isNew 'Secret' 'Version'}} data"
@value={{stringified-secret-data @form.data.secretData}}
@valueUpdated={{this.onJsonChange}}
/>
{{#if (or this.modelValidations.secretData.errors this.lintingErrors)}}
<AlertInline
@color={{if this.lintingErrors "warning" "critical"}}
class="has-top-padding-s"
@message={{or
this.modelValidations.secretData.errors
"JSON is unparsable. Fix linting errors to avoid data discrepancies."
}}
/>
{{/if}}
{{else}}
<KvObjectEditor
class="has-top-margin-m"
@label="{{if @form.isNew 'Secret' 'Version'}} data"
@value={{@form.data.secretData}}
@onChange={{this.onKvObjectChange}}
@isMasked={{true}}
@warnNonStringValues={{true}}
/>
{{/if}}
{{! edit page renders version diff and create page renders metadata form }}
{{yield this.modelValidations}}
</div>
<div class="box is-fullwidth is-bottomless">
<div class="control">
<Hds::Button
@text="Save"
@icon={{if this.save.isRunning "loading"}}
type="submit"
disabled={{this.save.isRunning}}
data-test-kv-save
/>
<Hds::Button
@text="Cancel"
@color="secondary"
class="has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.onCancel}}
data-test-kv-cancel
/>
</div>
{{#if this.invalidFormAlert}}
<AlertInline
data-test-invalid-form-alert
class="has-top-padding-s"
@type="danger"
@message={{this.invalidFormAlert}}
/>
{{/if}}
</div>
</form>

View file

@ -0,0 +1,144 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { pathIsFromDirectory } from 'kv/utils/kv-breadcrumbs';
import { waitFor } from '@ember/test-waiters';
/**
* @module KvCreateEditForm is used for creating and editing kv secret data and metadata, it hides/shows a json editor and renders validation errors for the json editor
*
* <KvCreateEditForm
* @form={{@form}}
* @path={{@path}}
* @backend={{@backend}}
* @showJson={{true}}
* @onChange={{@onChange}}
* />
*
* @param {Form} form - kv form
* @param {string} path - secret path
* @param {string} backend - secret mount path
* @param {boolean} showJson - boolean passed from parent to hide/show json editor
* @param {function} onSecretDataChange - function passed from parent to handle secret data change side effects
*/
export default class KvCreateEditForm extends Component {
@service api;
@service controlGroup;
@service flashMessages;
@service('app-router') router;
@tracked lintingErrors;
@tracked modelValidations;
@tracked invalidFormAlert;
@tracked errorMessage;
@action
onJsonChange(value) {
try {
const json = JSON.parse(value);
this.args.form.data.secretData = json;
this.lintingErrors = false;
this.args.onChange?.(json);
} catch {
this.lintingErrors = true;
}
}
@action
onKvObjectChange(value) {
this.args.form.data.secretData = value;
this.args.onChange?.(value);
}
@action
pathValidations() {
// check path attribute warnings on key up for new secrets
const { state } = this.args.form.toJSON();
if (state?.path?.warnings) {
// only set model validations if warnings exist
this.modelValidations = state;
}
}
@action
onCancel() {
const { form, path } = this.args;
if (form.isNew) {
pathIsFromDirectory(path)
? this.router.transitionTo('vault.cluster.secrets.backend.kv.list-directory', path)
: this.router.transitionTo('vault.cluster.secrets.backend.kv.list');
} else {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
}
}
hasMetadata(metadata) {
try {
const { custom_metadata = {}, max_versions, cas_required, delete_version_after = '0s' } = metadata;
return (
Object.keys(custom_metadata).length || max_versions || cas_required || delete_version_after !== '0s'
);
} catch (e) {
return false;
}
}
save = task(
waitFor(async (event) => {
event.preventDefault();
const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = invalidFormMessage;
this.errorMessage = null;
if (isValid) {
const { path, secretData, options, ...metadata } = data;
try {
// try saving secret data first
const payload = options ? { data: secretData, options } : { data: secretData };
await this.api.secrets.kvV2Write(path, this.args.backend, payload);
this.flashMessages.success(`Successfully saved secret data for: ${path}.`);
// users must have permission to create secret data to create metadata in the UI
// only attempt to save metadata if secret data saves successfully and metadata is added
if (this.hasMetadata(metadata)) {
try {
await this.api.secrets.kvV2WriteMetadata(path, this.args.backend, metadata);
this.flashMessages.success(`Successfully saved metadata.`);
} catch (error) {
const { message } = await this.api.parseError(error);
this.flashMessages.danger(`Secret data was saved but metadata was not: ${message}`, {
sticky: true,
});
}
}
} catch (error) {
const { message, response } = await this.api.parseError(error);
if (response.isControlGroupError) {
this.controlGroup.saveTokenFromError(response);
const err = this.controlGroup.logFromError(response);
this.errorMessage = err.content;
} else {
this.errorMessage = message;
}
}
// prevent transition if there are errors with secret data
if (this.errorMessage) {
this.invalidFormAlert = 'There was an error submitting this form.';
} else {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index', path);
}
}
})
);
}

View file

@ -1,64 +0,0 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
{{#let (find-by "name" "path" @secret.allFields) as |attr|}}
{{#if (eq @type "edit")}}
<ReadonlyFormField @attr={{attr}} @value={{get @secret attr.name}} />
{{else if (eq @type "create")}}
<FormField @attr={{attr}} @model={{@secret}} @modelValidations={{@modelValidations}} @onKeyUp={{@pathValidations}} />
{{/if}}
{{/let}}
<hr class="is-marginless has-background-gray-200" />
{{#if @showJson}}
{{#if (eq @type "details")}}
<Hds::CodeBlock
data-test-code-block="secret-data"
@value={{this.stringifiedSecretData}}
@language="json"
@hasCopyButton={{true}}
@maxHeight="300px"
as |CB|
>
<CB.Title @tag="h3">
Version data
</CB.Title>
</Hds::CodeBlock>
{{else}}
<JsonEditor
@title="{{if (eq @type 'create') 'Secret' 'Version'}} data"
@value={{this.stringifiedSecretData}}
@valueUpdated={{this.handleJson}}
/>
{{/if}}
{{#if (or @modelValidations.secretData.errors this.lintingErrors)}}
<AlertInline
@color={{if this.lintingErrors "warning" "critical"}}
class="has-top-padding-s"
@message={{if
@modelValidations.secretData.errors
@modelValidations.secretData.errors
"JSON is unparsable. Fix linting errors to avoid data discrepancies."
}}
/>
{{/if}}
{{else if (eq @type "details")}}
{{#each-in @secret.secretData as |key value|}}
<InfoTableRow @label={{key}} @value={{value}} @alwaysRender={{true}}>
<MaskedInput @name={{key}} @value={{value}} @displayOnly={{true}} @allowCopy={{true}} />
</InfoTableRow>
{{else}}
<InfoTableRow @label="" @value="" @alwaysRender={{true}} />
{{/each-in}}
{{else}}
<KvObjectEditor
class="has-top-margin-m"
@label="{{if (eq @type 'create') 'Secret' 'Version'}} data"
@value={{@secret.secretData}}
@onChange={{fn (mut @secret.secretData)}}
@isMasked={{true}}
@warnNonStringValues={{true}}
/>
{{/if}}

View file

@ -1,54 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { stringify } from 'core/helpers/stringify';
/**
* @module KvDataFields is used for rendering the fields associated with kv secret data, it hides/shows a json editor and renders validation errors for the json editor
*
* <KvDataFields
* @showJson={{true}}
* @secret={{@secret}}
* @type="edit"
* @modelValidations={{this.modelValidations}}
* @pathValidations={{this.pathValidations}}
* />
*
* @param {model} secret - Ember data model: 'kv/data', the new record saved by the form
* @param {boolean} showJson - boolean passed from parent to hide/show json editor
* @param {object} [modelValidations] - object of errors. If attr.name is in object and has error message display in AlertInline.
* @param {callback} [pathValidations] - callback function fired for the path input on key up
* @param {boolean} [type=null] - can be edit, create, or details. Used to change text for some form labels
*/
export default class KvDataFields extends Component {
@tracked lintingErrors;
get startingValue() {
// must pass the third param called "space" in JSON.stringify to structure object with whitespace
// otherwise the following codemirror modifier check will pass `this._editor.getValue() !== namedArgs.content` and _setValue will be called.
// the method _setValue moves the cursor to the beginning of the text field.
// the effect is that the cursor jumps after the first key input.
return JSON.stringify({ '': '' }, null, 2);
}
get stringifiedSecretData() {
return this.args.secret?.secretData ? stringify([this.args.secret.secretData], {}) : this.startingValue;
}
@action
handleJson(value) {
this.lintingErrors = false;
try {
this.args.secret.secretData = JSON.parse(value);
} catch {
this.lintingErrors = true;
}
}
}

View file

@ -7,6 +7,7 @@ import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { assert } from '@ember/debug';
import currentSecret from 'kv/helpers/current-secret';
/**
* @module KvDeleteModal displays a button for a delete type and launches a modal. Undelete is the only mode that does not launch the modal and is not handled in this component.
@ -15,13 +16,15 @@ import { assert } from '@ember/debug';
* @mode="destroy"
* @secret={{this.model.secret}}
* @metadata={{this.model.metadata}}
* @capabilities={{this.model.capabilities}}
* @onDelete={{this.handleDestruction}}
* />
*
* @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".
* @param {object} secret - secret data.
* @param {object} [metadata] - secret metadata. It is only required when mode is "delete".
* @param {string} [text] - Button text that renders in KV v2 toolbar, defaults to capitalize @mode
* @param {object} capabilities - capabilities for data, metadata, subkeys, delete and undelete paths
* @param {callback} onDelete - callback function fired to handle delete event.
*/
@ -57,24 +60,29 @@ export default class KvDeleteModal extends Component {
}
}
get currentSecret() {
return currentSecret(this.args.metadata);
}
get deleteOptions() {
const { secret, metadata, version } = this.args;
const isDeactivated = secret.canReadMetadata ? metadata?.currentSecret?.isDeactivated : false;
const { capabilities, secret, version } = this.args;
const { canDeleteVersion, canDeleteLatestVersion } = capabilities;
const isDeactivated = this.currentSecret?.isDeactivated || false;
return [
{
key: 'delete-version',
label: 'Delete this version',
description: `This deletes ${version ? `Version ${version}` : `a specific version`} of the secret.`,
disabled: !secret.canDeleteVersion,
disabled: !canDeleteVersion,
tooltipMessage: `Deleting a specific version requires "update" capabilities to ${secret.backend}/delete/${secret.path}.`,
},
{
key: 'delete-latest-version',
label: 'Delete latest version',
description: 'This deletes the most recent version of the secret.',
disabled: !secret.canDeleteLatestVersion || isDeactivated,
disabled: !canDeleteLatestVersion || isDeactivated,
tooltipMessage: isDeactivated
? `The latest version of the secret is already ${metadata.currentSecret.state}.`
? `The latest version of the secret is already ${this.currentSecret.state}.`
: `Deleting the latest version of this secret requires "delete" capabilities to ${secret.backend}/data/${secret.path}.`,
},
];

View file

@ -3,13 +3,11 @@
SPDX-License-Identifier: BUSL-1.1
}}
{{#each @metadata.formFields as |attr|}}
{{#if (eq attr.name "customMetadata")}}
<FormField @attr={{attr}} @model={{@metadata}} @modelValidations={{@modelValidations}} />
{{#each @form.metadataFields as |field|}}
<FormField @attr={{field}} @model={{@form}} @modelValidations={{@modelValidations}} />
{{#if (eq field.name "custom_metadata")}}
<label class="title has-top-padding-m is-4">
Additional options
</label>
{{else}}
<FormField @attr={{attr}} @model={{@metadata}} @modelValidations={{@modelValidations}} />
{{/if}}
{{/each}}

View file

@ -12,12 +12,12 @@
<D.Content @defaultClass="popup-menu-content">
<nav class="box menu">
<ul class="menu-list">
{{#each @metadata.sortedVersions as |versionData|}}
{{#each (sorted-versions @metadata.versions) as |versionData|}}
<li data-test-version={{versionData.version}}>
{{#if @onSelect}}
{{! TODO use Hds::Dropdown instead https://helios.hashicorp.design/components/dropdown }}
<button
disabled={{or versionData.destroyed versionData.isSecretDeleted}}
disabled={{or versionData.destroyed (is-deleted versionData.deletion_time)}}
{{on "click" (fn @onSelect versionData.version D.actions)}}
type="button"
class="link {{if (loose-equal versionData.version @displayVersion) 'is-active'}}"
@ -28,7 +28,7 @@
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
{{else if versionData.isSecretDeleted}}
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
{{else if (loose-equal versionData.version @metadata.currentVersion)}}
{{else if (loose-equal versionData.version @metadata.current_version)}}
<Icon @name="check-circle" class="has-text-success is-pulled-right" />
{{/if}}
</button>
@ -40,7 +40,7 @@
<Icon @name="x-square-fill" class="has-text-danger is-pulled-right" />
{{else if versionData.isSecretDeleted}}
<Icon @name="x-square-fill" class="has-text-grey is-pulled-right" />
{{else if (loose-equal versionData.version @metadata.currentVersion)}}
{{else if (loose-equal versionData.version @metadata.current_version)}}
<Icon @name="check-circle" class="has-text-success is-pulled-right" />
{{/if}}
</LinkTo>

View file

@ -14,16 +14,18 @@
</A.Title>
{{#each this.syncStatus as |status|}}
<A.Description data-test-sync-alert={{status.destinationName}}>
<SyncStatusBadge @status={{status.syncStatus}} />
<A.Description data-test-sync-alert={{status.name}}>
<SyncStatusBadge @status={{status.sync_status}} />
<Hds::Link::Inline
@route="syncDestination"
@color="secondary"
@isRouteExternal={{true}}
@models={{array status.destinationType status.destinationName}}
>{{status.destinationName}}</Hds::Link::Inline>
@models={{array status.type status.name}}
>
{{status.name}}
</Hds::Link::Inline>
- last updated
{{date-format status.updatedAt "MMMM do yyyy, h:mm:ss a"}}
{{date-format status.updated_at "MMMM do yyyy, h:mm:ss a"}}
</A.Description>
{{/each}}
@ -45,26 +47,18 @@
<:tabLinks>
<li>
<LinkTo
@route="secret.index"
@models={{array @secret.backend @path}}
data-test-secrets-tab="Overview"
>Overview</LinkTo>
<LinkTo @route="secret.index" @models={{array @backend @path}} data-test-secrets-tab="Overview">Overview</LinkTo>
</li>
<li>
<LinkTo @route="secret.details" @models={{array @secret.backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo>
<LinkTo @route="secret.details" @models={{array @backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo>
</li>
<li>
<LinkTo
@route="secret.metadata.index"
@models={{array @secret.backend @path}}
data-test-secrets-tab="Metadata"
>Metadata</LinkTo>
<LinkTo @route="secret.metadata" @models={{array @backend @path}} data-test-secrets-tab="Metadata">Metadata</LinkTo>
</li>
<li>
<LinkTo @route="secret.paths" @models={{array @secret.backend @path}} data-test-secrets-tab="Paths">Paths</LinkTo>
<LinkTo @route="secret.paths" @models={{array @backend @path}} data-test-secrets-tab="Paths">Paths</LinkTo>
</li>
{{#if @canReadMetadata}}
{{#if @capabilities.canReadMetadata}}
<li>
<LinkTo
@route="secret.metadata.versions"
@ -97,48 +91,50 @@
@mode="delete"
@secret={{@secret}}
@metadata={{@metadata}}
@capabilities={{@capabilities}}
@onDelete={{this.handleDestruction}}
@version={{this.version}}
/>
{{/if}}
{{#if this.showDestroy}}
<KvDeleteModal @mode="destroy" @secret={{@secret}} @onDelete={{this.handleDestruction}} @version={{this.version}} />
<KvDeleteModal
@mode="destroy"
@secret={{@secret}}
@capabilities={{@capabilities}}
@onDelete={{this.handleDestruction}}
@version={{this.version}}
/>
{{/if}}
{{#if (or @canReadData @canReadMetadata @canUpdateData)}}
{{#if (or @capabilities.canReadData @capabilities.canReadMetadata @capabilities.canUpdateData)}}
<div class="toolbar-separator"></div>
{{/if}}
{{#if (and @canReadData (eq @secret.state "created"))}}
{{#if (and @capabilities.canReadData (eq this.secretState "created"))}}
<CopySecretDropdown
@clipboardText={{stringify @secret.secretData}}
@clipboardText={{stringify @secret.data}}
@onWrap={{perform this.wrapSecret}}
@isWrapping={{this.wrapSecret.isRunning}}
@wrappedData={{this.wrappedData}}
@onClose={{this.clearWrappedData}}
/>
{{/if}}
{{#if @canReadMetadata}}
{{#if @capabilities.canReadMetadata}}
<KvVersionDropdown @displayVersion={{this.version}} @metadata={{@metadata}} @onClose={{this.closeVersionMenu}} />
{{/if}}
{{! @isPatchAllowed is true if the version is enterprise AND a user has "patch" secret + "read" subkeys capabilities }}
{{#if @isPatchAllowed}}
<ToolbarLink data-test-patch-latest-version @route="secret.patch" @models={{array @secret.backend @path}} @type="add">
<ToolbarLink data-test-patch-latest-version @route="secret.patch" @models={{array @backend @path}} @type="add">
Patch latest version
</ToolbarLink>
{{/if}}
{{#if @canUpdateData}}
<ToolbarLink
data-test-create-new-version
@route="secret.details.edit"
@models={{array @secret.backend @path}}
@type="add"
>
{{#if @capabilities.canUpdateData}}
<ToolbarLink data-test-create-new-version @route="secret.details.edit" @models={{array @backend @path}} @type="add">
Create new version
</ToolbarLink>
{{/if}}
</:toolbarActions>
</KvPageHeader>
{{#if (or @secret.isSecretDeleted (not this.emptyState))}}
{{#if (or this.isSecretDeleted (not this.emptyState))}}
<div class="info-table-row-header">
<div class="info-table-row thead {{if this.showJsonView 'is-shadowless'}} ">
{{#unless this.hideHeaders}}
@ -150,10 +146,10 @@
</div>
{{/unless}}
<div class="th column justify-right">
{{#if (or @secret.isSecretDeleted @secret.createdTime)}}
{{#if (or this.isSecretDeleted @secret.created_time)}}
<KvTooltipTimestamp
@text="Version {{if @secret.version @secret.version}} {{@secret.state}}"
@timestamp={{(if @secret.isSecretDeleted @secret.deletionTime @secret.createdTime)}}
@text="Version {{@secret.version}} {{this.secretState}}"
@timestamp={{(if this.isSecretDeleted @secret.deletion_time @secret.created_time)}}
/>
{{/if}}
</div>
@ -174,10 +170,27 @@
{{/if}}
</EmptyState>
{{else}}
<KvDataFields
@showJson={{this.showJsonView}}
@secret={{@secret}}
@modelValidations={{this.modelValidations}}
@type="details"
/>
<hr class="is-marginless has-background-gray-200" />
{{#if this.showJsonView}}
<Hds::CodeBlock
data-test-code-block="secret-data"
@value={{stringified-secret-data @secret.secretData}}
@language="json"
@hasCopyButton={{true}}
@maxHeight="300px"
as |CB|
>
<CB.Title @tag="h3">
Version data
</CB.Title>
</Hds::CodeBlock>
{{else}}
{{#each-in @secret.secretData as |key value|}}
<InfoTableRow @label={{key}} @value={{value}} @alwaysRender={{true}}>
<MaskedInput @name={{key}} @value={{value}} @displayOnly={{true}} @allowCopy={{true}} />
</InfoTableRow>
{{else}}
<InfoTableRow @label="" @value="" @alwaysRender={{true}} />
{{/each-in}}
{{/if}}
{{/if}}

View file

@ -10,39 +10,36 @@ import { next } from '@ember/runloop';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import { isDeleted } from 'kv/utils/kv-deleted';
import sortedVersions from 'kv/helpers/sorted-versions';
import isDeleted from 'kv/helpers/is-deleted';
import { isAdvancedSecret } from 'core/utils/advanced-secret';
/**
* @module KvSecretDetails renders the key/value data of a KV secret.
* It also renders a dropdown to display different versions of the secret.
* <Page::Secret::Details
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @canReadData={{this.model.canReadData}}
* @canReadMetadata={{this.model.canReadMetadata}}
* @canUpdateData={{this.model.canUpdateData}}
* @isPatchAllowed={{this.model.isPatchAllowed}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* @secret={{this.model.secret}}
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @capabilities={{this.model.capabilities}}
* @isPatchAllowed={{this.model.isPatchAllowed}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* @secret={{this.model.secret}}
* />
*
* @param {string} backend - path where kv engine is mounted
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
* @param {boolean} canReadData - if true and the secret is not destroyed/deleted the copy secret dropdown renders
* @param {boolean} canReadMetadata - if true it renders the kv select version dropdown in the toolbar and "Version History" tab
* @param {boolean} canUpdateData - if true it renders "Create new version" toolbar action
* @param {object} capabilities - capabilities for data, metadata, subkeys, delete and undelete paths
* @param {boolean} isPatchAllowed - if true it renders "Patch latest version" toolbar action. True when: (1) the version is enterprise, (2) a user has "patch" secret + "read" subkeys capabilities, (3) latest secret version is not deleted or destroyed
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {object} metadata - response object from /secret/metadata/path endpoint
* @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 {object} secret - data and metadata objects from kvV2Read response - { secretData: data, ...metadata }
*/
export default class KvSecretDetails extends Component {
@service flashMessages;
@service('app-router') router;
@service store;
@service api;
@tracked showJsonView = false;
@tracked wrappedData = null;
@ -73,12 +70,14 @@ export default class KvSecretDetails extends Component {
@task
@waitFor
*wrapSecret() {
const { backend, path } = this.args.secret;
const adapter = this.store.adapterFor('kv/data');
try {
const { token } = yield adapter.fetchWrapInfo({ backend, path, wrapTTL: 1800 });
if (!token) throw 'No token';
this.wrappedData = token;
const { secretData: data, ...metadata } = this.args.secret;
const { wrap_info } = yield this.api.sys.wrap(
{ data, metadata },
this.api.buildHeaders({ wrap: 1800 })
);
if (!wrap_info.token) throw 'No token';
this.wrappedData = wrap_info.token;
this.flashMessages.success('Secret successfully wrapped!');
} catch (error) {
this.flashMessages.danger('Could not wrap secret.');
@ -88,10 +87,12 @@ export default class KvSecretDetails extends Component {
@task
@waitFor
*fetchSyncStatus() {
const { backend, path } = this.args.secret;
const syncAdapter = this.store.adapterFor('sync/association');
try {
this.syncStatus = yield syncAdapter.fetchSyncStatus({ mount: backend, secretName: path });
const { backend: mount, path: secret_name } = this.args;
const { associated_destinations } = yield this.api.sys.systemReadSyncAssociationsDestinations(
(context) => this.api.addQueryParams(context, { mount, secret_name })
);
this.syncStatus = Object.values(associated_destinations);
} catch (e) {
// silently error
}
@ -99,34 +100,38 @@ export default class KvSecretDetails extends Component {
@action
async undelete() {
const { secret } = this.args;
const { backend, path } = this.args;
try {
await secret.destroyRecord({
adapterOptions: { deleteType: 'undelete', deleteVersions: this.version },
});
this.flashMessages.success(`Successfully undeleted ${secret.path}.`);
await this.api.secrets.kvV2UndeleteVersions(path, backend, { versions: [this.version] });
this.flashMessages.success(`Successfully undeleted ${path}.`);
this.transition();
} catch (err) {
this.flashMessages.danger(
`There was a problem undeleting ${secret.path}. Error: ${err.errors?.join(' ')}.`
);
const { message } = await this.api.parseError(err);
this.flashMessages.danger(`There was a problem undeleting ${path}. Error: ${message}.`);
}
}
@action
async handleDestruction(type) {
const { secret } = this.args;
const { backend, path } = this.args;
try {
await secret.destroyRecord({ adapterOptions: { deleteType: type, deleteVersions: this.version } });
if (type === 'destroy') {
await this.api.secrets.kvV2DestroyVersions(path, backend, { versions: [this.version] });
} else if (type === 'delete-latest-version') {
await this.api.secrets.kvV2Delete(path, backend);
} else if (type === 'delete-version') {
await this.api.secrets.kvV2DeleteVersions(path, backend, { versions: [this.version] });
} else {
throw 'type must be one of delete-latest-version, delete-version, or destroy.';
}
const verb = type.includes('delete') ? 'deleted' : 'destroyed';
this.flashMessages.success(`Successfully ${verb} Version ${this.version} of ${secret.path}.`);
this.flashMessages.success(`Successfully ${verb} Version ${this.version} of ${path}.`);
this.transition();
} catch (err) {
const { message } = await this.api.parseError(err);
const verb = type.includes('delete') ? 'deleting' : 'destroying';
this.flashMessages.danger(
`There was a problem ${verb} Version ${this.version} of ${secret.path}. Error: ${err.errors.join(
' '
)}.`
`There was a problem ${verb} Version ${this.version} of ${path}. Error: ${message}.`
);
}
}
@ -136,11 +141,15 @@ export default class KvSecretDetails extends Component {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
}
get sortedVersions() {
return sortedVersions(this.args.metadata?.versions);
}
get version() {
return (
this.args.secret?.version ||
this.router.currentRoute.queryParams?.version ||
this.args.metadata?.sortedVersions[0].version
this.sortedVersions[0]?.version
);
}
@ -148,18 +157,24 @@ export default class KvSecretDetails extends Component {
return this.showJsonView || this.emptyState;
}
get secretState() {
const { destroyed, created_time } = this.args.secret;
if (destroyed) return 'destroyed';
if (this.isSecretDeleted) return 'deleted';
if (created_time) return 'created';
return '';
}
get versionState() {
const { secret, metadata } = this.args;
const { secret } = this.args;
if (secret.failReadErrorCode !== 403) {
return secret.state;
return this.secretState;
}
// If the user can't read secret data, get the current version
// state from metadata versions
if (metadata?.sortedVersions) {
if (this.sortedVersions) {
const version = this.version;
const meta = version
? metadata.sortedVersions.find((v) => v.version == version)
: metadata.sortedVersions[0];
const meta = version ? this.sortedVersions.find((v) => v.version == version) : this.sortedVersions[0];
if (meta?.destroyed) {
return 'destroyed';
}
@ -174,31 +189,37 @@ export default class KvSecretDetails extends Component {
}
get showUndelete() {
const { secret } = this.args;
if (secret.canUndelete) {
const { canUndelete } = this.args.capabilities;
if (canUndelete) {
return this.versionState === 'deleted';
}
return false;
}
get showDelete() {
const { secret } = this.args;
if (secret.canDeleteVersion || secret.canDeleteLatestVersion) {
const { canDeleteVersion, canDeleteLatestVersion } = this.args.capabilities;
if (canDeleteVersion || canDeleteLatestVersion) {
return this.versionState === 'created' || this.versionState === '';
}
return false;
}
get isSecretDeleted() {
return isDeleted(this.args.secret.deletion_time);
}
get showDestroy() {
const { secret } = this.args;
if (secret.canDestroyVersion) {
const { canDestroyVersion } = this.args.capabilities;
if (canDestroyVersion) {
return this.versionState !== 'destroyed' && this.version;
}
return false;
}
get emptyState() {
if (!this.args.canReadData) {
const { canReadData, canReadMetadata } = this.args.capabilities;
if (!canReadData) {
return {
title: 'You do not have permission to read this secret',
message:
@ -206,23 +227,23 @@ export default class KvSecretDetails extends Component {
};
}
// only destructure if we can read secret data
const { version, destroyed, isSecretDeleted } = this.args.secret;
const { version, destroyed } = this.args.secret;
if (destroyed) {
return {
title: `Version ${version} of this secret has been permanently destroyed`,
message: `A version that has been permanently deleted cannot be restored. ${
this.args.canReadMetadata
canReadMetadata
? ' You can view other versions of this secret in the Version History tab above.'
: ''
}`,
link: '/vault/docs/secrets/kv/kv-v2',
};
}
if (isSecretDeleted) {
if (this.isSecretDeleted) {
return {
title: `Version ${version} of this secret has been deleted`,
message: `This version has been deleted but can be undeleted. ${
this.args.canReadMetadata
canReadMetadata
? 'View other versions of this secret by clicking the Version History tab above.'
: ''
}`,

View file

@ -16,14 +16,15 @@
<A.Title>Warning</A.Title>
<A.Description>
You are creating a new version based on data from Version
{{@previousVersion}}. The current version for
<code>{{@secret.path}}</code>
{{@secret.version}}. The current version for
<code>{{@path}}</code>
is Version
{{@currentVersion}}.
{{@metadata.current_version}}.
</A.Description>
</Hds::Alert>
{{/if}}
{{#if @noReadAccess}}
{{#if (eq @secret.failReadErrorCode 403)}}
<Hds::Alert data-test-secret-no-read-alert @type="inline" @color="warning" class="has-top-bottom-margin" as |A|>
<A.Title>Warning</A.Title>
<A.Description>
@ -32,59 +33,26 @@
</Hds::Alert>
{{/if}}
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-bottomless">
<NamespaceReminder @mode="create" @noun="secret" />
<MessageError @model={{@secret}} @errorMessage={{this.errorMessage}} />
<KvDataFields
@showJson={{this.showJsonView}}
@secret={{@secret}}
@modelValidations={{this.modelValidations}}
@type="edit"
/>
<div class="has-top-margin-m">
<Toggle @name="Show diff" @onChange={{fn (mut this.showDiff)}} @checked={{this.showDiff}}>
<span class="ttl-picker-label is-large">Show diff</span><br />
<div class="description has-text-grey" data-test-diff-description>{{if
this.diffDelta
"Showing the diff will reveal secret values"
"No changes to show. Update secret to view diff"
}}</div>
{{#if this.showDiff}}
<div class="form-section visual-diff text-grey-lightest background-color-black has-top-margin-s">
<pre data-test-visual-diff>{{sanitized-html this.visualDiff}}</pre>
</div>
{{/if}}
</Toggle>
</div>
<KvCreateEditForm
@form={{@form}}
@path={{@path}}
@backend={{@backend}}
@showJson={{this.showJsonView}}
@onChange={{this.onSecretDataUpdate}}
>
<div class="has-top-margin-m">
<Toggle @name="Show diff" @onChange={{fn (mut this.showDiff)}} @checked={{this.showDiff}}>
<span class="ttl-picker-label is-large">Show diff</span><br />
<div class="description has-text-grey" data-test-diff-description>{{if
this.diffDelta
"Showing the diff will reveal secret values"
"No changes to show. Update secret to view diff"
}}</div>
{{#if this.showDiff}}
<div class="form-section visual-diff text-grey-lightest background-color-black has-top-margin-s">
<pre data-test-visual-diff>{{sanitized-html this.visualDiff}}</pre>
</div>
{{/if}}
</Toggle>
</div>
<div class="box is-fullwidth is-bottomless">
<div class="control">
<Hds::Button
@text="Save"
@icon={{if this.save.isRunning "loading"}}
type="submit"
disabled={{this.save.isRunning}}
data-test-kv-save
/>
<Hds::Button
@text="Cancel"
@color="secondary"
class="has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.onCancel}}
data-test-kv-cancel
/>
</div>
{{#if this.invalidFormAlert}}
<AlertInline
data-test-invalid-form-alert
class="has-top-padding-s"
@type="danger"
@message={{this.invalidFormAlert}}
/>
{{/if}}
</div>
</form>
</KvCreateEditForm>

View file

@ -3,46 +3,41 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { service } from '@ember/service';
import errorMessage from 'vault/utils/error-message';
import { isAdvancedSecret } from 'core/utils/advanced-secret';
/**
* @module KvSecretEdit is used for creating a new version of a secret
*
* <Page::Secret::Edit
* @form={{this.model.form}}
* @secret={{this.model.newVersion}}
* @previousVersion={{this.model.secret.version}}
* @currentVersion={{this.model.metadata.currentVersion}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}
* />
*
* @param {model} secret - Ember data model: 'kv/data', the new record for the new secret version saved by the form
* @param {number} previousVersion - previous secret version number
* @param {number} currentVersion - current secret version, comes from the metadata endpoint
* @param {Form} form - kv form
* @param {object} secret - secret data
* @param {object} metadata - secret metadata
* @param {string} path - secret path
* @param {string} backend - secret mount path
* @param {array} breadcrumbs - breadcrumb objects to render in page header
*/
/* eslint-disable no-undef */
export default class KvSecretEdit extends Component {
@service controlGroup;
@service flashMessages;
@service('app-router') router;
@tracked showJsonView = false;
@tracked showDiff = false;
@tracked errorMessage;
@tracked modelValidations;
@tracked invalidFormAlert;
originalSecret;
@tracked updatedSecret;
constructor() {
super(...arguments);
this.originalSecret = JSON.stringify(this.args.secret.secretData || {});
this.originalSecret = JSON.stringify(this.args.form.data.secretData || {});
this.updatedSecret = this.args.form.data.secretData || {};
if (isAdvancedSecret(this.originalSecret)) {
// Default to JSON view if advanced
this.showJsonView = true;
@ -50,56 +45,31 @@ export default class KvSecretEdit extends Component {
}
get showOldVersionAlert() {
const { currentVersion, previousVersion } = this.args;
const { secret, metadata } = this.args;
// isNew check prevents alert from flashing after save but before route transitions
if (!currentVersion || !previousVersion || !this.args.secret.isNew) return false;
if (currentVersion !== previousVersion) return true;
if (metadata?.current_version && secret?.version) {
return metadata.current_version !== secret.version;
}
return false;
}
get diffDelta() {
const oldData = JSON.parse(this.originalSecret);
const newData = this.args.secret.secretData;
const diffpatcher = jsondiffpatch.create({});
return diffpatcher.diff(oldData, newData);
return diffpatcher.diff(oldData, this.updatedSecret);
}
get visualDiff() {
if (!this.showDiff) return null;
const newData = this.args.secret.secretData;
return this.diffDelta
? jsondiffpatch.formatters.html.format(this.diffDelta, newData)
: JSON.stringify(newData, undefined, 2);
}
@task
*save(event) {
event.preventDefault();
try {
const { isValid, state, invalidFormMessage } = this.args.secret.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = invalidFormMessage;
if (isValid) {
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.index');
}
} catch (error) {
let message = errorMessage(error);
if (error.message === 'Control Group encountered') {
this.controlGroup.saveTokenFromError(error);
const err = this.controlGroup.logFromError(error);
message = err.content;
}
this.errorMessage = message;
this.invalidFormAlert = 'There was an error submitting this form.';
if (this.showDiff) {
return this.diffDelta
? jsondiffpatch.formatters.html.format(this.diffDelta, this.updatedSecret)
: JSON.stringify(this.updatedSecret, undefined, 2);
}
return null;
}
@action
onCancel() {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
onSecretDataUpdate(value) {
this.updatedSecret = value;
}
}

View file

@ -21,7 +21,7 @@
<li>
<LinkTo @route="secret.paths" @models={{array @backend @path}} data-test-secrets-tab="Paths">Paths</LinkTo>
</li>
{{#if @canReadMetadata}}
{{#if @capabilities.canReadMetadata}}
<li>
<LinkTo
@route="secret.metadata.versions"
@ -33,10 +33,10 @@
</:tabLinks>
<:toolbarActions>
{{#if @canDeleteMetadata}}
{{#if @capabilities.canDeleteMetadata}}
<KvDeleteModal @mode="delete-metadata" @onDelete={{this.onDelete}} @text="Permanently delete" />
{{/if}}
{{#if @canUpdateMetadata}}
{{#if @capabilities.canUpdateMetadata}}
<ToolbarLink @route="secret.metadata.edit" @models={{array @backend @path}} data-test-edit-metadata>
Edit metadata
</ToolbarLink>
@ -48,17 +48,50 @@
Custom metadata
</h2>
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-custom-metadata-section>
{{! if the user had read permissions and there is no custom_metadata this is an empty object, without read capabilities it's falsy }}
{{#if this.customMetadata}}
{{#each-in this.customMetadata as |key value|}}
<InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} />
{{#each-in this.customMetadata as |key value|}}
<InfoTableRow @alwaysRender={{false}} @label={{key}} @value={{value}} />
{{else}}
{{#if (and (not @capabilities.canReadMetadata) (not @capabilities.canReadData))}}
<EmptyState
@title="You do not have access to read custom metadata"
@message="In order to read custom metadata you either need read access to the secret data and/or read access to metadata."
/>
{{else if this.canRequestData}}
{{! Offer opportunity to manually request /data/ for custom_metadata }}
{{#if this.error.isControlGroup}}
<ControlGroupInlineError @error={{this.error}} class="has-top-margin-s has-bottom-margin-s" />
{{else if this.error}}
<MessageError @errorMessage={{this.error}} />
{{/if}}
<EmptyState
@title="Request custom metadata?"
@bottomBorder={{true}}
@message="You do not have access to the metadata endpoint but you can retrieve custom metadata from the secret data endpoint."
>
<div class="is-block">
<Hds::Alert @type="compact" @color="critical" class="has-top-margin-xs" as |A|>
<A.Description>
Sensitive secret data will be retrieved.
</A.Description>
</Hds::Alert>
<Hds::Button
class="has-top-margin-xs"
@text="Request data"
@icon="reload"
@iconPosition="trailing"
@isFullWidth={{true}}
data-test-request-data
{{on "click" this.requestData}}
/>
</div>
</EmptyState>
{{else}}
<EmptyState
@title="No custom metadata"
@bottomBorder={{true}}
@message="This data is version-agnostic and is usually used to describe the secret being stored."
>
{{#if @canUpdateMetadata}}
{{#if @capabilities.canUpdateMetadata}}
<Hds::Link::Standalone
@icon="plus"
@text="Add metadata"
@ -68,42 +101,8 @@
/>
{{/if}}
</EmptyState>
{{/each-in}}
{{else if @canReadData}}
{{! Offer opportunity to manually request /data/ for custom_metadata }}
{{#if this.error.isControlGroup}}
<ControlGroupInlineError @error={{this.error}} class="has-top-margin-s has-bottom-margin-s" />
{{else if this.error}}
<MessageError @errorMessage={{this.error}} />
{{/if}}
<EmptyState
@title="Request custom metadata?"
@bottomBorder={{true}}
@message="You do not have access to the metadata endpoint but you can retrieve custom metadata from the secret data endpoint."
>
<div class="is-block">
<Hds::Alert @type="compact" @color="critical" class="has-top-margin-xs" as |A|>
<A.Description>
Sensitive secret data will be retrieved.
</A.Description>
</Hds::Alert>
<Hds::Button
class="has-top-margin-xs"
@text="Request data"
@icon="reload"
@iconPosition="trailing"
@isFullWidth={{true}}
data-test-request-data
{{on "click" this.requestData}}
/>
</div>
</EmptyState>
{{else}}
<EmptyState
@title="You do not have access to read custom metadata"
@message="In order to read custom metadata you either need read access to the secret data and/or read access to metadata."
/>
{{/if}}
{{/each-in}}
</div>
<section data-test-kv-metadata-section>
<h2 class="title is-5 has-bottom-padding-s has-top-margin-l">
@ -112,14 +111,18 @@
{{#if @metadata}}
<div class="box is-fullwidth is-sideless is-paddingless is-marginless" data-test-kv-metadata-section>
<InfoTableRow @alwaysRender={{true}} @label="Last updated">
<KvTooltipTimestamp @timestamp={{@metadata.updatedTime}} />
<KvTooltipTimestamp @timestamp={{@metadata.updated_time}} />
</InfoTableRow>
<InfoTableRow @alwaysRender={{true}} @label="Maximum versions" @value={{@metadata.maxVersions}} />
<InfoTableRow @alwaysRender={{true}} @label="Check-and-Set required" @value={{@metadata.casRequired}} />
<InfoTableRow @alwaysRender={{true}} @label="Maximum versions" @value={{@metadata.max_versions}} />
<InfoTableRow @alwaysRender={{true}} @label="Check-and-Set required" @value={{@metadata.cas_required}} />
<InfoTableRow
@alwaysRender={{true}}
@label="Delete version after"
@value={{if (eq @metadata.deleteVersionAfter "0s") "Never delete" (format-duration @metadata.deleteVersionAfter)}}
@value={{if
(eq @metadata.delete_version_after "0s")
"Never delete"
(format-duration @metadata.delete_version_after)
}}
/>
</div>
{{else}}

View file

@ -12,23 +12,17 @@ import errorMessage from 'vault/utils/error-message';
/**
* @module KvSecretMetadataDetails renders the details view for kv/metadata and button to delete (which deletes the whole secret) or edit metadata.
* <Page::Secret::Metadata::Details
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @canDeleteMetadata={{this.model.canDeleteMetadata}}
* @canReadData={{this.model.canReadData}}
* @canReadMetadata={{this.model.canReadMetadata}}
* @canUpdateMetadata={{this.model.canUpdateMetadata}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @capabilities={{this.model.capabilities}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* />
*
* @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} canReadData - if true, user can make a request for custom_metadata if they don't have "read" permissions for metadata
* @param {boolean} canReadMetadata - if true, secret metadata renders below custom_metadata
* @param {boolean} canUpdateMetadata - if true, "Edit" action renders in the toolbar
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {object} capabilities - capabilities for data, metadata, subkeys, delete and undelete paths
* @param {object} metadata - kv metadata
* @param {string} path - path of kv secret 'my/secret' used as the title for the KV page header
*
*
@ -38,24 +32,27 @@ export default class KvSecretMetadataDetails extends Component {
@service controlGroup;
@service flashMessages;
@service('app-router') router;
@service store;
@service pagination;
@service api;
@tracked error = null;
@tracked customMetadataFromData = null;
@tracked didRequestData = false;
get customMetadata() {
return this.args.metadata?.customMetadata || this.customMetadataFromData;
return this.args.metadata?.custom_metadata || this.customMetadataFromData;
}
get canRequestData() {
const { canReadMetadata, canReadData } = this.args.capabilities;
return !canReadMetadata && canReadData && !this.didRequestData;
}
@action
async onDelete() {
// 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 adapter.deleteMetadata(backend, path);
this.pagination.clearDataset('kv/metadata'); // Clear out the store cache so that the metadata/list view is updated.
await this.api.secrets.kvV2DeleteMetadataAndAllVersions(path, backend);
this.flashMessages.success(
`Successfully deleted the metadata and all version data for the secret ${path}.`
);
@ -69,17 +66,19 @@ export default class KvSecretMetadataDetails extends Component {
async requestData() {
const { backend, path } = this.args;
try {
const secretData = await this.store.queryRecord('kv/data', { backend, path });
this.customMetadataFromData = secretData.customMetadata;
} catch (error) {
if (error.message === 'Control Group encountered') {
this.controlGroup.saveTokenFromError(error);
this.error = this.controlGroup.logFromError(error);
const { metadata } = await this.api.secrets.kvV2Read(path, backend);
this.customMetadataFromData = metadata.custom_metadata;
this.didRequestData = true;
} catch (err) {
const { message, response } = await this.api.parseError(err);
if (response.isControlGroupError) {
this.controlGroup.saveTokenFromError(response);
this.error = this.controlGroup.logFromError(response);
this.error.isControlGroup = true;
return;
} else {
this.error.isControlGroup = false;
this.error = message;
}
this.error.isControlGroup = false;
this.error = errorMessage(error);
}
}
}

View file

@ -5,7 +5,7 @@
<KvPageHeader @breadcrumbs={{@breadcrumbs}} @pageTitle="Edit Secret Metadata" />
{{#if @metadata.canUpdateMetadata}}
{{#if @capabilities.canUpdateMetadata}}
<hr class="is-marginless has-background-gray-200" />
<p class="has-top-margin-m has-bottom-margin-m">
The options below are all version-agnostic; they apply to all versions of this secret.
@ -14,8 +14,9 @@
<div class="box is-sideless is-fullwidth is-marginless">
<NamespaceReminder @mode="update" @noun="KV secret metadata" />
<MessageError @errorMessage={{this.errorBanner}} class="has-top-margin-s" />
<KvMetadataFields @metadata={{@metadata}} @modelValidations={{this.modelValidations}} />
<KvMetadataFields @form={{@form}} @modelValidations={{this.modelValidations}} />
</div>
<div class="field is-grouped is-grouped-split is-fullwidth box is-bottomless">
<div class="has-top-padding-s">
<Hds::Button
@ -30,7 +31,7 @@
@color="secondary"
class="has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.cancel}}
{{on "click" @onCancel}}
data-test-kv-cancel
/>
{{#if this.invalidFormAlert}}

View file

@ -7,47 +7,45 @@ import Component from '@glimmer/component';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
import errorMessage from 'vault/utils/error-message';
import { action } from '@ember/object';
/**
* @module KvSecretMetadataEdit
* This component renders the view for editing a kv secret's metadata.
* While secret data and metadata are created on the same view, they are edited on different views/routes.
*
* @param {array} metadata - The kv/metadata model. It is version agnostic.
* @param {Form} form - kv form
* @param {string} backend - mount path of the kv secret engine
* @param {array} breadcrumbs - Breadcrumbs as an array of objects that contain label, route, and modelId. They are updated via the util kv-breadcrumbs to handle dynamic *pathToSecret on the list-directory route.
* @param {object} capabilities - capabilities for data, metadata, subkeys, delete and undelete paths
* @callback onCancel - Callback triggered when cancel button is clicked that transitions to the metadata details route.
* @callback onSave - Callback triggered on save success that transitions to the metadata details route.
*/
export default class KvSecretMetadataEditComponent extends Component {
@service flashMessages;
@service api;
@tracked errorBanner = '';
@tracked invalidFormAlert = '';
@tracked modelValidations = null;
@action
cancel() {
this.args.metadata.rollbackAttributes();
this.args.onCancel();
}
@task
*save(event) {
event.preventDefault();
try {
const { isValid, state, invalidFormMessage } = this.args.metadata.validate();
const { isValid, state, invalidFormMessage, data } = this.args.form.toJSON();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = invalidFormMessage;
if (isValid) {
const { path } = this.args.metadata;
yield this.args.metadata.save();
const { path, ...metadata } = data;
yield this.api.secrets.kvV2WriteMetadata(path, this.args.backend, metadata);
this.flashMessages.success(`Successfully updated ${path}'s metadata.`);
this.args.onSave();
}
} catch (error) {
this.errorBanner = errorMessage(error);
const { message } = yield this.api.parseError(error);
this.errorBanner = message;
this.invalidFormAlert = 'There was an error submitting this form.';
}
}

View file

@ -7,7 +7,9 @@ import Component from '@glimmer/component';
import { action } from '@ember/object';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { kvDataPath } from 'vault/utils/kv-path';
import sortedVersions from 'kv/helpers/sorted-versions';
import getCurrentSecret from 'kv/helpers/current-secret';
import isDeleted from 'kv/helpers/is-deleted';
/**
* @module KvSecretMetadataVersionDiff renders the version diff comparison
@ -18,7 +20,7 @@ import { kvDataPath } from 'vault/utils/kv-path';
* @breadcrumbs={{this.breadcrumbs}}
* />
*
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {object} metadata - secret metadata
* @param {string} path - path to request secret data for selected version
* @param {string} backend - kv secret mount to make network request
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
@ -26,7 +28,8 @@ import { kvDataPath } from 'vault/utils/kv-path';
/* eslint-disable no-undef */
export default class KvSecretMetadataVersionDiff extends Component {
@service store;
@service api;
@tracked leftVersion;
@tracked rightVersion;
@tracked visualDiff;
@ -36,20 +39,25 @@ export default class KvSecretMetadataVersionDiff extends Component {
super(...arguments);
// initialize with most recently (before current), active version on left
const olderVersions = this.args.metadata.sortedVersions.slice(1);
const recentlyActive = olderVersions.find((v) => !v.destroyed && !v.isSecretDeleted);
const olderVersions = this.sortedVersions.slice(1);
const recentlyActive = olderVersions.find((v) => !v.destroyed && !isDeleted(v.deletion_time));
this.leftVersion = Number(recentlyActive?.version);
this.rightVersion = this.args.metadata.currentVersion;
this.rightVersion = this.args.metadata.current_version;
// this diff is from older to newer (current) secret data
this.createVisualDiff();
}
get sortedVersions() {
return sortedVersions(this.args.metadata.versions);
}
// this can only be true on initialization if the current version is inactive
// selecting a deleted/destroyed version is otherwise disabled
get deactivatedState() {
const { currentVersion, currentSecret } = this.args.metadata;
return this.rightVersion === currentVersion && currentSecret.isDeactivated ? currentSecret.state : '';
const { current_version } = this.args.metadata;
const currentSecret = getCurrentSecret(this.args.metadata);
return this.rightVersion === current_version && currentSecret.isDeactivated ? currentSecret.state : '';
}
@action
@ -73,10 +81,12 @@ export default class KvSecretMetadataVersionDiff extends Component {
async fetchSecretData(version) {
const { backend, path } = this.args;
// check the store first, avoiding an extra network request if possible.
const storeData = await this.store.peekRecord('kv/data', kvDataPath(backend, path, version));
const data = storeData ? storeData : await this.store.queryRecord('kv/data', { backend, path, version });
return data?.secretData;
const initOverride = version ? (context) => this.api.addQueryParams(context, { version }) : undefined;
try {
const { data } = await this.api.secrets.kvV2Read(path, backend, initOverride);
return data;
} catch (e) {
// capabilities checks are higher up the tree so this request should not fail
}
}
}

View file

@ -12,11 +12,9 @@
<LinkTo @route="secret.details" @models={{array @backend @path}} data-test-secrets-tab="Secret">Secret</LinkTo>
</li>
<li>
<LinkTo
@route="secret.metadata.index"
@models={{array @backend @path}}
data-test-secrets-tab="Metadata"
>Metadata</LinkTo>
<LinkTo @route="secret.metadata.index" @models={{array @backend @path}} data-test-secrets-tab="Metadata">
Metadata
</LinkTo>
</li>
<li>
<LinkTo @route="secret.paths" @models={{array @backend @path}} data-test-secrets-tab="Paths">Paths</LinkTo>
@ -27,25 +25,27 @@
@models={{array @backend @path}}
data-test-secrets-tab="Version History"
current-when={{true}}
>Version History</LinkTo>
>
Version History
</LinkTo>
</li>
</:tabLinks>
<:toolbarActions>
{{#if @metadata.canReadMetadata}}
{{#if @capabilities.canReadMetadata}}
<ToolbarLink @route="secret.metadata.diff" @models={{array @backend @path}}>Version diff</ToolbarLink>
{{/if}}
</:toolbarActions>
</KvPageHeader>
{{#if @metadata.canReadMetadata}}
{{#if @capabilities.canReadMetadata}}
<div class="sub-text has-text-weight-semibold is-flex-end has-short-padding">
<KvTooltipTimestamp @text="Secret last updated" @timestamp={{@metadata.updatedTime}} />
<KvTooltipTimestamp @text="Secret last updated" @timestamp={{@metadata.updated_time}} />
</div>
{{#each @metadata.sortedVersions as |versionData|}}
{{#each (sorted-versions @metadata.versions) as |versionData|}}
<LinkedBlock
class="list-item-row"
@params={{array "vault.cluster.secrets.backend.kv.secret.details" @backend @metadata.path}}
@params={{array "vault.cluster.secrets.backend.kv.secret.details" @backend @path}}
@queryParams={{hash version=versionData.version}}
data-test-version-linked-block={{versionData.version}}
>
@ -67,14 +67,14 @@
<Icon @name="x-square-fill" />Destroyed
</span>
</div>
{{else if versionData.isSecretDeleted}}
{{else if (is-deleted versionData.deletion_time)}}
<div>
<span class="has-text-grey is-size-8 is-block">
<Icon @name="x-square-fill" />
<KvTooltipTimestamp @text="Deleted" @timestamp={{versionData.deletion_time}} />
</span>
</div>
{{else if (loose-equal versionData.version @metadata.currentVersion)}}
{{else if (loose-equal versionData.version @metadata.current_version)}}
<div>
<span class="has-text-success is-size-8 is-block">
<Icon @name="check-circle-fill" />Current
@ -102,13 +102,20 @@
@models={{array @backend @path}}
@query={{hash version=versionData.version}}
>View version {{versionData.version}}</dd.Interactive>
{{#if (and @metadata.canCreateVersionData (not versionData.destroyed) (not versionData.isSecretDeleted))}}
{{#if
(and
@capabilities.canCreateVersionData (not versionData.destroyed) (not (is-deleted versionData.deletion_time))
)
}}
<dd.Interactive
@route="secret.details.edit"
@models={{array @backend @path}}
@query={{hash version=versionData.version}}
data-test-create-new-version-from={{versionData.version}}
>Create new version from {{versionData.version}}</dd.Interactive>
>
Create new version from
{{versionData.version}}
</dd.Interactive>
{{/if}}
</Hds::Dropdown>
</div>

View file

@ -21,7 +21,7 @@
<li>
<LinkTo @route="secret.paths" @models={{array @backend @path}} data-test-secrets-tab="Paths">Paths</LinkTo>
</li>
{{#if @canReadMetadata}}
{{#if @capabilities.canReadMetadata}}
<li>
<LinkTo
@route="secret.metadata.versions"
@ -52,7 +52,7 @@
</Hds::Text::Display>
</:customTitle>
<:action>
{{#if @canUpdateData}}
{{#if @capabilities.canUpdateData}}
<Hds::Link::Standalone
@text="Create new"
@route="secret.details.edit"
@ -65,20 +65,20 @@
</:action>
<:content>
<Hds::Text::Display @tag="p" @size="400" @weight="medium" class="has-top-margin-m">
{{or @metadata.currentVersion @subkeys.metadata.version}}
{{or @metadata.current_version @subkeys.metadata.version}}
</Hds::Text::Display>
</:content>
</OverviewCard>
{{#if (eq this.secretState "created")}}
{{#let (or @metadata.updatedTime @subkeys.metadata.created_time) as |timestamp|}}
{{#let (or @metadata.updated_time @subkeys.metadata.created_time) as |timestamp|}}
<OverviewCard
@cardTitle="Secret age"
@subText="Current secret version age. Last updated on {{date-format timestamp}}."
class="is-flex-1"
>
<:action>
{{#if @canReadMetadata}}
{{#if @capabilities.canReadMetadata}}
<Hds::Link::Standalone
@text="View metadata"
@route="secret.metadata"

View file

@ -5,35 +5,37 @@
import Component from '@glimmer/component';
import { dateFormat } from 'core/helpers/date-format';
import { isDeleted } from 'kv/utils/kv-deleted';
import currentSecret from 'kv/helpers/current-secret';
import isDeleted from 'kv/helpers/is-deleted';
/**
* @module KvSecretOverview
* <Page::Secret::Overview
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @canReadMetadata={{true}}
* @canUpdateData={{true}}
* @isPatchAllowed={{true}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* @subkeys={{this.model.subkeys}}
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* @capabilities={{this.model.capabilities}}
* @isPatchAllowed={{true}}
* @metadata={{this.model.metadata}}
* @path={{this.model.path}}
* @subkeys={{this.model.subkeys}}
* />
*
* @param {string} backend - kv secret mount to make network request
* @param {array} breadcrumbs - Array to generate breadcrumbs, passed to the page header component
* @param {boolean} canReadMetadata - permissions to read metadata
* @param {boolean} canUpdateData - permissions to create a new version of a secret
* @param {object} capabilities - capabilities for data, metadata, subkeys, delete and undelete paths
* @param {boolean} isPatchAllowed - passed to KvSubkeysCard for rendering patch action. True when: (1) the version is enterprise, (2) a user has "patch" secret + "read" subkeys capabilities, (3) latest secret version is not deleted or destroyed
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {model} metadata - secret metadata
* @param {string} path - path to request secret data for selected version
* @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 currentSecret() {
return currentSecret(this.args.metadata);
}
get secretState() {
if (this.args.metadata) {
return this.args.metadata.currentSecret.state;
return this.currentSecret?.state;
}
if (this.args.subkeys?.metadata) {
const { metadata } = this.args.subkeys;
@ -53,9 +55,8 @@ export default class KvSecretOverview extends Component {
return 'The current version of this secret has been permanently deleted and cannot be restored.';
}
if (state === 'deleted') {
const time =
this.args.metadata?.currentSecret.deletionTime || this.args.subkeys?.metadata.deletion_time;
const date = dateFormat([time], {});
const time = this.currentSecret?.deletionTime || this.args.subkeys?.metadata.deletion_time;
const date = dateFormat([time], 'MMM d yyyy, h:mm:ss aa');
return `The current version of this secret was deleted ${date}.`;
}
return 'The current version of this secret.';

View file

@ -9,7 +9,6 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { waitFor } from '@ember/test-waiters';
import errorMessage from 'vault/utils/error-message';
/**
* @module KvSecretPatch
@ -28,7 +27,7 @@ import errorMessage from 'vault/utils/error-message';
*
* @param {model} path - Secret path
* @param {string} backend - Mount backend path
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {model} metadata - secret metadata
* @param {object} subkeys - subkeys (leaf keys with null values) of kv v2 secret
* @param {object} subkeysMeta - metadata object returned from the /subkeys endpoint, contains: version, created_time, custom_metadata, deletion status and time
* @param {array} breadcrumbs - breadcrumb objects to render in page header
@ -38,7 +37,7 @@ export default class KvSecretPatch extends Component {
@service controlGroup;
@service flashMessages;
@service('app-router') router;
@service store;
@service api;
@tracked controlGroupError;
@tracked errorMessage;
@ -52,28 +51,29 @@ export default class KvSecretPatch extends Component {
@task
@waitFor
*save(patchData) {
const isEmpty = this.isEmpty(patchData);
*save(data) {
const isEmpty = this.isEmpty(data);
if (isEmpty) {
this.flashMessages.info(`No changes to submit. No updates made to "${this.args.path}".`);
return this.onCancel();
}
const { backend, path, metadata, subkeysMeta } = this.args;
// if no metadata permission, use subkey metadata as backup
const version = metadata?.currentVersion || subkeysMeta?.version;
const adapter = this.store.adapterFor('kv/data');
try {
yield adapter.patchSecret(backend, path, patchData, version);
const { backend, path, metadata, subkeysMeta } = this.args;
// if no metadata permission, use subkey metadata as backup
const version = metadata?.current_version || subkeysMeta?.version;
const payload = { options: { cas: version }, data };
yield this.api.secrets.kvV2Patch(path, backend, payload);
this.flashMessages.success(`Successfully patched new version of ${path}.`);
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index');
} catch (error) {
if (error.message === 'Control Group encountered') {
this.controlGroup.saveTokenFromError(error);
this.controlGroupError = this.controlGroup.logFromError(error);
const { message, response } = yield this.api.parseError(error);
if (response.isControlGroupError) {
this.controlGroup.saveTokenFromError(response);
this.controlGroupError = this.controlGroup.logFromError(response);
return;
}
this.errorMessage = errorMessage(error);
this.errorMessage = message;
this.invalidFormAlert = 'There was an error submitting this form.';
}
}

View file

@ -11,58 +11,24 @@
</:toolbarFilters>
</KvPageHeader>
<form {{on "submit" (perform this.save)}}>
<div class="box is-sideless is-fullwidth is-bottomless">
<NamespaceReminder @mode="create" @noun="secret" />
<MessageError @errorMessage={{this.errorMessage}} />
<KvDataFields
@showJson={{this.showJsonView}}
@secret={{@secret}}
@modelValidations={{this.modelValidations}}
@pathValidations={{this.pathValidations}}
@type="create"
/>
<ToggleButton
@isOpen={{this.showMetadata}}
@openLabel="Hide secret metadata"
@closedLabel="Show secret metadata"
@onClick={{fn (mut this.showMetadata)}}
class="is-block"
data-test-metadata-toggle
/>
{{#if this.showMetadata}}
<div class="box has-container" data-test-metadata-section>
<KvMetadataFields @metadata={{@metadata}} @modelValidations={{this.modelValidations}} />
</div>
{{/if}}
</div>
<div class="box is-fullwidth is-bottomless">
<div class="control">
<Hds::Button
@text="Save"
@icon={{if this.save.isRunning "loading"}}
type="submit"
disabled={{this.save.isRunning}}
data-test-kv-save
/>
<Hds::Button
@text="Cancel"
@color="secondary"
class="has-left-margin-s"
disabled={{this.save.isRunning}}
{{on "click" this.onCancel}}
data-test-kv-cancel
/>
<KvCreateEditForm
@form={{@form}}
@path={{@path}}
@backend={{@backend}}
@showJson={{this.showJsonView}}
as |modelValidations|
>
<ToggleButton
@isOpen={{this.showMetadata}}
@openLabel="Hide secret metadata"
@closedLabel="Show secret metadata"
@onClick={{fn (mut this.showMetadata)}}
class="is-block"
data-test-metadata-toggle
/>
{{#if this.showMetadata}}
<div class="box has-container" data-test-metadata-section>
<KvMetadataFields @form={{@form}} @modelValidations={{modelValidations}} />
</div>
{{#if this.invalidFormAlert}}
<AlertInline
data-test-invalid-form-alert
class="has-top-padding-s"
@type="danger"
@message={{this.invalidFormAlert}}
/>
{{/if}}
</div>
</form>
{{/if}}
</KvCreateEditForm>

View file

@ -4,126 +4,25 @@
*/
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import { service } from '@ember/service';
import { pathIsFromDirectory } from 'kv/utils/kv-breadcrumbs';
import errorMessage from 'vault/utils/error-message';
/**
* @module KvSecretCreate is used for creating the initial version of a secret
*
* <Page::Secrets::Create
* @secret={{this.model.secret}}
* @metadata={{this.model.metadata}}
* @form={{this.model.form}}
* @path={{this.model.path}}
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
* />
*
* @param {model} secret - Ember data model: 'kv/data', the new record saved by the form
* @param {model} metadata - Ember data model: 'kv/metadata'
* @param {Form} form - kv form
* @param {string} path - secret path
* @param {string} backend - secret mount path
* @param {array} breadcrumbs - breadcrumb objects to render in page header
*/
export default class KvSecretCreate extends Component {
@service controlGroup;
@service flashMessages;
@service('app-router') router;
@service pagination;
@tracked showJsonView = false;
@tracked errorMessage;
@tracked modelValidations;
@tracked invalidFormAlert;
@action
pathValidations() {
// check path attribute warnings on key up
const { state } = this.args.secret.validate();
if (state?.path?.warnings) {
// only set model validations if warnings exist
this.modelValidations = state;
}
}
@task
*save(event) {
event.preventDefault();
this.resetErrors();
const { isValid, state } = this.validate();
this.modelValidations = isValid ? null : state;
this.invalidFormAlert = isValid ? '' : 'There is an error with this form.';
const { secret, metadata } = this.args;
if (isValid) {
try {
// try saving secret data first
yield secret.save();
this.pagination.clearDataset('kv/metadata'); // Clear out the pagination cache so that the metadata/list view is updated.
this.flashMessages.success(`Successfully saved secret data for: ${secret.path}.`);
} catch (error) {
let message = errorMessage(error);
if (error.message === 'Control Group encountered') {
this.controlGroup.saveTokenFromError(error);
const err = this.controlGroup.logFromError(error);
message = err.content;
}
this.errorMessage = message;
}
// users must have permission to create secret data to create metadata in the UI
// only attempt to save metadata if secret data saves successfully and metadata is edited
if (secret.createdTime && this.hasChanged(metadata)) {
try {
metadata.path = secret.path;
yield metadata.save();
this.flashMessages.success(`Successfully saved metadata.`);
} catch (error) {
this.flashMessages.danger(`Secret data was saved but metadata was not: ${errorMessage(error)}`, {
sticky: true,
});
}
}
// prevent transition if there are errors with secret data
if (this.errorMessage) {
this.invalidFormAlert = 'There was an error submitting this form.';
} else {
this.router.transitionTo('vault.cluster.secrets.backend.kv.secret.index', secret.path);
}
}
}
@action
onCancel() {
const { path } = this.args.secret;
pathIsFromDirectory(path)
? this.router.transitionTo('vault.cluster.secrets.backend.kv.list-directory', path)
: this.router.transitionTo('vault.cluster.secrets.backend.kv.list');
}
// HELPERS
validate() {
const dataValidations = this.args.secret.validate();
const metadataValidations = this.args.metadata.validate();
const state = { ...dataValidations.state, ...metadataValidations.state };
const failed = !dataValidations.isValid || !metadataValidations.isValid;
return { state, isValid: !failed };
}
hasChanged(model) {
const fieldName = model.formFields.map((attr) => attr.name);
const changedAttrs = Object.keys(model.changedAttributes());
// exclusively check if form field attributes have changed ('backend' and 'path' are passed to createRecord)
return changedAttrs.any((attr) => fieldName.includes(attr));
}
resetErrors() {
this.flashMessages.clearMessages();
this.errorMessage = null;
this.modelValidations = null;
this.invalidFormAlert = null;
}
@tracked showMetadata = false;
}

View file

@ -0,0 +1,20 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import isDeleted from 'kv/helpers/is-deleted';
// helps in long logic statements for state of a currentVersion
export default function currentSecret(metadata) {
if (metadata?.versions && metadata?.current_version) {
const data = metadata.versions[metadata.current_version];
const state = data.destroyed ? 'destroyed' : isDeleted(data.deletion_time) ? 'deleted' : 'created';
return {
state,
isDeactivated: state !== 'created',
deletionTime: data.deletion_time,
};
}
return false;
}

View file

@ -2,10 +2,11 @@
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import timestamp from 'core/utils/timestamp';
export function isDeleted(date) {
// on the kv/data model, deletion_time does not always mean the secret has been deleted.
export default function isDeleted(date) {
// deletion_time does not always mean the secret has been deleted.
// if the delete_version_after is set then the deletion_time will be UTC of that time, even if it's a future time from now.
// to determine if the secret is deleted we check if deletion_time <= time right now.
const deletionTime = new Date(date);

View file

@ -0,0 +1,17 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import isDeleted from 'kv/helpers/is-deleted';
export default function sortedVersions(versions) {
const array = [];
for (const key in versions) {
const version = versions[key];
const isSecretDeleted = isDeleted(version.deletion_time);
array.push({ version: key, isSecretDeleted, ...version });
}
// version keys are in order created with 1 being the oldest, we want newest first
return array.reverse();
}

View file

@ -0,0 +1,18 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { helper } from '@ember/component/helper';
import { stringify } from 'core/helpers/stringify';
export function stringifiedSecretData(secretData) {
// must pass the third param called "space" in JSON.stringify to structure object with whitespace
// otherwise the following codemirror modifier check will pass `this._editor.getValue() !== namedArgs.content` and _setValue will be called.
// the method _setValue moves the cursor to the beginning of the text field.
// the effect is that the cursor jumps after the first key input.
const startingValue = JSON.stringify({ '': '' }, null, 2);
return secretData ? stringify([secretData], {}) : startingValue;
}
export default helper(([secretData]) => stringifiedSecretData(secretData));

View file

@ -5,26 +5,30 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
import KvForm from 'vault/forms/secrets/kv';
@withConfirmLeave('model.secret', ['model.metadata'])
export default class KvSecretsCreateRoute extends Route {
@service store;
@service secretMountPath;
model(params) {
const backend = this.secretMountPath.currentPath;
const { initialKey: path } = params;
return hash({
return {
backend,
path,
// see serializer for logic behind setting casVersion
secret: this.store.createRecord('kv/data', { backend, path, casVersion: 0 }),
metadata: this.store.createRecord('kv/metadata', { backend, path }),
});
form: new KvForm(
{
path,
max_versions: 0,
delete_version_after: '0s',
cas_required: false,
options: { cas: 0 },
},
{ isNew: true }
),
};
}
setupController(controller, resolvedModel) {

View file

@ -5,47 +5,54 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import { action } from '@ember/object';
import { isDeleted } from 'kv/utils/kv-deleted';
import isDeleted from 'kv/helpers/is-deleted';
import { kvErrorHandler } from 'kv/utils/kv-error-handler';
export default class KvSecretRoute extends Route {
@service api;
@service secretMountPath;
@service store;
@service capabilities;
@service version;
fetchSecretMetadata(backend, path) {
async fetchSecretMetadata(backend, path) {
// 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;
try {
return await this.api.secrets.kvV2ReadMetadata(path, backend);
} catch (error) {
const { status } = await this.api.parseError(error);
if (status === 404) {
throw error;
}
return null;
});
}
}
// this request always returns subkeys for the latest version
fetchSubkeys(backend, path) {
async 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);
try {
return await this.api.secrets.kvV2ReadSubkeys(path, backend);
} catch (error) {
// metadata will throw if the secret does not exist
// kvErrorHandler will extract deletion state and relevant metadata from error
const { status, response } = await this.api.parseError(error);
return kvErrorHandler(status, response);
}
}
return null;
}
isPatchAllowed({ capabilities, subkeysMeta = {} }) {
if (!this.version.isEnterprise) return false;
const canReadSubkeys = capabilities.subkeys.canRead;
const canPatchData = capabilities.data.canPatch;
if (canReadSubkeys && canPatchData && subkeysMeta) {
const { deletion_time, destroyed } = subkeysMeta;
const isLatestActive = isDeleted(deletion_time) || destroyed ? false : true;
// only the latest secret version can be patched and it must not be deleted or destroyed
return isLatestActive;
if (this.version.isEnterprise) {
const { canReadSubkeys, canPatchData } = capabilities;
if (canReadSubkeys && canPatchData && subkeysMeta) {
const { deletion_time, destroyed } = subkeysMeta;
const isLatestActive = isDeleted(deletion_time) || destroyed ? false : true;
// only the latest secret version can be patched and it must not be deleted or destroyed
return isLatestActive;
}
}
return false;
}
@ -54,11 +61,32 @@ export default class KvSecretRoute extends Route {
const metadataPath = `${backend}/metadata/${path}`;
const dataPath = `${backend}/data/${path}`;
const subkeysPath = `${backend}/subkeys/${path}`;
const perms = await this.capabilities.fetch([metadataPath, dataPath, subkeysPath]);
const deletePath = `${backend}/delete/${path}`;
const undeletePath = `${backend}/undelete/${path}`;
const destroyPath = `${backend}/destroy/${path}`;
const perms = await this.capabilities.fetch([
metadataPath,
dataPath,
subkeysPath,
deletePath,
undeletePath,
destroyPath,
]);
return {
metadata: perms[metadataPath],
data: perms[dataPath],
subkeys: perms[subkeysPath],
canReadData: perms[dataPath].canRead,
canUpdateData: perms[dataPath].canUpdate,
canPatchData: perms[dataPath].canPatch,
canCreateVersionData: perms[dataPath].canUpdate,
canDeleteVersion: perms[deletePath].canUpdate,
canDeleteLatestVersion: perms[dataPath].canDelete,
canDestroyVersion: perms[destroyPath].canUpdate,
canReadMetadata: perms[metadataPath].canRead,
canDeleteMetadata: perms[metadataPath].canDelete,
canUpdateMetadata: perms[metadataPath].canUpdate,
canUndelete: perms[undeletePath].canUpdate,
canReadSubkeys: perms[subkeysPath].canRead,
};
}
@ -67,18 +95,16 @@ export default class KvSecretRoute extends Route {
const { name: path } = this.paramsFor('secret');
const capabilities = await this.fetchCapabilities(backend, path);
const subkeys = await this.fetchSubkeys(backend, path);
return hash({
const metadata = await this.fetchSecretMetadata(backend, path);
return {
path,
backend,
subkeys,
metadata: this.fetchSecretMetadata(backend, path),
metadata,
isPatchAllowed: this.isPatchAllowed({ capabilities, subkeysMeta: subkeys?.metadata }),
canUpdateData: capabilities.data.canUpdate,
canReadData: capabilities.data.canRead,
canReadMetadata: capabilities.metadata.canRead,
canDeleteMetadata: capabilities.metadata.canDelete,
canUpdateMetadata: capabilities.metadata.canUpdate,
});
capabilities,
};
}
@action

View file

@ -5,10 +5,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import { kvErrorHandler } from 'kv/utils/kv-error-handler';
export default class KvSecretDetailsRoute extends Route {
@service store;
@service api;
queryParams = {
version: {
@ -16,19 +16,28 @@ export default class KvSecretDetailsRoute extends Route {
},
};
model(params) {
async model(params) {
const parentModel = this.modelFor('secret');
const { backend, path } = parentModel;
const query = { backend, path };
let secret;
// 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;
try {
const initOverride = params.version
? (context) => this.api.addQueryParams(context, { version: params.version })
: undefined;
const { data, metadata } = await this.api.secrets.kvV2Read(path, backend, initOverride);
secret = { secretData: data, ...metadata };
} catch (error) {
const { status, response } = await this.api.parseError(error);
const { data, metadata, failReadErrorCode } = kvErrorHandler(status, response);
secret = failReadErrorCode ? { failReadErrorCode } : { secretData: data, ...metadata };
}
return hash({
return {
...parentModel,
secret: this.store.queryRecord('kv/data', query),
});
secret,
};
}
// breadcrumbs are set in details/index.js

View file

@ -4,33 +4,28 @@
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { hash } from 'rsvp';
import { withConfirmLeave } from 'core/decorators/confirm-leave';
import { breadcrumbsForSecret } from 'kv/utils/kv-breadcrumbs';
import KvForm from 'vault/forms/secrets/kv';
@withConfirmLeave('model.newVersion')
export default class KvSecretDetailsEditRoute extends Route {
@service store;
model() {
const parentModel = this.modelFor('secret.details');
const { backend, path, secret, metadata } = parentModel;
return hash({
secret,
metadata,
backend,
path,
newVersion: this.store.createRecord('kv/data', {
backend,
path,
secretData: secret?.secretData,
// see serializer for logic behind setting casVersion
casVersion: metadata?.currentVersion || secret?.version,
}),
});
const { metadata, secret } = parentModel;
const formData = {
path: parentModel.path,
max_versions: 0,
options: {
cas: metadata?.current_version || secret.version,
},
};
if (!parentModel.secret.failReadErrorCode) {
formData.secretData = parentModel.secret.secretData;
}
return {
...parentModel,
form: new KvForm(formData),
};
}
setupController(controller, resolvedModel) {
super.setupController(controller, resolvedModel);

View file

@ -5,19 +5,23 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import KvForm from 'vault/forms/secrets/kv';
export default class KvSecretMetadataRoute extends Route {
@service capabilities;
@service secretMountPath;
@service store;
@service api;
fetchMetadata(backend, path) {
return this.store.queryRecord('kv/metadata', { backend, path }).catch((error) => {
if (error.message === 'Control Group encountered') {
throw error;
async fetchMetadata(backend, path) {
try {
return await this.api.secrets.kvV2ReadMetadata(path, backend);
} catch (error) {
const { response } = await this.api.parseError(error);
if (response.isControlGroupError) {
throw response;
}
// if users can read secret data they can make an explicit request to retrieve secret data in the component
return null;
});
}
}
async model() {
@ -26,16 +30,13 @@ export default class KvSecretMetadataRoute extends Route {
if (!parentModel.metadata) {
// metadata read on the secret root fails silently
// if there's no metadata, try again in case it's a control group
const metadata = await this.fetchMetadata(backend, path);
if (metadata) {
return {
...parentModel,
metadata,
};
}
parentModel.metadata = await this.fetchMetadata(backend, path);
}
// if users can read secret data they can make an explicit request
// to retrieve secret data in the component
return parentModel;
const { custom_metadata, max_versions, cas_required, delete_version_after } = parentModel.metadata || {};
return {
...parentModel,
form: new KvForm({ path, custom_metadata, max_versions, cas_required, delete_version_after }),
};
}
}

View file

@ -3,4 +3,9 @@
SPDX-License-Identifier: BUSL-1.1
}}
<Page::Secrets::Create @secret={{this.model.secret}} @metadata={{this.model.metadata}} @breadcrumbs={{this.breadcrumbs}} />
<Page::Secrets::Create
@form={{this.model.form}}
@path={{this.model.path}}
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>

View file

@ -4,9 +4,10 @@
}}
<Page::Secret::Edit
@secret={{this.model.newVersion}}
@previousVersion={{this.model.secret.version}}
@currentVersion={{this.model.metadata.currentVersion}}
@form={{this.model.form}}
@secret={{this.model.secret}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@noReadAccess={{eq this.model.secret.failReadErrorCode 403}}
/>

View file

@ -6,9 +6,7 @@
<Page::Secret::Details
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadData={{this.model.canReadData}}
@canReadMetadata={{this.model.canReadMetadata}}
@canUpdateData={{this.model.canUpdateData}}
@capabilities={{this.model.capabilities}}
@isPatchAllowed={{this.model.isPatchAllowed}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}

View file

@ -6,8 +6,7 @@
<Page::Secret::Overview
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadMetadata={{this.model.metadata.canReadMetadata}}
@canUpdateData={{this.model.canUpdateData}}
@capabilities={{this.model.capabilities}}
@isPatchAllowed={{this.model.isPatchAllowed}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}

View file

@ -4,8 +4,10 @@
}}
<Page::Secret::Metadata::Edit
@metadata={{this.model.metadata}}
@form={{this.model.form}}
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{transition-to "vault.cluster.secrets.backend.kv.secret.metadata" this.model.metadata.path}}
@onSave={{transition-to "vault.cluster.secrets.backend.kv.secret.metadata" this.model.metadata.path}}
@capabilities={{this.model.capabilities}}
@onCancel={{transition-to "vault.cluster.secrets.backend.kv.secret.metadata" this.model.path}}
@onSave={{transition-to "vault.cluster.secrets.backend.kv.secret.metadata" this.model.path}}
/>

View file

@ -6,10 +6,7 @@
<Page::Secret::Metadata::Details
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canDeleteMetadata={{this.model.canDeleteMetadata}}
@canReadData={{this.model.canReadData}}
@canReadMetadata={{this.model.canReadMetadata}}
@canUpdateMetadata={{this.model.canUpdateMetadata}}
@capabilities={{this.model.capabilities}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
/>

View file

@ -6,6 +6,7 @@
<Page::Secret::Metadata::VersionHistory
@metadata={{this.model.metadata}}
@path={{this.model.path}}
@breadcrumbs={{this.breadcrumbs}}
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@capabilities={{this.model.capabilities}}
/>

View file

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

View file

@ -0,0 +1,33 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export function kvErrorHandler(status, errorResponse) {
// if it's a legitimate error - throw it!
if (errorResponse?.isControlGroupError) {
throw errorResponse;
}
if (typeof errorResponse === 'object' && errorResponse !== null) {
const { data } = errorResponse;
if (status === 403) {
return {
failReadErrorCode: 403,
};
}
// 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 (data) {
return data;
}
}
// if we get here, it's likely either a script error or 404 because it doesn't exist
throw errorResponse;
}

View file

@ -1,6 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { breadcrumbsForSecret as default } from 'kv/utils/kv-breadcrumbs';

View file

@ -1,6 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
export { isDeleted as default } from 'kv/utils/kv-deleted';

View file

@ -10,7 +10,8 @@
"ember-cli-babel": "*",
"ember-concurrency": "*",
"@ember/test-waiters": "*",
"ember-inflector": "*"
"ember-inflector": "*",
"ember-template-lint": "*"
},
"ember-addon": {
"paths": [

View file

@ -268,7 +268,7 @@ export default function (server) {
schema.db.syncAssociations.update({ type, name }, { sync_status: 'UNSYNCED' });
return associationsResponse(schema, req);
});
server.get('sys/sync/associations/:mount/*name', (schema, req) => {
server.get('sys/sync/associations/destinations', (schema, req) => {
return syncStatusResponse(schema, req);
});

View file

@ -176,20 +176,20 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await click(FORM.toggleMetadata);
assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled');
// Check initial values
assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0');
assert.dom(FORM.inputByAttr('casRequired')).isNotChecked();
assert.dom(FORM.inputByAttr('max_versions')).hasValue('0');
assert.dom(FORM.inputByAttr('cas_required')).isNotChecked();
assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked();
// MaxVersions validation
await fillIn(FORM.inputByAttr('maxVersions'), 'seven');
// max_versions validation
await fillIn(FORM.inputByAttr('max_versions'), 'seven');
await click(FORM.saveBtn);
assert.dom(GENERAL.validationErrorByAttr('maxVersions')).hasText('Maximum versions must be a number.');
await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999');
assert.dom(GENERAL.validationErrorByAttr('max_versions')).hasText('Maximum versions must be a number.');
await fillIn(FORM.inputByAttr('max_versions'), '99999999999999999');
await click(FORM.saveBtn);
assert.dom(GENERAL.validationErrorByAttr('maxVersions')).hasText('You cannot go over 16 characters.');
await fillIn(FORM.inputByAttr('maxVersions'), '7');
assert.dom(GENERAL.validationErrorByAttr('max_versions')).hasText('You cannot go over 16 characters.');
await fillIn(FORM.inputByAttr('max_versions'), '7');
// Fill in other metadata
await click(FORM.inputByAttr('casRequired'));
await click(FORM.inputByAttr('cas_required'));
await click(FORM.toggleByLabel('Automate secret deletion'));
await fillIn(FORM.ttlValue('Automate secret deletion'), '1000');
@ -434,20 +434,20 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await click(FORM.toggleMetadata);
assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled');
// Check initial values
assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0');
assert.dom(FORM.inputByAttr('casRequired')).isNotChecked();
assert.dom(FORM.inputByAttr('max_versions')).hasValue('0');
assert.dom(FORM.inputByAttr('cas_required')).isNotChecked();
assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked();
// MaxVersions validation
await fillIn(FORM.inputByAttr('maxVersions'), 'seven');
// max_versions validation
await fillIn(FORM.inputByAttr('max_versions'), 'seven');
await click(FORM.saveBtn);
assert.dom(GENERAL.validationErrorByAttr('maxVersions')).hasText('Maximum versions must be a number.');
await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999');
assert.dom(GENERAL.validationErrorByAttr('max_versions')).hasText('Maximum versions must be a number.');
await fillIn(FORM.inputByAttr('max_versions'), '99999999999999999');
await click(FORM.saveBtn);
assert.dom(GENERAL.validationErrorByAttr('maxVersions')).hasText('You cannot go over 16 characters.');
await fillIn(FORM.inputByAttr('maxVersions'), '7');
assert.dom(GENERAL.validationErrorByAttr('max_versions')).hasText('You cannot go over 16 characters.');
await fillIn(FORM.inputByAttr('max_versions'), '7');
// Fill in other metadata
await click(FORM.inputByAttr('casRequired'));
await click(FORM.inputByAttr('cas_required'));
await click(FORM.toggleByLabel('Automate secret deletion'));
await fillIn(FORM.ttlValue('Automate secret deletion'), '1000');
@ -583,20 +583,20 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await click(FORM.toggleMetadata);
assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled');
// Check initial values
assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0');
assert.dom(FORM.inputByAttr('casRequired')).isNotChecked();
assert.dom(FORM.inputByAttr('max_versions')).hasValue('0');
assert.dom(FORM.inputByAttr('cas_required')).isNotChecked();
assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked();
// MaxVersions validation
await fillIn(FORM.inputByAttr('maxVersions'), 'seven');
// max_versions validation
await fillIn(FORM.inputByAttr('max_versions'), 'seven');
await click(FORM.saveBtn);
assert.dom(GENERAL.validationErrorByAttr('maxVersions')).hasText('Maximum versions must be a number.');
await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999');
assert.dom(GENERAL.validationErrorByAttr('max_versions')).hasText('Maximum versions must be a number.');
await fillIn(FORM.inputByAttr('max_versions'), '99999999999999999');
await click(FORM.saveBtn);
assert.dom(GENERAL.validationErrorByAttr('maxVersions')).hasText('You cannot go over 16 characters.');
await fillIn(FORM.inputByAttr('maxVersions'), '7');
assert.dom(GENERAL.validationErrorByAttr('max_versions')).hasText('You cannot go over 16 characters.');
await fillIn(FORM.inputByAttr('max_versions'), '7');
// Fill in other metadata
await click(FORM.inputByAttr('casRequired'));
await click(FORM.inputByAttr('cas_required'));
await click(FORM.toggleByLabel('Automate secret deletion'));
await fillIn(FORM.ttlValue('Automate secret deletion'), '1000');
@ -752,20 +752,20 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await click(FORM.toggleMetadata);
assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled');
// Check initial values
assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0');
assert.dom(FORM.inputByAttr('casRequired')).isNotChecked();
assert.dom(FORM.inputByAttr('max_versions')).hasValue('0');
assert.dom(FORM.inputByAttr('cas_required')).isNotChecked();
assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked();
// MaxVersions validation
await fillIn(FORM.inputByAttr('maxVersions'), 'seven');
// max_versions validation
await fillIn(FORM.inputByAttr('max_versions'), 'seven');
await click(FORM.saveBtn);
assert.dom(GENERAL.validationErrorByAttr('maxVersions')).hasText('Maximum versions must be a number.');
await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999');
assert.dom(GENERAL.validationErrorByAttr('max_versions')).hasText('Maximum versions must be a number.');
await fillIn(FORM.inputByAttr('max_versions'), '99999999999999999');
await click(FORM.saveBtn);
assert.dom(GENERAL.validationErrorByAttr('maxVersions')).hasText('You cannot go over 16 characters.');
await fillIn(FORM.inputByAttr('maxVersions'), '7');
assert.dom(GENERAL.validationErrorByAttr('max_versions')).hasText('You cannot go over 16 characters.');
await fillIn(FORM.inputByAttr('max_versions'), '7');
// Fill in other metadata
await click(FORM.inputByAttr('casRequired'));
await click(FORM.inputByAttr('cas_required'));
await click(FORM.toggleByLabel('Automate secret deletion'));
await fillIn(FORM.ttlValue('Automate secret deletion'), '1000');
@ -980,20 +980,20 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
await click(FORM.toggleMetadata);
assert.dom(PAGE.create.metadataSection).exists('Shows metadata section after toggled');
// Check initial values
assert.dom(FORM.inputByAttr('maxVersions')).hasValue('0');
assert.dom(FORM.inputByAttr('casRequired')).isNotChecked();
assert.dom(FORM.inputByAttr('max_versions')).hasValue('0');
assert.dom(FORM.inputByAttr('cas_required')).isNotChecked();
assert.dom(FORM.toggleByLabel('Automate secret deletion')).isNotChecked();
// MaxVersions validation
await fillIn(FORM.inputByAttr('maxVersions'), 'seven');
// max_versions validation
await fillIn(FORM.inputByAttr('max_versions'), 'seven');
await click(FORM.saveBtn);
assert.dom(GENERAL.validationErrorByAttr('maxVersions')).hasText('Maximum versions must be a number.');
await fillIn(FORM.inputByAttr('maxVersions'), '99999999999999999');
assert.dom(GENERAL.validationErrorByAttr('max_versions')).hasText('Maximum versions must be a number.');
await fillIn(FORM.inputByAttr('max_versions'), '99999999999999999');
await click(FORM.saveBtn);
assert.dom(GENERAL.validationErrorByAttr('maxVersions')).hasText('You cannot go over 16 characters.');
await fillIn(FORM.inputByAttr('maxVersions'), '7');
assert.dom(GENERAL.validationErrorByAttr('max_versions')).hasText('You cannot go over 16 characters.');
await fillIn(FORM.inputByAttr('max_versions'), '7');
// Fill in other metadata
await click(FORM.inputByAttr('casRequired'));
await click(FORM.inputByAttr('cas_required'));
await click(FORM.toggleByLabel('Automate secret deletion'));
await fillIn(FORM.ttlValue('Automate secret deletion'), '1000');

View file

@ -182,7 +182,7 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
assert.expect(7);
const backend = this.backend;
const [root, subdirectory] = this.fullSecretPath.split('/');
setupOnerror((error) => assert.strictEqual(error.httpStatus, 404), '404 error is thrown'); // catches error so qunit test doesn't fail
setupOnerror((error) => assert.strictEqual(error.response.status, 404), '404 error is thrown'); // catches error so qunit test doesn't fail
await visit(`/vault/secrets/${backend}/kv/list`);
await typeIn(PAGE.list.overviewInput, `${root}/${subdirectory}`); // intentionally leave out trailing slash

View file

@ -1549,7 +1549,7 @@ path "${this.backend}/subkeys/*" {
'redirects to access control group route'
);
await grantAccess({
apiPath: `${backend}/data/${encodeURIComponent(secretPath)}`,
apiPath: `${backend}/data/${secretPath}`,
originUrl: `/vault/secrets/${backend}/kv/list`,
userToken: this.userToken,
backend: this.backend,
@ -1605,7 +1605,7 @@ path "${this.backend}/subkeys/*" {
const url = find('[data-test-control-error="href"]').innerText;
await visit(url);
await grantAccess({
apiPath: `${backend}/data/${encodeURIComponent(secretPath)}`,
apiPath: `${backend}/data/${secretPath}`,
originUrl: `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/metadata`,
userToken: this.userToken,
backend: this.backend,
@ -1636,7 +1636,7 @@ path "${this.backend}/subkeys/*" {
const url = find('[data-test-control-error="href"]').innerText;
await visit(url);
await grantAccess({
apiPath: `${backend}/data/${encodeURIComponent(secretPath)}`,
apiPath: `${backend}/data/${secretPath}`,
originUrl: `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/patch`,
userToken: this.userToken,
backend: this.backend,
@ -1675,7 +1675,7 @@ path "${this.backend}/subkeys/*" {
const url = find('[data-test-control-error="href"]').innerText;
await visit(url);
await grantAccess({
apiPath: `${backend}/data/${encodeURIComponent(secretPath)}`,
apiPath: `${backend}/data/${secretPath}`,
originUrl: `/vault/secrets/${backend}/kv/${secretPathUrlEncoded}/metadata`,
userToken: this.userToken,
backend: this.backend,

View file

@ -0,0 +1,228 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupEngine } from 'ember-engines/test-support';
import { hbs } from 'ember-cli-htmlbars';
import { fillIn, typeIn, render, click, waitFor, findAll } from '@ember/test-helpers';
import codemirror, { getCodeEditorValue, setCodeEditorValue } from 'vault/tests/helpers/codemirror';
import { FORM } from 'vault/tests/helpers/kv/kv-selectors';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import KvForm from 'vault/forms/secrets/kv';
import sinon from 'sinon';
import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
module('Integration | Component | kv-v2 | KvCreateEditForm', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
hooks.beforeEach(function () {
this.backend = 'my-kv-engine';
this.showJson = false;
this.onChange = sinon.stub();
this.renderComponent = (isNew = false) => {
this.path = isNew ? undefined : 'my-secret';
const options = isNew ? undefined : { cas: 2 };
const secretData = isNew ? undefined : { foo: 'bar' };
this.form = new KvForm(
{
path: this.path,
secretData,
max_versions: 0,
delete_version_after: '0s',
cas_required: false,
options,
},
{ isNew }
);
return render(
hbs`
<KvCreateEditForm
@form={{this.form}}
@path={{this.path}}
@backend={{this.backend}}
@showJson={{this.showJson}}
@onChange={{this.onChange}}
as |modelValidations|
>
<span data-test-yield-block>{{modelValidations.invalidFormMessage}}</span>
</KvCreateEditForm>
`,
{ owner: this.engine }
);
};
const api = this.owner.lookup('service:api');
this.dataWrite = sinon.stub(api.secrets, 'kvV2Write').resolves();
this.metadataWrite = sinon.stub(api.secrets, 'kvV2WriteMetadata').resolves();
this.transitionTo = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
setRunOptions({
rules: {
// failing on .CodeMirror-scroll
'scrollable-region-focusable': { enabled: false },
},
});
});
test('it should render namespace reminder', async function (assert) {
this.owner.lookup('service:namespace').path = 'test';
await this.renderComponent();
assert.dom('#namespace-reminder').hasText('This secret will be created in the test/ namespace.');
});
test('it should render validation errors and warnings', async function (assert) {
await this.renderComponent(true);
await click(FORM.saveBtn);
assert.dom(FORM.validationError('path')).hasText(`Path can't be blank.`);
assert.dom(FORM.invalidFormAlert).hasText('There is an error with this form.');
await typeIn(FORM.inputByAttr('path'), 'my secret');
assert
.dom(FORM.validationWarning)
.hasText(
`Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.`
);
});
test('it should render JSON editor and add new secretData', async function (assert) {
assert.expect(4);
this.showJson = true;
await this.renderComponent(true);
await waitFor('.cm-editor');
const editor = codemirror();
const editorValue = getCodeEditorValue(editor);
assert.strictEqual(
editorValue,
`{\n "": ""\n}`,
'json editor initializes with empty object that includes whitespace'
);
setCodeEditorValue(editor, 'blah');
await waitFor('.cm-lint-marker');
const lintMarkers = findAll('.cm-lint-marker');
assert.strictEqual(lintMarkers.length, 1, 'codemirror lints input');
setCodeEditorValue(editor, `{ "hello": "there"}`);
assert.propEqual(this.form.data.secretData, { hello: 'there' }, 'json editor updates secret data');
assert.true(
this.onChange.calledWith({ hello: 'there' }),
'onChange is called with secretData on json editor change'
);
});
test('it should create new secret', async function (assert) {
const path = 'new-secret';
const secretData = { foo: 'bar' };
await this.renderComponent(true);
await fillIn(FORM.inputByAttr('path'), path);
await fillIn(FORM.keyInput(), 'foo');
await fillIn(FORM.maskedValueInput(), 'bar');
assert.dom(FORM.dataInputLabel({})).hasText('Secret data', 'Correct data label renders');
assert.true(
this.onChange.calledWith(secretData),
'onChange is called with secretData on kv object change'
);
await click(FORM.saveBtn);
assert.true(this.dataWrite.calledWith(path, this.backend, { data: secretData }), 'secret data is saved');
assert.true(this.metadataWrite.notCalled, 'metadata is not saved when there are no changes');
assert.true(
this.transitionTo.calledWith('vault.cluster.secrets.backend.kv.secret.index', path),
'transitions to secret on success'
);
// metadata is updated outside of component
// simulate metadata change by updating form data
this.form.data.max_versions = 5;
await click(FORM.saveBtn);
assert.true(this.dataWrite.calledWith(path, this.backend, { data: secretData }), 'secret data is saved');
assert.true(
this.metadataWrite.calledWith(path, this.backend, {
max_versions: 5,
delete_version_after: '0s',
cas_required: false,
}),
'metadata is saved when there are changes'
);
assert.true(
this.transitionTo.calledWith('vault.cluster.secrets.backend.kv.secret.index', path),
'transitions to secret on success'
);
});
test('it should create new secret version', async function (assert) {
await this.renderComponent();
assert.dom(FORM.inputByAttr('path')).isDisabled();
assert.dom(FORM.inputByAttr('path')).hasValue(this.path);
assert.dom(FORM.dataInputLabel({})).hasText('Version data', 'Correct data label renders');
assert.dom(FORM.keyInput()).hasValue('foo');
await click(`${FORM.valueInput()} button`); // reveal value
assert.dom(FORM.maskedValueInput()).hasValue('bar');
await fillIn(FORM.keyInput(1), 'bar');
await fillIn(FORM.maskedValueInput(1), 'baz');
await click(FORM.saveBtn);
assert.true(
this.dataWrite.calledWith(this.path, this.backend, {
data: { foo: 'bar', bar: 'baz' },
options: { cas: 2 },
}),
'secret data is saved'
);
assert.true(this.metadataWrite.notCalled, 'metadata is not saved when there are no changes');
assert.true(
this.transitionTo.calledWith('vault.cluster.secrets.backend.kv.secret.index', this.path),
'transitions to secret on success'
);
});
test('it should handle save errors', async function (assert) {
await this.renderComponent();
this.form.data.max_versions = 5;
// data save failure
const dataError = getErrorResponse({ errors: ['error saving secret data'] }, 400);
this.dataWrite.rejects(dataError);
await click(FORM.saveBtn);
assert.dom(FORM.messageError).includesText('error saving secret data', 'data error message renders');
assert.true(this.metadataWrite.notCalled, 'metadata is not saved on data save failure');
// data control group error
sinon
.stub(this.owner.lookup('service:control-group'), 'logFromError')
.returns({ content: 'A Control Group was encountered' });
const ctrlError = getErrorResponse({ accessor: 'foobar', isControlGroupError: true }, 500);
this.dataWrite.rejects(ctrlError);
await click(FORM.saveBtn);
assert
.dom(FORM.messageError)
.includesText('A Control Group was encountered', 'control group error message renders');
// data success and metadata failure
this.dataWrite.resolves();
const metaError = getErrorResponse({ errors: ['error saving secret metadata'] }, 400);
this.metadataWrite.rejects(metaError);
const flashStub = sinon.stub(this.owner.lookup('service:flash-messages'), 'danger');
await click(FORM.saveBtn);
const flashMessage = 'Secret data was saved but metadata was not: error saving secret metadata';
assert.true(flashStub.calledWith(flashMessage), 'flash message displays with metadata save error');
});
});

View file

@ -1,124 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { hbs } from 'ember-cli-htmlbars';
import { fillIn, render, click, waitFor, findAll } from '@ember/test-helpers';
import codemirror, { getCodeEditorValue, setCodeEditorValue } from 'vault/tests/helpers/codemirror';
import { PAGE, FORM } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { setRunOptions } from 'ember-a11y-testing/test-support';
module('Integration | Component | kv-v2 | KvDataFields', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.backend = 'my-kv-engine';
this.path = 'my-secret';
this.secret = this.store.createRecord('kv/data', { backend: this.backend });
setRunOptions({
rules: {
// failing on .CodeMirror-scroll
'scrollable-region-focusable': { enabled: false },
},
});
});
test('it updates the secret model', async function (assert) {
assert.expect(2);
await render(hbs`<KvDataFields @showJson={{false}} @secret={{this.secret}} @type="create" />`, {
owner: this.engine,
});
await fillIn(FORM.inputByAttr('path'), this.path);
await fillIn(FORM.keyInput(), 'foo');
await fillIn(FORM.maskedValueInput(), 'bar');
assert.strictEqual(this.secret.path, this.path);
assert.propEqual(this.secret.secretData, { foo: 'bar' });
});
test('it JSON editor initializes with empty object and modifies secretData', async function (assert) {
assert.expect(3);
await render(hbs`<KvDataFields @showJson={{true}} @secret={{this.secret}} />`, { owner: this.engine });
await waitFor('.cm-editor');
const editor = codemirror();
const editorValue = getCodeEditorValue(editor);
assert.strictEqual(
editorValue,
`{
"": ""
}`,
'json editor initializes with empty object that includes whitespace'
);
setCodeEditorValue(editor, 'blah');
await waitFor('.cm-lint-marker');
const lintMarkers = findAll('.cm-lint-marker');
assert.strictEqual(lintMarkers.length, 1, 'codemirror lints input');
setCodeEditorValue(editor, `{ "hello": "there"}`);
assert.propEqual(this.secret.secretData, { hello: 'there' }, 'json editor updates secret data');
});
test('it disables path and prefills secret data when creating a new secret version', async function (assert) {
assert.expect(5);
this.secret.secretData = { foo: 'bar' };
this.secret.path = this.path;
this.newVersion = this.store.createRecord('kv/data', {
backend: this.backend,
path: this.path,
secretData: this.secret.secretData,
});
await render(hbs`<KvDataFields @showJson={{false}} @secret={{this.secret}} @type="edit" />`, {
owner: this.engine,
});
assert.dom(FORM.inputByAttr('path')).isDisabled();
assert.dom(FORM.inputByAttr('path')).hasValue(this.path);
assert.dom(FORM.keyInput()).hasValue('foo');
assert.dom(FORM.maskedValueInput()).hasValue('bar');
assert.dom(FORM.dataInputLabel({ isJson: false })).hasText('Version data');
});
test('it shows readonly info rows when viewing secret details of simple secret', async function (assert) {
assert.expect(3);
this.secret.secretData = { foo: 'bar' };
this.secret.path = this.path;
await render(hbs`<KvDataFields @showJson={{false}} @secret={{this.secret}} @type="details" />`, {
owner: this.engine,
});
assert.dom(PAGE.infoRow).exists({ count: 1 }, '1 row of data shows');
assert.dom(PAGE.infoRowValue('foo')).hasText('***********');
await click(PAGE.infoRowToggleMasked('foo'));
assert.dom(PAGE.infoRowValue('foo')).hasText('bar', 'secret value shows after toggle');
});
test('it shows hds codeblock when viewing secret details of complex secret', async function (assert) {
this.secret.secretData = {
foo: {
bar: 'baz',
},
};
this.secret.path = this.path;
await render(hbs`<KvDataFields @showJson={{true}} @secret={{this.secret}} @type="details" />`, {
owner: this.engine,
});
assert.dom(PAGE.infoRowValue('foo')).doesNotExist('does not render rows of secret data');
assert.dom(GENERAL.codeBlock('secret-data')).exists('hds codeBlock exists');
assert
.dom(GENERAL.codeBlock('secret-data'))
.hasText(`Version data { "foo": { "bar": "baz" } } `, 'Json data is displayed');
});
});

View file

@ -6,70 +6,73 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
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);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(async function () {
// this.metadata is setup by baseSetup
baseSetup(this);
// this is the route model, not an ember data model
this.model = {
backend: this.backend,
path: this.path,
secret: this.secret,
metadata: this.metadata,
canDeleteMetadata: true,
canReadData: true,
canReadCustomMetadata: true,
this.metadata = {
custom_metadata: null,
max_versions: 15,
cas_required: true,
delete_version_after: '4h30m',
updated_time: '2025-09-16T22:19:59.916935Z',
};
this.backend = 'kv-engine';
this.path = 'my-secret';
this.capabilities = {
canReadMetadata: true,
canUpdateMetadata: true,
canDeleteMetadata: true,
canReadData: true,
};
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.model.backend, route: 'list' },
{ label: this.model.path },
{ label: this.backend, route: 'list' },
{ label: this.path },
];
this.renderComponent = () => {
return render(
this.renderComponent = () =>
render(
hbs`
<Page::Secret::Metadata::Details
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canDeleteMetadata={{this.model.canDeleteMetadata}}
@canReadData={{this.model.canReadData}}
@canReadMetadata={{this.model.canReadMetadata}}
@canUpdateMetadata={{this.model.canUpdateMetadata}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
/>
`,
<Page::Secret::Metadata::Details
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
@capabilities={{this.capabilities}}
@metadata={{this.metadata}}
@path={{this.path}}
/>
`,
{ owner: this.engine }
);
};
});
test('it should render page title and toolbar elements', async function (assert) {
await this.renderComponent();
assert.dom(PAGE.title).includesText(this.path, 'renders secret path as page title');
assert.dom(PAGE.secretTab('Overview')).exists('renders Secrets tab');
assert.dom(PAGE.secretTab('Secret')).exists('renders Secret tab');
assert.dom(PAGE.secretTab('Metadata')).exists('renders Metadata tab');
assert.dom(PAGE.secretTab('Paths')).exists('renders Paths tab');
assert.dom(PAGE.secretTab('Version History')).exists('renders Version History tab');
});
test('it renders metadata details', async function (assert) {
assert.expect(8);
assert.expect(7);
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');
assert.dom(PAGE.metadata.deleteMetadata).exists();
assert.dom(PAGE.metadata.editBtn).exists();
// Metadata details
const expectedTime = dateFormat([this.metadata.updatedTime, 'MMM d, yyyy hh:mm aa'], {});
const expectedTime = dateFormat([this.metadata.updated_time, 'MMM d, yyyy hh:mm aa'], {});
assert
.dom(PAGE.infoRowValue('Last updated'))
.hasTextContaining(expectedTime, 'Displays updated date with formatting');
@ -77,36 +80,39 @@ module('Integration | Component | kv-v2 | Page::Secret::Metadata::Details', func
assert.dom(PAGE.infoRowValue('Check-and-Set required')).hasText('Yes');
assert
.dom(PAGE.infoRowValue('Delete version after'))
.hasText('3 hours 25 minutes 19 seconds', 'correctly shows and formats the timestamp.');
.hasText('4 hours 30 minutes', 'correctly shows and formats the timestamp.');
});
test('it renders empty state if cannot read metadata but can read data', async function (assert) {
this.model.metadata = null;
// this.metadata = null;
this.capabilities.canReadMetadata = false;
this.capabilities.canReadData = true;
await this.renderComponent();
assert
.dom(`${PAGE.metadata.customMetadataSection} ${PAGE.emptyStateTitle}`)
.hasText('Request custom metadata?');
});
test('it renders custom metadata from metadata model', async function (assert) {
assert.expect(4);
this.model.metadata = metadataModel(this, { withCustom: true });
test('it renders custom metadata', async function (assert) {
assert.expect(3);
this.metadata.custom_metadata = { foo: 'bar', bar: 'baz' };
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');
assert.dom(PAGE.infoRowValue('foo')).hasText('bar');
assert.dom(PAGE.infoRowValue('bar')).hasText('baz');
});
test('it hides delete modal when no permissions', async function (assert) {
this.model.canDeleteMetadata = false;
this.capabilities.canDeleteMetadata = false;
await this.renderComponent();
assert.dom(PAGE.metadata.deleteMetadata).doesNotExist();
});
test('it hides edit action when no permissions', async function (assert) {
this.model.canUpdateMetadata = false;
this.capabilities.canUpdateMetadata = false;
await this.renderComponent();
assert.dom(PAGE.metadata.editBtn).doesNotExist();
});
});

View file

@ -10,56 +10,61 @@ import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, fillIn, click } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import sinon from 'sinon';
import { kvMetadataPath } from 'vault/utils/kv-path';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import KvForm from 'vault/forms/secrets/kv';
module('Integration | Component | kv | Page::Secret::Metadata::Edit', function (hooks) {
module('Integration | Component | kv-v2 | Page::Secret::Metadata::Edit', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(async function () {
this.path = 'my-secret';
this.form = new KvForm({
path: this.path,
custom_metadata: { foo: 'bar' },
max_versions: 15,
cas_required: true,
delete_version_after: '4h30m',
});
this.backend = 'my-kv';
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.backend, route: 'list' },
{ label: this.path, route: 'secret.details', model: this.path },
{ label: 'Metadata' },
];
this.capabilities = { canUpdateMetadata: true };
this.onCancel = sinon.spy();
const store = this.owner.lookup('service:store');
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
const data = this.server.create('kv-metadatum', 'withCustomMetadata');
data.id = kvMetadataPath('kv-engine', 'my-secret');
store.pushPayload('kv/metadatum', {
modelName: 'kv/metadata',
...data,
});
// Used to test a model with custom_metadata and non-default inputs.
this.metadataModelEdit = store.peekRecord('kv/metadata', data.id);
// Used to test a model with no custom_metadata and default values.
this.metadataModelCreate = store.createRecord('kv/metadata', {
backend: 'kv-engine-new',
path: 'my-secret-new',
});
this.onSave = sinon.spy();
this.renderComponent = () =>
render(
hbs`
<Page::Secret::Metadata::Edit
@form={{this.form}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
@capabilities={{this.capabilities}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}}
/>
`,
{ owner: this.engine }
);
});
test('it renders all inputs for a model that has all default values', async function (assert) {
assert.expect(5);
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.metadataModelCreate.backend, route: 'list' },
{ label: this.metadataModelCreate.path, route: 'secret.details', model: this.metadataModelCreate.path },
{ label: 'Metadata' },
];
await render(
hbs`
<Page::Secret::Metadata::Edit
@metadata={{this.metadataModelCreate}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}} />`,
{
owner: this.engine,
}
);
assert.dom(FORM.kvRow).exists({ count: 1 }, 'renders one kv row when model is new.');
assert.dom(FORM.inputByAttr('maxVersions')).exists('renders Max versions.');
assert.dom(FORM.inputByAttr('casRequired')).exists('renders Required Check and Set.');
this.form.data.custom_metadata = null;
this.form.data.delete_version_after = null;
await this.renderComponent();
assert.dom(FORM.kvRow).exists({ count: 1 }, 'renders one kv row for custom metadata');
assert.dom(FORM.inputByAttr('max_versions')).exists('renders Max versions.');
assert.dom(FORM.inputByAttr('cas_required')).exists('renders Required Check and Set.');
assert
.dom('[data-test-toggle-label="Automate secret deletion"]')
.exists('the label for automate secret deletion renders.');
@ -69,112 +74,66 @@ module('Integration | Component | kv | Page::Secret::Metadata::Edit', function (
});
test('it displays previous inputs from metadata record and saves new values', async function (assert) {
assert.expect(5);
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.metadataModelEdit.backend, route: 'list' },
{ label: this.metadataModelEdit.path, route: 'secret.details', model: this.metadataModelEdit.path },
{ label: 'Metadata' },
];
await render(
hbs`
<Page::Secret::Metadata::Edit
@metadata={{this.metadataModelEdit}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}} />`,
{
owner: this.engine,
}
);
assert.expect(7);
await this.renderComponent();
assert.dom(FORM.keyInput()).hasValue('foo', 'renders custom metadata key');
assert.dom(FORM.valueInput()).hasValue('bar', 'renders custom metadata value');
assert
.dom(FORM.kvRow)
.exists({ count: 4 }, 'renders all kv rows including previous data and one extra to fill out.');
assert
.dom(FORM.inputByAttr('maxVersions'))
.dom(FORM.inputByAttr('max_versions'))
.hasValue('15', 'renders Max versions that was on the record.');
assert
.dom(FORM.inputByAttr('casRequired'))
.dom(FORM.inputByAttr('cas_required'))
.hasValue('on', 'renders Required Check and Set that was on the record.');
assert
.dom(FORM.ttlValue('Automate secret deletion'))
.hasValue('12319', 'renders Automate secret deletion that was on the record.');
.hasValue('270', 'renders Automate secret deletion that was on the record.'); // 4h30m = 270m
// change the "Additional option" values
await click(FORM.deleteRow()); // delete the first kv row
await fillIn(FORM.keyInput(2), 'last');
await fillIn(FORM.valueInput(2), 'value');
await fillIn(FORM.inputByAttr('maxVersions'), '8');
await click(FORM.inputByAttr('casRequired'));
await fillIn(FORM.ttlValue('Automate secret deletion'), '1000');
// save test and check record
this.server.post('/kv-engine/metadata/my-secret', (schema, req) => {
const data = JSON.parse(req.requestBody);
const expected = {
max_versions: 8,
cas_required: false,
delete_version_after: '1000s',
custom_metadata: {
baz: '5c07d823-3810-48f6-a147-4c06b5219e84',
foo: 'abc',
last: 'value',
},
};
assert.propEqual(data, expected, 'POST request made to save metadata with correct properties.');
});
// update values
await fillIn(FORM.keyInput(1), 'last');
await fillIn(FORM.valueInput(1), 'value');
await fillIn(FORM.inputByAttr('max_versions'), '8');
await click(FORM.inputByAttr('cas_required'));
await fillIn(FORM.ttlValue('Automate secret deletion'), '60'); // 60m = 3600s
this.writeStub = sinon.stub(this.owner.lookup('service:api').secrets, 'kvV2WriteMetadata').resolves();
const metadata = {
max_versions: '8',
cas_required: false,
delete_version_after: '3600s',
custom_metadata: {
foo: 'bar',
last: 'value',
},
};
await click(FORM.saveBtn);
assert.true(this.writeStub.calledWith(this.path, this.backend, metadata), 'updated metadata is saved');
assert.true(this.onSave.called, 'onSave action is called');
});
test('it displays validation errors and does not save inputs on cancel', async function (assert) {
assert.expect(2);
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.metadataModelEdit.backend, route: 'list' },
{ label: this.metadataModelEdit.path, route: 'secret.details', model: this.metadataModelEdit.path },
{ label: 'Metadata' },
];
await render(
hbs`
<Page::Secret::Metadata::Edit
@metadata={{this.metadataModelEdit}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}} />`,
{
owner: this.engine,
}
);
await this.renderComponent();
// trigger validation error
await fillIn(FORM.inputByAttr('maxVersions'), 'a');
await fillIn(FORM.inputByAttr('max_versions'), 'a');
await click(FORM.saveBtn);
assert
.dom(FORM.validationError('maxVersions'))
.dom(FORM.validationError('max_versions'))
.hasText('Maximum versions must be a number.', 'Validation message is shown for max_versions');
await click(FORM.cancelBtn);
assert.strictEqual(this.metadataModelEdit.maxVersions, 15, 'Model is rolled back on cancel.');
assert.true(this.onCancel.called, 'onCancel action is called');
});
test('it shows an empty state if user does not have metadata update permissions', async function (assert) {
assert.expect(1);
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub('list'));
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.metadataModelEdit.backend, route: 'list' },
{ label: this.metadataModelEdit.path, route: 'secret.details', model: this.metadataModelEdit.path },
{ label: 'Metadata' },
];
await render(
hbs`
<Page::Secret::Metadata::Edit
@metadata={{this.metadataModelEdit}}
@breadcrumbs={{this.breadcrumbs}}
@onCancel={{this.onCancel}}
@onSave={{this.onSave}} />`,
{
owner: this.engine,
}
);
this.capabilities.canUpdateMetadata = false;
await this.renderComponent();
assert.dom(PAGE.emptyStateTitle).hasText('You do not have permissions to edit metadata');
});
});

View file

@ -6,14 +6,12 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { dateFormat } from 'core/helpers/date-format';
import { dateFromNow } from 'core/helpers/date-from-now';
import { baseSetup } from 'vault/tests/helpers/kv/kv-run-commands';
const { overviewCard } = GENERAL;
@ -21,15 +19,31 @@ const { overviewCard } = GENERAL;
module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(async function () {
baseSetup(this);
this.backend = 'kv-engine';
this.path = 'my-secret';
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.backend, route: 'list' },
{ label: this.path },
];
this.metadata = {
current_version: 1,
updated_time: '2023-07-21T03:11:58.095971Z',
versions: {
1: {
created_time: '2018-03-22T02:24:06.945319214Z',
deletion_time: '',
destroyed: false,
},
2: {
created_time: '2023-07-20T02:15:35.86465Z',
deletion_time: '2023-07-25T00:36:19.950545Z',
destroyed: false,
},
},
};
this.subkeys = {
subkeys: {
foo: null,
@ -46,22 +60,23 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
version: 1,
},
};
this.canReadMetadata = true;
this.canUpdateData = true;
this.capabilities = { canReadMetadata: true, canUpdateData: true };
this.isPatchAllowed = false;
this.format = (time) => dateFormat([time, 'MMM d yyyy, h:mm:ss aa'], {});
this.renderComponent = async () => {
return render(
hbs`
<Page::Secret::Overview
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadMetadata={{this.canReadMetadata}}
@canUpdateData={{this.canUpdateData}}
@metadata={{this.metadata}}
@path={{this.path}}
@subkeys={{this.subkeys}}
/>`,
<Page::Secret::Overview
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
@capabilities={{this.capabilities}}
@isPatchAllowed={{this.isPatchAllowed}}
@metadata={{this.metadata}}
@path={{this.path}}
@subkeys={{this.subkeys}}
/>
`,
{ owner: this.engine }
);
};
@ -84,18 +99,18 @@ 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.updatedTime]); // uses date-fns so can't stub timestamp util
const fromNow = dateFromNow([this.metadata.updated_time]); // uses date-fns so can't stub timestamp util
assert.dom(`${overviewCard.container('Current version')} .hds-badge`).doesNotExist();
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Create new The current version of this secret. ${this.metadata.currentVersion}`
`Current version Create new The current version of this secret. ${this.metadata.current_version}`
);
assert
.dom(overviewCard.container('Secret age'))
.hasText(
`Secret age View metadata Current secret version age. Last updated on ${this.format(
this.metadata.updatedTime
this.metadata.updated_time
)}. ${fromNow}`
);
assert
@ -116,19 +131,19 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
// creating a new version of a secret is updating a secret
// the overview only exists after an initial version is created
// which is why we just check for update and not also create
this.canUpdateData = false;
this.capabilities.canUpdateData = false;
await this.renderComponent();
assert
.dom(`${overviewCard.container('Current version')} a`)
.doesNotExist('create link does not render');
assert
.dom(overviewCard.container('Current version'))
.hasText(`Current version The current version of this secret. ${this.metadata.currentVersion}`);
.hasText(`Current version The current version of this secret. ${this.metadata.current_version}`);
});
test('it renders with no metadata permissions', async function (assert) {
this.metadata = null;
this.canReadMetadata = false;
this.capabilities.canReadMetadata = false;
// all secret metadata instead comes from subkeys endpoint
const subkeyMeta = this.subkeys.metadata;
await this.renderComponent();
@ -161,12 +176,12 @@ 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.updatedTime]); // uses date-fns so can't stub timestamp util
const expectedTime = this.format(this.metadata.updatedTime);
const fromNow = dateFromNow([this.metadata.updated_time]); // uses date-fns so can't stub timestamp util
const expectedTime = this.format(this.metadata.updated_time);
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Create new The current version of this secret. ${this.metadata.currentVersion}`
`Current version Create new The current version of this secret. ${this.metadata.current_version}`
);
assert
.dom(overviewCard.container('Secret age'))
@ -204,12 +219,12 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
metadata: {
created_time: '2021-12-14T20:28:00.773477Z',
custom_metadata: null,
deletion_time: '2022-02-14T20:28:00.773477Z',
deletion_time: '2023-07-25T00:36:19.950545Z',
destroyed: false,
version: 1,
},
};
this.metadata.versions[4].deletion_time = '2024-08-15T23:01:08.312332Z';
this.metadata.current_version = 2;
this.assertBadge = (assert) => {
assert
.dom(`${overviewCard.container('Current version')} .hds-badge`)
@ -222,13 +237,13 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
});
test('with full permissions', async function (assert) {
const expectedTime = this.format(this.metadata.versions[4].deletion_time);
const expectedTime = this.format(this.metadata.versions[2].deletion_time);
await this.renderComponent();
this.assertBadge(assert);
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Deleted Create new The current version of this secret was deleted ${expectedTime}. ${this.metadata.currentVersion}`
`Current version Deleted Create new The current version of this secret was deleted ${expectedTime}. ${this.metadata.current_version}`
);
assert.dom(overviewCard.container('Secret age')).doesNotExist();
assert.dom(overviewCard.container('Subkeys')).doesNotExist();
@ -253,13 +268,13 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
test('with no subkey permissions', async function (assert) {
this.subkeys = null;
const expectedTime = this.format(this.metadata.versions[4].deletion_time);
const expectedTime = this.format(this.metadata.versions[2].deletion_time);
await this.renderComponent();
this.assertBadge(assert);
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Deleted Create new The current version of this secret was deleted ${expectedTime}. ${this.metadata.currentVersion}`
`Current version Deleted Create new The current version of this secret was deleted ${expectedTime}. ${this.metadata.current_version}`
);
assert.dom(overviewCard.container('Subkeys')).doesNotExist();
});
@ -285,7 +300,8 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
version: 1,
},
};
this.metadata.versions[4].destroyed = true;
this.metadata.versions[2].destroyed = true;
this.metadata.current_version = 2;
this.assertBadge = (assert) => {
assert
.dom(`${overviewCard.container('Current version')} .hds-badge`)
@ -303,7 +319,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Destroyed Create new The current version of this secret has been permanently deleted and cannot be restored. ${this.metadata.currentVersion}`
`Current version Destroyed Create new The current version of this secret has been permanently deleted and cannot be restored. ${this.metadata.current_version}`
);
assert.dom(overviewCard.container('Secret age')).doesNotExist();
assert.dom(overviewCard.container('Subkeys')).doesNotExist();
@ -332,7 +348,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Overview', function (hoo
assert
.dom(overviewCard.container('Current version'))
.hasText(
`Current version Destroyed Create new The current version of this secret has been permanently deleted and cannot be restored. ${this.metadata.currentVersion}`
`Current version Destroyed Create new The current version of this secret has been permanently deleted and cannot be restored. ${this.metadata.current_version}`
);
assert.dom(overviewCard.container('Subkeys')).doesNotExist();
});

View file

@ -6,25 +6,24 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { blur, click, fillIn, find, render, waitUntil } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import sinon from 'sinon';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { baseSetup } from 'vault/tests/helpers/kv/kv-run-commands';
import codemirror, { setCodeEditorValue } from 'vault/tests/helpers/codemirror';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { overrideResponse } from 'vault/tests/helpers/stubs';
import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(async function () {
baseSetup(this);
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
this.backend = 'kv-engine';
this.path = 'my-secret';
this.metadata = { current_version: 4 };
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.backend, route: 'list' },
@ -113,26 +112,13 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
hooks.beforeEach(async function () {
this.endpoint = `${encodePath(this.backend)}/data/${encodePath(this.path)}`;
this.patchStub = sinon
.stub(this.owner.lookup('service:api').secrets, 'kvV2Patch')
.resolves(EXAMPLE_KV_DATA_CREATE_RESPONSE);
});
test('patch data from kv editor form', async function (assert) {
assert.expect(3);
this.server.patch(this.endpoint, (schema, req) => {
const payload = JSON.parse(req.requestBody);
const expected = {
data: { bar: null, foo: 'foovalue', aKey: '1', bKey: 'null' },
options: {
cas: this.metadata.currentVersion,
},
};
assert.true(true, `PATCH request made to ${this.endpoint}`);
assert.propEqual(
payload,
expected,
`payload: ${JSON.stringify(payload)} matches expected: ${JSON.stringify(payload)}`
);
return EXAMPLE_KV_DATA_CREATE_RESPONSE;
});
assert.expect(2);
await this.renderComponent();
// patch existing, delete and create a new key key
@ -147,64 +133,52 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
await fillIn(FORM.keyInput('new'), 'bKey');
await fillIn(FORM.valueInput('new'), 'null');
await click(FORM.saveBtn);
const [route] = this.transitionStub.lastCall.args;
assert.strictEqual(
route,
'vault.cluster.secrets.backend.kv.secret.index',
`it transitions on save to: ${route}`
const payload = {
data: { bar: null, foo: 'foovalue', aKey: '1', bKey: 'null' },
options: {
cas: this.metadata.current_version,
},
};
assert.true(
this.patchStub.calledWith(this.path, this.backend, payload),
'Patch request made with correct args'
);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.kv.secret.index'),
'transitions to overview route on save'
);
});
test('patch data from json form', async function (assert) {
assert.expect(3);
this.server.patch(this.endpoint, (schema, req) => {
const payload = JSON.parse(req.requestBody);
const expected = {
data: { foo: 'foovalue', bar: null, number: 1 },
options: {
cas: 4,
},
};
assert.true(true, `PATCH request made to ${this.endpoint}`);
assert.propEqual(
payload,
expected,
`payload: ${JSON.stringify(payload)} matches expected: ${JSON.stringify(payload)}`
);
return EXAMPLE_KV_DATA_CREATE_RESPONSE;
});
assert.expect(2);
await this.renderComponent();
await click(GENERAL.inputByAttr('JSON'));
await waitUntil(() => find('.cm-editor'));
const editor = codemirror();
setCodeEditorValue(editor, '{ "foo": "foovalue", "bar":null, "number":1 }');
await click(FORM.saveBtn);
const [route] = this.transitionStub.lastCall.args;
assert.strictEqual(
route,
'vault.cluster.secrets.backend.kv.secret.index',
`it transitions on save to: ${route}`
const payload = {
data: { foo: 'foovalue', bar: null, number: 1 },
options: {
cas: this.metadata.current_version,
},
};
assert.true(
this.patchStub.calledWith(this.path, this.backend, payload),
'Patch request made with correct args'
);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.kv.secret.index'),
'transitions to overview route on save'
);
});
// this assertion confirms submit allows empty values
test('empty string values from kv editor form', async function (assert) {
assert.expect(1);
this.server.patch(this.endpoint, (schema, req) => {
const payload = JSON.parse(req.requestBody);
const expected = {
data: { foo: '', aKey: '', bKey: '' },
options: {
cas: this.metadata.currentVersion,
},
};
assert.propEqual(
payload,
expected,
`payload: ${JSON.stringify(payload)} matches expected: ${JSON.stringify(payload)}`
);
return EXAMPLE_KV_DATA_CREATE_RESPONSE;
});
await this.renderComponent();
await click(FORM.patchEdit());
@ -218,26 +192,22 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
await fillIn(FORM.keyInput('new'), 'bKey');
await fillIn(FORM.valueInput('new'), '');
await click(FORM.saveBtn);
const payload = {
data: { foo: '', aKey: '', bKey: '' },
options: {
cas: this.metadata.current_version,
},
};
assert.true(
this.patchStub.calledWith(this.path, this.backend, payload),
'Patch request made with correct args'
);
});
// this assertion confirms submit allows empty values
test('empty string value from json form', async function (assert) {
assert.expect(1);
this.server.patch(this.endpoint, (schema, req) => {
const payload = JSON.parse(req.requestBody);
const expected = {
data: { foo: '' },
options: {
cas: this.metadata.currentVersion,
},
};
assert.propEqual(
payload,
expected,
`payload: ${JSON.stringify(payload)} matches expected: ${JSON.stringify(payload)}`
);
return EXAMPLE_KV_DATA_CREATE_RESPONSE;
});
await this.renderComponent();
await click(GENERAL.inputByAttr('JSON'));
@ -245,37 +215,41 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
const editor = codemirror();
setCodeEditorValue(editor, '{ "foo": "" }');
await click(FORM.saveBtn);
const payload = {
data: { foo: '' },
options: {
cas: this.metadata.current_version,
},
};
assert.true(
this.patchStub.calledWith(this.path, this.backend, payload),
'Patch request made with correct args'
);
});
test('patch data without metadata permissions', async function (assert) {
assert.expect(3);
assert.expect(2);
this.metadata = null;
this.server.patch(this.endpoint, (schema, req) => {
const payload = JSON.parse(req.requestBody);
const expected = {
data: { aKey: '1' },
options: {
cas: this.subkeysMeta.version,
},
};
assert.true(true, `PATCH request made to ${this.endpoint}`);
assert.propEqual(
payload,
expected,
`payload: ${JSON.stringify(payload)} matches expected: ${JSON.stringify(payload)}`
);
return EXAMPLE_KV_DATA_CREATE_RESPONSE;
});
await this.renderComponent();
await fillIn(FORM.keyInput('new'), 'aKey');
await fillIn(FORM.valueInput('new'), '1');
await click(FORM.saveBtn);
const [route] = this.transitionStub.lastCall.args;
assert.strictEqual(
route,
'vault.cluster.secrets.backend.kv.secret.index',
`it transitions on save to: ${route}`
const payload = {
data: { aKey: '1' },
options: {
cas: this.subkeysMeta.version,
},
};
assert.true(
this.patchStub.calledWith(this.path, this.backend, payload),
'Patch request made with correct args'
);
assert.true(
this.transitionStub.calledWith('vault.cluster.secrets.backend.kv.secret.index'),
'transitions to overview route on save'
);
});
});
@ -284,13 +258,15 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
hooks.beforeEach(async function () {
this.endpoint = `${encodePath(this.backend)}/data/${encodePath(this.path)}`;
this.flashSpy = sinon.spy(this.owner.lookup('service:flash-messages'), 'info');
const errors = { errors: ['Something went wrong. This should not have happened!'] };
this.patchStub = sinon
.stub(this.owner.lookup('service:api').secrets, 'kvV2Patch')
.rejects(getErrorResponse(errors, 500));
});
test('if no changes from kv editor form', async function (assert) {
assert.expect(3);
this.server.patch(this.endpoint, () =>
overrideResponse(500, `Request made to: ${this.endpoint}. This should not have happened!`)
);
await this.renderComponent();
await click(FORM.saveBtn);
assert.dom(GENERAL.messageError).doesNotExist('PATCH request is not made');
@ -310,9 +286,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
test('if no changes from json form', async function (assert) {
assert.expect(3);
this.server.patch(this.endpoint, () =>
overrideResponse(500, `Request made to: ${this.endpoint}. This should not have happened!`)
);
await this.renderComponent();
await click(GENERAL.inputByAttr('JSON'));
await waitUntil(() => find('.cm-editor'));
@ -335,10 +309,10 @@ module('Integration | Component | kv-v2 | Page::Secret::Patch', function (hooks)
module('it passes error', function (hooks) {
hooks.beforeEach(async function () {
this.endpoint = `${encodePath(this.backend)}/data/${encodePath(this.path)}`;
this.server.patch(this.endpoint, () => {
return overrideResponse(403);
});
const errors = { errors: ['permission denied'] };
this.patchStub = sinon
.stub(this.owner.lookup('service:api').secrets, 'kvV2Patch')
.rejects(getErrorResponse(errors, 403));
});
test('to kv editor form', async function (assert) {

View file

@ -6,122 +6,93 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { click, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { kvDataPath } from 'vault/utils/kv-path';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { syncStatusResponse } from 'vault/mirage/handlers/sync';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { baseSetup } from 'vault/tests/helpers/kv/kv-run-commands';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
import { getErrorResponse } from 'vault/tests/helpers/api/error-response';
module('Integration | Component | kv-v2 | Page::Secret::Details', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(async function () {
baseSetup(this);
this.pathComplex = 'my-secret-object';
this.version = 2;
this.dataId = kvDataPath(this.backend, this.path);
this.dataIdComplex = kvDataPath(this.backend, this.pathComplex);
this.backend = 'kv-engine';
this.path = 'my-secret';
this.secretData = { foo: 'bar' };
this.store.pushPayload('kv/data', {
modelName: 'kv/data',
id: this.dataId,
secret_data: this.secretData,
created_time: '2023-07-20T02:12:17.379762Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: this.version,
backend: this.backend,
path: this.path,
});
// nested secret
this.secretDataComplex = {
this.secretDataNested = {
foo: {
bar: 'baz',
},
};
this.store.pushPayload('kv/data', {
modelName: 'kv/data',
id: this.dataIdComplex,
secret_data: this.secretDataComplex,
created_time: '2023-08-20T02:12:17.379762Z',
custom_metadata: null,
deletion_time: '',
this.secret = {
secretData: this.secretData,
version: 1,
destroyed: false,
version: this.version,
});
this.secret = this.store.peekRecord('kv/data', this.dataId);
this.secretComplex = this.store.peekRecord('kv/data', this.dataIdComplex);
// this is the route model, not an ember data model
this.model = {
backend: this.backend,
// permissions are tested in navigation acceptance test, so just stub as all true here
canReadData: true,
canReadMetadata: true,
canUpdateData: true,
isPatchAllowed: true,
metadata: this.metadata,
path: this.path,
secret: this.secret,
deletion_time: '',
created_time: '2023-07-20T02:12:17.379762Z',
custom_metadata: null,
};
this.metadata = {
current_version: 1,
updated_time: '2023-07-21T03:11:58.095971Z',
versions: {
1: {
created_time: '2018-03-22T02:24:06.945319214Z',
deletion_time: '',
destroyed: false,
},
},
};
this.capabilities = { canReadData: true, canReadMetadata: true, canUpdateData: true };
this.isPatchAllowed = true;
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.model.backend, route: 'list' },
{ label: this.model.path },
{ label: this.backend, route: 'list' },
{ label: this.path },
];
this.modelComplex = {
backend: this.backend,
path: this.pathComplex,
secret: this.secretComplex,
metadata: this.metadata,
};
this.renderComponent = (model) => {
this.model = model ? { ...this.model, ...model } : this.model;
return render(
this.api = this.owner.lookup('service:api');
this.queryParamsStub = sinon.stub(this.api, 'addQueryParams');
this.syncStub = sinon
.stub(this.api.sys, 'systemReadSyncAssociationsDestinations')
.callsFake((initOverride) => {
initOverride();
return Promise.reject(getErrorResponse({ errors: [] }, 404));
});
this.renderComponent = () =>
render(
hbs`
<Page::Secret::Details
@backend={{this.model.backend}}
@breadcrumbs={{this.breadcrumbs}}
@canReadData={{this.model.canReadData}}
@canReadMetadata={{this.model.canReadMetadata}}
@canUpdateData={{this.model.canUpdateData}}
@isPatchAllowed={{this.model.isPatchAllowed}}
@metadata={{this.model.metadata}}
@path={{this.model.path}}
@secret={{this.model.secret}}
/>
`,
<Page::Secret::Details
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
@capabilities={{this.capabilities}}
@isPatchAllowed={{this.isPatchAllowed}}
@metadata={{this.metadata}}
@path={{this.path}}
@secret={{this.secret}}
/>
`,
{ owner: this.engine }
);
};
});
test('it renders secret details and toggles json view', async function (assert) {
assert.expect(9);
this.server.get(`sys/sync/associations/destinations`, (schema, req) => {
assert.ok(true, 'request made to fetch sync status');
assert.propEqual(
req.queryParams,
{
mount: this.backend,
secret_name: this.path,
},
'query params include mount and secret name'
);
// no records so response returns 404
return syncStatusResponse(schema, req);
});
await this.renderComponent();
assert.true(this.syncStub.calledOnce, 'sync status request made');
assert.deepEqual(
this.queryParamsStub.lastCall.args[1],
{ mount: this.backend, secret_name: this.path },
'sync query params include mount and secret name'
);
assert
.dom(PAGE.detail.syncAlert())
.doesNotExist('sync page alert banner does not render when sync status errors');
assert.dom(PAGE.title).includesText(this.model.path, 'renders secret path as page title');
assert.dom(PAGE.title).includesText(this.path, 'renders secret path as page title');
assert.dom(PAGE.infoRowValue('foo')).exists('renders row for secret data');
assert.dom(PAGE.infoRowValue('foo')).hasText('***********');
await click(GENERAL.button('toggle-masked'));
@ -135,12 +106,13 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
);
assert
.dom(PAGE.detail.versionTimestamp)
.includesText(`Version ${this.version} created`, 'renders version and time created');
.includesText(`Version ${this.secret.version} created`, 'renders version and time created');
});
test('it renders hds codeblock view when secret is complex', async function (assert) {
assert.expect(4);
await this.renderComponent(this.modelComplex);
this.secret.secretData = this.secretDataNested;
await this.renderComponent();
assert.dom(PAGE.infoRowValue('foo')).doesNotExist('does not render rows of secret data');
assert.dom(GENERAL.toggleInput('json')).isChecked();
assert.dom(GENERAL.toggleInput('json')).isNotDisabled();
@ -149,10 +121,10 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
test('it renders deleted empty state', async function (assert) {
assert.expect(3);
this.secret.deletionTime = '2023-07-23T02:12:17.379762Z';
this.secret.deletion_time = '2023-07-23T02:12:17.379762Z';
await this.renderComponent();
assert.dom(PAGE.emptyStateTitle).hasText('Version 2 of this secret has been deleted');
assert.dom(PAGE.emptyStateTitle).hasText('Version 1 of this secret has been deleted');
assert
.dom(PAGE.emptyStateMessage)
.hasText(
@ -160,7 +132,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
);
assert
.dom(PAGE.detail.versionTimestamp)
.includesText(`Version ${this.version} deleted`, 'renders version and time deleted');
.includesText(`Version ${this.secret.version} deleted`, 'renders version and time deleted');
});
test('it renders destroyed empty state', async function (assert) {
@ -168,7 +140,7 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
this.secret.destroyed = true;
await this.renderComponent();
assert.dom(PAGE.emptyStateTitle).hasText('Version 2 of this secret has been permanently destroyed');
assert.dom(PAGE.emptyStateTitle).hasText('Version 1 of this secret has been permanently destroyed');
assert
.dom(PAGE.emptyStateMessage)
.hasText(
@ -177,10 +149,15 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
});
test('it renders secret version dropdown', async function (assert) {
assert.expect(9);
assert.expect(6);
this.metadata.versions[2] = {
created_time: '2023-07-20T02:15:35.86465Z',
deletion_time: '2023-07-25T00:36:19.950545Z',
destroyed: false,
};
await this.renderComponent();
assert.dom(PAGE.detail.versionTimestamp).includesText(this.version, 'renders version');
assert.dom(PAGE.detail.versionTimestamp).includesText(this.secret.version, 'renders version');
assert.dom(PAGE.detail.versionDropdown).hasText(`Version ${this.secret.version}`);
await click(PAGE.detail.versionDropdown);
@ -196,36 +173,27 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
}
assert
.dom(`${PAGE.detail.version(this.metadata.currentVersion)} [data-test-icon="check-circle"]`)
.dom(`${PAGE.detail.version(this.metadata.current_version)} [data-test-icon="check-circle"]`)
.exists('renders current version icon');
});
test('it renders sync status page alert and refreshes', async function (assert) {
assert.expect(6); // assert count important because confirms request made to fetch sync status twice
const destinationName = 'my-destination';
this.server.create('sync-association', {
type: 'aws-sm',
name: destinationName,
mount: this.backend,
secret_name: this.path,
});
this.server.get(`sys/sync/associations/destinations`, (schema, req) => {
// these assertions should be hit twice, once on init and again when the 'Refresh' button is clicked
assert.ok(true, 'request made to fetch sync status');
assert.propEqual(
req.queryParams,
{
mount: this.backend,
secret_name: this.path,
assert.expect(3);
this.syncStub.resolves({
associated_destinations: {
'aws-sm': {
sync_status: 'SYNCED',
name: 'my-destination',
type: 'aws-sm',
updated_at: '2023-09-01T12:00:00Z',
},
'query params include mount and secret name'
);
return syncStatusResponse(schema, req);
},
});
await this.renderComponent();
assert
.dom(PAGE.detail.syncAlert(destinationName))
.dom(PAGE.detail.syncAlert('my-destination'))
.hasTextContaining(
'Synced my-destination - last updated September',
'renders sync status alert banner'
@ -238,46 +206,42 @@ module('Integration | Component | kv-v2 | Page::Secret::Details', function (hook
);
// sync status refresh button
await click(`${PAGE.detail.syncAlert()} button`);
assert.true(this.syncStub.calledTwice, 'sync status request made on refresh click');
});
test('it makes request to wrap a secret', async function (assert) {
assert.expect(2);
const url = `${encodePath(this.backend)}/data/${encodePath(this.path)}`;
const wrapStub = sinon.stub(this.api.sys, 'wrap').resolves({ wrap_info: { token: 'hvs.token' } });
this.server.get(url, (schema, { requestHeaders }) => {
assert.true(true, `GET request made to url: ${url}`);
assert.strictEqual(requestHeaders['X-Vault-Wrap-TTL'], '1800', 'request header includes wrap ttl');
return {
data: null,
token: 'hvs.token',
accessor: 'nTgqnw3S4GMz8NKHsOhTBhlk',
ttl: 1800,
creation_time: '2024-07-26T10:20:32.359107-07:00',
creation_path: `${this.backend}/data/${this.path}}`,
};
});
await this.renderComponent();
await click(PAGE.detail.copy);
await click(GENERAL.button('wrap'));
const { secretData: data, ...metadata } = this.secret;
assert.true(
wrapStub.calledWith({ data, metadata }, { headers: { 'X-Vault-Wrap-TTL': 1800 } }),
'makes request to wrap secret with correct data'
);
});
test('it renders sync status page alert for multiple destinations', async function (assert) {
assert.expect(3); // assert count important because confirms request made to fetch sync status twice
this.server.create('sync-association', {
type: 'aws-sm',
name: 'aws-dest',
mount: this.backend,
secret_name: this.path,
});
this.server.create('sync-association', {
type: 'gh',
name: 'gh-dest',
mount: this.backend,
secret_name: this.path,
});
this.server.get(`sys/sync/associations/destinations`, (schema, req) => {
return syncStatusResponse(schema, req);
assert.expect(3);
this.syncStub.resolves({
associated_destinations: {
'aws-sm': {
sync_status: 'SYNCED',
name: 'aws-dest',
type: 'aws-sm',
updated_at: '2023-09-01T12:00:00Z',
},
'gh-dest': {
sync_status: 'SYNCING',
name: 'gh-dest',
type: 'gh',
updated_at: '2023-09-01T12:00:00Z',
},
},
});
await this.renderComponent();

View file

@ -7,14 +7,14 @@ import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { Response } from 'miragejs';
import { hbs } from 'ember-cli-htmlbars';
import { click, fillIn, render, settled, waitFor } from '@ember/test-helpers';
import codemirror, { getCodeEditorValue, setCodeEditorValue } from 'vault/tests/helpers/codemirror';
import codemirror, { setCodeEditorValue } from 'vault/tests/helpers/codemirror';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import sinon from 'sinon';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import KvForm from 'vault/forms/secrets/kv';
module('Integration | Component | kv-v2 | Page::Secret::Edit', function (hooks) {
setupRenderingTest(hooks);
@ -22,22 +22,44 @@ module('Integration | Component | kv-v2 | Page::Secret::Edit', function (hooks)
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.router = this.owner.lookup('service:router');
this.transitionStub = sinon.stub(this.router, 'transitionTo');
this.backend = 'my-kv-engine';
this.path = 'my-secret';
this.secret = this.store.createRecord('kv/data', {
backend: this.backend,
path: this.path,
this.secret = {
secretData: { foo: 'bar' },
casVersion: 1,
version: 1,
};
this.metadata = { current_version: 1 };
this.form = new KvForm({
path: this.path,
secretData: this.secret.secretData,
max_versions: 0,
options: {
cas: this.secret.version,
},
});
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.backend, route: 'list' },
{ label: 'Edit' },
];
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
this.writeStub = sinon.stub(this.owner.lookup('service:api').secrets, 'kvV2Write').resolves();
this.renderComponent = () =>
render(
hbs`
<Page::Secret::Edit
@form={{this.form}}
@secret={{this.secret}}
@metadata={{this.metadata}}
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
setRunOptions({
rules: {
// TODO fix JSONEditor, KVObjectEditor, MaskedInput
@ -47,81 +69,38 @@ module('Integration | Component | kv-v2 | Page::Secret::Edit', function (hooks)
});
});
hooks.afterEach(function () {
this.router.transitionTo.restore();
});
test('it should toggle json editor', async function (assert) {
assert.expect(4);
test('it saves a new secret version', async function (assert) {
assert.expect(10);
this.server.post(`${this.backend}/data/${this.path}`, (schema, req) => {
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 } },
'request has expected payload'
);
return {
request_id: 'bd76db73-605d-fcbc-0dad-d44a008f9b95',
data: {
created_time: '2023-07-28T18:47:32.924809Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 2,
},
};
});
await this.renderComponent();
await render(
hbs`<Page::Secret::Edit
@secret={{this.secret}}
@previousVersion={{4}}
@currentVersion={{4}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
assert.dom(FORM.inputByAttr('path')).isDisabled();
assert.dom(FORM.inputByAttr('path')).hasValue(this.path);
assert.dom(FORM.keyInput()).hasValue('foo');
assert.dom(FORM.maskedValueInput()).hasValue('bar');
assert.dom(FORM.dataInputLabel({ isJson: false })).hasText('Version data');
assert.dom('.cm-editor').doesNotExist('CodeMirror editor is not rendered');
await click(GENERAL.toggleInput('json'));
await waitFor('.cm-editor');
const editor = codemirror();
const editorValue = getCodeEditorValue(editor);
assert.strictEqual(
editorValue,
`{
"foo": "bar"
}`,
'json editor initializes with empty object'
);
assert.dom(FORM.dataInputLabel({ isJson: true })).hasText('Version data');
await click(GENERAL.toggleInput('json'));
await fillIn(FORM.keyInput(1), 'foo2');
await fillIn(FORM.maskedValueInput(1), 'bar2');
await click(FORM.saveBtn);
const [actual] = this.transitionStub.lastCall.args;
assert.strictEqual(
actual,
'vault.cluster.secrets.backend.kv.secret.index',
'router transitions to secret overview route on save'
);
await waitFor('.cm-editor');
assert.dom('.cm-editor').exists('CodeMirror editor is rendered');
});
test('diff works correctly', async function (assert) {
await render(
hbs`<Page::Secret::Edit
@secret={{this.secret}}
@previousVersion={{4}}
@currentVersion={{4}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
test('it should show old version alert', async function (assert) {
this.metadata.current_version = 2;
await this.renderComponent();
assert
.dom(FORM.versionAlert)
.hasText(
`Warning You are creating a new version based on data from Version 1. The current version for my-secret is Version 2.`
);
});
test('it should render fail read error', async function (assert) {
this.secret.failReadErrorCode = 403;
await this.renderComponent();
assert.dom(FORM.noReadAlert).exists('it renders no read alert');
});
test('it should render diff view', async function (assert) {
await this.renderComponent();
assert.dom(GENERAL.toggleInput('Show diff')).isNotDisabled('Diff toggle is not disabled');
assert.dom(PAGE.edit.toggleDiffDescription).hasText('No changes to show. Update secret to view diff');
@ -148,157 +127,4 @@ module('Integration | Component | kv-v2 | Page::Secret::Edit', function (hooks)
assert.dom(PAGE.diff.deleted).hasText(`foo"bar"`);
assert.dom(PAGE.diff.added).hasText(`foo3"bar3"`);
});
test('it saves nested secrets', async function (assert) {
assert.expect(3);
const nestedSecret = 'path/to/secret';
this.secret.path = nestedSecret;
this.server.post(`${this.backend}/data/${nestedSecret}`, (schema, req) => {
assert.ok(true, 'Request made to save secret');
const payload = JSON.parse(req.requestBody);
assert.propEqual(payload, {
data: { foo: 'bar' },
options: { cas: 1 },
});
return {
request_id: 'bd76db73-605d-fcbc-0dad-d44a008f9b95',
data: {
created_time: '2023-07-28T18:47:32.924809Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 2,
},
};
});
await render(
hbs`<Page::Secret::Edit
@secret={{this.secret}}
@previousVersion={{4}}
@currentVersion={{4}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
assert.dom(FORM.inputByAttr('path')).hasValue(nestedSecret);
await click(FORM.saveBtn);
});
test('it renders API errors', async function (assert) {
assert.expect(3);
this.server.post(`${this.backend}/data/${this.path}`, () => {
return new Response(500, {}, { errors: ['nope'] });
});
await render(
hbs`<Page::Secret::Edit
@secret={{this.secret}}
@previousVersion={{4}}
@currentVersion={{4}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
await click(FORM.saveBtn);
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);
const [actual] = this.transitionStub.lastCall.args;
assert.strictEqual(
actual,
'vault.cluster.secrets.backend.kv.secret.index',
'router transitions to secret overview route on cancel'
);
});
test('it renders kv secret validations', async function (assert) {
assert.expect(2);
await render(
hbs`<Page::Secret::Edit
@secret={{this.secret}}
@previousVersion={{4}}
@currentVersion={{4}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
await click(GENERAL.toggleInput('json'));
await waitFor('.cm-editor');
const editor = codemirror();
setCodeEditorValue(editor, 'i am a string and not JSON');
await settled();
assert
.dom(FORM.inlineAlert)
.hasText('JSON is unparsable. Fix linting errors to avoid data discrepancies.');
setCodeEditorValue(editor, '""');
await settled();
await click(FORM.saveBtn);
assert.dom(FORM.inlineAlert).hasText('Vault expects data to be formatted as an JSON object.');
});
test('it toggles JSON view and saves modified data', async function (assert) {
assert.expect(4);
this.server.post(`${this.backend}/data/${this.path}`, (schema, req) => {
assert.ok(true, 'Request made to save secret');
const payload = JSON.parse(req.requestBody);
assert.propEqual(payload, {
data: { hello: 'there' },
options: { cas: 1 },
});
return {
request_id: 'bd76db73-605d-fcbc-0dad-d44a008f9b95',
data: {
created_time: '2023-07-28T18:47:32.924809Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 2,
},
};
});
await render(
hbs`<Page::Secret::Edit
@secret={{this.secret}}
@previousVersion={{3}}
@currentVersion={{4}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
assert.dom(FORM.dataInputLabel({ isJson: false })).hasText('Version data');
await click(GENERAL.toggleInput('json'));
assert.dom(FORM.dataInputLabel({ isJson: true })).hasText('Version data');
await waitFor('.cm-editor');
const editor = codemirror();
setCodeEditorValue(editor, '{ "hello": "there" }');
await click(FORM.saveBtn);
});
test('it renders alert when creating a new secret version from an old version', async function (assert) {
assert.expect(1);
await render(
hbs`<Page::Secret::Edit
@secret={{this.secret}}
@previousVersion={{1}}
@currentVersion={{4}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
assert
.dom(FORM.versionAlert)
.hasText(
`Warning You are creating a new version based on data from Version 1. The current version for my-secret is Version 4.`
);
});
});

View file

@ -6,37 +6,51 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'vault/tests/helpers';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { Response } from 'miragejs';
import { hbs } from 'ember-cli-htmlbars';
import { click, fillIn, render, typeIn, waitFor, settled } from '@ember/test-helpers';
import codemirror, { setCodeEditorValue } from 'vault/tests/helpers/codemirror';
import { FORM } from 'vault/tests/helpers/kv/kv-selectors';
import { click, render, waitFor } from '@ember/test-helpers';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import sinon from 'sinon';
import { setRunOptions } from 'ember-a11y-testing/test-support';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import KvForm from 'vault/forms/secrets/kv';
module('Integration | Component | kv-v2 | Page::Secrets::Create', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.router = this.owner.lookup('service:router');
this.transitionStub = sinon.stub(this.router, 'transitionTo');
this.backend = 'my-kv-engine';
this.path = 'my-secret';
this.maxVersions = 10;
this.secret = this.store.createRecord('kv/data', { backend: this.backend, casVersion: 0 });
this.metadata = this.store.createRecord('kv/metadata', { backend: this.backend });
this.form = new KvForm(
{
path: this.path,
max_versions: 0,
delete_version_after: '0s',
cas_required: false,
},
{ isNew: true }
);
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.backend, route: 'list' },
{ label: 'Create' },
];
this.renderComponent = () =>
render(
hbs`
<Page::Secrets::Create
@form={{this.form}}
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
this.transitionStub = sinon.stub(this.owner.lookup('service:router'), 'transitionTo');
setRunOptions({
rules: {
// TODO fix JSONEditor, KVObjectEditor, MaskedInput
@ -46,266 +60,27 @@ module('Integration | Component | kv-v2 | Page::Secrets::Create', function (hook
});
});
hooks.afterEach(function () {
this.router.transitionTo.restore();
});
test('it saves secret data and metadata', async function (assert) {
assert.expect(5);
this.server.post(`${this.backend}/data/${this.path}`, (schema, req) => {
assert.ok(true, 'Request made to save secret');
const payload = JSON.parse(req.requestBody);
assert.propEqual(payload, {
data: { foo: 'bar' },
options: { cas: 0 },
});
return {
request_id: 'bd76db73-605d-fcbc-0dad-d44a008f9b95',
data: {
created_time: '2023-07-28T18:47:32.924809Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
},
};
});
this.server.post(`${this.backend}/metadata/${this.path}`, (schema, req) => {
assert.ok(true, 'Request made to save metadata');
const payload = JSON.parse(req.requestBody);
assert.propEqual(payload, {
cas_required: false,
custom_metadata: {
'my-custom': 'metadata',
},
delete_version_after: '0s',
max_versions: 10,
});
});
await render(
hbs`<Page::Secrets::Create
@secret={{this.secret}}
@metadata={{this.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
await fillIn(FORM.inputByAttr('path'), this.path);
await fillIn(FORM.keyInput(), 'foo');
await fillIn(FORM.maskedValueInput(), 'bar');
await click(FORM.toggleMetadata);
await fillIn(`${GENERAL.fieldByAttr('customMetadata')} ${FORM.keyInput()}`, 'my-custom');
await fillIn(`${GENERAL.fieldByAttr('customMetadata')} ${FORM.valueInput()}`, 'metadata');
await fillIn(FORM.inputByAttr('maxVersions'), this.maxVersions);
await click(FORM.saveBtn);
const [actual] = this.transitionStub.lastCall.args;
assert.strictEqual(
actual,
'vault.cluster.secrets.backend.kv.secret.index',
'router transitions to secret overview route on save'
);
});
test('it does not send request to save secret metadata if fields are unchanged', async function (assert) {
// this test contains two assertions, but only expects one because a request to kv/metadata
// should NOT happen if its form inputs have not been edited
assert.expect(1);
this.server.post(`${this.backend}/data/${this.path}`, () => {
assert.ok(true, 'Request only made to save secret');
return {
request_id: 'bd76db73-605d-fcbc-0dad-d44a008f9b95',
data: {
created_time: '2023-07-28T18:47:32.924809Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
},
};
});
this.server.post(`${this.backend}/metadata/${this.path}`, () => {
// this assertion should not be hit!!
assert.notOk(true, 'Request should not be made to save metadata');
return new Response(403, {}, { errors: ['This request should not have been made'] });
});
await render(
hbs`<Page::Secrets::Create
@secret={{this.secret}}
@metadata={{this.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
await fillIn(FORM.inputByAttr('path'), this.path);
await fillIn(FORM.keyInput(), 'foo');
await fillIn(FORM.maskedValueInput(), 'bar');
await click(FORM.saveBtn);
});
test('it saves nested secrets', async function (assert) {
assert.expect(3);
const pathToSecret = 'path/to/secret/';
this.secret.path = pathToSecret;
this.server.post(`${this.backend}/data/${pathToSecret + this.path}`, (schema, req) => {
assert.ok(true, 'Request made to save secret');
const payload = JSON.parse(req.requestBody);
assert.propEqual(payload, {
data: { foo: 'bar' },
options: { cas: 0 },
});
return {
request_id: 'bd76db73-605d-fcbc-0dad-d44a008f9b95',
data: {
created_time: '2023-07-28T18:47:32.924809Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
},
};
});
await render(
hbs`<Page::Secrets::Create
@secret={{this.secret}}
@metadata={{this.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
assert.dom(FORM.inputByAttr('path')).hasValue(pathToSecret);
await typeIn(FORM.inputByAttr('path'), this.path);
await fillIn(FORM.keyInput(), 'foo');
await fillIn(FORM.maskedValueInput(), 'bar');
await click(FORM.saveBtn);
});
test('it renders API errors', async function (assert) {
// this test contains an extra assertion because a request to kv/metadata
// should NOT happen if kv/data fails
assert.expect(3);
this.server.post(`${this.backend}/data/${this.path}`, () => {
return new Response(500, {}, { errors: ['nope'] });
});
this.server.post(`${this.backend}/metadata/${this.path}`, () => {
// this assertion should not be hit because the request to save secret data failed!!
assert.ok(true, 'Request made to save metadata');
return new Response(403, {}, { errors: ['This request should not have been made'] });
});
await render(
hbs`<Page::Secrets::Create
@secret={{this.secret}}
@metadata={{this.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
await fillIn(FORM.inputByAttr('path'), this.path);
await click(FORM.saveBtn);
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.list'),
'router transitions to secret list route on cancel'
);
});
test('it renders kv secret validations', async function (assert) {
assert.expect(6);
await render(
hbs`<Page::Secrets::Create
@secret={{this.secret}}
@metadata={{this.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
await typeIn(FORM.inputByAttr('path'), 'space ');
assert
.dom(FORM.validation('path'))
.hasText(
`Path contains whitespace. If this is desired, you'll need to encode it with %20 in API requests.`
);
await fillIn(FORM.inputByAttr('path'), ''); // clear input
await typeIn(FORM.inputByAttr('path'), 'slash/');
assert.dom(FORM.validationError('path')).hasText(`Path can't end in forward slash '/'.`);
await typeIn(FORM.inputByAttr('path'), 'secret');
assert
.dom(FORM.validationError('path'))
.doesNotExist('it removes validation on key up when secret contains slash but does not end in one');
await click(GENERAL.toggleInput('json'));
await waitFor('.cm-editor');
const editor = codemirror();
setCodeEditorValue(editor, 'i am a string and not JSON');
await settled();
assert
.dom(FORM.inlineAlert)
.hasText('JSON is unparsable. Fix linting errors to avoid data discrepancies.');
setCodeEditorValue(editor, '{}');
await settled();
await fillIn(FORM.inputByAttr('path'), '');
await click(FORM.saveBtn);
assert.dom(FORM.validationError('path')).hasText(`Path can't be blank.`);
assert.dom(FORM.inlineAlert).hasText('There is an error with this form.');
});
test('it toggles JSON view and saves modified data', async function (assert) {
test('it should toggle json editor', async function (assert) {
assert.expect(4);
this.server.post(`${this.backend}/data/${this.path}`, (schema, req) => {
assert.ok(true, 'Request made to save secret');
const payload = JSON.parse(req.requestBody);
assert.propEqual(payload, {
data: { hello: 'there' },
options: { cas: 0 },
});
return {
request_id: 'bd76db73-605d-fcbc-0dad-d44a008f9b95',
data: {
created_time: '2023-07-28T18:47:32.924809Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
},
};
});
await render(
hbs`<Page::Secrets::Create
@secret={{this.secret}}
@metadata={{this.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>`,
{ owner: this.engine }
);
await this.renderComponent();
assert.dom(FORM.dataInputLabel({ isJson: false })).hasText('Secret data');
assert.dom('.cm-editor').doesNotExist('CodeMirror editor is not rendered');
await click(GENERAL.toggleInput('json'));
assert.dom(FORM.dataInputLabel({ isJson: true })).hasText('Secret data');
await waitFor('.cm-editor');
const editor = codemirror();
setCodeEditorValue(editor, `{ "hello": "there"}`);
await fillIn(FORM.inputByAttr('path'), this.path);
await click(FORM.saveBtn);
assert.dom('.cm-editor').exists('CodeMirror editor is rendered');
});
test('it should toggle metadata', async function (assert) {
await this.renderComponent();
assert.dom(FORM.toggleMetadata).hasText('Show secret metadata');
assert.dom(PAGE.create.metadataSection).doesNotExist('metadata section is hidden');
await click(FORM.toggleMetadata);
assert.dom(FORM.toggleMetadata).hasText('Hide secret metadata');
assert.dom(PAGE.create.metadataSection).exists('metadata section is shown');
});
});

View file

@ -9,11 +9,10 @@ import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { click, findAll, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { kvMetadataPath, kvDataPath } from 'vault/utils/kv-path';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import sinon from 'sinon';
module('Integration | Component | kv | Page::Secret::Metadata::VersionDiff', function (hooks) {
module('Integration | Component | kv-v2 | Page::Secret::Metadata::VersionDiff', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
@ -21,51 +20,82 @@ module('Integration | Component | kv | Page::Secret::Metadata::VersionDiff', fun
hooks.beforeEach(async function () {
this.backend = 'kv-engine';
this.path = 'my-secret';
this.metadata = {
current_version: 4,
updated_time: '2023-07-21T03:11:58.095971Z',
versions: {
1: {
created_time: '2018-03-22T02:24:06.945319214Z',
deletion_time: '',
destroyed: false,
},
2: {
created_time: '2023-07-20T02:15:35.86465Z',
deletion_time: '2023-07-25T00:36:19.950545Z',
destroyed: false,
},
3: {
created_time: '2023-07-20T02:15:40.164549Z',
deletion_time: '',
destroyed: true,
},
4: {
created_time: '2023-07-21T03:11:58.095971Z',
deletion_time: '',
destroyed: false,
},
},
};
this.breadcrumbs = [{ label: 'Version History', route: 'secret.metadata.versions' }, { label: 'Diff' }];
this.store = this.owner.lookup('service:store');
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
this.renderComponent = () =>
render(
hbs`
<Page::Secret::Metadata::VersionDiff
@metadata={{this.metadata}}
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
const metadata = this.server.create('kv-metadatum');
metadata.id = kvMetadataPath(this.backend, this.path);
this.store.pushPayload('kv/metadata', { modelName: 'kv/metadata', ...metadata });
this.metadata = this.store.peekRecord('kv/metadata', metadata.id);
// push current secret version record into the store to assert only one request is made
const dataId = kvDataPath(this.backend, this.path, 4);
this.store.pushPayload('kv/data', {
modelName: 'kv/data',
id: dataId,
secret_data: { foo: 'bar' },
version: this.metadata.currentVersion,
this.api = this.owner.lookup('service:api');
this.queryParamsStub = sinon.stub(this.api, 'addQueryParams');
this.fetchStub = sinon.stub(this.api.secrets, 'kvV2Read').callsFake((path, backend, initOverride) => {
initOverride();
return Promise.resolve({});
});
});
test('it renders empty states when current version is deleted or destroyed', async function (assert) {
assert.expect(4);
this.server.get(`/${this.backend}/data/${this.path}`, () => {});
const { currentVersion } = this.metadata;
assert.expect(7);
const { current_version } = this.metadata;
// destroyed
this.metadata.versions[currentVersion].destroyed = true;
await render(
hbs`
<Page::Secret::Metadata::VersionDiff
@metadata={{this.metadata}}
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
this.metadata.versions[current_version].destroyed = true;
await this.renderComponent();
assert.true(this.fetchStub.calledWith(this.path, this.backend));
assert.deepEqual(
this.queryParamsStub.firstCall.args[1],
{ version: 1 },
'correct version passed as query param to first request'
);
assert.dom(PAGE.emptyStateTitle).hasText(`Version ${currentVersion} has been destroyed`);
assert.deepEqual(
this.queryParamsStub.secondCall.args[1],
{ version: 4 },
'correct version passed as query param to second request'
);
assert.dom(PAGE.emptyStateTitle).hasText(`Version ${current_version} has been destroyed`);
assert
.dom(PAGE.emptyStateMessage)
.hasText('The current version of this secret has been destroyed. Select another version to compare.');
// deleted
this.metadata.versions[currentVersion].destroyed = false;
this.metadata.versions[currentVersion].deletion_time = '2023-07-25T00:36:19.950545Z';
this.metadata.versions[current_version].destroyed = false;
this.metadata.versions[current_version].deletion_time = '2023-07-25T00:36:19.950545Z';
await render(
hbs`
<Page::Secret::Metadata::VersionDiff
@ -78,44 +108,19 @@ module('Integration | Component | kv | Page::Secret::Metadata::VersionDiff', fun
{ owner: this.engine }
);
assert.dom(PAGE.emptyStateTitle).hasText(`Version ${currentVersion} has been deleted`);
assert.dom(PAGE.emptyStateTitle).hasText(`Version ${current_version} has been deleted`);
assert
.dom(PAGE.emptyStateMessage)
.hasText('The current version of this secret has been deleted. Select another version to compare.');
});
test('it renders compared data of the two versions and shows icons for deleted, destroyed and current', async function (assert) {
assert.expect(14);
this.server.get(`/${this.backend}/data/${this.path}`, (schema, req) => {
assert.ok('request made to the fetch version 1 data.');
// request should not be made for version 4 (current version) because that record already exists in the store
assert.strictEqual(req.queryParams.version, '1', 'request includes version param');
return {
request_id: 'foobar',
data: {
data: { hello: 'world' },
metadata: {
created_time: '2023-06-20T21:26:47.592306Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
},
},
};
});
assert.expect(12);
await render(
hbs`
<Page::Secret::Metadata::VersionDiff
@metadata={{this.metadata}}
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
this.fetchStub.onFirstCall().resolves({ data: { hello: 'world' } }); // version 1
this.fetchStub.onSecondCall().resolves({ data: { foo: 'bar' } }); // version 4 (current version)
await this.renderComponent();
const [left, right] = findAll(PAGE.detail.versionDropdown);
assert.dom(PAGE.diff.visualDiff).hasText(

View file

@ -6,56 +6,72 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupEngine } from 'ember-engines/test-support';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { click, render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { kvMetadataPath } from 'vault/utils/kv-path';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import isDeleted from 'kv/helpers/is-deleted';
module('Integration | Component | kv | Page::Secret::Metadata::Version-History', function (hooks) {
module('Integration | Component | kv-v2 | Page::Secret::Metadata::Version-History', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(async function () {
const store = this.owner.lookup('service:store');
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
const metadata = this.server.create('kv-metadatum');
this.backend = 'kv-engine';
this.path = 'my-secret';
// we want to test a scenario where the current version is also destroyed so there are two icons.
// we override the mirage factory to account for this use case.
metadata.data.versions[4] = {
created_time: '2023-07-21T03:11:58.095971Z',
deletion_time: '',
destroyed: true,
this.metadata = {
current_version: 4,
updated_time: '2023-07-21T03:11:58.095971Z',
versions: {
1: {
created_time: '2018-03-22T02:24:06.945319214Z',
deletion_time: '',
destroyed: false,
},
2: {
created_time: '2023-07-20T02:15:35.86465Z',
deletion_time: '2023-07-25T00:36:19.950545Z',
destroyed: false,
},
3: {
created_time: '2023-07-20T02:15:40.164549Z',
deletion_time: '',
destroyed: true,
},
4: {
created_time: '2023-07-21T03:11:58.095971Z',
deletion_time: '',
destroyed: true,
},
},
};
metadata.id = kvMetadataPath('kv-engine', 'my-secret');
store.pushPayload('kv/metadatum', {
modelName: 'kv/metadata',
...metadata,
});
this.metadata = store.peekRecord('kv/metadata', metadata.id);
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.metadata.backend, route: 'list' },
{ label: this.metadata.path, route: 'secret.details', model: this.metadata.path },
{ label: this.backend, route: 'list' },
{ label: this.path, route: 'secret.details', model: this.path },
{ label: 'Version History' },
];
this.capabilities = { canReadMetadata: true, canCreateVersionData: true };
this.renderComponent = () =>
render(
hbs`
<Page::Secret::Metadata::VersionHistory
@metadata={{this.metadata}}
@path={{this.path}}
@backend={{this.backend}}
@breadcrumbs={{this.breadcrumbs}}
@capabilities={{this.capabilities}}
/>
`,
{ owner: this.engine }
);
});
test('it renders version history and shows icons for deleted, destroyed and current', async function (assert) {
assert.expect(7); // 4 linked blocks, 2 destroyed, 1 deleted.
await render(
hbs`
<Page::Secret::Metadata::VersionHistory
@path={{this.metadata.path}}
@metadata={{this.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent();
for (const version in this.metadata.versions) {
const data = this.metadata.versions[version];
@ -66,7 +82,7 @@ module('Integration | Component | kv | Page::Secret::Metadata::Version-History',
.dom(`${PAGE.versions.icon(version)} [data-test-icon="x-square-fill"]`)
.hasStyle({ color: 'rgb(229, 34, 40)' });
}
if (data.isSecretDeleted) {
if (isDeleted(data.deletion_time)) {
assert
.dom(`${PAGE.versions.icon(version)} [data-test-icon="x-square-fill"]`)
.hasStyle({ color: 'rgb(101, 106, 118)' });
@ -76,16 +92,8 @@ module('Integration | Component | kv | Page::Secret::Metadata::Version-History',
test('it gives the option to create a new version from a secret from the popup menu', async function (assert) {
assert.expect(1);
await render(
hbs`
<Page::Secret::Metadata::VersionHistory
@path={{this.metadata.path}}
@metadata={{this.metadata}}
@breadcrumbs={{this.breadcrumbs}}
/>
`,
{ owner: this.engine }
);
await this.renderComponent();
// because the popup menu is nested in a linked block we must combine the two selectors
const popupSelector = `${PAGE.versions.linkedBlock(1)} ${PAGE.popup}`;
await click(popupSelector);

View file

@ -25,6 +25,7 @@ module('Unit | Service | api', function (hooks) {
const controlGroupService = this.owner.lookup('service:control-group');
this.wrapInfo = { token: 'ctrl-group', accessor: '84tfdfd5pQ5vOOEMxC2o3Ymt' };
this.tokenForUrl = sinon.stub(controlGroupService, 'tokenForUrl').returns(this.wrapInfo);
this.tokenToUnwrap = sinon.stub(controlGroupService, 'tokenToUnwrap').value(this.wrapInfo);
this.deleteControlGroupToken = sinon.spy(controlGroupService, 'deleteControlGroupToken');
this.isRequestedPathLocked = sinon.stub(controlGroupService, 'isRequestedPathLocked').returns(true);
@ -132,11 +133,11 @@ module('Unit | Service | api', function (hooks) {
test('it should check for control group', async function (assert) {
const headers = new Headers({ 'Content-Length': '100', 'X-Vault-Wrap-TTL': 1800 });
const body = { data: null, wrap_info: this.wrapInfo };
const init = { headers: new Headers({ 'X-Vault-Token': this.wrapInfo.token }) };
const apiResponse = new Response(JSON.stringify(body), { headers });
const response = await this.apiService.checkControlGroup({ url: this.url, response: apiResponse });
const response = await this.apiService.checkControlGroup({ url: this.url, response: apiResponse, init });
assert.true(this.tokenForUrl.calledWith(this.url), 'Url is passed to tokenForUrl method');
assert.true(
this.deleteControlGroupToken.calledWith(this.wrapInfo.accessor),
'Control group token is deleted'