mirror of
https://github.com/hashicorp/vault.git
synced 2026-02-03 20:40:45 -05:00
* 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:
parent
eadd2bde15
commit
0b939eaaf4
66 changed files with 1845 additions and 2031 deletions
|
|
@ -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;
|
||||
});
|
||||
|
|
|
|||
84
ui/app/forms/secrets/kv.ts
Normal file
84
ui/app/forms/secrets/kv.ts
Normal 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:',
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export interface FieldOptions {
|
|||
helperTextEnabled?: string;
|
||||
placeholder?: string;
|
||||
noDefault?: boolean;
|
||||
isSectionHeader?: boolean;
|
||||
}
|
||||
|
||||
export default class FormField {
|
||||
|
|
|
|||
83
ui/lib/kv/addon/components/kv-create-edit-form.hbs
Normal file
83
ui/lib/kv/addon/components/kv-create-edit-form.hbs
Normal 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>
|
||||
144
ui/lib/kv/addon/components/kv-create-edit-form.js
Normal file
144
ui/lib/kv/addon/components/kv-create-edit-form.js
Normal 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);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
@ -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}}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}.`,
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
@ -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.'
|
||||
: ''
|
||||
}`,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
20
ui/lib/kv/addon/helpers/current-secret.js
Normal file
20
ui/lib/kv/addon/helpers/current-secret.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
17
ui/lib/kv/addon/helpers/sorted-versions.js
Normal file
17
ui/lib/kv/addon/helpers/sorted-versions.js
Normal 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();
|
||||
}
|
||||
18
ui/lib/kv/addon/helpers/stringified-secret-data.js
Normal file
18
ui/lib/kv/addon/helpers/stringified-secret-data.js
Normal 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));
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
/>
|
||||
|
|
@ -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}}
|
||||
/>
|
||||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
/>
|
||||
|
|
@ -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}}
|
||||
/>
|
||||
|
|
@ -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}}
|
||||
/>
|
||||
|
|
@ -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}}
|
||||
/>
|
||||
33
ui/lib/kv/addon/utils/kv-error-handler.js
Normal file
33
ui/lib/kv/addon/utils/kv-error-handler.js
Normal 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;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { breadcrumbsForSecret as default } from 'kv/utils/kv-breadcrumbs';
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
export { isDeleted as default } from 'kv/utils/kv-deleted';
|
||||
|
|
@ -10,7 +10,8 @@
|
|||
"ember-cli-babel": "*",
|
||||
"ember-concurrency": "*",
|
||||
"@ember/test-waiters": "*",
|
||||
"ember-inflector": "*"
|
||||
"ember-inflector": "*",
|
||||
"ember-template-lint": "*"
|
||||
},
|
||||
"ember-addon": {
|
||||
"paths": [
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
228
ui/tests/integration/components/kv/kv-create-edit-form-test.js
Normal file
228
ui/tests/integration/components/kv/kv-create-edit-form-test.js
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
Loading…
Reference in a new issue