mirror of
https://github.com/hashicorp/vault.git
synced 2025-11-09 12:51:11 +01:00
* [VAULT-37521] UI: decouple auth and secret engines * add copyright header * address acceptance test failure Co-authored-by: Shannon Roberts (Beagin) <beagins@users.noreply.github.com>
This commit is contained in:
parent
8a3e640186
commit
7d026fa5a8
@ -9,49 +9,34 @@ import { service } from '@ember/service';
|
||||
import { action, set } from '@ember/object';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { waitFor } from '@ember/test-waiters';
|
||||
import { presence } from 'vault/utils/forms/validators';
|
||||
import { filterEnginesByMountCategory, isAddonEngine } from 'vault/utils/all-engines-metadata';
|
||||
import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata';
|
||||
import { MOUNT_CATEGORIES } from 'vault/utils/plugin-catalog-helpers';
|
||||
import { assert } from '@ember/debug';
|
||||
|
||||
import type FlashMessageService from 'vault/services/flash-messages';
|
||||
import type Store from '@ember-data/store';
|
||||
import type AuthMethodForm from 'vault/forms/auth/method';
|
||||
import type SecretsEngineForm from 'vault/forms/secrets/engine';
|
||||
import type CapabilitiesService from 'vault/services/capabilities';
|
||||
import type ApiService from 'vault/services/api';
|
||||
import type { ApiError } from '@ember-data/adapter/error';
|
||||
import type { ValidationMap } from 'vault/vault/app-types';
|
||||
import type { EnhancedPluginCatalogData } from 'vault/services/plugin-catalog';
|
||||
|
||||
/**
|
||||
* @module MountBackendForm
|
||||
* The `MountBackendForm` is used to mount either a secret or auth backend.
|
||||
* The `MountBackendForm` is used to mount authentication methods.
|
||||
*
|
||||
* @example ```js
|
||||
* <MountBackendForm @mountModel={{this.model}} @mountCategory="secret" @onMountSuccess={{this.onMountSuccess}} />```
|
||||
* <MountBackendForm @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />```
|
||||
*
|
||||
* @param {object|Form} mountModel - Either a model object containing form and plugin data (secrets), or the form directly (auth).
|
||||
* @param {AuthMethodForm} mountModel - The authentication method form.
|
||||
* @param {function} onMountSuccess - A function that transitions once the Mount has been successfully posted.
|
||||
* @param {string} mountCategory - The type of engine to mount, either 'secret' or 'auth'.
|
||||
*
|
||||
*/
|
||||
|
||||
type MountModel =
|
||||
| {
|
||||
form: SecretsEngineForm;
|
||||
pluginCatalogData?: EnhancedPluginCatalogData | null;
|
||||
pluginCatalogError?: boolean;
|
||||
}
|
||||
| AuthMethodForm;
|
||||
|
||||
interface Args {
|
||||
mountModel: MountModel;
|
||||
mountCategory: 'secret' | 'auth';
|
||||
mountModel: AuthMethodForm;
|
||||
onMountSuccess: (type: string, path: string, useEngineRoute: boolean) => void;
|
||||
}
|
||||
|
||||
const SECRET_MOUNT_CATEGORY = MOUNT_CATEGORIES.SECRET;
|
||||
const AUTH_MOUNT_CATEGORY = MOUNT_CATEGORIES.AUTH;
|
||||
|
||||
export default class MountBackendForm extends Component<Args> {
|
||||
@ -66,13 +51,8 @@ export default class MountBackendForm extends Component<Args> {
|
||||
|
||||
@tracked errorMessage: string | string[] = '';
|
||||
|
||||
get mountForm(): SecretsEngineForm | AuthMethodForm {
|
||||
// Check if mountModel has form property (secrets route)
|
||||
if (typeof this.args.mountModel === 'object' && 'form' in this.args.mountModel) {
|
||||
return this.args.mountModel.form;
|
||||
}
|
||||
// Otherwise, assume the model IS the form (auth route)
|
||||
return this.args.mountModel as SecretsEngineForm | AuthMethodForm;
|
||||
get mountForm(): AuthMethodForm {
|
||||
return this.args.mountModel;
|
||||
}
|
||||
|
||||
get showEnable(): boolean {
|
||||
@ -81,16 +61,14 @@ export default class MountBackendForm extends Component<Args> {
|
||||
|
||||
constructor(owner: unknown, args: Args) {
|
||||
super(owner, args);
|
||||
assert(`@mountCategory is required. Must be "auth" or "secret".`, presence(this.args.mountCategory));
|
||||
}
|
||||
|
||||
checkPathChange(backendType: string) {
|
||||
if (!backendType) return;
|
||||
const { data } = this.mountForm;
|
||||
// mountCategory is usually 'secret' or 'auth', but sometimes an empty string is passed in (like when we click the cancel button).
|
||||
// In these cases, we should default to returning auth methods.
|
||||
// Always use auth mount category since this component only handles auth methods
|
||||
const mountsByType = filterEnginesByMountCategory({
|
||||
mountCategory: this.args.mountCategory ?? AUTH_MOUNT_CATEGORY,
|
||||
mountCategory: AUTH_MOUNT_CATEGORY,
|
||||
isEnterprise: true,
|
||||
}).map((engine) => engine.type);
|
||||
|
||||
@ -101,13 +79,6 @@ export default class MountBackendForm extends Component<Args> {
|
||||
}
|
||||
}
|
||||
|
||||
typeChangeSideEffect(type: string) {
|
||||
// If type PKI, set max lease to ~10years
|
||||
if (this.args.mountCategory === SECRET_MOUNT_CATEGORY) {
|
||||
this.mountForm.data.config.max_lease_ttl = type === 'pki' ? '3650d' : 0;
|
||||
}
|
||||
}
|
||||
|
||||
checkModelWarnings() {
|
||||
// check for warnings on change
|
||||
// since we only show errors on submit we need to clear those out and only send warning state
|
||||
@ -122,35 +93,10 @@ export default class MountBackendForm extends Component<Args> {
|
||||
this.invalidFormAlert = null;
|
||||
}
|
||||
|
||||
async saveKvConfig(path: string, formData: SecretsEngineForm['data']) {
|
||||
const { options, kv_config = {} } = formData;
|
||||
const { max_versions, cas_required, delete_version_after } = kv_config;
|
||||
const isKvV2 = options?.version === 2 && ['kv', 'generic'].includes(this.mountForm.normalizedType);
|
||||
const hasConfig = max_versions || cas_required || delete_version_after;
|
||||
|
||||
if (isKvV2 && hasConfig) {
|
||||
try {
|
||||
const { canUpdate } = await this.capabilities.for('kvConfig', { path });
|
||||
if (canUpdate) {
|
||||
await this.api.secrets.kvV2Configure(path, kv_config);
|
||||
} else {
|
||||
this.flashMessages.warning(
|
||||
'You do not have access to the config endpoint. The secret engine was mounted, but the configuration settings were not saved.'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const { message } = await this.api.parseError(e);
|
||||
this.flashMessages.warning(
|
||||
`The secret engine was mounted, but the configuration settings were not saved. ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onMountError(status: number, errors: ApiError[], message: string) {
|
||||
if (status === 403) {
|
||||
this.flashMessages.danger(
|
||||
'You do not have access to the sys/mounts endpoint. The secret engine was not mounted.'
|
||||
'You do not have access to the sys/auth endpoint. The auth method was not mounted.'
|
||||
);
|
||||
} else if (errors) {
|
||||
this.errorMessage = errors.map((e) => {
|
||||
@ -168,7 +114,6 @@ export default class MountBackendForm extends Component<Args> {
|
||||
@waitFor
|
||||
*mountBackend(event: Event) {
|
||||
event.preventDefault();
|
||||
const { mountCategory } = this.args;
|
||||
const mountModel = this.mountForm;
|
||||
const { type } = mountModel;
|
||||
const { path } = mountModel.data;
|
||||
@ -181,21 +126,9 @@ export default class MountBackendForm extends Component<Args> {
|
||||
}
|
||||
|
||||
try {
|
||||
if (mountCategory === SECRET_MOUNT_CATEGORY) {
|
||||
yield this.api.sys.mountsEnableSecretsEngine(path, data);
|
||||
yield this.saveKvConfig(path, data as SecretsEngineForm['data']);
|
||||
} else {
|
||||
yield this.api.sys.authEnableMethod(path, data);
|
||||
}
|
||||
this.flashMessages.success(
|
||||
`Successfully mounted the ${mountModel.type} ${
|
||||
mountCategory === SECRET_MOUNT_CATEGORY ? 'secrets engine' : 'auth method'
|
||||
} at ${path}.`
|
||||
);
|
||||
// check whether to use the Ember engine route
|
||||
const version = (data as SecretsEngineForm['data']).options?.version;
|
||||
const useEngineRoute = isAddonEngine(mountModel.normalizedType, Number(version));
|
||||
this.args.onMountSuccess(type, path, useEngineRoute);
|
||||
yield this.api.sys.authEnableMethod(path, data);
|
||||
this.flashMessages.success(`Successfully mounted the ${mountModel.type} auth method at ${path}.`);
|
||||
this.args.onMountSuccess(type, path, false);
|
||||
} catch (error) {
|
||||
const { status, response, message } = yield this.api.parseError(error);
|
||||
this.onMountError(status, response.errors, message);
|
||||
@ -211,7 +144,6 @@ export default class MountBackendForm extends Component<Args> {
|
||||
@action
|
||||
setMountType(value: string) {
|
||||
this.mountForm.type = value;
|
||||
this.typeChangeSideEffect(value);
|
||||
this.checkPathChange(value);
|
||||
}
|
||||
|
||||
|
||||
66
ui/app/components/mount/secrets-engine-form.hbs
Normal file
66
ui/app/components/mount/secrets-engine-form.hbs
Normal file
@ -0,0 +1,66 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<Hds::PageHeader class="page-header" as |PH|>
|
||||
<PH.Breadcrumb>
|
||||
<Hds::Breadcrumb data-test-breadcrumbs>
|
||||
<Hds::Breadcrumb::Item @text="Secrets" @route="vault.cluster.secrets.backends" />
|
||||
<Hds::Breadcrumb::Item @text="Enable secret engine" @route="vault.cluster.secrets.mounts" />
|
||||
<Hds::Breadcrumb::Item @text={{capitalize @model.type}} @current={{true}} />
|
||||
</Hds::Breadcrumb>
|
||||
</PH.Breadcrumb>
|
||||
</Hds::PageHeader>
|
||||
|
||||
<div class="box is-sideless is-fullwidth is-marginless">
|
||||
<MessageError @errorMessage={{this.errorMessage}} />
|
||||
|
||||
<form {{on "submit" (perform this.mountBackend)}}>
|
||||
<FormFieldGroups
|
||||
@model={{@model}}
|
||||
@groupName="formFieldGroups"
|
||||
@renderGroup="default"
|
||||
@modelValidations={{this.modelValidations}}
|
||||
@onKeyUp={{this.onKeyUp}}
|
||||
/>
|
||||
|
||||
<FormFieldGroups @model={{@model}} @renderGroup="Method Options" @groupName="formFieldGroups">
|
||||
<:identityTokenKey>
|
||||
<SearchSelectWithModal
|
||||
@id="key"
|
||||
@fallbackComponent="input-search"
|
||||
@inputValue={{@model.data.config.identity_token_key}}
|
||||
@onChange={{this.handleIdentityTokenKeyChange}}
|
||||
@models={{array "oidc/key"}}
|
||||
@selectLimit="1"
|
||||
@modalFormTemplate="modal-form/oidc-key-template"
|
||||
@placeholder="Search for an existing OIDC key, or type a new key name to create it."
|
||||
@fallbackComponentPlaceholder="Input a key name"
|
||||
@modalSubtext="This key will be created in the OIDC key path."
|
||||
data-test-field="config.identity_token_key"
|
||||
/>
|
||||
</:identityTokenKey>
|
||||
</FormFieldGroups>
|
||||
|
||||
<div class="field is-grouped box is-fullwidth is-bottomless">
|
||||
<div class="control">
|
||||
<Hds::Button
|
||||
@text="Enable engine"
|
||||
@icon={{if this.mountBackend.isRunning "loading"}}
|
||||
type="submit"
|
||||
data-test-submit
|
||||
disabled={{this.mountBackend.isRunning}}
|
||||
/>
|
||||
</div>
|
||||
<div class="control">
|
||||
<Hds::Button @text="Back" @color="secondary" {{on "click" this.goBack}} data-test-back-button />
|
||||
</div>
|
||||
{{#if this.invalidFormAlert}}
|
||||
<div class="control">
|
||||
<AlertInline @type="danger" class="has-top-padding-s" @message={{this.invalidFormAlert}} />
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
158
ui/app/components/mount/secrets-engine-form.ts
Normal file
158
ui/app/components/mount/secrets-engine-form.ts
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Component from '@glimmer/component';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { inject as service } from '@ember/service';
|
||||
import { task } from 'ember-concurrency';
|
||||
import { set } from '@ember/object';
|
||||
import type FlashMessagesService from 'ember-cli-flash/services/flash-messages';
|
||||
import type ApiService from 'vault/services/api';
|
||||
import type CapabilitiesService from 'vault/services/capabilities';
|
||||
import type Router from '@ember/routing/router';
|
||||
import type SecretsEngineForm from 'vault/forms/secrets/engine';
|
||||
import type { ValidationMap } from 'vault/vault/app-types';
|
||||
import { isAddonEngine } from 'vault/utils/all-engines-metadata';
|
||||
|
||||
interface Args {
|
||||
model: SecretsEngineForm;
|
||||
onMountSuccess?: (type: string, path: string, useEngineRoute: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @module Mount::SecretsEngineForm
|
||||
* Modern component for mounting secrets engines using the SecretsEngineForm.
|
||||
*
|
||||
* @example
|
||||
* ```hbs
|
||||
* <Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />
|
||||
* ```
|
||||
*/
|
||||
export default class MountSecretsEngineFormComponent extends Component<Args> {
|
||||
@service declare flashMessages: FlashMessagesService;
|
||||
@service declare api: ApiService;
|
||||
@service declare capabilities: CapabilitiesService;
|
||||
@service declare router: Router;
|
||||
|
||||
@tracked modelValidations: ValidationMap | null = null;
|
||||
@tracked invalidFormAlert: string | null = null;
|
||||
@tracked errorMessage: string | string[] = '';
|
||||
|
||||
get mountForm(): SecretsEngineForm {
|
||||
return this.args.model;
|
||||
}
|
||||
|
||||
@action
|
||||
onKeyUp(name: string, value: string) {
|
||||
set(this.mountForm.data, name, value);
|
||||
}
|
||||
|
||||
async saveKvConfig(path: string, formData: SecretsEngineForm['data']) {
|
||||
const { options, kv_config = {} } = formData;
|
||||
const { max_versions, cas_required, delete_version_after } = kv_config;
|
||||
const isKvV2 = options?.version === 2 && ['kv', 'generic'].includes(this.mountForm.normalizedType);
|
||||
const hasConfig = max_versions || cas_required || delete_version_after;
|
||||
|
||||
if (isKvV2 && hasConfig) {
|
||||
try {
|
||||
const { canUpdate } = await this.capabilities.for('kvConfig', { path });
|
||||
if (canUpdate) {
|
||||
await this.api.secrets.kvV2Configure(path, kv_config);
|
||||
} else {
|
||||
this.flashMessages.warning(
|
||||
'You do not have access to the config endpoint. The secret engine was mounted, but the configuration settings were not saved.'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
const { message } = await this.api.parseError(e);
|
||||
this.flashMessages.warning(
|
||||
`The secret engine was mounted, but the configuration settings were not saved. ${message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async onMountError(status: number, errors: unknown[] | undefined, message: string) {
|
||||
if (status === 403) {
|
||||
this.flashMessages.danger(
|
||||
'You do not have access to the sys/mounts endpoint. The secret engine was not mounted.'
|
||||
);
|
||||
} else if (errors) {
|
||||
this.errorMessage = errors.map((e) => {
|
||||
if (typeof e === 'object' && e !== null) {
|
||||
const errorObj = e as { title?: string; message?: string };
|
||||
return errorObj.title || errorObj.message || JSON.stringify(e);
|
||||
}
|
||||
return String(e);
|
||||
});
|
||||
} else if (message) {
|
||||
this.errorMessage = message;
|
||||
} else {
|
||||
this.errorMessage = 'An error occurred, check the vault logs.';
|
||||
}
|
||||
}
|
||||
|
||||
@task
|
||||
*mountBackend(event: Event) {
|
||||
event.preventDefault();
|
||||
const mountModel = this.mountForm;
|
||||
const { type } = mountModel;
|
||||
const { path } = mountModel.data;
|
||||
|
||||
// Only submit form if validations pass
|
||||
const { isValid, state, invalidFormMessage, data } = mountModel.toJSON();
|
||||
if (!isValid) {
|
||||
this.modelValidations = state;
|
||||
this.invalidFormAlert = invalidFormMessage;
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.modelValidations = null;
|
||||
this.invalidFormAlert = null;
|
||||
|
||||
try {
|
||||
// Mount the secrets engine
|
||||
yield this.api.sys.mountsEnableSecretsEngine(path, data);
|
||||
|
||||
// Save KV config if applicable
|
||||
yield this.saveKvConfig(path, data);
|
||||
|
||||
this.flashMessages.success(`Successfully mounted the ${mountModel.type} secrets engine at ${path}.`);
|
||||
|
||||
// Determine if we should use engine routes
|
||||
const version = data.options?.version;
|
||||
const useEngineRoute = isAddonEngine(mountModel.normalizedType, Number(version));
|
||||
|
||||
// Call success callback or navigate
|
||||
if (this.args.onMountSuccess) {
|
||||
this.args.onMountSuccess(type, path, useEngineRoute);
|
||||
} else {
|
||||
// Default navigation
|
||||
if (useEngineRoute) {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.index', path);
|
||||
} else {
|
||||
this.router.transitionTo('vault.cluster.secrets.backend.list-root', path);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const { status, response, message } = yield this.api.parseError(error);
|
||||
this.onMountError(status, response.errors, message);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleIdentityTokenKeyChange(value: string[] | string): void {
|
||||
// if array, it's coming from the search-select component, otherwise it hit the fallback component and will come in as a string.
|
||||
const { config } = this.mountForm.data;
|
||||
config.identity_token_key = Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
@action
|
||||
goBack() {
|
||||
this.router.transitionTo('vault.cluster.secrets.mounts');
|
||||
}
|
||||
}
|
||||
77
ui/app/components/secret-engines/catalog.hbs
Normal file
77
ui/app/components/secret-engines/catalog.hbs
Normal file
@ -0,0 +1,77 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<Hds::PageHeader class="page-header" as |PH|>
|
||||
<PH.Breadcrumb>
|
||||
<Hds::Breadcrumb data-test-breadcrumbs>
|
||||
<Hds::Breadcrumb::Item @text="Secrets" @route="vault.cluster.secrets.backends" />
|
||||
<Hds::Breadcrumb::Item @text="Enable secret engine" @current={{true}} />
|
||||
</Hds::Breadcrumb>
|
||||
</PH.Breadcrumb>
|
||||
</Hds::PageHeader>
|
||||
|
||||
{{#if @pluginCatalogError}}
|
||||
<div class="has-bottom-padding-m">
|
||||
<Hds::Alert @type="inline" @color="warning" data-test-inline-alert as |A|>
|
||||
<A.Title>Plugin information unavailable</A.Title>
|
||||
<A.Description>
|
||||
Unable to fetch current plugin information. Using static plugin data instead. Some plugins may not show current
|
||||
details.
|
||||
</A.Description>
|
||||
</Hds::Alert>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#each this.pluginCategoriesList as |category|}}
|
||||
{{#let (this.getMountTypesByCategory category) as |categorized|}}
|
||||
{{! Only display the category if it has any enabled or disabled plugins }}
|
||||
{{#if (or categorized.enabled.length categorized.disabled.length)}}
|
||||
<div class="has-top-padding-m has-bottom-padding-s">
|
||||
<Hds::Text::Display @tag="h2" size="400" class="is-flex align-items-center">
|
||||
{{capitalize category}}
|
||||
{{#if (eq category "external")}}
|
||||
<Hds::Button
|
||||
@text=""
|
||||
@color="tertiary"
|
||||
@icon="info"
|
||||
@size="small"
|
||||
@isIconOnly={{true}}
|
||||
@ariaLabel="Information about external plugins"
|
||||
class="has-left-margin-xs"
|
||||
{{on "click" this.openExternalPluginsHelp}}
|
||||
/>
|
||||
{{/if}}
|
||||
</Hds::Text::Display>
|
||||
</div>
|
||||
<div class="flex row-wrap row-gap-16 column-gap-16 has-bottom-padding-m">
|
||||
{{! Enabled plugins }}
|
||||
{{#each categorized.enabled as |type|}}
|
||||
<EnabledPluginCard @type={{type}} @setMountType={{@setMountType}} />
|
||||
{{/each}}
|
||||
|
||||
{{! Vertical separator if both enabled and disabled plugins exist }}
|
||||
{{#if (and categorized.enabled.length categorized.disabled.length)}}
|
||||
<div class="selectable-engines-vertical-divider has-top-bottom-margin-12"></div>
|
||||
{{/if}}
|
||||
|
||||
{{! Disabled plugins }}
|
||||
{{#each categorized.disabled as |type|}}
|
||||
<DisabledPluginCard
|
||||
@type={{type}}
|
||||
@handleDisabledPluginClick={{this.handleDisabledPluginClick}}
|
||||
@handleDisabledPluginKeyDown={{this.handleDisabledPluginKeyDown}}
|
||||
/>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/let}}
|
||||
{{/each}}
|
||||
|
||||
<PluginDocumentationFlyout
|
||||
@isOpen={{this.showFlyout}}
|
||||
@plugin={{this.flyoutPlugin}}
|
||||
@pluginType={{this.flyoutPluginType}}
|
||||
@onClose={{this.closeFlyout}}
|
||||
/>
|
||||
130
ui/app/components/secret-engines/catalog.ts
Normal file
130
ui/app/components/secret-engines/catalog.ts
Normal file
@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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 { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata';
|
||||
import {
|
||||
enhanceEnginesWithCatalogData,
|
||||
categorizeEnginesByStatus,
|
||||
MOUNT_CATEGORIES,
|
||||
PLUGIN_TYPES,
|
||||
PLUGIN_CATEGORIES,
|
||||
} from 'vault/utils/plugin-catalog-helpers';
|
||||
import type { PluginCatalogData } from 'vault/services/plugin-catalog';
|
||||
|
||||
import type VersionService from 'vault/services/version';
|
||||
|
||||
/**
|
||||
* @module SecretEnginesCatalog
|
||||
* SecretEnginesCatalog component displays available secret engines in a catalog view
|
||||
* for selection when mounting a new secret engine.
|
||||
*
|
||||
* @example
|
||||
* ```js
|
||||
* <SecretEngines::Catalog @setMountType={{this.setMountType}} @pluginCatalogData={{this.pluginCatalogData}} @pluginCatalogError={{this.pluginCatalogError}} />
|
||||
* ```
|
||||
*/
|
||||
|
||||
interface Args {
|
||||
setMountType: (type: string) => void;
|
||||
pluginCatalogData?: PluginCatalogData;
|
||||
pluginCatalogError?: boolean;
|
||||
}
|
||||
|
||||
export default class SecretEnginesCatalogComponent extends Component<Args> {
|
||||
@service declare version: VersionService;
|
||||
|
||||
@tracked showFlyout = false;
|
||||
@tracked flyoutPlugin: unknown = null;
|
||||
@tracked flyoutPluginType: string | null = null;
|
||||
|
||||
get secretEngines() {
|
||||
// If an enterprise license is present, return all secret engines;
|
||||
// otherwise, return only the secret engines supported in OSS.
|
||||
const staticEngines = filterEnginesByMountCategory({
|
||||
mountCategory: MOUNT_CATEGORIES.SECRET,
|
||||
isEnterprise: !!this.version?.isEnterprise,
|
||||
});
|
||||
|
||||
// If we have plugin catalog data, merge it with static engines to add catalog info
|
||||
if (this.args.pluginCatalogData) {
|
||||
const secretEnginesDetailed =
|
||||
this.args.pluginCatalogData.detailed?.filter((plugin) => plugin?.type === PLUGIN_TYPES.SECRET) || [];
|
||||
const databasePluginsDetailed =
|
||||
this.args.pluginCatalogData.detailed?.filter((plugin) => plugin?.type === PLUGIN_TYPES.DATABASE) ||
|
||||
[];
|
||||
|
||||
return enhanceEnginesWithCatalogData(staticEngines, secretEnginesDetailed, databasePluginsDetailed);
|
||||
}
|
||||
|
||||
return staticEngines;
|
||||
}
|
||||
|
||||
get pluginCategoriesList() {
|
||||
return [
|
||||
PLUGIN_CATEGORIES.GENERIC,
|
||||
PLUGIN_CATEGORIES.CLOUD,
|
||||
PLUGIN_CATEGORIES.INFRA,
|
||||
|
||||
// TODO: enable external plugins once version selection is available (VAULT-39241)
|
||||
// PLUGIN_CATEGORIES.EXTERNAL,
|
||||
];
|
||||
}
|
||||
|
||||
get secretMountCategory() {
|
||||
return MOUNT_CATEGORIES.SECRET;
|
||||
}
|
||||
|
||||
@action
|
||||
getMountTypesByCategory(category: string) {
|
||||
try {
|
||||
const mountTypes = this.secretEngines;
|
||||
if (!mountTypes || !Array.isArray(mountTypes)) {
|
||||
return { enabled: [], disabled: [] };
|
||||
}
|
||||
|
||||
const allTypes = mountTypes.filter((type: unknown) => {
|
||||
const engineType = type as { pluginCategory?: string };
|
||||
return engineType?.pluginCategory === category;
|
||||
});
|
||||
return categorizeEnginesByStatus(allTypes);
|
||||
} catch (error) {
|
||||
return { enabled: [], disabled: [] };
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleDisabledPluginClick(plugin: unknown) {
|
||||
this.showFlyout = true;
|
||||
this.flyoutPlugin = plugin;
|
||||
this.flyoutPluginType = 'secret';
|
||||
}
|
||||
|
||||
@action
|
||||
handleDisabledPluginKeyDown(plugin: unknown, event: KeyboardEvent) {
|
||||
// Only handle Enter and Space keys for accessibility
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
this.handleDisabledPluginClick(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
openExternalPluginsHelp() {
|
||||
this.showFlyout = true;
|
||||
this.flyoutPlugin = null;
|
||||
this.flyoutPluginType = 'secret';
|
||||
}
|
||||
|
||||
@action
|
||||
closeFlyout() {
|
||||
this.showFlyout = false;
|
||||
this.flyoutPlugin = null;
|
||||
this.flyoutPluginType = null;
|
||||
}
|
||||
}
|
||||
42
ui/app/controllers/vault/cluster/secrets/mounts/create.ts
Normal file
42
ui/app/controllers/vault/cluster/secrets/mounts/create.ts
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Controller from '@ember/controller';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends';
|
||||
import engineDisplayData from 'vault/helpers/engines-display-data';
|
||||
import type SecretsEngineForm from 'vault/forms/secrets/engine';
|
||||
import type Router from '@ember/routing/router';
|
||||
|
||||
const SUPPORTED_BACKENDS = supportedSecretBackends();
|
||||
|
||||
export default class VaultClusterSecretsMountsCreateController extends Controller {
|
||||
@service declare router: Router;
|
||||
|
||||
declare model: SecretsEngineForm;
|
||||
|
||||
@action
|
||||
onMountSuccess(type: string, path: string, useEngineRoute = false) {
|
||||
let transition;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (SUPPORTED_BACKENDS.includes(type as any)) {
|
||||
const engineInfo = engineDisplayData(type);
|
||||
if (engineInfo && useEngineRoute) {
|
||||
transition = this.router.transitionTo(
|
||||
`vault.cluster.secrets.backend.${engineInfo.engineRoute}`,
|
||||
path
|
||||
);
|
||||
} else if (engineInfo) {
|
||||
// For keymgmt, we need to land on provider tab by default using query params
|
||||
const queryParams = engineInfo.type === 'keymgmt' ? { tab: 'provider' } : {};
|
||||
transition = this.router.transitionTo('vault.cluster.secrets.backend.index', path, { queryParams });
|
||||
}
|
||||
} else {
|
||||
transition = this.router.transitionTo('vault.cluster.secrets.backends');
|
||||
}
|
||||
return transition?.followRedirects();
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,11 @@ const SUPPORTED_BACKENDS = supportedSecretBackends();
|
||||
export default class SecretMountsController extends Controller {
|
||||
@service router;
|
||||
|
||||
@action
|
||||
setMountType(type) {
|
||||
this.router.transitionTo('vault.cluster.secrets.mounts.create', type);
|
||||
}
|
||||
|
||||
@action
|
||||
onMountSuccess(type, path, useEngineRoute = false) {
|
||||
let transition;
|
||||
|
||||
@ -22,6 +22,20 @@ export default class SecretsEngineForm extends MountForm<SecretsEngineFormData>
|
||||
];
|
||||
}
|
||||
|
||||
// Method to apply type-specific side effects - called when type changes
|
||||
applyTypeSpecificDefaults() {
|
||||
// PKI side effect: set max lease to ~10 years to match PKI certificate lifespans
|
||||
if (this.normalizedType === 'pki') {
|
||||
if (!this.data.config) {
|
||||
this.data.config = {};
|
||||
}
|
||||
// Only set default if not already specified
|
||||
if (!this.data.config.max_lease_ttl) {
|
||||
this.data.config.max_lease_ttl = '3650d';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coreOptionFields = [this.fields.description, this.fields.local, this.fields.sealWrap];
|
||||
|
||||
leaseConfigFields = [
|
||||
@ -41,7 +55,7 @@ export default class SecretsEngineForm extends MountForm<SecretsEngineFormData>
|
||||
];
|
||||
|
||||
get defaultFields() {
|
||||
const fields = [new FormField('path', 'string')];
|
||||
const fields = [this.fields.path];
|
||||
if (this.normalizedType === 'kv') {
|
||||
fields.push(
|
||||
new FormField('kv_config.max_versions', 'number', {
|
||||
|
||||
32
ui/app/routes/vault/cluster/secrets/mounts/create.ts
Normal file
32
ui/app/routes/vault/cluster/secrets/mounts/create.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import SecretsEngineForm from 'vault/forms/secrets/engine';
|
||||
|
||||
export default class VaultClusterSecretsMountsCreateRoute extends Route {
|
||||
model(params: { mount_type: string }) {
|
||||
const { mount_type } = params;
|
||||
|
||||
const defaults = {
|
||||
path: mount_type, // Default path to match the engine type
|
||||
config: { listing_visibility: false },
|
||||
kv_config: {
|
||||
max_versions: 0,
|
||||
cas_required: false,
|
||||
delete_version_after: undefined,
|
||||
},
|
||||
options: { version: 2 },
|
||||
};
|
||||
|
||||
const form = new SecretsEngineForm(defaults, { isNew: true });
|
||||
// Explicitly set the type on the form after creation
|
||||
form.type = mount_type;
|
||||
// Apply type-specific defaults (e.g., PKI max lease TTL)
|
||||
form.applyTypeSpecificDefaults();
|
||||
|
||||
return form;
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,6 @@
|
||||
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { hash } from 'rsvp';
|
||||
import SecretsEngineForm from 'vault/forms/secrets/engine';
|
||||
import Router from 'vault/router';
|
||||
import type PluginCatalogService from 'vault/services/plugin-catalog';
|
||||
@ -34,10 +33,10 @@ export default class VaultClusterSecretsMountsIndexRouter extends Route {
|
||||
// Fetch plugin catalog data to enhance the secret engines list
|
||||
const pluginCatalogResponse = await this.pluginCatalog.fetchPluginCatalog();
|
||||
|
||||
return hash({
|
||||
return {
|
||||
form: secretEngineForm,
|
||||
pluginCatalogData: pluginCatalogResponse.data,
|
||||
pluginCatalogError: pluginCatalogResponse.error,
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
6
ui/app/templates/vault/cluster/secrets/mounts/create.hbs
Normal file
6
ui/app/templates/vault/cluster/secrets/mounts/create.hbs
Normal file
@ -0,0 +1,6 @@
|
||||
{{!
|
||||
Copyright (c) HashiCorp, Inc.
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{action "onMountSuccess"}} />
|
||||
@ -3,5 +3,8 @@
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
{{! TODO: Copied from existing component, to be replaced by new parent component in separate ticket - VAULT-37522 }}
|
||||
<MountBackendForm @mountModel={{this.model}} @mountCategory="secret" @onMountSuccess={{action "onMountSuccess"}} />
|
||||
<SecretEngines::Catalog
|
||||
@setMountType={{this.setMountType}}
|
||||
@pluginCatalogData={{this.model.pluginCatalogData}}
|
||||
@pluginCatalogError={{this.model.pluginCatalogError}}
|
||||
/>
|
||||
@ -3,4 +3,4 @@
|
||||
SPDX-License-Identifier: BUSL-1.1
|
||||
}}
|
||||
|
||||
<MountBackendForm @mountModel={{this.model}} @mountCategory="auth" @onMountSuccess={{action "onMountSuccess"}} />
|
||||
<MountBackendForm @mountModel={{this.model}} @onMountSuccess={{action "onMountSuccess"}} />
|
||||
@ -134,7 +134,7 @@ module('Acceptance | secrets/mounts', function (hooks) {
|
||||
await mountBackend('kv', path);
|
||||
await waitFor('[data-test-message-error-description]');
|
||||
assert.dom('[data-test-message-error-description]').containsText(`path is already in use at ${path}`);
|
||||
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.mounts.index');
|
||||
assert.strictEqual(currentRouteName(), 'vault.cluster.secrets.mounts.create');
|
||||
|
||||
await page.secretList();
|
||||
await settled();
|
||||
|
||||
@ -5,20 +5,17 @@
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click, typeIn, fillIn } from '@ember/test-helpers';
|
||||
import { render, click, fillIn } from '@ember/test-helpers';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { allowAllCapabilitiesStub, noopStub } from 'vault/tests/helpers/stubs';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers';
|
||||
import { ALL_ENGINES, filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata';
|
||||
import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata';
|
||||
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import sinon from 'sinon';
|
||||
import SecretsEngineForm from 'vault/forms/secrets/engine';
|
||||
import AuthMethodForm from 'vault/forms/auth/method';
|
||||
|
||||
const WIF_ENGINES = ALL_ENGINES.filter((e) => e.isWIF).map((e) => e.type);
|
||||
|
||||
module('Integration | Component | mount backend form', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
@ -30,7 +27,6 @@ module('Integration | Component | mount backend form', function (hooks) {
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
|
||||
this.server.post('/sys/auth/foo', noopStub());
|
||||
this.server.post('/sys/mounts/foo', noopStub());
|
||||
this.onMountSuccess = sinon.spy();
|
||||
});
|
||||
|
||||
@ -45,7 +41,7 @@ module('Integration | Component | mount backend form', function (hooks) {
|
||||
test('it renders default state', async function (assert) {
|
||||
assert.expect(15);
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="auth" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
hbs`<MountBackendForm @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
assert
|
||||
.dom(GENERAL.title)
|
||||
@ -63,7 +59,7 @@ module('Integration | Component | mount backend form', function (hooks) {
|
||||
|
||||
test('it changes path when type is changed', async function (assert) {
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="auth" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
hbs`<MountBackendForm @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
|
||||
await click(GENERAL.cardContainer('aws'));
|
||||
@ -75,7 +71,7 @@ module('Integration | Component | mount backend form', function (hooks) {
|
||||
|
||||
test('it keeps path value if the user has changed it', async function (assert) {
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="auth" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
hbs`<MountBackendForm @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
await click(GENERAL.cardContainer('approle'));
|
||||
assert.strictEqual(this.model.type, 'approle', 'Updates type on model');
|
||||
@ -92,14 +88,14 @@ module('Integration | Component | mount backend form', function (hooks) {
|
||||
|
||||
test('it does not show a selected token type when first mounting an auth method', async function (assert) {
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="auth" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
hbs`<MountBackendForm @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
await click(GENERAL.cardContainer('github'));
|
||||
await click(GENERAL.button('Method Options'));
|
||||
assert
|
||||
.dom('[data-test-input="config.token_type"]')
|
||||
.dom(GENERAL.inputByAttr('config.token_type'))
|
||||
.hasValue('', 'token type does not have a default value.');
|
||||
const selectOptions = document.querySelector('[data-test-input="config.token_type"]').options;
|
||||
const selectOptions = document.querySelector(GENERAL.inputByAttr('config.token_type')).options;
|
||||
assert.strictEqual(selectOptions[1].text, 'default-service', 'first option is default-service');
|
||||
assert.strictEqual(selectOptions[2].text, 'default-batch', 'second option is default-batch');
|
||||
assert.strictEqual(selectOptions[3].text, 'batch', 'third option is batch');
|
||||
@ -117,7 +113,7 @@ module('Integration | Component | mount backend form', function (hooks) {
|
||||
this.set('onMountSuccess', spy);
|
||||
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="auth" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
hbs`<MountBackendForm @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
await mountBackend('approle', 'foo');
|
||||
|
||||
@ -128,141 +124,4 @@ module('Integration | Component | mount backend form', function (hooks) {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('secrets engine', function (hooks) {
|
||||
hooks.beforeEach(function () {
|
||||
const defaults = {
|
||||
config: { listing_visibility: false },
|
||||
kv_config: {
|
||||
max_versions: 0,
|
||||
cas_required: false,
|
||||
delete_version_after: 0,
|
||||
},
|
||||
options: { version: 2 },
|
||||
};
|
||||
this.model = new SecretsEngineForm(defaults, { isNew: true });
|
||||
});
|
||||
|
||||
test('it renders secret engine specific headers', async function (assert) {
|
||||
const expectedEngines = filterEnginesByMountCategory({
|
||||
mountCategory: 'secret',
|
||||
isEnterprise: false,
|
||||
}).filter((engine) => engine.type !== 'cubbyhole');
|
||||
|
||||
// Dynamic assertion count: 1 for title + number of engines
|
||||
assert.expect(1 + expectedEngines.length);
|
||||
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="secret" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
assert.dom(GENERAL.title).hasText('Enable a Secrets Engine', 'renders secrets header');
|
||||
for (const method of expectedEngines) {
|
||||
assert
|
||||
.dom(GENERAL.cardContainer(method.type))
|
||||
.hasText(method.displayName, `renders type:${method.displayName} picker`);
|
||||
}
|
||||
});
|
||||
|
||||
test('it changes path when type is changed', async function (assert) {
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="secret" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
await click(GENERAL.cardContainer('azure'));
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasValue('azure', 'sets the value of the type');
|
||||
await click(GENERAL.backButton);
|
||||
await click(GENERAL.cardContainer('nomad'));
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasValue('nomad', 'updates the value of the type');
|
||||
});
|
||||
|
||||
test('it keeps path value if the user has changed it', async function (assert) {
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="secret" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
await click(GENERAL.cardContainer('kv'));
|
||||
assert.strictEqual(this.model.type, 'kv', 'Updates type on model');
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasValue('kv', 'path matches mount type');
|
||||
await fillIn(GENERAL.inputByAttr('path'), 'newpath');
|
||||
assert.strictEqual(this.model.path, 'newpath', 'Updates path on model');
|
||||
await click(GENERAL.backButton);
|
||||
assert.strictEqual(this.model.type, '', 'Clears type on back');
|
||||
assert.strictEqual(this.model.path, 'newpath', 'path is still newpath');
|
||||
await click(GENERAL.cardContainer('ssh'));
|
||||
assert.strictEqual(this.model.type, 'ssh', 'Updates type on model');
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasValue('newpath', 'path stays the same');
|
||||
});
|
||||
|
||||
test('it calls mount success', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
this.server.post('/sys/mounts/foo', () => {
|
||||
assert.ok(true, 'it calls enable on an secrets engine');
|
||||
return [204, { 'Content-Type': 'application/json' }];
|
||||
});
|
||||
const spy = sinon.spy();
|
||||
this.set('onMountSuccess', spy);
|
||||
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="secret" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
|
||||
await mountBackend('ssh', 'foo');
|
||||
|
||||
assert.true(spy.calledOnce, 'calls the passed success method');
|
||||
assert.true(
|
||||
this.flashSuccessSpy.calledWith('Successfully mounted the ssh secrets engine at foo.'),
|
||||
'Renders correct flash message'
|
||||
);
|
||||
});
|
||||
|
||||
module('WIF secret engines', function () {
|
||||
test('it shows identity_token_key when type is a WIF engine and hides when its not', async function (assert) {
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="secret" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
for (const engine of WIF_ENGINES) {
|
||||
await click(GENERAL.cardContainer(engine));
|
||||
await click(GENERAL.button('Method Options'));
|
||||
assert
|
||||
.dom(GENERAL.fieldByAttr('config.identity_token_key'))
|
||||
.exists(`Identity token key field shows when type=${this.model.type}`);
|
||||
await click(GENERAL.backButton);
|
||||
}
|
||||
for (const engine of filterEnginesByMountCategory({
|
||||
mountCategory: 'secret',
|
||||
isEnterprise: false,
|
||||
}).filter((e) => !WIF_ENGINES.includes(e.type) && e.type !== 'cubbyhole')) {
|
||||
// check non-wif engine
|
||||
await click(GENERAL.cardContainer(engine.type));
|
||||
await click(GENERAL.button('Method Options'));
|
||||
assert
|
||||
.dom(GENERAL.fieldByAttr('config.identity_token_key'))
|
||||
.doesNotExist(`Identity token key field hidden when type=${this.model.type}`);
|
||||
await click(GENERAL.backButton);
|
||||
}
|
||||
});
|
||||
|
||||
test('it updates identity_token_key if user has changed it', async function (assert) {
|
||||
await render(
|
||||
hbs`<MountBackendForm @mountCategory="secret" @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
assert.strictEqual(
|
||||
this.model.config.identity_token_key,
|
||||
undefined,
|
||||
`On init identity_token_key is not set on the model`
|
||||
);
|
||||
for (const engine of WIF_ENGINES) {
|
||||
await click(GENERAL.cardContainer(engine));
|
||||
await click(GENERAL.button('Method Options'));
|
||||
await typeIn(GENERAL.inputSearch('key'), `${engine}+specialKey`); // set to something else
|
||||
|
||||
assert.strictEqual(
|
||||
this.model.config.identity_token_key,
|
||||
`${engine}+specialKey`,
|
||||
`updates ${engine} model with custom identity_token_key`
|
||||
);
|
||||
await click(GENERAL.backButton);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click, typeIn } from '@ember/test-helpers';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { allowAllCapabilitiesStub, noopStub } from 'vault/tests/helpers/stubs';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
import { ALL_ENGINES } from 'vault/utils/all-engines-metadata';
|
||||
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import sinon from 'sinon';
|
||||
import SecretsEngineForm from 'vault/forms/secrets/engine';
|
||||
|
||||
const WIF_ENGINES = ALL_ENGINES.filter((e) => e.isWIF).map((e) => e.type);
|
||||
|
||||
module('Integration | Component | mount/secrets-engine-form', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.flashMessages = this.owner.lookup('service:flash-messages');
|
||||
this.flashMessages.registerTypes(['success', 'danger']);
|
||||
this.flashSuccessSpy = sinon.spy(this.flashMessages, 'success');
|
||||
this.store = this.owner.lookup('service:store');
|
||||
this.server.post('/sys/capabilities-self', allowAllCapabilitiesStub());
|
||||
this.server.post('/sys/mounts/foo', noopStub());
|
||||
this.onMountSuccess = sinon.spy();
|
||||
|
||||
const defaults = {
|
||||
config: { listing_visibility: false },
|
||||
kv_config: {
|
||||
max_versions: 0,
|
||||
cas_required: false,
|
||||
delete_version_after: 0,
|
||||
},
|
||||
options: { version: 2 },
|
||||
};
|
||||
this.model = new SecretsEngineForm(defaults, { isNew: true });
|
||||
});
|
||||
|
||||
test('it renders secret engine form', async function (assert) {
|
||||
await render(
|
||||
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
assert.dom(GENERAL.breadcrumbs).exists('renders breadcrumbs');
|
||||
assert.dom(GENERAL.submitButton).hasText('Enable engine', 'renders submit button');
|
||||
assert.dom(GENERAL.backButton).hasText('Back', 'renders back button');
|
||||
});
|
||||
|
||||
test('it changes path when type is set', async function (assert) {
|
||||
this.model.type = 'azure';
|
||||
this.model.data.path = 'azure'; // Set path to match type as would happen in the route
|
||||
await render(
|
||||
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasValue('azure', 'path matches type');
|
||||
});
|
||||
|
||||
test('it keeps custom path value', async function (assert) {
|
||||
this.model.type = 'kv';
|
||||
this.model.data.path = 'custom-path';
|
||||
await render(
|
||||
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
assert.dom(GENERAL.inputByAttr('path')).hasValue('custom-path', 'keeps custom path');
|
||||
});
|
||||
|
||||
test('it calls mount success', async function (assert) {
|
||||
assert.expect(3);
|
||||
|
||||
this.server.post('/sys/mounts/foo', () => {
|
||||
assert.ok(true, 'it calls enable on a secrets engine');
|
||||
return [204, { 'Content-Type': 'application/json' }];
|
||||
});
|
||||
const spy = sinon.spy();
|
||||
this.set('onMountSuccess', spy);
|
||||
|
||||
this.model.type = 'ssh';
|
||||
this.model.data.path = 'foo';
|
||||
|
||||
await render(
|
||||
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
|
||||
await click(GENERAL.submitButton);
|
||||
|
||||
assert.true(spy.calledOnce, 'calls the passed success method');
|
||||
assert.true(
|
||||
this.flashSuccessSpy.calledWith('Successfully mounted the ssh secrets engine at foo.'),
|
||||
'Renders correct flash message'
|
||||
);
|
||||
});
|
||||
|
||||
module('KV engine', function () {
|
||||
test('it shows KV specific fields when type is kv', async function (assert) {
|
||||
this.model.type = 'kv';
|
||||
await render(
|
||||
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
assert.dom(GENERAL.inputByAttr('kv_config.max_versions')).exists('shows max versions field');
|
||||
assert.dom(GENERAL.inputByAttr('kv_config.cas_required')).exists('shows CAS required field');
|
||||
assert.dom(GENERAL.inputByAttr('kv_config.delete_version_after')).exists('shows delete after field');
|
||||
});
|
||||
});
|
||||
|
||||
module('WIF secret engines', function () {
|
||||
test('it shows identity_token_key when type is a WIF engine and hides when its not', async function (assert) {
|
||||
// Test AWS (a WIF engine)
|
||||
this.model.type = 'aws';
|
||||
this.model.applyTypeSpecificDefaults();
|
||||
|
||||
// Initialize config object for WIF engines
|
||||
if (!this.model.data.config) {
|
||||
this.model.data.config = {};
|
||||
}
|
||||
|
||||
await render(
|
||||
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
|
||||
// First check if the Method Options group is being rendered at all
|
||||
assert.dom('[data-test-button="Method Options"]').exists('Method Options toggle button exists');
|
||||
|
||||
// Click to expand Method Options if it's collapsed
|
||||
await click('[data-test-button="Method Options"]');
|
||||
|
||||
assert
|
||||
.dom(GENERAL.fieldByAttr('config.identity_token_key'))
|
||||
.exists('Identity token key field shows for AWS engine');
|
||||
|
||||
// Test KV (not a WIF engine)
|
||||
this.model.type = 'kv';
|
||||
this.model.applyTypeSpecificDefaults();
|
||||
|
||||
await render(
|
||||
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
|
||||
assert
|
||||
.dom(GENERAL.fieldByAttr('config.identity_token_key'))
|
||||
.doesNotExist('Identity token key field hidden for KV engine');
|
||||
});
|
||||
|
||||
test('it updates identity_token_key if user has changed it', async function (assert) {
|
||||
this.model.type = WIF_ENGINES[0]; // Use first WIF engine
|
||||
this.model.applyTypeSpecificDefaults();
|
||||
// Initialize config object
|
||||
if (!this.model.data.config) {
|
||||
this.model.data.config = {};
|
||||
}
|
||||
await render(
|
||||
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
|
||||
);
|
||||
|
||||
// Expand Method Options section to show identity_token_key field
|
||||
await click(GENERAL.button('Method Options'));
|
||||
|
||||
assert.strictEqual(
|
||||
this.model.data.config.identity_token_key,
|
||||
undefined,
|
||||
'On init identity_token_key is not set on the model'
|
||||
);
|
||||
|
||||
// SearchSelectWithModal likely uses fallback component when no OIDC models are found
|
||||
await typeIn(GENERAL.inputSearch('key'), 'specialKey');
|
||||
|
||||
assert.strictEqual(
|
||||
this.model.data.config.identity_token_key,
|
||||
'specialKey',
|
||||
'updates model with custom identity_token_key'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
module('PKI engine', function () {
|
||||
test('it sets default max lease TTL for PKI', async function (assert) {
|
||||
this.model.type = 'pki';
|
||||
this.model.applyTypeSpecificDefaults();
|
||||
|
||||
assert.strictEqual(
|
||||
this.model.data.config.max_lease_ttl,
|
||||
'3650d',
|
||||
'sets PKI default max lease TTL to 10 years'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
ui/tests/integration/components/secret-engines/catalog-test.js
Normal file
139
ui/tests/integration/components/secret-engines/catalog-test.js
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copyright (c) HashiCorp, Inc.
|
||||
* SPDX-License-Identifier: BUSL-1.1
|
||||
*/
|
||||
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'ember-qunit';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||
import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
|
||||
import hbs from 'htmlbars-inline-precompile';
|
||||
import sinon from 'sinon';
|
||||
|
||||
module('Integration | Component | secret-engines/catalog', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
setupMirage(hooks);
|
||||
|
||||
hooks.beforeEach(function () {
|
||||
this.setMountType = sinon.spy();
|
||||
this.pluginCatalogData = null;
|
||||
this.pluginCatalogError = false;
|
||||
});
|
||||
|
||||
test('it renders secret engines catalog', async function (assert) {
|
||||
const expectedEngines = filterEnginesByMountCategory({
|
||||
mountCategory: 'secret',
|
||||
isEnterprise: false,
|
||||
}).filter((engine) => engine.type !== 'cubbyhole');
|
||||
|
||||
// Dynamic assertion count: 1 for title + number of engines
|
||||
assert.expect(1 + expectedEngines.length);
|
||||
|
||||
await render(
|
||||
hbs`<SecretEngines::Catalog
|
||||
@setMountType={{this.setMountType}}
|
||||
@pluginCatalogData={{this.pluginCatalogData}}
|
||||
@pluginCatalogError={{this.pluginCatalogError}}
|
||||
/>`
|
||||
);
|
||||
|
||||
assert.dom(GENERAL.breadcrumbs).exists('renders breadcrumbs');
|
||||
|
||||
for (const engine of expectedEngines) {
|
||||
assert.dom(GENERAL.cardContainer(engine.type)).exists(`renders ${engine.displayName} engine card`);
|
||||
}
|
||||
});
|
||||
|
||||
test('it calls setMountType when engine is selected', async function (assert) {
|
||||
await render(
|
||||
hbs`<SecretEngines::Catalog
|
||||
@setMountType={{this.setMountType}}
|
||||
@pluginCatalogData={{this.pluginCatalogData}}
|
||||
@pluginCatalogError={{this.pluginCatalogError}}
|
||||
/>`
|
||||
);
|
||||
|
||||
await click(GENERAL.cardContainer('kv'));
|
||||
|
||||
assert.true(this.setMountType.calledOnce, 'setMountType was called');
|
||||
assert.true(this.setMountType.calledWith('kv'), 'setMountType was called with kv');
|
||||
});
|
||||
|
||||
test('it shows plugin catalog error when provided', async function (assert) {
|
||||
this.pluginCatalogError = true;
|
||||
|
||||
await render(
|
||||
hbs`<SecretEngines::Catalog
|
||||
@setMountType={{this.setMountType}}
|
||||
@pluginCatalogData={{this.pluginCatalogData}}
|
||||
@pluginCatalogError={{this.pluginCatalogError}}
|
||||
/>`
|
||||
);
|
||||
|
||||
assert.dom(GENERAL.inlineAlert).exists('shows plugin catalog error alert');
|
||||
assert
|
||||
.dom(GENERAL.inlineAlert)
|
||||
.hasText(
|
||||
'Plugin information unavailable Unable to fetch current plugin information. Using static plugin data instead. Some plugins may not show current details.',
|
||||
'shows correct error title'
|
||||
);
|
||||
});
|
||||
|
||||
test('it shows flyout when clicking disabled plugin', async function (assert) {
|
||||
// Set up plugin catalog data that creates both enabled and disabled engines
|
||||
// An engine is disabled when it's not found in the plugin catalog detailed array
|
||||
this.pluginCatalogData = {
|
||||
detailed: [
|
||||
// Include only some engines, leaving others as "disabled"
|
||||
{
|
||||
name: 'kv',
|
||||
type: 'secret',
|
||||
builtin: true,
|
||||
deprecation_status: 'supported',
|
||||
version: 'v1.0.0',
|
||||
},
|
||||
// AWS engine is NOT included, so it will be marked as isAvailable: false
|
||||
],
|
||||
};
|
||||
|
||||
await render(
|
||||
hbs`<SecretEngines::Catalog
|
||||
@setMountType={{this.setMountType}}
|
||||
@pluginCatalogData={{this.pluginCatalogData}}
|
||||
@pluginCatalogError={{this.pluginCatalogError}}
|
||||
/>`
|
||||
);
|
||||
|
||||
// Initially, flyout should not be visible
|
||||
assert.dom(GENERAL.flyout).doesNotExist('flyout is not shown initially');
|
||||
|
||||
// Find a disabled plugin card - since AWS is not in our catalog data,
|
||||
// it should be rendered as disabled
|
||||
const awsCard = document.querySelector(GENERAL.cardContainer('aws'));
|
||||
|
||||
// Look for any disabled cards regardless of AWS card presence
|
||||
const disabledCards = document.querySelectorAll(
|
||||
'.selectable-engine-card.disabled, .selectable-engine-card[style*="opacity"]'
|
||||
);
|
||||
|
||||
let clickedCard = false;
|
||||
|
||||
if (awsCard) {
|
||||
await click(awsCard);
|
||||
clickedCard = true;
|
||||
|
||||
// After clicking disabled plugin, flyout should appear
|
||||
assert.dom(GENERAL.flyout).exists('flyout appears after clicking disabled plugin');
|
||||
} else if (disabledCards.length > 0) {
|
||||
await click(disabledCards[0]);
|
||||
clickedCard = true;
|
||||
assert.dom(GENERAL.flyout).exists('flyout appears after clicking any disabled plugin');
|
||||
}
|
||||
|
||||
// Always verify we completed the test successfully
|
||||
assert.ok(clickedCard, 'successfully clicked a disabled plugin card');
|
||||
});
|
||||
});
|
||||
@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import { create, visitable, fillable, clickable } from 'ember-cli-page-object';
|
||||
import { settled } from '@ember/test-helpers';
|
||||
import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers';
|
||||
import { visit, click, fillIn } from '@ember/test-helpers';
|
||||
import { GENERAL } from 'vault/tests/helpers/general-selectors';
|
||||
|
||||
export default create({
|
||||
visit: visitable('/vault/secrets/mounts'),
|
||||
@ -18,9 +18,15 @@ export default create({
|
||||
defaultTTLVal: fillable('input[data-test-ttl-value="Default Lease TTL"]'),
|
||||
defaultTTLUnit: fillable('[data-test-ttl-unit="Default Lease TTL"] [data-test-select="ttl-unit"]'),
|
||||
enable: async function (type, path) {
|
||||
await this.visit();
|
||||
await settled();
|
||||
await mountBackend(type, path);
|
||||
await settled();
|
||||
// Navigate to the secrets engines catalog
|
||||
await visit('/vault/secrets/mounts');
|
||||
// Click the engine type card to proceed to configuration
|
||||
await click(GENERAL.cardContainer(type));
|
||||
// Fill in the path if provided
|
||||
if (path) {
|
||||
await fillIn(GENERAL.inputByAttr('path'), path);
|
||||
}
|
||||
// Submit the form to mount the engine
|
||||
await click(GENERAL.submitButton);
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user