[UI] Ember Data Migration - KV Cleanup (#9623) (#9794)

* 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

* removes kv models, adapters and serializers

* removes store from kv addon

* removes ember data related test helpers from kv-run-commands

* updates comments that referred to kv ember data models

* updates kv-page-header tests

* updates model-form-fields test to use totp-key model rather than kv/data

* removes another reference to kv/data model from path-help test

* 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-suggestion-input component to use api service

* removes kv metadata model reference from quick actions card

* fixes sync destination sync secrets tests

* 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

* attempts to fix test flakiness requesting custom metadata from data endpoint

* more tweaks to requesting metadata from data

* adds waitFor to requestData method

Co-authored-by: Jordan Reimer <zofskeez@gmail.com>
This commit is contained in:
Vault Automation 2025-10-02 14:52:26 -04:00 committed by GitHub
parent 0c3dcbc30e
commit bf32d52450
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 269 additions and 1742 deletions

View file

@ -1,13 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationAdapter from '../application';
export default class KvConfigAdapter extends ApplicationAdapter {
namespace = 'v1';
urlForFindRecord(id) {
return `${this.buildURL()}/${id}/config`;
}
}

View file

@ -1,166 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationAdapter from '../application';
import { kvDataPath, kvDeletePath, kvDestroyPath, kvSubkeysPath, kvUndeletePath } from 'vault/utils/kv-path';
import { assert } from '@ember/debug';
import ControlGroupError from 'vault/lib/control-group-error';
export default class KvDataAdapter extends ApplicationAdapter {
namespace = 'v1';
_url(fullPath) {
return `${this.buildURL()}/${fullPath}`;
}
createRecord(store, type, snapshot) {
const { backend, path } = snapshot.record;
const url = this._url(kvDataPath(backend, path));
return this.ajax(url, 'POST', { data: this.serialize(snapshot) }).then((res) => {
return {
data: {
id: kvDataPath(backend, path, res.data.version),
backend,
path,
...res.data,
},
};
});
}
fetchSubkeys(backend, path, query) {
const url = this._url(kvSubkeysPath(backend, path, query));
return (
this.ajax(url, 'GET')
.then((resp) => resp.data)
// deleted/destroyed secret versions throw an error
// but still have metadata that we want to return
.catch((errorOrResponse) => {
return this.parseErrorOrResponse(errorOrResponse, { backend, path }, true);
})
);
}
fetchWrapInfo(query) {
const { backend, path, version, wrapTTL } = query;
const id = kvDataPath(backend, path, version);
return this.ajax(this._url(id), 'GET', { wrapTTL }).then((resp) => resp.wrap_info);
}
// patching a secret happens without retrieving the ember data model
// so we use a custom method instead of updateRecord
patchSecret(backend, path, patchData, version) {
const url = this._url(kvDataPath(backend, path));
const data = {
options: { cas: version },
data: patchData,
};
return this.ajax(url, 'PATCH', { data });
}
queryRecord(store, type, query) {
const { backend, path, version } = query;
// ID is the full path for the data (including version)
let id = kvDataPath(backend, path, version);
return this.ajax(this._url(id), 'GET')
.then((resp) => {
// if no version is queried, add version from response to ID
// otherwise duplicate ember data models will exist in store
// (one with an ID that includes the version and one without)
if (!version) {
id = kvDataPath(backend, path, resp.data.metadata.version);
}
return {
...resp,
data: {
id,
backend,
path,
...resp.data,
},
};
})
.catch((errorOrResponse) => {
return this.parseErrorOrResponse(errorOrResponse, { id, backend, path, version });
});
}
/* Five types of delete operations */
deleteRecord(store, type, snapshot) {
const { backend, path } = snapshot.record;
const { deleteType, deleteVersions } = snapshot.adapterOptions;
if (!backend || !path) {
throw new Error('The request to delete or undelete is missing required attributes.');
}
switch (deleteType) {
case 'delete-latest-version':
return this.ajax(this._url(kvDataPath(backend, path)), 'DELETE');
case 'delete-version':
return this.ajax(this._url(kvDeletePath(backend, path)), 'POST', {
data: { versions: deleteVersions },
});
case 'destroy':
return this.ajax(this._url(kvDestroyPath(backend, path)), 'PUT', {
data: { versions: deleteVersions },
});
case 'undelete':
return this.ajax(this._url(kvUndeletePath(backend, path)), 'POST', {
data: { versions: deleteVersions },
});
default:
assert('deleteType must be one of delete-latest-version, delete-version, destroy, or undelete.');
}
}
handleResponse(status, headers, payload, requestData) {
// after deleting a secret version, data is null and the API returns a 404
// but there could be relevant metadata
if (status === 404 && payload.data?.metadata) {
return super.handleResponse(200, headers, payload, requestData);
}
return super.handleResponse(...arguments);
}
parseErrorOrResponse(errorOrResponse, secretDataBaseResponse, isSubkeys = false) {
// if it's a legitimate error - throw it!
if (errorOrResponse instanceof ControlGroupError) {
throw errorOrResponse;
}
const errorCode = errorOrResponse.httpStatus;
if (errorCode === 403) {
return {
data: {
...secretDataBaseResponse,
fail_read_error_code: errorCode,
},
};
}
// in the case of a deleted/destroyed secret the API returns a 404 because { data: null }
// however, there could be a metadata block with important information like deletion_time
// handleResponse below checks 404 status codes for metadata and updates the code to 200 if it exists.
// we still end up in the good ol' catch() block, but instead of a 404 adapter error we've "caught"
// the metadata that sneakily tried to hide from us
if (errorOrResponse.data) {
// subkeys response doesn't correspond to a model, no need to include base response
if (isSubkeys) return errorOrResponse.data;
return {
...errorOrResponse,
data: {
...secretDataBaseResponse,
...errorOrResponse.data, // includes the { metadata } key we want
},
};
}
// If we get here, it's probably a 404 because it doesn't exist
throw errorOrResponse;
}
}

View file

@ -1,84 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationAdapter from '../application';
import { kvMetadataPath } from 'vault/utils/kv-path';
export default class KvMetadataAdapter extends ApplicationAdapter {
namespace = 'v1';
_url(fullPath) {
return `${this.buildURL()}/${fullPath}`;
}
createRecord(store, type, snapshot) {
const { backend, path } = snapshot.record;
const id = kvMetadataPath(backend, path);
const url = this._url(id);
const data = this.serialize(snapshot);
return this.ajax(url, 'POST', { data }).then(() => {
return {
id,
data,
};
});
}
updateRecord(store, type, snapshot) {
const { backend, path } = snapshot.record;
const id = kvMetadataPath(backend, path);
const url = this._url(id);
const data = this.serialize(snapshot);
return this.ajax(url, 'POST', { data }).then(() => {
return {
id,
data,
};
});
}
query(store, type, query) {
const { backend, pathToSecret } = query;
// example of pathToSecret: beep/boop/
return this.ajax(this._url(kvMetadataPath(backend, pathToSecret)), 'GET', {
data: { list: true },
}).then((resp) => {
resp.backend = backend;
resp.path = pathToSecret;
return resp;
});
}
queryRecord(store, type, query) {
const { backend, path } = query;
// ID is the full path for the metadata
const id = kvMetadataPath(backend, path);
return this.ajax(this._url(id), 'GET').then((resp) => {
return {
id,
...resp,
data: {
backend,
path,
...resp.data,
},
};
});
}
// This method is called when deleting from the list or metadata details view.
// Otherwise, delete happens in kv/data adapter
deleteRecord(store, type, snapshot) {
const { backend, path, fullSecretPath } = snapshot.record;
// fullSecretPath is used when deleting from the LIST view and is defined via the serializer
// path is used when deleting from the metadata details view.
return this.ajax(this._url(kvMetadataPath(backend, fullSecretPath || path)), 'DELETE');
}
// custom method used if users do not have "read" permissions to fetch record
deleteMetadata(backend, path) {
return this.ajax(this._url(kvMetadataPath(backend, path)), 'DELETE');
}
}

View file

@ -105,7 +105,6 @@ export default class App extends Application {
'namespace',
{ 'app-router': 'router' },
'secret-mount-path',
'store',
'pagination',
'version',
],

View file

@ -39,16 +39,16 @@
@ariaLabel="Action"
/>
{{#if this.searchSelectParams.model}}
{{#if this.selectedAction}}
{{! use special input to allow searching for KVv2 secrets inside a directory }}
{{#if (eq this.selectedEngine.type "kv")}}
{{#if this.searchSelectParams.isKV}}
<KvSuggestionInput
@label="Secret Path"
@value={{this.paramValue}}
@mountPath={{this.selectedEngine.id}}
@onChange={{fn (mut this.paramValue)}}
/>
{{else}}
{{else if this.searchSelectParams.model}}
<h3 class="title is-6" data-test-card-subtitle="param">{{this.searchSelectParams.title}}</h3>
<SearchSelect

View file

@ -45,14 +45,9 @@ export default class DashboardQuickActionsCard extends Component {
switch (this.selectedAction) {
case 'Find KV secrets':
return {
title: 'Secret path',
subText: 'Path of the secret you want to read.',
isKV: true,
buttonText: 'Read secrets',
model: 'kv/metadata',
route: 'vault.cluster.secrets.backend.kv.secret.index',
nameKey: 'path',
queryObject: { pathToSecret: '', backend: this.selectedEngine.id },
objectKeys: ['path', 'id'],
};
case 'Generate credentials for database':
return {

View file

@ -1,36 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Model, { attr } from '@ember-data/model';
import lazyCapabilities, { apiPath } from 'vault/macros/lazy-capabilities';
import { withFormFields } from 'vault/decorators/model-form-fields';
import { duration } from 'core/helpers/format-duration';
// This model is used only for display only - configuration happens via secret-engine model when an engine is mounted
@withFormFields(['casRequired', 'deleteVersionAfter', 'maxVersions'])
export default class KvConfigModel extends Model {
@attr backend;
@attr('number', { label: 'Maximum number of versions' }) maxVersions;
@attr('boolean', { label: 'Require check and set' }) casRequired;
@attr({ label: 'Automate secret deletion' }) deleteVersionAfter;
@lazyCapabilities(apiPath`${'backend'}/config`, 'backend') configPath;
get canRead() {
return this.configPath.get('canRead') !== false;
}
// used in template to render using this model instead of secret-engine (where these attrs also exist)
get displayFields() {
return ['casRequired', 'deleteVersionAfter', 'maxVersions'];
}
get displayDeleteTtl() {
if (this.deleteVersionAfter === '0s') return 'Never delete';
return duration([this.deleteVersionAfter]);
}
}

View file

@ -1,130 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
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/helpers/is-deleted';
import { WHITESPACE_WARNING } from 'vault/utils/forms/validators';
/* sample response
{
"data": {
"data": {
"foo": "bar"
},
"metadata": {
"created_time": "2018-03-22T02:24:06.945319214Z",
"custom_metadata": {
"owner": "jdoe",
"mission_critical": "false"
},
"deletion_time": "",
"destroyed": false,
"version": 2
}
}
}
*/
const 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: (model) =>
model.secretData !== undefined && typeof model.secretData !== 'object' ? false : true,
message: 'Vault expects data to be formatted as an JSON object.',
},
],
};
@withModelValidations(validations)
@withFormFields()
export default class KvSecretDataModel extends Model {
@attr('string') backend; // dynamic path of secret -- set on response from value passed to queryRecord.
@attr('string', {
label: 'Path for this secret',
subText: 'Names with forward slashes define hierarchical path structures.',
})
path;
@attr('object') secretData; // { key: value } data of the secret version
// Params returned on the GET response.
@attr('string') createdTime;
@attr('object') customMetadata;
@attr('string') deletionTime;
@attr('boolean') destroyed;
@attr('number') version;
// Set in adapter if read failed
@attr('number') failReadErrorCode;
// if creating a new version this value is set in the edit route's
// model hook from metadata or secret version, pending permissions
// if the value is not a number, don't send options.cas on payload
@attr('number')
casVersion;
get state() {
if (this.destroyed) return 'destroyed';
if (this.isSecretDeleted) return 'deleted';
if (this.createdTime) return 'created';
return '';
}
// cannot use isDeleted as model property name because of an ember property conflict
get isSecretDeleted() {
return isDeleted(this.deletionTime);
}
// Permissions
@lazyCapabilities(apiPath`${'backend'}/data/${'path'}`, 'backend', 'path') dataPath;
@lazyCapabilities(apiPath`${'backend'}/metadata/${'path'}`, 'backend', 'path') metadataPath;
@lazyCapabilities(apiPath`${'backend'}/delete/${'path'}`, 'backend', 'path') deletePath;
@lazyCapabilities(apiPath`${'backend'}/destroy/${'path'}`, 'backend', 'path') destroyPath;
@lazyCapabilities(apiPath`${'backend'}/undelete/${'path'}`, 'backend', 'path') undeletePath;
@lazyCapabilities(apiPath`${'backend'}/subkeys/${'path'}`, 'backend', 'path') subkeysPath;
get canDeleteLatestVersion() {
return this.dataPath.get('canDelete') !== false;
}
get canDeleteVersion() {
return this.deletePath.get('canUpdate') !== false;
}
get canUndelete() {
return this.undeletePath.get('canUpdate') !== false;
}
get canDestroyVersion() {
return this.destroyPath.get('canUpdate') !== false;
}
get canEditData() {
return this.dataPath.get('canUpdate') !== false;
}
get canReadData() {
return this.dataPath.get('canRead') !== false;
}
get canReadMetadata() {
return this.metadataPath.get('canRead') !== false;
}
get canUpdateMetadata() {
return this.metadataPath.get('canUpdate') !== false;
}
get canListMetadata() {
return this.metadataPath.get('canList') !== false;
}
get canDeleteMetadata() {
return this.metadataPath.get('canDelete') !== false;
}
get canReadSubkeys() {
return this.subkeysPath.get('canRead') !== false;
}
}

View file

@ -1,118 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
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 { keyIsFolder } from 'core/utils/key-utils';
import { isDeleted } from 'kv/helpers/is-deleted';
const validations = {
maxVersions: [
{ type: 'number', message: 'Maximum versions must be a number.' },
{ type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' },
],
};
const formFieldProps = ['customMetadata', 'maxVersions', 'casRequired', 'deleteVersionAfter'];
@withModelValidations(validations)
@withFormFields(formFieldProps)
export default class KvSecretMetadataModel extends Model {
@attr('string') backend;
@attr('string') path;
@attr('string') fullSecretPath;
@attr('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.',
})
maxVersions;
@attr('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.`,
})
casRequired;
@attr('string', {
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:',
})
deleteVersionAfter;
// the API returns custom_metadata: null if empty but because the attr is an 'object' ember data transforms it to an empty object.
// this is important because we rely on the empty object as a truthy value in template conditionals
@attr('object', {
editType: 'kv',
isSectionHeader: true,
subText: 'An optional set of informational key-value pairs that will be stored with all secret versions.',
})
customMetadata;
// Additional Params only returned on the GET response.
@attr('string') createdTime;
@attr('number') currentVersion;
@attr('number') oldestVersion;
@attr('string') updatedTime;
@attr('object') versions;
// used for KV list and list-directory view
get pathIsDirectory() {
// ex: beep/
return keyIsFolder(this.path);
}
// turns version object into an array for version dropdown menu
get sortedVersions() {
const array = [];
for (const key in this.versions) {
this.versions[key].isSecretDeleted = isDeleted(this.versions[key].deletion_time);
array.push({ version: key, ...this.versions[key] });
}
// version keys are in order created with 1 being the oldest, we want newest first
return array.reverse();
}
// helps in long logic statements for state of a currentVersion
get currentSecret() {
if (!this.versions || !this.currentVersion) return false;
const data = this.versions[this.currentVersion];
const state = data.destroyed ? 'destroyed' : isDeleted(data.deletion_time) ? 'deleted' : 'created';
return {
state,
isDeactivated: state !== 'created',
deletionTime: data.deletion_time,
};
}
get permissionsPath() {
return this.fullSecretPath || this.path;
}
// permissions needed for the list view where kv/data has not yet been called. Allows us to conditionally show action items in the LinkedBlock popups.
@lazyCapabilities(apiPath`${'backend'}/data/${'permissionsPath'}`, 'backend', 'permissionsPath') dataPath;
@lazyCapabilities(apiPath`${'backend'}/metadata/${'permissionsPath'}`, 'backend', 'permissionsPath')
metadataPath;
get canDeleteMetadata() {
return this.metadataPath.get('canDelete') !== false;
}
get canReadMetadata() {
return this.metadataPath.get('canRead') !== false;
}
get canUpdateMetadata() {
return this.metadataPath.get('canUpdate') !== false;
}
get canCreateVersionData() {
return this.dataPath.get('canUpdate') !== false;
}
}

View file

@ -1,45 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import ApplicationSerializer from '../application';
export default class KvDataSerializer extends ApplicationSerializer {
serialize(snapshot) {
const { secretData, casVersion } = snapshot.record;
if (typeof casVersion === 'number') {
/* if this is a number it is set by one of the following:
A) user is creating initial version of a secret
-> 0 : default value set in route
B) user is creating a new version of a secret:
-> metadata.current_version : has metadata read permissions (data permissions are irrelevant)
-> secret.version : has data read permissions. without metadata read access a user is unable to navigate,
to older secret versions so we assume creation is from the latest version */
return { data: secretData, options: { cas: casVersion } };
}
// a non-number value means no read permission for both data and metadata
return { data: secretData };
}
normalizeKvData(payload) {
const { data, metadata } = payload.data;
return {
...payload,
data: {
...payload.data,
// Rename to secret_data so it doesn't get removed by normalizer
secret_data: data,
...metadata,
},
};
}
normalizeResponse(store, primaryModelClass, payload, id, requestType) {
if (requestType === 'queryRecord') {
const transformed = this.normalizeKvData(payload);
return super.normalizeResponse(store, primaryModelClass, transformed, id, requestType);
}
return super.normalizeResponse(store, primaryModelClass, payload, id, requestType);
}
}

View file

@ -1,39 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { assert } from '@ember/debug';
import ApplicationSerializer from '../application';
import { kvMetadataPath } from 'vault/utils/kv-path';
export default class KvMetadataSerializer extends ApplicationSerializer {
attrs = {
backend: { serialize: false },
path: { serialize: false },
oldestVersion: { serialize: false },
createdTime: { serialize: false },
updatedTime: { serialize: false },
currentVersion: { serialize: false },
versions: { serialize: false },
};
normalizeItems(payload) {
if (payload.data.keys) {
assert('payload.backend must be provided on kv/metadata list response', !!payload.backend);
return payload.data.keys.map((secret) => {
// If there is no payload.path then we're either on a "top level" secret or the first level directory of a nested secret.
// We set the path to the current secret or pathToSecret. e.g. my-secret or beep/boop/
// We add a param called full_secret_path to the model which we use to navigate to the nested secret. e.g. beep/boop/bop.
const fullSecretPath = payload.path ? payload.path + secret : secret;
return {
id: kvMetadataPath(payload.backend, fullSecretPath),
path: secret,
backend: payload.backend,
full_secret_path: fullSecretPath,
};
});
}
return super.normalizeItems(payload);
}
}

View file

@ -29,6 +29,6 @@
data-test-kv-suggestion-select
as |secret|
>
{{secret.path}}
{{secret}}
</PowerSelect>
</div>

View file

@ -10,9 +10,9 @@ import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
import { run } from '@ember/runloop';
import { keyIsFolder, parentKeyForKey, keyWithoutParentKey } from 'core/utils/key-utils';
import { KvV2ListListEnum } from '@hashicorp/vault-client-typescript';
import type Store from '@ember-data/store';
import type KvSecretMetadataModel from 'vault/models/kv/metadata';
import type ApiService from 'vault/services/api';
/**
* @module KvSuggestionInput
@ -49,12 +49,13 @@ interface PowerSelectAPI {
}
export default class KvSuggestionInputComponent extends Component<Args> {
@service declare readonly store: Store;
@service declare readonly api: ApiService;
@tracked secrets: KvSecretMetadataModel[] = [];
@tracked secrets: string[] = [];
powerSelectAPI: PowerSelectAPI | undefined;
_cachedSecrets: KvSecretMetadataModel[] = []; // cache the response for filtering purposes
_cachedSecrets: string[] = []; // cache the response for filtering purposes
inputId = `suggestion-input-${guidFor(this)}`; // add unique segment to id in case multiple instances of component are used on the same page
pathToSecret = ''; // keeps track of the full path to the secret as user builds it out
constructor(owner: unknown, args: Args) {
super(owner, args);
@ -63,18 +64,19 @@ export default class KvSuggestionInputComponent extends Component<Args> {
}
}
async fetchSecrets(isDirectory: boolean) {
const { mountPath } = this.args;
get isDirectory() {
return keyIsFolder(this.args.value);
}
async fetchSecrets() {
try {
const { mountPath } = this.args;
const backend = keyIsFolder(mountPath) ? mountPath.slice(0, -1) : mountPath;
const parentDirectory = parentKeyForKey(this.args.value);
const pathToSecret = isDirectory ? this.args.value : parentDirectory;
const kvModels = (await this.store.query('kv/metadata', {
backend,
pathToSecret,
})) as unknown;
this.pathToSecret = this.isDirectory ? this.args.value : parentDirectory;
const { keys } = await this.api.secrets.kvV2List(this.pathToSecret, backend, KvV2ListListEnum.TRUE);
// this will be used to filter the existing result set when the search term changes within the same path
this._cachedSecrets = kvModels as KvSecretMetadataModel[];
this._cachedSecrets = keys || [];
return this._cachedSecrets;
} catch (error) {
console.log(error); // eslint-disable-line
@ -82,33 +84,32 @@ export default class KvSuggestionInputComponent extends Component<Args> {
}
}
filterSecrets(kvModels: KvSecretMetadataModel[] | undefined = [], isDirectory: boolean) {
filterSecrets(secrets: string[] | undefined = []) {
const { value } = this.args;
const secretName = keyWithoutParentKey(value) || '';
return kvModels.filter((model) => {
if (!value || isDirectory) {
return secrets.filter((path) => {
if (!value || this.isDirectory) {
return true;
}
if (value === model.fullSecretPath) {
if (secretName === path) {
// don't show suggestion if it's currently selected
return false;
}
return model.path.toLowerCase().includes(secretName.toLowerCase());
return path.toLowerCase().includes(secretName.toLowerCase());
});
}
@action
async updateSuggestions() {
const isFirstUpdate = !this._cachedSecrets.length;
const isDirectory = keyIsFolder(this.args.value);
if (!this.args.mountPath) {
this.secrets = [];
} else if (this.args.value && !isDirectory && this.secrets) {
} else if (this.args.value && !this.isDirectory && this.secrets) {
// if we don't need to fetch from a new path, filter the previous result set with the updated search term
this.secrets = this.filterSecrets(this._cachedSecrets, isDirectory);
this.secrets = this.filterSecrets(this._cachedSecrets);
} else {
const kvModels = await this.fetchSecrets(isDirectory);
this.secrets = this.filterSecrets(kvModels, isDirectory);
const secrets = await this.fetchSecrets();
this.secrets = this.filterSecrets(secrets);
}
// don't do anything on first update -- allow dropdown to open on input click
if (!isFirstUpdate) {
@ -131,11 +132,11 @@ export default class KvSuggestionInputComponent extends Component<Args> {
}
@action
onSuggestionSelect(secret: KvSecretMetadataModel) {
onSuggestionSelect(secret: string) {
// user may partially type a value to filter result set and then select a suggestion
// in this case the partially typed value must be replaced with suggestion value
// the fullSecretPath contains the previous selections or typed path segments
this.args.onChange(secret.fullSecretPath);
// pathToSecret contains the previous selections or typed path segments
this.args.onChange(`${this.pathToSecret}${secret}`);
this.updateSuggestions();
// refocus the input after selection
run(() => document.getElementById(this.inputId)?.focus());

View file

@ -13,9 +13,9 @@ import { pathIsDirectory } from 'kv/utils/kv-breadcrumbs';
/**
* @module List
* ListPage component is a component to show a list of kv/metadata secrets.
* ListPage component is a component to show a list of secrets.
*
* @param {array} secrets - An array of models generated form kv/metadata query.
* @param {array} secrets - An array of secrets
* @param {string} backend - The name of the kv secret engine.
* @param {string} pathToSecret - The directory name that the secret belongs to ex: beep/boop/
* @param {string} filterValue - The concatenation of the pathToSecret and pageFilter ex: beep/boop/my-

View file

@ -56,51 +56,53 @@
@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}}
{{else}}
{{! 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 @capabilities.canUpdateMetadata}}
<Hds::Link::Standalone
@icon="plus"
@text="Add metadata"
@route="secret.metadata.edit"
@models={{array @backend @path}}
data-test-add-custom-metadata
/>
{{/if}}
</EmptyState>
{{#if this.canRequestData}}
<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 @capabilities.canUpdateMetadata}}
<Hds::Link::Standalone
@icon="plus"
@text="Add metadata"
@route="secret.metadata.edit"
@models={{array @backend @path}}
data-test-add-custom-metadata
/>
{{/if}}
</EmptyState>
{{/if}}
{{/if}}
{{/each-in}}
</div>

View file

@ -8,9 +8,10 @@ import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { service } from '@ember/service';
import errorMessage from 'vault/utils/error-message';
import { waitFor } from '@ember/test-waiters';
/**
* @module KvSecretMetadataDetails renders the details view for kv/metadata and button to delete (which deletes the whole secret) or edit metadata.
* @module KvSecretMetadataDetails renders the details view for kv metadata and button to delete (which deletes the whole secret) or edit metadata.
* <Page::Secret::Metadata::Details
* @backend={{this.model.backend}}
* @breadcrumbs={{this.breadcrumbs}}
@ -63,6 +64,7 @@ export default class KvSecretMetadataDetails extends Component {
}
@action
@waitFor
async requestData() {
const { backend, path } = this.args;
try {

View file

@ -25,7 +25,6 @@ export default class KvEngine extends Engine {
'namespace',
'app-router',
'secret-mount-path',
'store',
'pagination',
'version',
],

View file

@ -11,7 +11,7 @@ import { setupApplicationTest } from 'vault/tests/helpers';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { deleteEngineCmd, mountEngineCmd, runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands';
import { personas } from 'vault/tests/helpers/kv/policy-generator';
import { clearRecords, writeSecret, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands';
import { writeSecret, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { grantAccessForWrite, setupControlGroup } from 'vault/tests/helpers/control-groups';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
@ -40,9 +40,7 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
module('admin persona', function (hooks) {
hooks.beforeEach(async function () {
const token = await runCmd(tokenWithPolicyCmd(`admin-${this.backend}`, personas.admin(this.backend)));
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('cancel on create clears model (a)', async function (assert) {
const backend = this.backend;
@ -346,9 +344,7 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
const token = await runCmd(
tokenWithPolicyCmd(`data-reader-${this.backend}`, personas.dataReader(this.backend))
);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('cancel on create clears model (dr)', async function (assert) {
const backend = this.backend;
@ -492,9 +488,7 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
const token = await runCmd(
tokenWithPolicyCmd(`data-list-reader-${this.backend}`, personas.dataListReader(this.backend))
);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('cancel on create clears model (dlr)', async function (assert) {
const backend = this.backend;
@ -641,9 +635,7 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
const token = await runCmd(
tokenWithPolicyCmd(`data-list-reader-${this.backend}`, personas.metadataMaintainer(this.backend))
);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('cancel on create clears model (mm)', async function (assert) {
const backend = this.backend;
@ -838,9 +830,7 @@ module('Acceptance | kv-v2 workflow | secret and version create', function (hook
const token = await runCmd(
tokenWithPolicyCmd(`secret-creator-${this.backend}`, personas.secretCreator(this.backend))
);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('cancel on create clears model (sc)', async function (assert) {
const backend = this.backend;
@ -1090,9 +1080,7 @@ path "${this.backend}/metadata/*" {
backend: this.backend,
});
this.userToken = userToken;
await login(userToken);
clearRecords(this.store);
return;
return login(userToken);
});
test('create & update root secret with default metadata (cg)', async function (assert) {
const backend = this.backend;

View file

@ -9,7 +9,7 @@ import { setupApplicationTest } from 'vault/tests/helpers';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { deleteEngineCmd, mountEngineCmd, runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands';
import { personas } from 'vault/tests/helpers/kv/policy-generator';
import { clearRecords, deleteLatestCmd, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands';
import { deleteLatestCmd, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands';
import { setupControlGroup } from 'vault/tests/helpers/control-groups';
import { click, currentRouteName, currentURL, waitUntil, visit } from '@ember/test-helpers';
import { PAGE } from 'vault/tests/helpers/kv/kv-selectors';
@ -63,9 +63,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
// patch actions exist before/after deletion can run on both CE and ent repos
this.version = this.owner.lookup('service:version').type = 'enterprise';
const token = await runCmd(makeToken('admin', this.backend, personas.admin));
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('can delete and undelete the latest secret version (a)', async function (assert) {
assert.expect(21);
@ -194,9 +192,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
await runCmd(deleteLatestCmd(this.backend, 'nuke'));
// login as data-reader persona
const token = await runCmd(makeToken('data-reader', this.backend, personas.dataReader));
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('cannot delete and undelete the latest secret version (dr)', async function (assert) {
assert.expect(9);
@ -246,9 +242,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
await runCmd(deleteLatestCmd(this.backend, 'nuke'));
// login as data-list-reader persona
const token = await runCmd(makeToken('data-list-reader', this.backend, personas.dataListReader));
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('can delete and cannot undelete the latest secret version (dlr)', async function (assert) {
assert.expect(12);
@ -310,9 +304,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
await runCmd(deleteLatestCmd(this.backend, 'nuke'));
// login as metadata-maintainer persona
const token = await runCmd(makeToken('metadata-maintainer', this.backend, personas.metadataMaintainer));
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('cannot delete but can undelete the latest secret version (mm)', async function (assert) {
assert.expect(18);
@ -409,9 +401,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
const token = await runCmd(
makeToken('secret-nested-creator', this.backend, personas.secretNestedCreator)
);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('can delete all secret versions from the nested list view (snc)', async function (assert) {
assert.expect(1);
@ -434,9 +424,7 @@ module('Acceptance | kv-v2 workflow | delete, undelete, destroy', function (hook
module('secret-creator persona', function (hooks) {
hooks.beforeEach(async function () {
const token = await runCmd(makeToken('secret-creator', this.backend, personas.secretCreator));
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('cannot delete and undelete the latest secret version (sc)', async function (assert) {
assert.expect(9);
@ -520,9 +508,7 @@ path "sys/control-group/request" {
const { userToken } = await setupControlGroup({ userPolicy, backend: this.backend });
this.userToken = userToken;
await login(userToken);
clearRecords(this.store);
return;
return login(userToken);
});
// Copy test outline from admin persona
});

View file

@ -32,7 +32,7 @@ import {
metadataListPolicy,
metadataPolicy,
} from 'vault/tests/helpers/kv/policy-generator';
import { clearRecords, writeSecret, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands';
import { writeSecret, writeVersionedSecret } from 'vault/tests/helpers/kv/kv-run-commands';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { SECRET_ENGINE_SELECTORS as SES } from 'vault/tests/helpers/secret-engine/secret-engine-selectors';
@ -429,9 +429,7 @@ module('Acceptance | kv-v2 workflow | edge cases', function (hooks) {
),
createTokenCmd(`secret-patcher-${this.backend}`),
]);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('it patches a secret from the overview page', async function (assert) {
@ -560,9 +558,7 @@ module('Acceptance | Enterprise | kv-v2 workflow | edge cases', function (hooks)
hooks.beforeEach(async function () {
await loginNs(this.namespace);
// mount engine within namespace
await runCmd(mountEngineCmd('kv-v2', this.backend), false);
clearRecords(this.store);
return;
return runCmd(mountEngineCmd('kv-v2', this.backend), false);
});
hooks.afterEach(async function () {
// visit logout with namespace query param because we're transitioning from within an engine

View file

@ -29,7 +29,6 @@ import {
import { personas } from 'vault/tests/helpers/kv/policy-generator';
import {
addSecretMetadataCmd,
clearRecords,
writeSecret,
writeVersionedSecret,
} from 'vault/tests/helpers/kv/kv-run-commands';
@ -242,9 +241,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
const token = await runCmd(
tokenWithPolicyCmd('admin', personas.admin(this.backend) + personas.admin(this.emptyBackend))
);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('empty backend - breadcrumbs, title, tabs, emptyState (a)', async function (assert) {
assert.expect(23);
@ -599,9 +596,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
),
createTokenCmd(`data-reader-${this.backend}`),
]);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('empty backend - breadcrumbs, title, tabs, emptyState (dr)', async function (assert) {
assert.expect(16);
@ -792,9 +787,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
createTokenCmd(`data-reader-list-${this.backend}`),
]);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('empty backend - breadcrumbs, title, tabs, emptyState (dlr)', async function (assert) {
assert.expect(15);
@ -985,9 +978,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
),
createTokenCmd(`metadata-maintainer-${this.backend}`),
]);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('empty backend - breadcrumbs, title, tabs, emptyState (mm)', async function (assert) {
assert.expect(15);
@ -1208,9 +1199,7 @@ module('Acceptance | kv-v2 workflow | navigation', function (hooks) {
),
createTokenCmd(`secret-creator-${this.backend}`),
]);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('empty backend - breadcrumbs, title, tabs, emptyState (sc)', async function (assert) {
assert.expect(15);
@ -1444,9 +1433,7 @@ path "${this.backend}/subkeys/*" {
`;
const { userToken } = await setupControlGroup({ userPolicy, backend: this.backend });
this.userToken = userToken;
await login(userToken);
clearRecords(this.store);
return;
return login(userToken);
});
test('can access nested secret (cg)', async function (assert) {
assert.expect(44);
@ -1695,9 +1682,7 @@ path "${this.backend}/subkeys/*" {
),
createTokenCmd(`secret-patcher-${this.backend}`),
]);
await login(token);
clearRecords(this.store);
return;
return login(token);
});
test('it navigates to patch a secret from overview', async function (assert) {

View file

@ -10,7 +10,6 @@ import { login } from 'vault/tests/helpers/auth/auth-helpers';
import { deleteEngineCmd, mountEngineCmd, runCmd, tokenWithPolicyCmd } from 'vault/tests/helpers/commands';
import { personas } from 'vault/tests/helpers/kv/policy-generator';
import {
clearRecords,
deleteVersionCmd,
destroyVersionCmd,
writeVersionedSecret,
@ -56,8 +55,7 @@ module('Acceptance | kv-v2 workflow | version history, paths', function (hooks)
module('admin persona', function (hooks) {
hooks.beforeEach(async function () {
const token = await runCmd(makeToken('admin', this.backend, personas.admin));
await login(token);
clearRecords(this.store);
return login(token);
});
test('can navigate to the version history page (a)', async function (assert) {
await this.navToSecret();
@ -105,8 +103,7 @@ module('Acceptance | kv-v2 workflow | version history, paths', function (hooks)
module('data-reader persona', function (hooks) {
hooks.beforeEach(async function () {
const token = await runCmd(makeToken('data-reader', this.backend, personas.dataReader));
await login(token);
clearRecords(this.store);
return login(token);
});
test('cannot navigate to the version history page (dr)', async function (assert) {
await this.navToSecret();
@ -132,8 +129,7 @@ module('Acceptance | kv-v2 workflow | version history, paths', function (hooks)
module('data-list-reader persona', function (hooks) {
hooks.beforeEach(async function () {
const token = await runCmd(makeToken('data-list-reader', this.backend, personas.dataListReader));
await login(token);
clearRecords(this.store);
return login(token);
});
test('cannot navigate to the version history page (dlr)', async function (assert) {
await this.navToSecret();
@ -159,8 +155,7 @@ module('Acceptance | kv-v2 workflow | version history, paths', function (hooks)
module('metadata-maintainer persona', function (hooks) {
hooks.beforeEach(async function () {
const token = await runCmd(makeToken('metadata-maintainer', this.backend, personas.metadataMaintainer));
await login(token);
clearRecords(this.store);
return login(token);
});
test('can navigate to the version history page (mm)', async function (assert) {
await this.navToSecret();
@ -208,8 +203,7 @@ module('Acceptance | kv-v2 workflow | version history, paths', function (hooks)
module('secret-creator persona', function (hooks) {
hooks.beforeEach(async function () {
const token = await runCmd(makeToken('secret-creator', this.backend, personas.secretCreator));
await login(token);
clearRecords(this.store);
return login(token);
});
test('cannot navigate to the version history page (sc)', async function (assert) {
await this.navToSecret();
@ -255,8 +249,7 @@ path "${this.backend}/*" {
`;
const { userToken } = await setupControlGroup({ userPolicy, backend: this.backend });
this.userToken = userToken;
await login(userToken);
clearRecords(this.store);
return login(userToken);
});
test('can navigate to the version history page (cg)', async function (assert) {
await this.navToSecret();

View file

@ -7,9 +7,6 @@
import { click, fillIn, visit, settled } from '@ember/test-helpers';
import { FORM } from './kv-selectors';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { allowAllCapabilitiesStub } from 'vault/tests/helpers/stubs';
import { assert } from '@ember/debug';
import { kvMetadataPath } from 'vault/utils/kv-path';
// CUSTOM ACTIONS RELEVANT TO KV-V2
@ -61,38 +58,3 @@ export const addSecretMetadataCmd = (backend, secret, options = { max_versions:
}, '');
return `write ${backend}/metadata/${secret} ${stringOptions}`;
};
// Clears kv-related data and capabilities so that admin
// capabilities from setup don't rollover
export function clearRecords(store) {
store.unloadAll('kv/data');
store.unloadAll('kv/metatata');
store.unloadAll('capabilities');
}
// TEST SETUP HELPERS
// sets basic path, backend, and metadata
export const baseSetup = (context) => {
assert(
`'baseSetup()' requires mirage: import { setupMirage } from 'ember-cli-mirage/test-support'`,
context.server
);
context.store = context.owner.lookup('service:store');
context.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
context.backend = 'kv-engine';
context.path = 'my-secret';
context.metadata = metadataModel(context, { withCustom: false });
};
export const metadataModel = (context, { withCustom = false }) => {
const metadata = withCustom
? context.server.create('kv-metadatum', 'withCustomMetadata')
: context.server.create('kv-metadatum');
metadata.id = kvMetadataPath(context.backend, context.path);
context.store.pushPayload('kv/metadata', {
modelName: 'kv/metadata',
...metadata,
});
return context.store.peekRecord('kv/metadata', metadata.id);
};

View file

@ -5,10 +5,10 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { render, click, fillIn, settled, typeIn } from '@ember/test-helpers';
import { render, click, fillIn, typeIn } from '@ember/test-helpers';
import hbs from 'htmlbars-inline-precompile';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
const {
searchSelect: { option, options },
@ -17,16 +17,16 @@ const {
module('Integration | Component | kv-suggestion-input', function (hooks) {
setupRenderingTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.label = 'Input something';
this.mountPath = 'secret/';
this.keys = ['foo/', 'my-secret'];
const response = () => ({ data: { keys: this.keys } });
this.server.get('/:mount/metadata', response);
this.server.get('/:mount/metadata/*', response);
return render(hbs`
this.apiStub = sinon
.stub(this.owner.lookup('service:api').secrets, 'kvV2List')
.resolves({ keys: ['foo/', 'my-secret'] });
this.renderComponent = () =>
render(hbs`
<KvSuggestionInput
@label={{this.label}}
@subText="Suggestions will display"
@ -38,19 +38,23 @@ module('Integration | Component | kv-suggestion-input', function (hooks) {
});
test('it should render label and sub text', async function (assert) {
await this.renderComponent();
assert.dom('label').hasText('Input something', 'Label renders');
assert.dom('[data-test-label-subtext]').hasText('Suggestions will display', 'Label subtext renders');
this.set('label', null);
await settled();
this.label = null;
await this.renderComponent();
assert.dom('label').doesNotExist('Label is hidden when arg is not provided');
});
test('it should disable input when mountPath is not provided', async function (assert) {
this.set('mountPath', null);
this.mountPath = null;
await this.renderComponent();
assert.dom(input).isDisabled('Input is disabled when mountPath is not provided');
});
test('it should fetch suggestions for initial mount path', async function (assert) {
await this.renderComponent();
assert.dom(options).doesNotExist('Suggestions are hidden initially');
await click(input);
['foo/', 'my-secret'].forEach((secret, index) => {
@ -59,14 +63,15 @@ module('Integration | Component | kv-suggestion-input', function (hooks) {
});
test('it should fetch secrets and update suggestions on mountPath change', async function (assert) {
this.keys = ['test1'];
this.set('mountPath', 'foo/');
await settled();
this.apiStub.resolves({ keys: ['test1'] });
this.mountPath = 'foo/';
await this.renderComponent();
await click(input);
assert.dom(option()).hasText('test1', 'Suggestions are fetched and render on mountPath change');
});
test('it should filter current result set', async function (assert) {
await this.renderComponent();
await click(input);
await typeIn(input, 'sec');
assert.dom(options).exists({ count: 1 }, 'Correct number of options render based on input value');
@ -74,12 +79,13 @@ module('Integration | Component | kv-suggestion-input', function (hooks) {
});
test('it should replace filter terms with full path to secret', async function (assert) {
await this.renderComponent();
await fillIn(input, 'sec');
await click(option());
assert.dom(input).hasValue('my-secret', 'Partial term replaced with selected secret');
await fillIn(input, '');
this.keys = ['secret-nested', 'bar', 'baz'];
this.apiStub.resolves({ keys: ['secret-nested', 'bar', 'baz'] });
await click(option());
await fillIn(input, 'nest');
await click(option());
@ -89,23 +95,25 @@ module('Integration | Component | kv-suggestion-input', function (hooks) {
});
test('it should fetch secrets at nested paths', async function (assert) {
this.keys = ['bar/'];
await this.renderComponent();
this.apiStub.resolves({ keys: ['bar/'] });
await click(input);
await click(option());
assert.dom(input).hasValue('foo/', 'Input value updates on select');
assert.dom(option()).hasText('bar/', 'Suggestions are fetched at new path');
this.keys = ['baz/'];
this.apiStub.resolves({ keys: ['baz/'] });
await click(option());
assert.dom(input).hasValue('foo/bar/', 'Input value updates on select');
assert.dom(option()).hasText('baz/', 'Suggestions are fetched at new path');
this.keys = ['nested-secret'];
this.apiStub.resolves({ keys: ['nested-secret'] });
await typeIn(input, 'baz/');
assert.dom(option()).hasText('nested-secret', 'Suggestions are fetched at new path');
});
test('it should only render dropdown when suggestions exist', async function (assert) {
await this.renderComponent();
await click(input);
assert.dom(options).exists({ count: 2 }, 'Suggestions render');
@ -115,7 +123,7 @@ module('Integration | Component | kv-suggestion-input', function (hooks) {
await fillIn(input, '');
assert.dom(options).exists({ count: 2 }, 'Suggestions render');
this.keys = [];
this.apiStub.resolves({ keys: [] });
await click(option());
assert.dom(options).doesNotExist('Drop down is hidden when there are no suggestions');
});

View file

@ -6,46 +6,54 @@
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 { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';
import { kvDataPath } from 'vault/utils/kv-path';
module('Integration | Component | kv | kv-page-header', function (hooks) {
setupRenderingTest(hooks);
setupEngine(hooks, 'kv');
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.backend = 'kv-engine';
this.path = 'my-secret';
this.version = 2;
this.id = kvDataPath(this.backend, this.path, this.version);
this.payload = {
backend: this.backend,
path: this.path,
version: 2,
};
this.store.pushPayload('kv/data', {
modelName: 'kv/data',
id: this.id,
...this.payload,
});
this.model = this.store.peekRecord('kv/data', this.id);
this.breadcrumbs = [
{ label: 'Secrets', route: 'secrets', linkExternal: true },
{ label: this.model.backend, route: 'secrets' },
{ label: this.model.path, route: 'secrets.secret.details', model: this.model.path },
{ label: this.backend, route: 'secrets' },
{ label: this.path, route: 'secrets.secret.details', model: this.path },
{ label: 'Edit' },
];
this.renderComponent = () =>
render(
hbs`
<KvPageHeader
@breadcrumbs={{this.breadcrumbs}}
@pageTitle={{this.pageTitle}}
@mountName={{this.mountName}}
@secretPath={{this.secretPath}}
>
<:tabLinks>
<li><LinkTo @route="list" data-test-secrets-tab="Secrets">Secrets</LinkTo></li>
<li><LinkTo @route="configuration" data-test-secrets-tab="Configuration">Configuration</LinkTo></li>
</:tabLinks>
<:toolbarActions>
<ToolbarLink @route="secrets.create" @type="add">Create secret</ToolbarLink>
</:toolbarActions>
<:toolbarFilters>
<p>stuff here</p>
</:toolbarFilters>
</KvPageHeader>
`,
{ owner: this.engine }
);
});
test('it renders breadcrumbs', async function (assert) {
assert.expect(4);
await render(hbs`<KvPageHeader @breadcrumbs={{this.breadcrumbs}} @pageTitle="Create new version"/>`, {
owner: this.engine,
});
await this.renderComponent();
assert.dom('[data-test-breadcrumbs] li:nth-child(1) a').hasText('Secrets', 'Secrets breadcrumb renders');
assert.dom('[data-test-breadcrumbs] li:nth-child(2) a').hasText(this.backend, 'engine name renders');
assert.dom('[data-test-breadcrumbs] li:nth-child(3) a').hasText(this.path, 'secret path renders');
@ -56,17 +64,16 @@ module('Integration | Component | kv | kv-page-header', function (hooks) {
test('it renders a custom title for @pageTitle', async function (assert) {
assert.expect(2);
await render(hbs`<KvPageHeader @breadcrumbs={{this.breadcrumbs}} @pageTitle="Create new version"/>`, {
owner: this.engine,
});
this.pageTitle = 'Create new version';
await this.renderComponent();
assert.dom('[data-test-header-title]').hasText('Create new version', 'displays custom title.');
assert.dom('[data-test-header-title] svg').doesNotExist('Does not show icon if not at engine level.');
});
test('it renders a title and copy button for @secretPath', async function (assert) {
await render(hbs`<KvPageHeader @breadcrumbs={{this.breadcrumbs}} @secretPath="my/secret/path"/>`, {
owner: this.engine,
});
assert.expect(3);
this.secretPath = 'my/secret/path';
await this.renderComponent();
assert.dom('[data-test-header-title]').hasText('my/secret/path', 'displays path');
assert.dom('[data-test-header-title] button').exists('renders copy button for path');
assert.dom('[data-test-icon="clipboard-copy"]').exists('renders copy icon');
@ -74,9 +81,8 @@ module('Integration | Component | kv | kv-page-header', function (hooks) {
test('it renders a title, icon and tag if engine view', async function (assert) {
assert.expect(2);
await render(hbs`<KvPageHeader @breadcrumbs={{this.breadcrumbs}} @mountName={{this.backend}} />`, {
owner: this.engine,
});
this.mountName = this.backend;
await this.renderComponent();
assert
.dom('[data-test-header-title]')
.hasText(`${this.backend} version 2`, 'Mount path and version tag render for title.');
@ -87,18 +93,7 @@ module('Integration | Component | kv | kv-page-header', function (hooks) {
test('it renders tabs', async function (assert) {
assert.expect(2);
await render(
hbs`<KvPageHeader @breadcrumbs={{this.breadcrumbs}} @mountName="my-engine">
<:tabLinks>
<li><LinkTo @route="list" data-test-secrets-tab="Secrets">Secrets</LinkTo></li>
<li><LinkTo @route="configuration" data-test-secrets-tab="Configuration">Configuration</LinkTo></li>
</:tabLinks>
</KvPageHeader>
`,
{
owner: this.engine,
}
);
await this.renderComponent();
assert.dom('[data-test-secrets-tab="Secrets"]').hasText('Secrets', 'Secrets tab renders');
assert
.dom('[data-test-secrets-tab="Configuration"]')
@ -107,29 +102,13 @@ module('Integration | Component | kv | kv-page-header', function (hooks) {
test('it should yield block for toolbar actions', async function (assert) {
assert.expect(1);
await render(
hbs`<KvPageHeader @breadcrumbs={{this.breadcrumbs}} @mountName="my-engine">
<:toolbarActions>
<ToolbarLink @route="secrets.create" @type="add">Create secret</ToolbarLink>
</:toolbarActions>
</KvPageHeader>
`,
{ owner: this.engine }
),
assert.dom('.toolbar-actions').exists('Block is yielded for toolbar actions');
await this.renderComponent();
assert.dom('.toolbar-actions').exists('Block is yielded for toolbar actions');
});
test('it should yield block for toolbar filters', async function (assert) {
assert.expect(1);
await render(
hbs`<KvPageHeader @breadcrumbs={{this.breadcrumbs}} @mountName="my-engine">
<:toolbarFilters>
<p>stuff here</p>
</:toolbarFilters>
</KvPageHeader>
`,
{ owner: this.engine }
),
assert.dom('.toolbar-filters').exists('Block is yielded for toolbar filters');
await this.renderComponent();
assert.dom('.toolbar-filters').exists('Block is yielded for toolbar filters');
});
});

View file

@ -14,6 +14,7 @@ import { PAGE } from 'vault/tests/helpers/sync/sync-selectors';
import { selectChoose } from 'ember-power-select/test-support';
import { Response } from 'miragejs';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import sinon from 'sinon';
const { destinations, searchSelect, messageError, kvSuggestion } = PAGE;
const { mountSelect, mountInput, successMessage } = destinations.sync;
@ -25,15 +26,12 @@ module('Integration | Component | sync | Secrets::Page::Destinations::Destinatio
setupDataStubs(hooks);
hooks.beforeEach(async function () {
this.server.get('/sys/internal/ui/mounts', () => ({
data: { secret: { 'my-kv/': { type: 'kv', options: { version: '2' } } } },
}));
this.server.get('/my-kv/metadata', () => ({
data: { keys: ['my-path/', 'my-secret'] },
}));
this.server.get('/my-kv/metadata/my-path', () => ({
data: { keys: ['nested-secret'] },
}));
const api = this.owner.lookup('service:api');
this.mountsStub = sinon
.stub(api.sys, 'internalUiListEnabledVisibleMounts')
.resolves({ secret: { 'my-kv/': { type: 'kv', options: { version: '2' } } } });
this.secretsStub = sinon.stub(api.secrets, 'kvV2List').resolves({ keys: ['my-path/', 'my-secret'] });
await render(
hbs`<Secrets::Page::Destinations::Destination::Sync @destination={{this.destination}} @capabilities={{this.capabilities}} />`,
@ -62,6 +60,7 @@ module('Integration | Component | sync | Secrets::Page::Destinations::Destinatio
test('it should render secret suggestions for nested paths', async function (assert) {
await selectChoose(mountSelect, '.ember-power-select-option', 1);
this.secretsStub.resolves({ keys: ['nested-secret'] });
await click(kvSuggestion.input);
await click(searchSelect.option());
assert
@ -99,9 +98,7 @@ module('Integration | Component | sync | Secrets::Page::Destinations::Destinatio
test('it should allow manual mount path input if kv mounts are not returned', async function (assert) {
assert.expect(1);
this.server.get('/sys/internal/ui/mounts', () => ({
data: { secret: { 'cubbyhole/': { type: 'cubbyhole' } } },
}));
this.mountsStub.resolves({ secret: { 'cubbyhole/': { type: 'cubbyhole' } } });
const { type, name } = this.destination;
this.server.post(`/sys/sync/destinations/${type}/${name}/associations/set`, (schema, req) => {

View file

@ -1,469 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { kvDataPath } from 'vault/utils/kv-path';
import { encodePath } from 'vault/utils/path-encoding-helpers';
import { Response } from 'miragejs';
const EXAMPLE_KV_DATA_CREATE_RESPONSE = {
request_id: 'foobar',
data: {
created_time: '2023-06-21T16:18:31.479993Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 1,
},
};
const EXAMPLE_KV_DATA_GET_RESPONSE = {
request_id: 'foobar',
data: {
data: { foo: 'bar' },
metadata: {
created_time: '2023-06-20T21:26:47.592306Z',
custom_metadata: null,
deletion_time: '',
destroyed: false,
version: 2,
},
},
};
const EXAMPLE_KV_SUBKEYS_RESPONSE = {
request_id: 'foobar',
data: {
...EXAMPLE_KV_DATA_GET_RESPONSE,
data: { foo: null },
},
};
const EXAMPLE_CONTROL_GROUP_RESPONSE = {
data: null,
wrap_info: {
token: 'some-token',
accessor: 'some-accessor',
ttl: 86400,
creation_time: '2023-08-09T16:08:06-05:00',
creation_path: 'some/path/here',
},
};
const EXAMPLE_KV_DATA_DESTROYED = {
data: {
data: null,
metadata: {
created_time: '2023-08-09T20:10:24.4825Z',
custom_metadata: null,
deletion_time: '',
destroyed: true,
version: 2,
},
},
};
const EXAMPLE_KV_DATA_DELETED = {
data: {
data: null,
metadata: {
created_time: '2023-08-09T20:10:24.571332Z',
custom_metadata: null,
deletion_time: '2023-08-09T20:10:24.70176Z',
destroyed: false,
version: 2,
},
},
};
module('Unit | Adapter | kv/data', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.backend = 'my/kv-back&end';
this.secretMountPath.currentPath = this.backend;
this.path = 'beep/bop/my secret';
this.version = '2';
this.id = kvDataPath(this.backend, this.path, this.version);
this.data = {
options: {
cas: 2,
},
data: {
foo: 'bar',
},
};
this.payload = {
backend: this.backend,
path: this.path,
version: 2,
};
this.endpoint = (noun) => `${encodePath(this.backend)}/${noun}/${encodePath(this.path)}`;
});
module('createRecord', function () {
test('it should make request to correct endpoint on createRecord', async function (assert) {
assert.expect(8);
this.server.post(this.endpoint('data'), (schema, req) => {
assert.ok('POST request made to correct endpoint when creating new record');
const body = JSON.parse(req.requestBody);
assert.deepEqual(body, {
data: {
foo: 'bar',
},
options: {
cas: 0,
},
});
return EXAMPLE_KV_DATA_CREATE_RESPONSE;
});
const record = this.store.createRecord('kv/data', {
backend: this.backend,
path: this.path,
secretData: { foo: 'bar' },
casVersion: 0,
});
await record.save();
assert.strictEqual(record.path, this.path, 'record has correct path');
assert.strictEqual(record.backend, this.backend, 'record has correct backend');
assert.strictEqual(record.version, 1, 'record has correct version');
assert.deepEqual(record.secretData, { foo: 'bar' }, 'record has correct data');
assert.strictEqual(record.createdTime, '2023-06-21T16:18:31.479993Z', 'record has correct createdTime');
assert.strictEqual(
record.id,
`${encodePath(this.backend)}/data/${encodePath(this.path)}?version=1`,
'record has correct id'
);
});
test('it should not send cas if casVersion is not a number', async function (assert) {
assert.expect(8);
this.server.post(this.endpoint('data'), (schema, req) => {
assert.ok('POST request made to correct endpoint when creating new record');
const body = JSON.parse(req.requestBody);
assert.deepEqual(body, {
data: {
foo: 'bar',
},
});
return EXAMPLE_KV_DATA_CREATE_RESPONSE;
});
const record = this.store.createRecord('kv/data', {
backend: this.backend,
path: this.path,
secretData: { foo: 'bar' },
});
await record.save();
assert.strictEqual(record.path, this.path, 'record has correct path');
assert.strictEqual(record.backend, this.backend, 'record has correct backend');
assert.strictEqual(record.version, 1, 'record has correct version');
assert.deepEqual(record.secretData, { foo: 'bar' }, 'record has correct data');
assert.strictEqual(record.createdTime, '2023-06-21T16:18:31.479993Z', 'record has correct createdTime');
assert.strictEqual(
record.id,
`${encodePath(this.backend)}/data/${encodePath(this.path)}?version=1`,
'record has correct id'
);
});
});
module('queryRecord', function () {
test('it should make request to correct endpoint on queryRecord', async function (assert) {
assert.expect(8);
this.server.get(this.endpoint('data'), (schema, req) => {
assert.ok(true, 'request is made to correct url on queryRecord.');
assert.strictEqual(
req.queryParams.version,
this.version,
'request includes the version flag on queryRecord.'
);
return EXAMPLE_KV_DATA_GET_RESPONSE;
});
const record = await this.store.queryRecord('kv/data', this.payload);
assert.strictEqual(record.path, this.path, 'record has correct path');
assert.strictEqual(record.backend, this.backend, 'record has correct backend');
assert.strictEqual(record.version, 2, 'record has correct version');
assert.deepEqual(record.secretData, { foo: 'bar' }, 'record has correct data');
assert.strictEqual(record.createdTime, '2023-06-20T21:26:47.592306Z', 'record has correct createdTime');
assert.strictEqual(
record.id,
`${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`,
'record has correct id'
);
});
test('it should handle a 404 not found response properly', async function (assert) {
assert.expect(1);
this.server.get(this.endpoint('data'), () => {
// This is what the API currently returns for not found
return new Response(404, {}, { errors: [] });
});
try {
await this.store.queryRecord('kv/data', this.payload);
} catch (e) {
assert.ok('throws the error');
}
});
test('it should handle 404 for a soft-deleted version properly', async function (assert) {
this.server.get(this.endpoint('data'), () => {
return new Response(404, {}, EXAMPLE_KV_DATA_DELETED);
});
const record = await this.store.queryRecord('kv/data', this.payload);
assert.strictEqual(record.path, this.path, 'record has correct path');
assert.strictEqual(record.backend, this.backend, 'record has correct backend');
assert.strictEqual(record.version, 2, 'record has version based on request');
assert.strictEqual(record.deletionTime, '2023-08-09T20:10:24.70176Z', 'record includes deletion time');
assert.strictEqual(record.failReadErrorCode, undefined, 'record does not have failed error code');
assert.strictEqual(
record.id,
`${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`,
'record has correct id'
);
});
test('it should handle 404 for a destroyed version properly', async function (assert) {
this.server.get(this.endpoint('data'), () => {
return new Response(404, {}, EXAMPLE_KV_DATA_DESTROYED);
});
const record = await this.store.queryRecord('kv/data', this.payload);
assert.strictEqual(record.path, this.path, 'record has correct path');
assert.strictEqual(record.backend, this.backend, 'record has correct backend');
assert.strictEqual(record.version, 2, 'record has version based on request');
assert.true(record.destroyed, 'record has destroyed value');
assert.strictEqual(record.failReadErrorCode, undefined, 'record does not have error code');
assert.strictEqual(
record.id,
`${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`,
'record has correct id'
);
});
test('it should handle a 403 permission denied properly', async function (assert) {
assert.expect(8);
this.server.get(this.endpoint('data'), (schema, req) => {
assert.ok(true, 'request is made to correct url on queryRecord.');
assert.strictEqual(
req.queryParams.version,
this.version,
'request includes the version flag on queryRecord.'
);
return new Response(403, {}, { errors: ['1 error occurred:\n\t* permission denied\n\n'] });
});
const record = await this.store.queryRecord('kv/data', this.payload);
assert.strictEqual(record.path, this.path, 'record has correct path');
assert.strictEqual(record.backend, this.backend, 'record has correct backend');
assert.strictEqual(record.version, 2, 'record has version based on request');
assert.strictEqual(record.secretData, undefined, 'record does not include data');
assert.strictEqual(record.failReadErrorCode, 403, 'record has error response recorded');
assert.strictEqual(
record.id,
`${encodePath(this.backend)}/data/${encodePath(this.path)}?version=${this.version}`,
'record has correct id'
);
});
test('it should handle a control group response properly', async function (assert) {
assert.expect(1);
this.owner.lookup('service:version').type = 'enterprise'; // Required for testing control-group flow
this.server.get(this.endpoint('data'), () => {
return EXAMPLE_CONTROL_GROUP_RESPONSE;
});
try {
await this.store.queryRecord('kv/data', this.payload);
} catch (e) {
assert.ok('throws the error');
}
});
});
module('destroyRecord', function () {
test('it should make request to correct endpoint on delete latest version', async function (assert) {
assert.expect(3);
this.server.delete(this.endpoint('data'), () => {
assert.ok(true, 'request made to correct endpoint on delete latest version.');
return new Response(204);
});
this.store.pushPayload('kv/data', {
modelName: 'kv/data',
id: this.id,
...this.payload,
});
let record = await this.store.peekRecord('kv/data', this.id);
await record.destroyRecord({ adapterOptions: { deleteType: 'delete-latest-version' } });
assert.true(record.isDeleted, 'record is deleted');
record = await this.store.peekRecord('kv/data', this.id);
assert.strictEqual(record, null, 'record is no longer in store');
});
test('it should make request to correct endpoint on delete specific versions', async function (assert) {
assert.expect(4);
this.server.post(this.endpoint('delete'), (schema, req) => {
const { versions } = JSON.parse(req.requestBody);
assert.strictEqual(versions, 2, 'version array is sent in the payload.');
assert.ok(true, 'request made to correct endpoint on delete specific version.');
});
this.store.pushPayload('kv/data', {
modelName: 'kv/data',
id: this.id,
...this.payload,
});
let record = await this.store.peekRecord('kv/data', this.id);
await record.destroyRecord({
adapterOptions: { deleteType: 'delete-version', deleteVersions: 2 },
});
assert.true(record.isDeleted, 'record is deleted');
record = await this.store.peekRecord('kv/data', this.id);
assert.strictEqual(record, null, 'record is no longer in store');
});
test('it should make request to correct endpoint on undelete', async function (assert) {
assert.expect(4);
this.server.post(`${this.backend}/undelete/${this.path}`, (schema, req) => {
const { versions } = JSON.parse(req.requestBody);
assert.strictEqual(versions, 2, 'version array is sent in the payload.');
assert.ok(true, 'request made to correct endpoint on undelete specific version.');
});
this.store.pushPayload('kv/data', {
modelName: 'kv/data',
id: this.id,
...this.payload,
});
let record = await this.store.peekRecord('kv/data', this.id);
await record.destroyRecord({
adapterOptions: { deleteType: 'undelete', deleteVersions: 2 },
});
assert.true(record.isDeleted, 'record is deleted');
record = await this.store.peekRecord('kv/data', this.id);
assert.strictEqual(record, null, 'record is no longer in store');
});
test('it should make request to correct endpoint on destroy specific versions', async function (assert) {
assert.expect(4);
this.server.put(`${encodePath(this.backend)}/destroy/${encodePath(this.path)}`, (schema, req) => {
const { versions } = JSON.parse(req.requestBody);
assert.strictEqual(versions, 2, 'version array is sent in the payload.');
assert.ok(true, 'request made to correct endpoint on destroy specific version.');
});
this.store.pushPayload('kv/data', {
modelName: 'kv/data',
id: this.id,
...this.payload,
});
let record = await this.store.peekRecord('kv/data', this.id);
await record.destroyRecord({
adapterOptions: { deleteType: 'destroy', deleteVersions: 2 },
});
assert.true(record.isDeleted, 'record is deleted');
record = await this.store.peekRecord('kv/data', this.id);
assert.strictEqual(record, null, 'record is no longer in store');
});
});
module('fetchSubkeys', function (hooks) {
hooks.beforeEach(function () {
this.adapter = this.store.adapterFor('kv/data');
this.subkeysUrl = `${encodePath(this.backend)}/subkeys/${encodePath(this.path)}`;
});
test('it should make request with no query', async function (assert) {
assert.expect(2);
const expectedQuery = {};
this.server.get(this.subkeysUrl, (schema, { queryParams }) => {
assert.true(true, `GET request made to ${this.subkeysUrl}`);
assert.propEqual(queryParams, expectedQuery, `queryParams contain: ${JSON.stringify(queryParams)}`);
return EXAMPLE_KV_SUBKEYS_RESPONSE;
});
this.adapter.fetchSubkeys(this.backend, this.path);
});
test('it should make request with version param', async function (assert) {
assert.expect(1);
const query = { version: '2' };
this.server.get(this.subkeysUrl, (schema, { queryParams }) => {
assert.propEqual(queryParams, query, `queryParams contain: ${JSON.stringify(queryParams)}`);
return EXAMPLE_KV_SUBKEYS_RESPONSE;
});
this.adapter.fetchSubkeys(this.backend, this.path, query);
});
test('it should make request with depth param', async function (assert) {
assert.expect(1);
const query = { depth: '1' };
this.server.get(this.subkeysUrl, (schema, { queryParams }) => {
assert.propEqual(queryParams, query, `queryParams contain: ${JSON.stringify(queryParams)}`);
return EXAMPLE_KV_SUBKEYS_RESPONSE;
});
this.adapter.fetchSubkeys(this.backend, this.path, query);
});
test('it should make request with both params as strings', async function (assert) {
assert.expect(1);
const query = { version: '2', depth: '1' };
this.server.get(this.subkeysUrl, (schema, { queryParams }) => {
assert.propEqual(queryParams, query, `queryParams contain: ${JSON.stringify(queryParams)}`);
return EXAMPLE_KV_SUBKEYS_RESPONSE;
});
this.adapter.fetchSubkeys(this.backend, this.path, query);
});
test('it should make request with both params as integers', async function (assert) {
assert.expect(1);
const query = { depth: 0, version: 2 };
const expectedQuery = { depth: '0', version: '2' };
this.server.get(this.subkeysUrl, (schema, { queryParams }) => {
assert.propEqual(queryParams, expectedQuery, `queryParams contain: ${JSON.stringify(queryParams)}`);
return EXAMPLE_KV_SUBKEYS_RESPONSE;
});
this.adapter.fetchSubkeys(this.backend, this.path, query);
});
});
module('patchSecret', function (hooks) {
hooks.beforeEach(function () {
this.adapter = this.store.adapterFor('kv/data');
});
test('it should make request to patch', async function (assert) {
assert.expect(2);
const data = { foo: 'bar', baz: null };
const expectedPayload = {
data,
options: {
cas: 1,
},
};
this.server.patch(this.endpoint('data'), (schema, req) => {
const body = JSON.parse(req.requestBody);
assert.true(true, `PATCH request made to ${this.endpoint('data')}`);
assert.propEqual(body, expectedPayload, 'payload includes cas version');
return EXAMPLE_KV_DATA_CREATE_RESPONSE;
});
this.adapter.patchSecret(this.backend, this.path, data, 1);
});
});
});

View file

@ -1,189 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { kvMetadataPath } from 'vault/utils/kv-path';
import { Response } from 'miragejs';
const UTC_DATE = '1994-11-05T00:00:00.000Z';
const EXAMPLE_KV_METADATA_GET_RESPONSE = {
request_id: 'foobar',
data: {
cas_required: true,
created_time: 'created-time',
current_version: 2,
custom_metadata: { application: 'staging' },
delete_version_after: '0s',
max_versions: 10,
oldest_version: 0, // TODO: is this a bug? payload from real API
updated_time: 'updated-time',
versions: {
1: {
created_time: 'created-time',
deletion_time: UTC_DATE,
destroyed: false,
},
2: { created_time: 'created-time', deletion_time: '', destroyed: false },
},
},
};
module('Unit | Adapter | kv/metadata', function (hooks) {
setupTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function () {
this.store = this.owner.lookup('service:store');
this.secretMountPath = this.owner.lookup('service:secret-mount-path');
this.backend = 'some/kv-back&end';
this.secretMountPath.currentPath = this.backend;
this.path = 'beep/bop my/secret';
this.id = kvMetadataPath(this.backend, this.path);
this.data = {
options: {
cas: 2,
},
data: {
foo: 'bar',
},
};
this.payload = {
max_versions: 2,
cas_required: false,
delete_version_after: '0s',
custom_metadata: {
admin: 'bob',
},
};
this.endpoint = kvMetadataPath(this.backend, this.path);
});
test('it should make request to correct endpoint on createRecord', async function (assert) {
assert.expect(10);
const recordData = {
backend: this.backend,
path: this.path,
deleteVersionAfter: '45h',
customMetadata: { application: 'staging' },
oldestVersion: 4,
currentVersion: 6,
createdTime: 'created',
updatedTime: 'updated',
versions: {
1: {
created_time: 'created-time',
deletion_time: UTC_DATE,
destroyed: false,
},
2: {
created_time: 'created-time',
deletion_time: '',
destroyed: false,
},
},
};
const expectedBody = {
max_versions: 0,
delete_version_after: '45h',
cas_required: false,
custom_metadata: { application: 'staging' },
};
this.server.post(this.endpoint, (schema, req) => {
const body = JSON.parse(req.requestBody);
assert.ok('POST request made to correct endpoint when creating new record');
assert.propEqual(body, expectedBody, 'POST request has correct body');
return new Response(204);
});
const record = this.store.createRecord('kv/metadata', recordData);
await record.save();
assert.strictEqual(record.id, this.id, 'record has correct id');
assert.strictEqual(record.backend, this.backend, 'record has correct backend');
assert.strictEqual(record.path, this.path, 'record has correct path');
assert.strictEqual(record.maxVersions, 0, 'record has default maxVersions');
assert.false(record.casRequired, 'record has correct casRequired');
assert.strictEqual(record.deleteVersionAfter, '45h', 'record has correct deleteVersionAfter');
assert.deepEqual(record.customMetadata, { application: 'staging' }, 'record has correct customMetadata');
assert.deepEqual(
record.versions,
EXAMPLE_KV_METADATA_GET_RESPONSE.data.versions,
'record has correct versions data'
);
});
test('it should make request to correct endpoint on update record', async function (assert) {
assert.expect(1);
const data = this.server.create('kv-metadatum');
data.id = kvMetadataPath('kv-engine', 'my-secret');
this.store.pushPayload('kv/metadata', {
modelName: 'kv/metadata',
...data,
});
this.server.post(kvMetadataPath('kv-engine', 'my-secret'), () => {
assert.ok(true, 'request made to correct endpoint on delete metadata.');
});
const record = await this.store.peekRecord('kv/metadata', data.id);
await record.save();
});
test('it should make request to correct endpoint on queryRecord', async function (assert) {
assert.expect(13);
this.server.get(this.endpoint, () => {
assert.ok(true, 'request is made to correct url on queryRecord.');
return EXAMPLE_KV_METADATA_GET_RESPONSE;
});
const record = await this.store.queryRecord('kv/metadata', { backend: this.backend, path: this.path });
assert.strictEqual(record.id, this.id, 'record has correct id');
assert.strictEqual(record.backend, this.backend, 'record has correct backend');
assert.strictEqual(record.path, this.path, 'record has correct path');
assert.strictEqual(record.maxVersions, 10, 'record has correct maxVersions');
assert.true(record.casRequired, 'record has correct casRequired');
assert.strictEqual(record.deleteVersionAfter, '0s', 'record has correct deleteVersionAfter');
assert.deepEqual(record.customMetadata, { application: 'staging' }, 'record has correct customMetadata');
assert.strictEqual(record.createdTime, 'created-time', 'record has correct createdTime');
assert.strictEqual(record.currentVersion, 2, 'record has correct currentVersion');
assert.strictEqual(record.oldestVersion, 0, 'record has correct oldestVersion');
assert.strictEqual(record.updatedTime, 'updated-time', 'record has correct updatedTime');
assert.deepEqual(
record.versions,
EXAMPLE_KV_METADATA_GET_RESPONSE.data.versions,
'record has correct versions data'
);
});
test('it should make request to correct endpoint on query', async function (assert) {
assert.expect(1);
this.server.get(kvMetadataPath(this.backend, 'directory/'), (schema, req) => {
assert.ok(req.queryParams.list, 'list query param sent when listing secrets');
return { data: { keys: [] } };
});
this.store.query('kv/metadata', { backend: this.backend, pathToSecret: 'directory/' });
});
test('it should make request to correct endpoint on delete metadata', async function (assert) {
assert.expect(3);
const data = this.server.create('kv-metadatum');
data.id = kvMetadataPath('kv-engine', 'my-secret');
this.store.pushPayload('kv/metadata', {
modelName: 'kv/metadata',
...data,
});
this.server.delete(kvMetadataPath('kv-engine', 'my-secret'), () => {
assert.ok(true, 'request made to correct endpoint on delete metadata.');
});
let record = await this.store.peekRecord('kv/metadata', data.id);
await record.destroyRecord();
assert.true(record.isDeleted, 'record is deleted');
record = await this.store.peekRecord('kv/metadata', this.id);
assert.strictEqual(record, null, 'record is no longer in store');
});
});

View file

@ -27,65 +27,116 @@ module('Unit | Decorators | ModelFormFields', function (hooks) {
assert.ok(this.spy.calledWith(message), 'Error is printed to console');
});
test('it return allFields when arguments not provided', function (assert) {
test('it returns allFields when arguments not provided', function (assert) {
assert.expect(1);
// test by instantiating a record that uses this decorator
const record = this.store.createRecord('kv/data');
const record = this.store.createRecord('totp-key');
assert.deepEqual(
record.allFields,
[
{
name: 'backend',
options: {},
options: { readOnly: true },
type: 'string',
},
{
name: 'path',
name: 'name',
options: {
label: 'Path for this secret',
subText: 'Names with forward slashes define hierarchical path structures.',
subText: 'Specifies the name for this key.',
},
type: 'string',
},
{
name: 'secretData',
options: {},
type: 'object',
},
{
name: 'createdTime',
options: {},
name: 'accountName',
options: {
subText: 'The name of the account associated with the key. Required for keys generated by Vault.',
},
type: 'string',
},
{
name: 'customMetadata',
options: {},
type: 'object',
},
{
name: 'deletionTime',
options: {},
name: 'algorithm',
options: { possibleValues: ['SHA1', 'SHA256', 'SHA512'], defaultValue: 'SHA1' },
type: 'string',
},
{
name: 'destroyed',
options: {},
name: 'digits',
options: { possibleValues: [6, 8], defaultValue: 6 },
type: 'number',
},
{
name: 'issuer',
options: {
subText: `The name of the key's issuing organization. Required for keys generated by Vault.`,
},
type: 'string',
},
{
name: 'period',
options: {
editType: 'ttl',
helperTextEnabled: 'How long each generated TOTP is valid.',
defaultValue: 30,
},
type: undefined,
},
{
name: 'generate',
options: {
label: 'Key Provider',
defaultValue: true,
editType: 'radio',
possibleValues: ['Vault', 'Other service'],
fieldValue: 'generateString',
subText: 'Specifies if the key should be generated by Vault or passed from another service.',
},
type: undefined,
},
{
name: 'keySize',
options: { defaultValue: 20 },
type: 'number',
},
{
name: 'skew',
options: { possibleValues: [0, 1], defaultValue: 1 },
type: 'number',
},
{
name: 'exported',
options: {
editType: 'toggleButton',
defaultValue: true,
helperTextDisabled: 'Vault will not return QR code and url upon key creation.',
helperTextEnabled: 'QR code and URL will be returned upon generating a key.',
},
type: 'boolean',
},
{
name: 'version',
options: {},
name: 'qrSize',
options: { label: 'QR size', defaultValue: 200 },
type: 'number',
},
{
name: 'failReadErrorCode',
options: {},
type: 'number',
name: 'url',
options: {
label: 'URL',
helpText:
'If a URL is provided the other fields can be left empty. E.g. otpauth://totp/Vault:test@test.com?secret=Y64VEVMBTSXCYIWRSHRNDZW62MPGVU2G&issuer=Vault',
subText: 'The TOTP key url string that can be used to configure a key.',
},
type: 'string',
},
{
name: 'casVersion',
options: {},
type: 'number',
name: 'key',
options: {
subText: 'The root key used to generate a TOTP code.',
},
type: 'string',
},
{
name: 'barcode',
options: { readOnly: true },
type: 'string',
},
],
'allFields set on Model class'

View file

@ -1,31 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | kv/data', function (hooks) {
setupTest(hooks);
test('it should always pass the cas option when creating/updating a secret', function (assert) {
const store = this.owner.lookup('service:store');
const record = store.createRecord('kv/data', {
path: 'my-secret-path',
backend: 'kv-test',
version: 2,
casVersion: 3,
secretData: { foo: 'bar' },
});
const expectedResult = {
data: { foo: 'bar' },
options: {
cas: 3,
},
};
const serializedRecord = record.serialize();
assert.deepEqual(serializedRecord, expectedResult, 'cas option was correctly added to the payload.');
});
});

View file

@ -1,88 +0,0 @@
/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
module('Unit | Serializer | kv/metadata', function (hooks) {
setupTest(hooks);
test('it should properly normalize a list response', function (assert) {
const serializer = this.owner.lookup('serializer:kv/metadata');
const serverData = {
request_id: 'foo',
backend: 'my-backend',
path: '',
data: {
keys: ['first', 'second', 'third/'],
},
};
const expectedData = [
{
id: 'my-backend/metadata/first',
path: 'first',
backend: 'my-backend',
full_secret_path: 'first',
},
{
id: 'my-backend/metadata/second',
path: 'second',
backend: 'my-backend',
full_secret_path: 'second',
},
{
id: 'my-backend/metadata/third/',
path: 'third/',
backend: 'my-backend',
full_secret_path: 'third/',
},
];
const serializedRecord = serializer.normalizeItems(serverData);
assert.deepEqual(serializedRecord, expectedData, 'transformed keys into proper IDs');
});
test('it should properly normalize a nested secret list response', function (assert) {
const serializer = this.owner.lookup('serializer:kv/metadata');
const serverData = {
request_id: 'foo',
backend: 'my-backend',
path: 'beep/',
data: {
keys: ['boop/'],
},
};
const expectedData = [
{
id: 'my-backend/metadata/beep/boop/',
path: 'boop/',
backend: 'my-backend',
full_secret_path: 'beep/boop/',
},
];
const serializedRecord = serializer.normalizeItems(serverData);
assert.deepEqual(serializedRecord, expectedData, 'transformed keys into proper IDs');
});
test('it throws an assertion if backend not on payload', function (assert) {
const serializer = this.owner.lookup('serializer:kv/metadata');
const serverData = {
request_id: 'foo',
data: {
keys: ['first', 'second'],
},
};
let result;
try {
result = serializer.normalizeItems(serverData);
} catch (e) {
result = e.message;
}
assert.strictEqual(
result,
'Assertion Failed: payload.backend must be provided on kv/metadata list response'
);
});
});

View file

@ -97,7 +97,7 @@ module('Unit | Service | path-help', function (hooks) {
assert.notOk(true, 'this method should not be called');
return reject();
});
const modelType = 'kv/data';
const modelType = 'totp-key';
await this.pathHelp.getNewModel(modelType, 'my-kv').then(() => {
assert.true(true, 'getNewModel resolves');
});

View file

@ -8,8 +8,6 @@ import Adapter from 'ember-data/adapter';
import ModelRegistry from 'ember-data/types/registries/model';
import ClientsActivityAdapter from 'vault/vault/adapters/clients/activity';
import KvDataAdapter from 'vault/adapters/kv/data';
import KvMetadataAdapter from 'vault/adapters/kv/metadata';
import LdapLibraryAdapter from 'vault/adapters/ldap/library';
import LdapRoleAdapter from 'vault/adapters/ldap/role';
import PkiIssuerAdapter from 'vault/adapters/pki/issuer';
@ -26,8 +24,6 @@ export default interface AdapterRegistry {
'ldap/role': LdapRoleAdapter;
'pki/issuer': PkiIssuerAdapter;
'pki/tidy': PkiTidyAdapter;
'kv/data': KvDataAdapterAdapter;
'kv/metadata': KvMetadataAdapter;
'sync/destination': SyncDestinationAdapter;
'sync/association': SyncAssociationAdapter;
application: Application;

View file

@ -4,8 +4,6 @@
*/
import Model from '@ember-data/model';
import KvSecretDataModel from 'vault/models/kv/data';
import KvSecretMetadataModel from 'vault/models/kv/metadata';
import PkiActionModel from 'vault/models/pki/action';
import PkiCertificateGenerateModel from 'vault/models/pki/certificate/generate';
import PkiConfigAcmeModel from 'vault/models/pki/config/acme';
@ -24,8 +22,6 @@ declare module 'ember-data/types/registries/model' {
'pki/config/cluster': PkiConfigClusterModel;
'pki/config/crl': PkiConfigCrlModel;
'pki/config/urls': PkiConfigUrlModel;
'kv/data': KvSecretDataModel;
'kv/metadata': KvSecretMetadataModel;
'clients/activity': ClientsActivityModel;
'clients/config': ClientsConfigModel;
'clients/version-history': ClientsVersionHistoryModel;