[VAULT-37521] UI: decouple auth and secret engines (#9307) (#9347)

* [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:
Vault Automation 2025-09-15 14:22:35 -04:00 committed by GitHub
parent 8a3e640186
commit 7d026fa5a8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 904 additions and 245 deletions

View File

@ -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);
}

View 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>

View 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');
}
}

View 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}}
/>

View 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;
}
}

View 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();
}
}

View File

@ -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;

View File

@ -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', {

View 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;
}
}

View File

@ -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,
});
};
}
}

View File

@ -0,0 +1,6 @@
{{!
Copyright (c) HashiCorp, Inc.
SPDX-License-Identifier: BUSL-1.1
}}
<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{action "onMountSuccess"}} />

View File

@ -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}}
/>

View File

@ -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"}} />

View File

@ -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();

View File

@ -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);
}
});
});
});
});

View File

@ -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'
);
});
});
});

View 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');
});
});

View File

@ -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);
},
});