Merge remote-tracking branch 'remotes/from/ce/main'
Some checks are pending
build / setup (push) Waiting to run
build / Check ce/* Pull Requests (push) Blocked by required conditions
build / ui (push) Blocked by required conditions
build / artifacts-ce (push) Blocked by required conditions
build / artifacts-ent (push) Blocked by required conditions
build / hcp-image (push) Blocked by required conditions
build / test (push) Blocked by required conditions
build / test-hcp-image (push) Blocked by required conditions
build / completed-successfully (push) Blocked by required conditions
CI / setup (push) Waiting to run
CI / Run Autopilot upgrade tool (push) Blocked by required conditions
CI / Run Go tests (push) Blocked by required conditions
CI / Run Go tests tagged with testonly (push) Blocked by required conditions
CI / Run Go tests with data race detection (push) Blocked by required conditions
CI / Run Go tests with FIPS configuration (push) Blocked by required conditions
CI / Test UI (push) Blocked by required conditions
CI / tests-completed (push) Blocked by required conditions
Run linters / Setup (push) Waiting to run
Run linters / Deprecated functions (push) Blocked by required conditions
Run linters / Code checks (push) Blocked by required conditions
Run linters / Protobuf generate delta (push) Blocked by required conditions
Run linters / Format (push) Blocked by required conditions
Run linters / Semgrep (push) Waiting to run
Check Copywrite Headers / copywrite (push) Waiting to run
Security Scan / scan (push) Waiting to run

This commit is contained in:
hc-github-team-secure-vault-core 2026-02-10 23:13:12 +00:00
commit 64d7b4978b
24 changed files with 2727 additions and 156 deletions

3
changelog/_11659.txt Normal file
View file

@ -0,0 +1,3 @@
```release-note:feature
**UI: Mount versioned external plugins**: Adds ability to mount previously registered, external plugins and specify a version when enabling secrets engines.
```

View file

@ -13,20 +13,105 @@
<MessageError @errorMessage={{this.errorMessage}} />
<form {{on "submit" (perform this.mountBackend)}}>
{{! Plugin registration type (built-in vs external) }}
<Hds::Form::RadioCard::Group @name="plugin-type" class="has-bottom-margin-m" as |RadioGroup|>
<RadioGroup.Legend>Plugin registration type</RadioGroup.Legend>
{{#each this.pluginTypeOptions as |option|}}
<RadioGroup.RadioCard
@checked={{eq this.pluginRegistrationType option.type}}
{{on "change" (fn this.setPluginType option.type)}}
@disabled={{option.disabled}}
data-test-radio-card={{option.dataTestAttr}}
as |Card|
>
<Card.Icon @name={{option.icon}} />
<Card.Label>{{option.label}}</Card.Label>
{{#if option.showBadge}}
<Card.Badge @text="Enterprise" data-test-badge="external-enterprise" />
{{/if}}
<Card.Description>{{option.description}}</Card.Description>
{{#if option.showAlert}}
<Card.Generic>
<Hds::Alert @type="compact" @color="neutral" class="has-top-padding-xs" data-test-inline-alert as |A|>
<A.Description>No external plugins for this engine are currently registered in your plugin catalog.</A.Description>
</Hds::Alert>
</Card.Generic>
{{/if}}
</RadioGroup.RadioCard>
{{/each}}
</Hds::Form::RadioCard::Group>
{{! Plugin version selection (only shows for external plugins) }}
{{#if this.shouldShowPluginVersionField}}
<div class="field" data-test-field="config.plugin_version">
<Hds::Form::Select::Field
@isRequired={{true}}
name="plugin-version"
data-test-select="plugin-version"
@value={{this.selectedPluginVersion}}
{{on "change" this.onPluginVersionChange}}
as |F|
>
<F.Label>Plugin version</F.Label>
<F.HelperText data-test-help-text="config.plugin_version">
Specifies the semantic version of the plugin to use, e.g. "v1.0.0".
{{#if @model.hasUnversionedPlugins}}
Un-versioned plugins are not supported, they must be enabled via CLI.
{{/if}}
{{#if this.pinnedVersionForCurrentPlugin}}
{{this.pinnedVersionForCurrentPlugin}}
is pinned for this plugin.
{{/if}}
</F.HelperText>
<F.Options>
{{#each this.filteredVersionOptions as |version|}}
<option value={{version}} data-test-version-option={{version}}>
{{version}}
{{#if (eq version this.pinnedVersionForCurrentPlugin)}}
(pinned)
{{/if}}
</option>
{{/each}}
</F.Options>
{{#if (get this.formValidations "config.plugin_version.errors.length")}}
<F.Error>
{{#each (get this.formValidations "config.plugin_version.errors") as |error|}}
{{error}}
{{/each}}
</F.Error>
{{/if}}
</Hds::Form::Select::Field>
{{! Warning when selected version differs from pinned version }}
{{#if this.shouldShowPinWarning}}
<Hds::Alert @type="inline" @color="warning" class="has-top-margin-s" as |A|>
<A.Title>Version differs from pinned</A.Title>
<A.Description>
You have selected
{{this.selectedPluginVersion}}, but version
{{this.pinnedVersionForCurrentPlugin}}
is pinned for this plugin. Enabling the engine with this version will override the pinned version for this
mount.
</A.Description>
</Hds::Alert>
{{/if}}
</div>
{{/if}}
<FormFieldGroups
@model={{@model}}
@model={{@model.form}}
@groupName="formFieldGroups"
@renderGroup="default"
@modelValidations={{this.modelValidations}}
@modelValidations={{this.formValidations}}
@onKeyUp={{this.onKeyUp}}
/>
<FormFieldGroups @model={{@model}} @renderGroup="Method Options" @groupName="formFieldGroups">
<FormFieldGroups @model={{@model.form}} @renderGroup="Method Options" @groupName="formFieldGroups">
<:identityTokenKey>
<SearchSelectWithModal
@id="key"
@fallbackComponent="input-search"
@inputValue={{@model.data.config.identity_token_key}}
@inputValue={{@model.form.data.config.identity_token_key}}
@onChange={{this.handleIdentityTokenKeyChange}}
@models={{array "oidc/key"}}
@selectLimit="1"

View file

@ -3,24 +3,44 @@
* 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 { capitalize } from '@ember/string';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
import type Router from '@ember/routing/router';
import type FlashMessagesService from 'ember-cli-flash/services/flash-messages';
import type SecretsEngineForm from 'vault/forms/secrets/engine';
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 type VersionService from 'vault/services/version';
import { isAddonEngine } from 'vault/utils/all-engines-metadata';
import { getExternalPluginNameFromBuiltin } from 'vault/utils/external-plugin-helpers';
import type { EngineVersionInfo } from 'vault/utils/plugin-catalog-helpers';
import { sortVersions } from 'vault/utils/version-utils';
import type { ValidationMap } from 'vault/vault/app-types';
// Extended config interface for plugin mounting
interface ExtendedMountConfig {
plugin_version?: string;
override_pinned_version?: boolean;
[key: string]: any;
}
enum PluginRegistrationType {
BUILTIN = 'builtin',
EXTERNAL = 'external',
}
interface Args {
model: SecretsEngineForm;
model: {
form: SecretsEngineForm;
availableVersions?: EngineVersionInfo[];
hasUnversionedPlugins?: boolean;
pinnedVersion?: string | null;
};
onMountSuccess?: (type: string, path: string, useEngineRoute: boolean) => void;
}
@ -28,6 +48,12 @@ interface Args {
* @module Mount::SecretsEngineForm
* Modern component for mounting secrets engines using the SecretsEngineForm.
*
* Plugin version handling:
* - Plugin type (built-in/external) is selected via radio cards
* - Version dropdown appears only for external plugins
* - When version changes, onPluginVersionChange updates model and type
* - The model's handlePluginVersionChange method updates the type to use external plugin name if needed
*
* @example
* ```hbs
* <Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />
@ -38,10 +64,43 @@ export default class MountSecretsEngineFormComponent extends Component<Args> {
@service declare api: ApiService;
@service declare capabilities: CapabilitiesService;
@service declare router: Router;
@service declare version: VersionService;
@tracked modelValidations: ValidationMap | null = null;
@tracked formValidations: ValidationMap | null = null;
@tracked invalidFormAlert: string | null = null;
@tracked errorMessage: string | string[] = '';
@tracked pluginRegistrationType: 'builtin' | 'external' = PluginRegistrationType.BUILTIN;
@tracked selectedPluginVersion = '';
_originalBuiltinType = '';
// Plugin registration type constants
PluginRegistrationType = PluginRegistrationType;
constructor(owner: unknown, args: Args) {
super(owner, args);
// Store the original builtin type for restoration when switching back from external
this._originalBuiltinType = this.args.model.form.normalizedType;
// Initialize plugin version
this.configObject.plugin_version = '';
}
// Helper to get config object with proper typing
get configObject() {
return this.args.model.form.data.config as ExtendedMountConfig;
}
// Check if current plugin registration type is builtin
get isBuiltinPlugin(): boolean {
return this.pluginRegistrationType === PluginRegistrationType.BUILTIN;
}
// Check if current plugin registration type is external
get isExternalPlugin(): boolean {
return this.pluginRegistrationType === PluginRegistrationType.EXTERNAL;
}
get breadcrumbs() {
const breadcrumbs: { label: string; route?: string; icon?: string }[] = [
@ -50,26 +109,171 @@ export default class MountSecretsEngineFormComponent extends Component<Args> {
{ label: 'Enable secrets engine', route: 'vault.cluster.secrets.enable' },
];
if (this.args?.model.type) {
breadcrumbs.push({ label: capitalize(this.args?.model?.type) });
if (this.args?.model?.form?.normalizedType) {
breadcrumbs.push({ label: capitalize(this.args?.model?.form?.normalizedType) });
}
return breadcrumbs;
}
get mountForm(): SecretsEngineForm {
return this.args.model;
get pluginTypeOptions() {
return [
{
type: this.PluginRegistrationType.BUILTIN,
icon: 'server',
label: 'Built-in plugin',
description:
'Preregistered plugins shipped with Vault. The plugin version is tied to your Vault version and cannot be specified.',
dataTestAttr: 'builtin',
disabled: false,
showBadge: false,
showAlert: false,
},
{
type: this.PluginRegistrationType.EXTERNAL,
icon: 'download',
label: 'External plugin',
description:
'External plugins manually registered in your plugin catalog. If multiple versions are registered, you can specify which version to enable.',
dataTestAttr: 'external',
disabled: this.shouldDisableExternal,
showBadge: !this.version.isEnterprise,
showAlert: this.shouldShowNoExternalVersionsMessage,
},
];
}
@action
onKeyUp(name: string, value: string) {
set(this.mountForm.data, name, value);
(this.args.model.form.data as any)[name] = value;
}
// Get pinned version for current external plugin
get pinnedVersionForCurrentPlugin(): string | null {
return this.args.model.pinnedVersion || null;
}
// Check if External radio should be disabled (only built-in versions available or no Enterprise license)
get shouldDisableExternal(): boolean {
// Disable if no Enterprise license
if (!this.version.isEnterprise) {
return true;
}
// Disable if no external versions available
if (!this.args.model.availableVersions) {
return true;
}
return !this.args.model.availableVersions.some((version) => !version.isBuiltin);
}
// Get the external plugin name for the current engine type
get externalPluginName(): string | null {
const engineType = this.args.model.form.normalizedType;
return getExternalPluginNameFromBuiltin(engineType);
}
// Check if we should show info message for disabled external card due to no external versions
get shouldShowNoExternalVersionsMessage(): boolean {
return this.version.isEnterprise && this.shouldDisableExternal;
}
// Check if plugin version field should be shown
get shouldShowPluginVersionField(): boolean {
// Only show for external plugins
if (!this.isExternalPlugin) {
return false;
}
// Only show if we have external versions
return this.getExternalVersionList().length > 0;
}
// Get external version options with default pinned version
get filteredVersionOptions(): string[] {
const versionList = this.getExternalVersionList();
if (versionList.length === 0) {
return [];
}
// Sort versions with pinned version first if it exists and pins are loaded
const pinnedVersion = this.pinnedVersionForCurrentPlugin;
if (pinnedVersion && versionList.includes(pinnedVersion)) {
const sortedVersions = [pinnedVersion, ...versionList.filter((v) => v !== pinnedVersion)];
return sortedVersions;
}
// Sort by semantic version (highest first)
return sortVersions(versionList, true);
}
// Extract common version list filtering logic
private getExternalVersionList(): string[] {
const versions = this.args.model.availableVersions;
if (!versions || !Array.isArray(versions)) {
return [];
}
// Filter external versions and exclude empty strings
const externalVersions = versions.filter((version) => !version.isBuiltin && version.version !== '');
return externalVersions.map((version) => version.version);
}
// Check if the currently selected version differs from the pinned version
get shouldShowPinWarning(): boolean {
if (!this.isExternalPlugin) {
return false;
}
const pinnedVersion = this.pinnedVersionForCurrentPlugin;
const currentVersion = this.selectedPluginVersion;
// If there's no pinned version, no warning needed
if (!pinnedVersion) {
return false;
}
// If the current version is undefined/empty, no warning needed
if (!currentVersion) {
return false;
}
// Show warning if there's a pinned version and it's different from current selection
return pinnedVersion !== currentVersion;
}
// Update override flag based on version selection
updateOverridePinnedVersionFlag() {
// For builtin plugins, ensure override flag is not sent
if (this.isBuiltinPlugin) {
delete this.configObject.override_pinned_version;
return;
}
const pinnedVersion = this.pinnedVersionForCurrentPlugin;
const currentVersion = this.configObject.plugin_version;
if (pinnedVersion && currentVersion && pinnedVersion !== currentVersion) {
// User selected a version different from pinned - include both parameters
this.configObject.plugin_version = currentVersion;
this.configObject.override_pinned_version = true;
} else if (pinnedVersion && currentVersion === pinnedVersion) {
// User is using the pinned version - omit both parameters (backend will use pin)
delete this.configObject.plugin_version;
delete this.configObject.override_pinned_version;
} else {
// No pinned version exists - include plugin_version but not override flag
this.configObject.plugin_version = currentVersion;
delete this.configObject.override_pinned_version;
}
}
// Save KV configuration if applicable
@action
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 isKvV2 = options?.version === 2 && ['kv', 'generic'].includes(this.args.model.form.normalizedType);
const hasConfig = max_versions || cas_required || delete_version_after;
if (isKvV2 && hasConfig) {
@ -91,6 +295,8 @@ export default class MountSecretsEngineFormComponent extends Component<Args> {
}
}
// Handle mount errors
@action
async onMountError(status: number, errors: unknown[] | undefined, message: string) {
if (status === 403) {
this.flashMessages.danger(
@ -114,20 +320,26 @@ export default class MountSecretsEngineFormComponent extends Component<Args> {
@task
*mountBackend(event: Event) {
event.preventDefault();
const mountModel = this.mountForm;
const mountModel = this.args.model.form;
const { type } = mountModel;
const { path } = mountModel.data;
// Handle plugin version change before validation in case onKeyUp wasn't called
if (this.args.model.availableVersions && this.configObject.plugin_version) {
mountModel.handlePluginVersionChange(this.args.model.availableVersions);
}
// Only submit form if validations pass
const { isValid, state, invalidFormMessage, data } = mountModel.toJSON();
if (!isValid) {
this.modelValidations = state;
this.formValidations = state;
this.invalidFormAlert = invalidFormMessage;
return;
}
this.errorMessage = '';
this.modelValidations = null;
this.formValidations = null;
this.invalidFormAlert = null;
try {
@ -163,7 +375,7 @@ export default class MountSecretsEngineFormComponent extends Component<Args> {
@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;
const { config } = this.args.model.form.data;
config.identity_token_key = Array.isArray(value) ? value[0] : value;
}
@ -171,4 +383,70 @@ export default class MountSecretsEngineFormComponent extends Component<Args> {
goBack() {
this.router.transitionTo('vault.cluster.secrets.enable');
}
// Set default plugin version for external plugins
private setDefaultPluginVersion() {
const versionList = this.getExternalVersionList();
if (versionList.length === 0) {
this.configObject.plugin_version = '';
this.selectedPluginVersion = '';
return;
}
// Check for pinned version first (pins should be loaded from constructor)
const pinnedVersion = this.pinnedVersionForCurrentPlugin;
if (pinnedVersion && versionList.includes(pinnedVersion)) {
// Use pinned version if available in catalog
this.selectedPluginVersion = pinnedVersion;
this.configObject.plugin_version = pinnedVersion;
} else {
// Use highest semantic version from catalog if no pin or pin not available
const sortedVersions = sortVersions(versionList, true);
const topVersion = sortedVersions[0] || '';
this.selectedPluginVersion = topVersion;
this.configObject.plugin_version = topVersion;
}
// Update override flag based on final selection
this.updateOverridePinnedVersionFlag();
}
@action
setPluginType(type: 'builtin' | 'external') {
this.pluginRegistrationType = type;
// Update the model type based on selection
if (type === PluginRegistrationType.BUILTIN) {
// Use the stored original built-in type (e.g., 'keymgmt')
this.args.model.form.type = this._originalBuiltinType;
// Clear plugin version and override flag for built-in plugins
this.selectedPluginVersion = '';
this.configObject.plugin_version = '';
delete this.configObject.override_pinned_version;
} else {
// Use the external plugin name (e.g., 'vault-plugin-secrets-keymgmt')
this.args.model.form.type = this.externalPluginName || '';
// Set appropriate plugin version based on available versions
this.setDefaultPluginVersion();
}
}
@action
onPluginVersionChange(event: Event) {
const target = event.target as HTMLSelectElement;
const value = target.value;
this.selectedPluginVersion = value;
this.configObject.plugin_version = value;
// Update override flag when user manually changes version
this.updateOverridePinnedVersionFlag();
// Update the type based on the selected version
if (this.args.model.availableVersions) {
this.args.model.form.handlePluginVersionChange(this.args.model.availableVersions);
}
}
}

View file

@ -6,32 +6,41 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends';
import {
supportedSecretBackends,
SupportedSecretBackendsEnum,
} from 'vault/helpers/supported-secret-backends';
import engineDisplayData from 'vault/helpers/engines-display-data';
import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers';
import type SecretsEngineForm from 'vault/forms/secrets/engine';
import type Router from '@ember/routing/router';
import type { EngineVersionInfo } from 'vault/utils/plugin-catalog-helpers';
const SUPPORTED_BACKENDS = supportedSecretBackends();
export default class VaultClusterSecretsEnableCreateController extends Controller {
@service declare router: Router;
declare model: SecretsEngineForm;
declare model: {
form: SecretsEngineForm;
availableVersions: EngineVersionInfo[];
};
@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) {
const engineInfo = engineDisplayData(type);
const effectiveType = getEffectiveEngineType(type);
if (engineInfo && SUPPORTED_BACKENDS.includes(effectiveType as SupportedSecretBackendsEnum)) {
if (useEngineRoute && engineInfo.engineRoute) {
transition = this.router.transitionTo(
`vault.cluster.secrets.backend.${engineInfo.engineRoute}`,
path
);
} else if (engineInfo) {
} else {
// For keymgmt, we need to land on provider tab by default using query params
const queryParams = engineInfo.type === 'keymgmt' ? { tab: 'provider' } : {};
const queryParams = effectiveType === 'keymgmt' ? { tab: 'provider' } : {};
transition = this.router.transitionTo('vault.cluster.secrets.backend.index', path, { queryParams });
}
} else {

View file

@ -8,6 +8,7 @@ import Controller from '@ember/controller';
import { action } from '@ember/object';
import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends';
import engineDisplayData from 'vault/helpers/engines-display-data';
import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers';
const SUPPORTED_BACKENDS = supportedSecretBackends();
@ -22,16 +23,19 @@ export default class SecretEnableController extends Controller {
@action
onMountSuccess(type, path, useEngineRoute = false) {
let transition;
if (SUPPORTED_BACKENDS.includes(type)) {
const engineInfo = engineDisplayData(type);
if (useEngineRoute) {
const effectiveType = getEffectiveEngineType(type);
const engineInfo = engineDisplayData(type);
if (SUPPORTED_BACKENDS.includes(effectiveType)) {
if (useEngineRoute && engineInfo?.engineRoute) {
transition = this.router.transitionTo(
`vault.cluster.secrets.backend.${engineInfo.engineRoute}`,
path
);
} else {
// For keymgmt, we need to land on provider tab by default using query params
const queryParams = engineInfo.type === 'keymgmt' ? { tab: 'provider' } : {};
const queryParams = effectiveType === 'keymgmt' ? { tab: 'provider' } : {};
transition = this.router.transitionTo('vault.cluster.secrets.backend.index', path, { queryParams });
}
} else {

View file

@ -3,15 +3,22 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import Form from 'vault/forms/form';
import FormField from 'vault/utils/forms/field';
import { tracked } from '@glimmer/tracking';
import Form from 'vault/forms/form';
import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers';
import FormField from 'vault/utils/forms/field';
import { WHITESPACE_WARNING } from 'vault/utils/forms/validators';
import type { Validations } from 'vault/app-types';
import type { SecretsEngineFormData } from 'vault/secrets/engine';
import type { EngineVersionInfo } from 'vault/utils/plugin-catalog-helpers';
import type { AuthMethodFormData } from 'vault/vault/auth/methods';
type ConfigWithPluginVersion = {
plugin_version?: string;
[key: string]: any;
};
// common fields and validations shared between secrets engine and auth methods (mounts)
// used in form classes for consistency and to avoid duplication
export default class MountForm<T extends SecretsEngineFormData | AuthMethodFormData> extends Form<T> {
@ -79,9 +86,83 @@ export default class MountForm<T extends SecretsEngineFormData | AuthMethodFormD
}),
};
// namespaces introduced types with a `ns_` prefix for built-in engines so we will strip that out for consistency
// normalizes type for UI configuration purposes by:
// 1. stripping `ns_` prefix (for namespaced types)
// 2. mapping external plugins to their builtin equivalents for consistent UI experience
get normalizedType() {
return (this.type || '').replace(/^ns_/, '');
const baseType = (this.type || '').replace(/^ns_/, '');
return getEffectiveEngineType(baseType);
}
/**
* Sets up plugin version configuration for the form.
* Since plugin version is handled manually in the template, this method
* only manages the data model setup.
*
* @param availableVersions - Array of available plugin versions
*/
setupPluginVersionField(availableVersions: EngineVersionInfo[] | null | undefined) {
if (!availableVersions || availableVersions.length === 0) {
return;
}
// Initialize plugin_version as empty (default option)
(this.data.config as ConfigWithPluginVersion).plugin_version = '';
}
/**
* Updates the form data with the selected plugin version information.
* For external plugins, this also updates the engine type to match the plugin name,
* enabling proper mounting of external plugins with their specific names.
*
* @param versionInfo - The selected version information containing plugin name, version, and builtin status
*/
setPluginVersionData(versionInfo: EngineVersionInfo) {
// Set the version in config
(this.data.config as ConfigWithPluginVersion).plugin_version = versionInfo.version;
// For external plugins, update the type to the plugin name
if (!versionInfo.isBuiltin) {
this.type = versionInfo.pluginName;
}
}
/**
* Locates the version information object that matches a user-selected value.
* This bridges the gap between the selected version value and the underlying plugin metadata needed for mounting.
*
* @param selectedValue - The selected version value from the UI dropdown (actual semantic version)
* @param availableVersions - Available version options from the plugin catalog
* @returns The matching version info or undefined if no match found
*/
findVersionByLabel(
selectedValue: string,
availableVersions: EngineVersionInfo[]
): EngineVersionInfo | undefined {
// Handle the empty value (default option) - return undefined so we don't send plugin_version
if (!selectedValue || selectedValue === '') {
return undefined;
}
return availableVersions.find((v) => v.version === selectedValue);
}
/**
* Handles plugin version changes and updates the type if needed
* This method should be called whenever the plugin version field changes
*/
handlePluginVersionChange(availableVersions: EngineVersionInfo[]) {
const config = this.data.config as ConfigWithPluginVersion;
const selectedVersion = config?.plugin_version;
if (!selectedVersion || !availableVersions) {
return;
}
// Find the selected version info
const selectedVersionInfo = this.findVersionByLabel(selectedVersion, availableVersions);
if (selectedVersionInfo) {
this.setPluginVersionData(selectedVersionInfo);
}
}
toJSON() {
@ -95,6 +176,13 @@ export default class MountForm<T extends SecretsEngineFormData | AuthMethodFormD
listing_visibility: config?.listing_visibility ? 'unauth' : 'hidden',
},
};
// Remove plugin_version if it's empty (let server choose default)
const configWithPluginVersion = data.config as ConfigWithPluginVersion;
if (!configWithPluginVersion.plugin_version || configWithPluginVersion.plugin_version === '') {
delete configWithPluginVersion.plugin_version;
}
// options are only relevant for kv/generic engines
if (!['kv', 'generic'].includes(this.type)) {
delete data.options;

View file

@ -4,12 +4,15 @@
*/
import MountForm from 'vault/forms/mount';
import { ALL_ENGINES } from 'vault/utils/all-engines-metadata';
import { isKnownExternalPlugin } from 'vault/utils/external-plugin-helpers';
import FormField from 'vault/utils/forms/field';
import FormFieldGroup from 'vault/utils/forms/field-group';
import { ALL_ENGINES } from 'vault/utils/all-engines-metadata';
import type { EngineVersionInfo } from 'vault/utils/plugin-catalog-helpers';
import { isValidVersion } from 'vault/utils/version-utils';
import type { SecretsEngineFormData } from 'vault/secrets/engine';
import type Form from 'vault/forms/form';
import type { SecretsEngineFormData } from 'vault/secrets/engine';
export default class SecretsEngineForm extends MountForm<SecretsEngineFormData> {
constructor(...args: ConstructorParameters<typeof Form>) {
@ -20,6 +23,59 @@ export default class SecretsEngineForm extends MountForm<SecretsEngineFormData>
{ type: 'number', message: 'Maximum versions must be a number.' },
{ type: 'length', options: { min: 1, max: 16 }, message: 'You cannot go over 16 characters.' },
];
// add validation for plugin_version when mounting external plugins
this.validations['config.plugin_version'] = [
{
validator: this.validatePluginVersionForExternalPlugins,
message: 'Plugin version is required when mounting external plugins.',
},
];
}
// Custom validator for plugin version when mounting external plugins
validatePluginVersionForExternalPlugins = (data: any) => {
const pluginVersion = data?.config?.plugin_version;
const pluginType = this.type;
// Check if this is a known external plugin using the proper mapping
const isExternalPluginType = pluginType && isKnownExternalPlugin(pluginType);
if (isExternalPluginType) {
// For external plugins, plugin_version is required UNLESS it's omitted due to pinned version
// When using pinned version, the frontend omits plugin_version entirely (it gets deleted)
// So we allow external plugin types to not have plugin_version (pinned version scenario)
// But if plugin_version IS provided, it must be valid
if (pluginVersion !== undefined && pluginVersion !== null) {
return isValidVersion(pluginVersion);
}
// Allow external plugin types without plugin_version (pinned version case)
return true;
}
// For non-external plugin types, if a version is specified, validate it
if (pluginVersion && pluginVersion.trim() && pluginVersion !== 'null') {
return isValidVersion(pluginVersion);
}
// For all other cases (builtin plugins without version), allow
return true;
};
// Method to handle plugin version changes and update the type accordingly
handlePluginVersionChange(availableVersions: EngineVersionInfo[]) {
const config = this.data.config as { plugin_version?: string };
const pluginVersion = config?.plugin_version;
if (pluginVersion && availableVersions) {
// Find the matching version info
const versionInfo = availableVersions.find((v) => v.version === pluginVersion && !v.isBuiltin);
if (versionInfo) {
// Use the external plugin name format
const externalPluginName = versionInfo.pluginName;
this.type = externalPluginName;
}
}
}
// Method to apply type-specific side effects - called when type changes

View file

@ -207,6 +207,7 @@ export default class SecretEngineModel extends Model {
'config.auditNonHmacResponseKeys',
'config.passthroughRequestHeaders',
'config.allowedResponseHeaders',
'config.plugin_version',
];
switch (this.engineType) {

View file

@ -4,10 +4,18 @@
*/
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import SecretsEngineForm from 'vault/forms/secrets/engine';
import type ApiService from 'vault/services/api';
import type PluginCatalogService from 'vault/services/plugin-catalog';
import { getExternalPluginNameFromBuiltin } from 'vault/utils/external-plugin-helpers';
import { getAllVersionsForEngineType, type EngineVersionInfo } from 'vault/utils/plugin-catalog-helpers';
export default class VaultClusterSecretsEnableCreateRoute extends Route {
model(params: { mount_type: string }) {
@service('plugin-catalog') declare readonly pluginCatalog: PluginCatalogService;
@service declare api: ApiService;
async model(params: { mount_type: string }) {
const { mount_type } = params;
const defaults = {
@ -27,6 +35,52 @@ export default class VaultClusterSecretsEnableCreateRoute extends Route {
// Apply type-specific defaults (e.g., PKI max lease TTL)
form.applyTypeSpecificDefaults();
return form;
// Fetch plugin catalog data to get available versions for this engine type
const pluginCatalogResponse = await this.pluginCatalog.fetchPluginCatalog();
let availableVersions: EngineVersionInfo[] = [];
let hasUnversionedPlugins = false;
if (pluginCatalogResponse.data?.detailed) {
const versionResult = getAllVersionsForEngineType(
pluginCatalogResponse.data.detailed,
mount_type,
'secret'
);
availableVersions = versionResult.versions;
hasUnversionedPlugins = versionResult.hasUnversionedPlugins;
// Set up the plugin version field with available versions
form.setupPluginVersionField(availableVersions);
}
// Get pinned version for this plugin type
let pinnedVersion: string | null = null;
// Only fetch external pinned version if there are external versions available
const hasExternalVersions = availableVersions.some((version) => !version.isBuiltin);
if (hasExternalVersions) {
try {
// Convert builtin type to external plugin name for API call
const externalPluginName = getExternalPluginNameFromBuiltin(mount_type);
if (externalPluginName) {
const response = await this.api.sys.pluginsCatalogPinsReadPinnedVersion(
externalPluginName,
'secret'
);
pinnedVersion = response?.version || null;
}
} catch (error) {
// Silently handle errors - pins are optional
pinnedVersion = null;
}
}
return {
form,
availableVersions,
hasUnversionedPlugins,
pinnedVersion,
};
}
}

View file

@ -63,3 +63,20 @@ export function isKnownExternalPlugin(pluginName: string): boolean {
export function getEffectiveEngineType(pluginType: string): string {
return getBuiltinTypeFromExternalPlugin(pluginType) || pluginType;
}
/**
* Get the external plugin name for a given builtin engine type.
* This function performs a reverse lookup on the external plugin mapping.
*
* @param builtinType - The builtin engine type (e.g., "keymgmt")
* @returns The external plugin name if a mapping exists, otherwise null
*/
export function getExternalPluginNameFromBuiltin(builtinType: string): string | null {
// Find the external plugin name that maps to this builtin type
for (const [externalName, mappedBuiltin] of Object.entries(EXTERNAL_PLUGIN_TO_BUILTIN_MAP)) {
if (mappedBuiltin === builtinType) {
return externalName;
}
}
return null;
}

View file

@ -4,8 +4,9 @@
*/
import { isEmpty } from '@ember/utils';
import type { EngineDisplayData } from './all-engines-metadata';
import type { PluginCatalogPlugin } from 'vault/services/plugin-catalog';
import type { EngineDisplayData } from './all-engines-metadata';
import { getBuiltinTypeFromExternalPlugin, isKnownExternalPlugin } from './external-plugin-helpers';
/**
* Constants for plugin catalog functionality
@ -127,8 +128,14 @@ export function enhanceEnginesWithCatalogData(
// Process secret engines from the detailed array
secretEnginesDetailed.forEach((plugin) => {
// Skip if this plugin already exists in static metadata
if (staticEngineTypes.has(plugin.name)) {
// Skip if this plugin already exists in static metadata or is a builtin plugin
if (staticEngineTypes.has(plugin.name) || plugin.builtin) {
return;
}
// Skip plugins that have known builtin mappings - these should appear in their
// respective categories (e.g., KV, AWS) rather than in the "External" category
if (isKnownExternalPlugin(plugin.name)) {
return;
}
@ -143,7 +150,7 @@ export function enhanceEnginesWithCatalogData(
return plugin.name.includes(engine.type) || plugin.name.includes(engine.type.replace('-', ''));
});
// Create external engine metadata with defaults
// Only create external engines for custom external plugins (external plugins without mappings to builtin Vault plugins)
const externalEngine: EnhancedEngineDisplayData = {
type: plugin.name,
displayName: plugin.name
@ -212,3 +219,87 @@ export function getPluginVersionsFromEngineType(list: PluginCatalogPlugin[] | un
return acc;
}, []);
}
/**
* Version information for a specific plugin engine
*/
export interface EngineVersionInfo {
version: string;
pluginName: string;
isBuiltin: boolean;
}
/**
* Result containing version information and unversioned plugin detection
*/
export interface EngineVersionResult {
versions: EngineVersionInfo[];
hasUnversionedPlugins: boolean;
}
/**
* Retrieves all available plugin versions for a specific engine type from the catalog.
* This enables users to choose between builtin and external plugin variants when mounting
* secrets engines, supporting both standard Vault engines and custom external plugins.
*
* The function handles the mapping between external plugin names (e.g., "vault-plugin-secrets-kv")
* and their corresponding engine types (e.g., "kv") to provide a unified version selection experience.
*
* @param secretEnginesDetailed - Array of detailed secret engine info from catalog API
* @param engineType - The engine type to get versions for (e.g., 'kv', 'aws')
* @param pluginType - Optional plugin type filter ('secret', 'auth', 'database')
* @returns Object containing version information array and flag for unversioned plugins
*/
export function getAllVersionsForEngineType(
secretEnginesDetailed: PluginCatalogPlugin[] | undefined,
engineType: string,
pluginType = 'secret'
): EngineVersionResult {
if (
!engineType ||
!secretEnginesDetailed ||
typeof engineType !== 'string' ||
!Array.isArray(secretEnginesDetailed)
) {
return { versions: [], hasUnversionedPlugins: false };
}
let hasUnversionedPlugins = false;
const filteredVersions: EngineVersionInfo[] = [];
secretEnginesDetailed.forEach((plugin) => {
// Basic validation
if (!plugin?.name || typeof plugin?.builtin !== 'boolean' || typeof plugin?.version !== 'string') {
return;
}
// Filter by plugin type (secret, auth, database)
if (plugin.type !== pluginType) {
return;
}
// Check if this plugin matches the engine type
const isDirectMatch = plugin.name === engineType;
const builtin = getBuiltinTypeFromExternalPlugin(plugin.name);
const isExternalMatch = builtin === engineType;
if (!isDirectMatch && !isExternalMatch) {
return;
}
// Check for unversioned plugins (empty version strings)
if (plugin.version === '') {
hasUnversionedPlugins = true;
return; // Don't include in versions array
}
// Include versioned plugins
filteredVersions.push({
version: plugin.version,
pluginName: plugin.name,
isBuiltin: plugin.builtin,
});
});
return { versions: filteredVersions, hasUnversionedPlugins };
}

View file

@ -0,0 +1,130 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { isKnownExternalPlugin } from 'vault/utils/external-plugin-helpers';
/**
* Utility functions for semantic version handling
*/
/**
* Clean a version string by removing prefixes and suffixes
* @param version - The version string to clean (e.g., "v1.2.3+ent")
* @returns The cleaned version string (e.g., "1.2.3")
*/
export function cleanVersion(version: string): string {
return version.replace(/^v/, '').split(/[+-]/)[0] || '';
}
/**
* Parse a version string into numeric parts
* @param version - The version string to parse
* @returns Array of numeric version parts
*/
export function parseVersion(version: string): number[] {
const cleanVer = cleanVersion(version);
return cleanVer.split('.').map((n) => parseInt(n) || 0);
}
/**
* Compare two version strings using semantic version rules
* @param a - First version to compare
* @param b - Second version to compare
* @returns Negative if a < b, positive if a > b, 0 if equal
*/
export function compareVersions(a: string, b: string): number {
const aParts = parseVersion(a);
const bParts = parseVersion(b);
const maxLength = Math.max(aParts.length, bParts.length);
for (let i = 0; i < maxLength; i++) {
const aPart = aParts[i] || 0;
const bPart = bParts[i] || 0;
if (aPart !== bPart) {
return aPart - bPart;
}
}
return 0;
}
/**
* Sort an array of version strings in semantic version order
* @param versions - Array of version strings to sort
* @param descending - If true, sort highest version first (default: false)
* @returns New sorted array (does not mutate original)
*/
export function sortVersions(versions: string[], descending = false): string[] {
const sorted = versions.slice().sort((a, b) => compareVersions(a, b));
return descending ? sorted.reverse() : sorted;
}
/**
* Find the highest version from an array of version strings
* @param versions - Array of version strings
* @returns The highest version string, or null if array is empty
*/
export function getHighestVersion(versions: string[]): string | null {
if (versions.length === 0) return null;
const sorted = sortVersions(versions, true);
return sorted[0] || null;
}
/**
* Check if version A is greater than version B
* @param a - First version
* @param b - Second version
* @returns True if a > b
*/
export function isVersionGreater(a: string, b: string): boolean {
return compareVersions(a, b) > 0;
}
/**
* Check if two versions are equal
* @param a - First version
* @param b - Second version
* @returns True if versions are equal
*/
export function areVersionsEqual(a: string, b: string): boolean {
return compareVersions(a, b) === 0;
}
/**
* Check if a version string is valid and non-empty
* @param version - The version string to validate
* @returns True if the version is valid
*/
export function isValidVersion(version: string): boolean {
if (!version || typeof version !== 'string') return false;
const trimmed = version.trim();
if (trimmed === '' || trimmed === 'null') return false;
// Basic semantic version pattern check (allows prefixes like 'v' and suffixes like '+ent')
const semverPattern = /^v?\d+(\.\d+)*([+-].+)?$/;
const cleanVer = cleanVersion(trimmed);
return semverPattern.test(`v${cleanVer}`);
}
/**
* Check if a plugin version is required for external plugins
* @param pluginType - The plugin type (e.g., 'keymgmt' or 'vault-plugin-secrets-keymgmt')
* @param pluginVersion - The plugin version string
* @returns True if the plugin version requirement is satisfied
*/
export function isPluginVersionValidForType(pluginType: string, pluginVersion?: string): boolean {
if (!pluginType) return false;
if (isKnownExternalPlugin(pluginType)) {
// External plugins require a valid version
return isValidVersion(pluginVersion || '');
} else {
// Builtin plugins should not have a version specified
return !pluginVersion || pluginVersion.trim() === '' || pluginVersion.trim() === 'null';
}
}

View file

@ -40,7 +40,6 @@ module('Acceptance | auth config form', function (hooks) {
'config.audit_non_hmac_response_keys',
'config.passthrough_request_headers',
'config.allowed_response_headers',
'config.plugin_version',
];
this.tokensGroup = {
Tokens: [

View file

@ -3,39 +3,39 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test, skip } from 'qunit';
import { v4 as uuidv4 } from 'uuid';
import {
click,
currentRouteName,
currentURL,
fillIn,
find,
findAll,
fillIn,
typeIn,
visit,
waitUntil,
waitFor,
waitUntil,
} from '@ember/test-helpers';
import { module, skip, test } from 'qunit';
import { v4 as uuidv4 } from 'uuid';
import { setupApplicationTest } from 'vault/tests/helpers';
import { login } from 'vault/tests/helpers/auth/auth-helpers';
import {
createPolicyCmd,
createTokenCmd,
deleteEngineCmd,
mountEngineCmd,
runCmd,
createTokenCmd,
tokenWithPolicyCmd,
} from 'vault/tests/helpers/commands';
import { personas } from 'vault/tests/helpers/kv/policy-generator';
import { grantAccess, setupControlGroup } from 'vault/tests/helpers/control-groups';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import {
addSecretMetadataCmd,
writeSecret,
writeVersionedSecret,
} from 'vault/tests/helpers/kv/kv-run-commands';
import { FORM, PAGE } from 'vault/tests/helpers/kv/kv-selectors';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { setupControlGroup, grantAccess } from 'vault/tests/helpers/control-groups';
import { personas } from 'vault/tests/helpers/kv/policy-generator';
const secretPath = `my-#:$=?-secret`;
// This doesn't encode in a normal way, so hardcoding it here until we sort that out
@ -44,6 +44,9 @@ const secretPathUrlEncoded = `my-%23:$=%3F-secret`;
const ALL_TABS = ['Overview', 'Secret', 'Metadata', 'Paths', 'Version History'];
const navToBackend = async (backend) => {
await visit(`/vault/secrets-engines`);
// Use search to find the specific backend instead of relying on pagination
await fillIn(GENERAL.inputSearch('secret-engine-path'), backend);
await waitUntil(() => find(`${GENERAL.tableData(`${backend}/`, 'path')} a`));
return click(`${GENERAL.tableData(`${backend}/`, 'path')} a`);
};
const assertPolicyGenerator = async (assert, expectedPaths) => {

View file

@ -3,18 +3,19 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, fillIn } from '@ember/test-helpers';
import { click, fillIn, render } 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 { setupRenderingTest } from 'ember-qunit';
import { module, test } from 'qunit';
import { mountBackend } from 'vault/tests/helpers/components/mount-backend-form-helpers';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import { allowAllCapabilitiesStub, noopStub } from 'vault/tests/helpers/stubs';
import { filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata';
import hbs from 'htmlbars-inline-precompile';
import sinon from 'sinon';
import AuthMethodForm from 'vault/forms/auth/method';
import SecretsEngineForm from 'vault/forms/secrets/engine';
module('Integration | Component | mount backend form', function (hooks) {
setupRenderingTest(hooks);
@ -39,7 +40,6 @@ module('Integration | Component | mount backend form', function (hooks) {
});
test('it renders default state', async function (assert) {
assert.expect(15);
await render(
hbs`<MountBackendForm @mountModel={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
);
@ -103,8 +103,6 @@ module('Integration | Component | mount backend form', function (hooks) {
});
test('it calls mount success', async function (assert) {
assert.expect(3);
this.server.post('/sys/auth/foo', () => {
assert.ok(true, 'it calls enable on an auth method');
return [204, { 'Content-Type': 'application/json' }];
@ -124,4 +122,319 @@ module('Integration | Component | mount backend form', function (hooks) {
);
});
});
module('Plugin Version Selection Integration (Community)', function (hooks) {
hooks.beforeEach(function () {
// Get version service for mocking in individual tests
this.version = this.owner.lookup('service:version');
// Mock plugin pins API endpoint
this.server.get('/sys/plugins/pins', () => {
return {
data: {
pinned_versions: [],
},
};
});
// Set up secrets engine form with KV type already selected
const defaults = {
config: {},
kv_config: {
max_versions: 0,
cas_required: false,
delete_version_after: 0,
},
options: { version: 2 },
};
this.form = new SecretsEngineForm(defaults, { isNew: true });
this.form.type = 'kv'; // Pre-select KV type to skip type selection
this.form.data.path = 'test-path'; // Set a test path
// Mock mount success handler
this.onMountSuccess = sinon.spy();
// Mock available versions (as would be passed from route)
this.availableVersions = [
{
version: 'v1.16.1+builtin',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: true,
sha256: 'abc123',
},
{
version: 'v0.25.0',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: false,
sha256: 'def456',
},
];
this.renderComponent = () =>
render(hbs`
<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>
`);
// Helper function to create a fresh model with new available versions
this.createFreshModel = (type = 'kv', availableVersions = this.availableVersions) => {
const defaults = {
config: {},
kv_config: {
max_versions: 0,
cas_required: false,
delete_version_after: 0,
},
options: { version: 2 },
};
this.form = new SecretsEngineForm(defaults, { isNew: true });
this.form.type = type;
this.form.data.path = 'test-path';
// Update the model structure with new data
this.model = {
form: this.form,
availableVersions: availableVersions,
hasUnversionedPlugins: false,
pinnedVersion: null, // Will be set per test as needed
};
};
// Initialize with default model
this.createFreshModel();
});
test('plugin version field is hidden when only builtin versions available', async function (assert) {
// Mock enterprise mode (even with enterprise, no external versions means no version field)
this.version.type = 'enterprise';
// Mock single builtin version response
this.availableVersions = [
{
version: 'v1.16.1+builtin',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: true,
sha256: 'abc123',
},
];
// Create a fresh model for this test with only builtin versions
this.createFreshModel('kv', this.availableVersions);
await this.renderComponent();
// External radio card should be disabled when no external versions are available
assert
.dom(`input${GENERAL.radioCardByAttr('external')}`)
.isDisabled('external radio card is disabled when only builtin versions available');
// With only builtin versions, external radio should be disabled and version field hidden
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.doesNotExist('plugin version field is hidden when only builtin versions available');
// Check for user messaging about why external option is disabled
assert
.dom(GENERAL.inlineAlert)
.exists('info message explains why external option is disabled when no external versions available');
});
test('external radio card is disabled for community version', async function (assert) {
// Mock version service to simulate community mode
this.version.type = 'community';
await this.renderComponent();
// External radio card should be disabled in community mode
assert
.dom(`input${GENERAL.radioCardByAttr('external')}`)
.isDisabled('external radio card is disabled for community version');
// Enterprise badge should be visible for community users
assert
.dom(GENERAL.badge('external-enterprise'))
.hasText('Enterprise', 'Enterprise badge is shown for community users');
// Plugin version field should not be visible
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.doesNotExist('plugin version field is hidden when external card is disabled');
});
module('Plugin Version Selection Integration (Ent)', function (hooks) {
hooks.beforeEach(function () {
// Set enterprise mode for all tests in this module
this.version.type = 'enterprise';
});
test('plugin version field shows when multiple versions available', async function (assert) {
await this.renderComponent();
// External radio card should not be disabled in enterprise mode
assert
.dom(`input${GENERAL.radioCardByAttr('external')}`)
.isNotDisabled('external radio card is enabled for enterprise version');
// Initially, version field should not be visible (builtin is default)
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.doesNotExist('plugin version field is hidden initially with builtin selection');
// Click External radio card to enable version selection
await click(`input${GENERAL.radioCardByAttr('external')}`);
// Now version field should appear
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.exists('plugin version field appears when external is selected');
// HDS Select component generates a different DOM structure
assert
.dom(`${GENERAL.fieldByAttr('config.plugin_version')} .hds-form-select`)
.exists('plugin version field uses HDS select component');
});
test('selecting default version omits plugin_version from payload', async function (assert) {
// Mock successful mount request
this.server.post('/sys/mounts/test-path', (schema, request) => {
const payload = JSON.parse(request.requestBody);
const hasPluginVersion =
Object.prototype.hasOwnProperty.call(payload, 'config') &&
Object.prototype.hasOwnProperty.call(payload.config, 'plugin_version');
assert.notOk(
hasPluginVersion,
'plugin_version is not included in payload when default is selected'
);
assert.strictEqual(payload.type, 'kv', 'correct engine type is sent');
return {};
});
await this.renderComponent();
// Builtin is default selection (no plugin version field visible), so just submit
await click(GENERAL.submitButton);
});
test('builtin plugin type selected by default sends correct payload', async function (assert) {
// Mock successful mount request
this.server.post('/sys/mounts/test-path', (schema, request) => {
const payload = JSON.parse(request.requestBody);
// With builtin selected (default), no plugin_version should be sent
const hasPluginVersion =
Object.prototype.hasOwnProperty.call(payload, 'config') &&
Object.prototype.hasOwnProperty.call(payload.config, 'plugin_version');
assert.notOk(hasPluginVersion, 'plugin_version is not included for builtin selection');
assert.strictEqual(payload.type, 'kv', 'type remains builtin type for builtin plugins');
return {};
});
await this.renderComponent();
// Builtin is selected by default, no version field shown, just submit
await click(GENERAL.submitButton);
});
test('selecting external version includes plugin_version in payload with external type', async function (assert) {
// Mock successful mount request
this.server.post('/sys/mounts/test-path', (schema, request) => {
const payload = JSON.parse(request.requestBody);
assert.strictEqual(
payload.config.plugin_version,
'v0.25.0',
'plugin_version is included for external version'
);
assert.strictEqual(
payload.type,
'vault-plugin-secrets-kv',
'type is external plugin name for external plugins'
);
return {};
});
await this.renderComponent();
// Click External radio card to enable version selection
await click(`input${GENERAL.radioCardByAttr('external')}`);
// Select the external version from the dropdown
await fillIn(GENERAL.selectByAttr('plugin-version'), 'v0.25.0');
await click(GENERAL.submitButton);
});
test('external radio card is enabled but version field is hidden when plugin has empty version', async function (assert) {
// Create availableVersions with a plugin that has an empty version (registered without version)
this.availableVersions = [
{
version: '', // Empty version when plugin registered without version
pluginName: 'vault-plugin-secrets-keymgmt',
isBuiltin: false,
},
];
// Create a fresh model for this test with empty version plugins
this.createFreshModel('keymgmt', this.availableVersions);
await this.renderComponent();
// External radio card should NOT be disabled
assert
.dom(`input${GENERAL.radioCardByAttr('external')}`)
.isNotDisabled('external radio card is enabled when plugin has empty version');
// Click External radio card to enable version selection
await click(`input${GENERAL.radioCardByAttr('external')}`);
// Version field should NOT appear since only empty versions exist
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.doesNotExist('plugin version field is hidden when only empty version plugins exist');
});
test('version field shows with filtered options when plugin has both empty and non-empty versions', async function (assert) {
// Create availableVersions with both empty and non-empty versions
this.availableVersions = [
{
version: 'v1.16.1+builtin',
pluginName: 'vault-plugin-secrets-keymgmt',
isBuiltin: true,
},
{
version: '', // Empty version (should be filtered out)
pluginName: 'vault-plugin-secrets-keymgmt',
isBuiltin: false,
},
{
version: 'v1.0.0', // Non-empty external version
pluginName: 'vault-plugin-secrets-keymgmt',
isBuiltin: false,
},
];
// Create a fresh model for this test with mixed versions
this.createFreshModel('keymgmt', this.availableVersions);
await this.renderComponent();
// External radio card should NOT be disabled
assert
.dom(`input${GENERAL.radioCardByAttr('external')}`)
.isNotDisabled('external radio card is enabled when plugin has valid external versions');
// Click External radio card to enable version selection
await click(`input${GENERAL.radioCardByAttr('external')}`);
// Version field should appear since we have non-empty external versions
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.exists('plugin version field appears when non-empty external versions exist');
assert
.dom(`${GENERAL.fieldByAttr('config.plugin_version')} option[value="v1.0.0"]`)
.exists('non-empty external version option exists');
});
});
});
});

View file

@ -3,17 +3,17 @@
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render, click, typeIn, fillIn } from '@ember/test-helpers';
import { click, fillIn, render, typeIn } from '@ember/test-helpers';
import { setupMirage } from 'ember-cli-mirage/test-support';
import { setupRenderingTest } from 'ember-qunit';
import { module, test } from 'qunit';
import { GENERAL } from 'vault/tests/helpers/general-selectors';
import {
allowAllCapabilitiesStub,
capabilitiesStub,
noopStub,
overrideResponse,
} 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';
@ -43,7 +43,13 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
},
options: { version: 2 },
};
this.model = new SecretsEngineForm(defaults, { isNew: true });
this.form = new SecretsEngineForm(defaults, { isNew: true });
this.model = {
form: this.form,
availableVersions: [],
hasUnversionedPlugins: false,
};
});
test('it renders secret engine form', async function (assert) {
@ -56,8 +62,8 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
});
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
this.form.type = 'azure';
this.form.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}} />`
);
@ -65,8 +71,8 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
});
test('it keeps custom path value', async function (assert) {
this.model.type = 'kv';
this.model.data.path = 'custom-path';
this.form.type = 'kv';
this.form.data.path = 'custom-path';
await render(
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
);
@ -83,8 +89,8 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
const spy = sinon.spy();
this.set('onMountSuccess', spy);
this.model.type = 'ssh';
this.model.data.path = 'foo';
this.form.type = 'ssh';
this.form.data.path = 'foo';
await render(
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
@ -101,7 +107,7 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
module('KV engine', function (hooks) {
hooks.beforeEach(function () {
this.model.type = 'kv';
this.form.type = 'kv';
});
test('it shows KV specific fields when type is kv', async function (assert) {
@ -150,12 +156,12 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
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();
this.form.type = 'aws';
this.form.applyTypeSpecificDefaults();
// Initialize config object for WIF engines
if (!this.model.data.config) {
this.model.data.config = {};
if (!this.form.data.config) {
this.form.data.config = {};
}
await render(
@ -163,18 +169,18 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
);
// 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');
assert.dom(GENERAL.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"]');
await click(GENERAL.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();
this.form.type = 'kv';
this.form.applyTypeSpecificDefaults();
await render(
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
@ -186,11 +192,11 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
});
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();
this.form.type = WIF_ENGINES[0]; // Use first WIF engine
this.form.applyTypeSpecificDefaults();
// Initialize config object
if (!this.model.data.config) {
this.model.data.config = {};
if (!this.form.data.config) {
this.form.data.config = {};
}
await render(
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
@ -200,7 +206,7 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
await click(GENERAL.button('Method Options'));
assert.strictEqual(
this.model.data.config.identity_token_key,
this.form.data.config.identity_token_key,
undefined,
'On init identity_token_key is not set on the model'
);
@ -209,7 +215,7 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
await typeIn(GENERAL.inputSearch('key'), 'specialKey');
assert.strictEqual(
this.model.data.config.identity_token_key,
this.form.data.config.identity_token_key,
'specialKey',
'updates model with custom identity_token_key'
);
@ -218,14 +224,541 @@ module('Integration | Component | mount/secrets-engine-form', function (hooks) {
module('PKI engine', function () {
test('it sets default max lease TTL for PKI', async function (assert) {
this.model.type = 'pki';
this.model.applyTypeSpecificDefaults();
this.form.type = 'pki';
this.form.applyTypeSpecificDefaults();
assert.strictEqual(
this.model.data.config.max_lease_ttl,
this.form.data.config.max_lease_ttl,
'3650d',
'sets PKI default max lease TTL to 10 years'
);
});
});
module('Plugin registration and versioning', function (hooks) {
hooks.beforeEach(function () {
this.form.type = 'keymgmt';
this.form.data.path = 'keymgmt';
// Mock version service for enterprise checks
this.versionService = this.owner.lookup('service:version');
sinon.stub(this.versionService, 'isEnterprise').value(true);
// Setup available versions for testing and add to model structure
const availableVersions = [
{ version: '1.0.0', pluginName: 'vault-plugin-secrets-keymgmt', isBuiltin: false },
{ version: '1.1.0', pluginName: 'vault-plugin-secrets-keymgmt', isBuiltin: false },
{ version: '2.0.0', pluginName: 'vault-plugin-secrets-keymgmt', isBuiltin: false },
{ version: '', pluginName: 'keymgmt', isBuiltin: true }, // Built-in version
];
this.availableVersions = availableVersions;
this.model.availableVersions = availableVersions;
});
test('it renders plugin type selection radio cards', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
);
assert.dom(`input${GENERAL.radioCardByAttr('builtin')}`).exists('shows built-in plugin radio card');
assert.dom(`input${GENERAL.radioCardByAttr('external')}`).exists('shows external plugin radio card');
assert
.dom(`input${GENERAL.radioCardByAttr('builtin')}`)
.isChecked('built-in plugin is selected by default');
assert
.dom(`input${GENERAL.radioCardByAttr('external')}`)
.isNotChecked('external plugin is not selected by default');
});
test('it defaults to built-in plugin type', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.doesNotExist('plugin version field is hidden for built-in');
assert.strictEqual(this.form.type, 'keymgmt', 'model type remains as built-in name');
});
test('it shows plugin version field when external plugin is selected', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.exists('plugin version field appears for external');
assert.dom(GENERAL.selectByAttr('plugin-version')).exists('plugin version select is rendered');
assert.strictEqual(
this.form.type,
'vault-plugin-secrets-keymgmt',
'model type updates to external plugin name'
);
});
test('it populates version dropdown with sorted options', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
// Note: version option selectors may need custom data-test attributes
assert.dom('[data-test-version-option="2.0.0"]').exists('includes version 2.0.0');
assert.dom('[data-test-version-option="1.1.0"]').exists('includes version 1.1.0');
assert.dom('[data-test-version-option="1.0.0"]').exists('includes version 1.0.0');
});
test('it disables external plugin when no enterprise license', async function (assert) {
this.versionService.isEnterprise = false;
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
assert
.dom(`input${GENERAL.radioCardByAttr('external')}`)
.isDisabled('external plugin is disabled without enterprise');
assert.dom('.hds-badge').hasText('Enterprise', 'shows enterprise badge');
});
test('it disables external plugin when no external versions available', async function (assert) {
this.model.availableVersions = [{ version: '', pluginName: 'keymgmt', isBuiltin: true }];
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
assert
.dom(`input${GENERAL.radioCardByAttr('external')}`)
.isDisabled('external plugin is disabled when no external versions');
});
test('it updates plugin version when selection changes', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
await fillIn(GENERAL.selectByAttr('plugin-version'), '1.0.0');
assert.strictEqual(
this.form.data.config.plugin_version,
'1.0.0',
'updates model config with selected version'
);
});
test('it clears plugin version when switching back to built-in', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
// Select external and set version
await click(`input${GENERAL.radioCardByAttr('external')}`);
await fillIn(GENERAL.selectByAttr('plugin-version'), '1.0.0');
// Switch back to built-in
await click(`input${GENERAL.radioCardByAttr('builtin')}`);
assert.strictEqual(this.form.data.config.plugin_version, '', 'clears plugin version for built-in');
assert.strictEqual(this.form.type, 'keymgmt', 'resets model type to built-in name');
assert.dom(GENERAL.fieldByAttr('config.plugin_version')).doesNotExist('hides plugin version field');
});
test('it shows unversioned plugins warning when hasUnversionedPlugins is true', async function (assert) {
this.model.hasUnversionedPlugins = true;
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
assert
.dom(GENERAL.helpTextByAttr('config.plugin_version'))
.containsText(
'Un-versioned plugins are not supported, they must be enabled via CLI',
'shows unversioned plugins warning'
);
});
test('it hides unversioned plugins warning when hasUnversionedPlugins is false', async function (assert) {
this.model.hasUnversionedPlugins = false;
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.doesNotContainText('Un-versioned plugins are not supported', 'hides unversioned plugins warning');
});
test('it hides unversioned plugins warning when hasUnversionedPlugins is not provided', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.doesNotContainText(
'Un-versioned plugins are not supported',
'hides unversioned plugins warning when property not provided'
);
});
});
module('Plugin pins integration', function (hooks) {
hooks.beforeEach(function () {
this.form.type = 'keymgmt';
this.form.data.path = 'keymgmt';
this.versionService = this.owner.lookup('service:version');
sinon.stub(this.versionService, 'isEnterprise').value(true);
const availableVersions = [
{ version: '1.0.0', pluginName: 'vault-plugin-secrets-keymgmt', isBuiltin: false },
{ version: '1.1.0', pluginName: 'vault-plugin-secrets-keymgmt', isBuiltin: false },
{ version: '2.0.0', pluginName: 'vault-plugin-secrets-keymgmt', isBuiltin: false },
];
this.availableVersions = availableVersions;
this.model.availableVersions = availableVersions;
// Add pinned version to model data for tests
this.model.pinnedVersion = '1.1.0';
});
test('it shows pinned version first in dropdown', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
// Check that pinned version is selected by default
assert
.dom(GENERAL.selectByAttr('plugin-version'))
.hasValue('1.1.0', 'pinned version is selected by default');
// Check pinned label appears
assert
.dom('[data-test-version-option="1.1.0"]')
.hasText('1.1.0 (pinned)', 'shows pinned label in dropdown');
});
test('it shows pinned version in helper text', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
assert
.dom(`${GENERAL.fieldByAttr('config.plugin_version')} .hds-form-helper-text`)
.containsText('1.1.0 is pinned', 'shows pinned version in helper text');
});
test('it shows warning when selecting non-pinned version', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
// Wait for external plugin to be selected and version field to appear
await fillIn(GENERAL.selectByAttr('plugin-version'), '2.0.0');
// Wait for warning logic to process
assert.dom('.hds-alert').exists('shows warning alert');
assert
.dom('.hds-alert .hds-alert__title')
.hasText('Version differs from pinned', 'shows correct warning title');
assert
.dom('.hds-alert .hds-alert__description')
.containsText(
'You have selected 2.0.0, but version 1.1.0 is pinned',
'shows correct warning description'
);
});
test('it does not show warning when using pinned version', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
// Pinned version should be selected by default, no need to change
assert.dom('.hds-alert--color-warning').doesNotExist('does not show warning when using pinned version');
});
test('it handles plugins with no pins correctly', async function (assert) {
// Clear pinned version
this.model.pinnedVersion = null;
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
// Should default to highest semantic version
assert
.dom(GENERAL.selectByAttr('plugin-version'))
.hasValue('2.0.0', 'defaults to highest version when no pins');
assert
.dom(`${GENERAL.fieldByAttr('config.plugin_version')} .hds-form-helper-text`)
.doesNotContainText('pinned', 'does not show pinned text when no pins');
assert.dom('.hds-alert--color-warning').doesNotExist('does not show warning when no pins');
});
});
module('Plugin version configuration handling', function (hooks) {
hooks.beforeEach(function () {
this.form.type = 'keymgmt';
this.form.data.path = 'keymgmt';
this.versionService = this.owner.lookup('service:version');
sinon.stub(this.versionService, 'isEnterprise').value(true);
const availableVersions = [
{ version: '1.0.0', pluginName: 'vault-plugin-secrets-keymgmt', isBuiltin: false },
{ version: '2.0.0', pluginName: 'vault-plugin-secrets-keymgmt', isBuiltin: false },
];
this.availableVersions = availableVersions;
this.model.availableVersions = availableVersions;
// No pinned version for this test
this.model.pinnedVersion = null;
});
test('it includes plugin_version in config for external plugins', async function (assert) {
this.server.post('/sys/mounts/keymgmt', (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.strictEqual(
payload.config.plugin_version,
'2.0.0',
'includes plugin_version in mount request'
);
assert.false(
Object.hasOwn(payload.config, 'override_pinned_version'),
'does not include override flag when no pins'
);
return [204, { 'Content-Type': 'application/json' }];
});
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
await click(GENERAL.submitButton);
});
test('it includes override flag when using non-pinned version', async function (assert) {
// Set pinned version for keymgmt plugin
this.model.pinnedVersion = '1.0.0';
this.server.post('/sys/mounts/keymgmt', (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.strictEqual(payload.config.plugin_version, '2.0.0', 'includes selected plugin_version');
assert.true(
payload.config.override_pinned_version,
'includes override flag when using non-pinned version'
);
return [204, { 'Content-Type': 'application/json' }];
});
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
await fillIn(GENERAL.selectByAttr('plugin-version'), '2.0.0');
await click(GENERAL.submitButton);
});
test('it omits plugin_version when using pinned version', async function (assert) {
// Set pinned version for keymgmt plugin
this.model.pinnedVersion = '1.0.0';
this.server.post('/sys/mounts/keymgmt', (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.false(
Object.hasOwn(payload.config, 'plugin_version'),
'omits plugin_version when using pinned version'
);
assert.false(
Object.hasOwn(payload.config, 'override_pinned_version'),
'omits override flag when using pinned version'
);
return [204, { 'Content-Type': 'application/json' }];
});
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
// The pinned version (1.0.0) should be auto-selected
await click(GENERAL.submitButton);
});
test('it does not include plugin_version for built-in plugins', async function (assert) {
this.server.post('/sys/mounts/keymgmt', (schema, req) => {
const payload = JSON.parse(req.requestBody);
assert.false(
Object.hasOwn(payload.config, 'plugin_version'),
'does not include plugin_version for built-in'
);
assert.false(
Object.hasOwn(payload.config, 'override_pinned_version'),
'does not include override flag for built-in'
);
assert.strictEqual(payload.type, 'keymgmt', 'uses built-in type name');
return [204, { 'Content-Type': 'application/json' }];
});
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
// Built-in is selected by default
await click(GENERAL.submitButton);
});
});
module('Error handling and edge cases', function (hooks) {
hooks.beforeEach(function () {
this.form.type = 'keymgmt';
this.form.data.path = 'keymgmt';
this.versionService = this.owner.lookup('service:version');
sinon.stub(this.versionService, 'isEnterprise').value(true);
// No pinned version for error handling tests
this.model.pinnedVersion = null;
});
test('it handles empty available versions gracefully', async function (assert) {
this.model.availableVersions = [];
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
// External should be disabled
assert
.dom(`input${GENERAL.radioCardByAttr('external')}`)
.isDisabled('external plugin disabled when no versions');
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.doesNotExist('plugin version field hidden when no external versions');
});
test('it handles missing availableVersions argument', async function (assert) {
await render(
hbs`<Mount::SecretsEngineForm @model={{this.model}} @onMountSuccess={{this.onMountSuccess}} />`
);
// External should be disabled
assert
.dom(`input${GENERAL.radioCardByAttr('external')}`)
.isDisabled('external plugin disabled when availableVersions not provided');
});
test('it shows version field immediately when pinned version available', async function (assert) {
// Set pinned version
this.model.pinnedVersion = '1.0.0';
// Set up available versions for this test
this.model.availableVersions = [
{ version: '1.0.0', pluginName: 'vault-plugin-secrets-keymgmt', isBuiltin: false },
];
await render(
hbs`<Mount::SecretsEngineForm
@model={{this.model}}
@onMountSuccess={{this.onMountSuccess}}
/>`
);
await click(`input${GENERAL.radioCardByAttr('external')}`);
// Version field should show even before pins are loaded, since versions are available
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.exists('version field shows when external selected and versions available');
// Field should remain visible since pinned version is available immediately
assert
.dom(GENERAL.fieldByAttr('config.plugin_version'))
.exists('version field remains visible with pinned version');
});
});
});

View file

@ -1,54 +0,0 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import engineDisplayData from 'vault/helpers/engines-display-data';
import { ALL_ENGINES } from 'vault/utils/all-engines-metadata';
module('Unit | Helper | engineDisplayData', function () {
test('it returns correct display data for a known engine type', function (assert) {
const awsData = engineDisplayData('aws');
const expected = ALL_ENGINES.find((e) => e.type === 'aws');
assert.propEqual(awsData, expected, 'Returns correct display data for aws');
});
test('it returns correct display data for an ent only engine', function (assert) {
const kmipData = engineDisplayData('kmip');
assert.true(kmipData.requiresEnterprise, 'KMIP requires enterprise');
assert.strictEqual(kmipData.displayName, 'KMIP', 'KMIP displayName is correct');
});
test('it returns fallback display data for unknown engine type', function (assert) {
const { displayName, type, mountCategory, glyph } = engineDisplayData('not-an-engine');
assert.strictEqual(displayName, 'not-an-engine', 'it returns passed type as fallback displayName');
assert.strictEqual(type, 'not-an-engine', 'it returns methodType type');
assert.propEqual(mountCategory, ['secret', 'auth'], 'mountCategory is correct');
assert.strictEqual(glyph, 'lock', 'default glyph is a lock');
});
test('it returns fallback display data for empty string', function (assert) {
const { displayName, type, mountCategory, glyph } = engineDisplayData('');
assert.strictEqual(displayName, 'Unknown plugin', 'it returns fallback displayName for empty string');
assert.strictEqual(type, 'unknown', 'it returns fallback type for empty string');
assert.propEqual(mountCategory, ['secret', 'auth'], 'mountCategory is correct');
assert.strictEqual(glyph, 'lock', 'default glyph is a lock');
});
test('it returns fallback display data for undefined', function (assert) {
const { displayName, type, mountCategory, glyph } = engineDisplayData(undefined);
assert.strictEqual(displayName, 'Unknown plugin', 'it returns fallback displayName for undefined');
assert.strictEqual(type, 'unknown', 'it returns fallback type for undefined');
assert.propEqual(mountCategory, ['secret', 'auth'], 'mountCategory is correct');
assert.strictEqual(glyph, 'lock', 'default glyph is a lock');
});
test('it returns fallback display data for null', function (assert) {
const { displayName, type, mountCategory, glyph } = engineDisplayData(null);
assert.strictEqual(displayName, 'Unknown plugin', 'it returns fallback displayName for null');
assert.strictEqual(type, 'unknown', 'it returns fallback type for null');
assert.propEqual(mountCategory, ['secret', 'auth'], 'mountCategory is correct');
assert.strictEqual(glyph, 'lock', 'default glyph is a lock');
});
});

View file

@ -0,0 +1,44 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { setupTest } from 'ember-qunit';
import { module, test } from 'qunit';
import SecretsEngineForm from 'vault/forms/secrets/engine';
import { getExternalPluginNameFromBuiltin } from 'vault/utils/external-plugin-helpers';
module('Unit | Component | mount/secrets-engine-form', function (hooks) {
setupTest(hooks);
hooks.beforeEach(function () {
// Setup default model
const defaults = {
config: { listing_visibility: false },
options: { version: 2 },
};
this.model = new SecretsEngineForm(defaults, { isNew: true });
this.model.type = 'keymgmt';
this.model.data.path = 'keymgmt';
this.availableVersions = [
{ version: '1.0.0', isBuiltin: false },
{ version: '1.1.0', isBuiltin: false },
{ version: '2.0.0', isBuiltin: false },
{ version: '', isBuiltin: true },
];
});
test('getExternalPluginNameFromBuiltin returns correct name for keymgmt', function (assert) {
const externalName = getExternalPluginNameFromBuiltin('keymgmt');
assert.strictEqual(
externalName,
'vault-plugin-secrets-keymgmt',
'generates correct external plugin name for keymgmt'
);
});
test('model normalizedType returns correct value', function (assert) {
assert.strictEqual(this.model.normalizedType, 'keymgmt', 'returns correct normalized type');
});
});

View file

@ -0,0 +1,325 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import MountForm from 'vault/forms/mount';
module('Unit | Form | mount', function (hooks) {
setupTest(hooks);
module('Plugin Version Selection', function () {
test('toJSON omits plugin_version when default is selected (empty value)', function (assert) {
assert.expect(2);
const form = new MountForm({});
form.type = 'kv';
form.data = {
path: 'test-kv',
description: 'Test KV engine',
config: {
plugin_version: '', // Empty string represents default selection
max_lease_ttl: '8760h',
},
};
const result = form.toJSON();
assert.strictEqual(result.data.type, 'kv', 'type is set correctly');
assert.notOk(
Object.prototype.hasOwnProperty.call(result.data.config, 'plugin_version'),
'plugin_version is omitted from config when empty'
);
});
test('toJSON omits plugin_version when undefined', function (assert) {
assert.expect(2);
const form = new MountForm({});
form.type = 'kv';
form.data = {
path: 'test-kv',
description: 'Test KV engine',
config: {
max_lease_ttl: '8760h',
// plugin_version not set
},
};
const result = form.toJSON();
assert.strictEqual(result.data.type, 'kv', 'type is set correctly');
assert.notOk(
Object.prototype.hasOwnProperty.call(result.data.config, 'plugin_version'),
'plugin_version is omitted from config when undefined'
);
});
test('toJSON includes plugin_version for builtin plugin selection', function (assert) {
assert.expect(3);
const form = new MountForm({});
form.type = 'kv'; // Builtin type should remain as 'kv'
form.data = {
path: 'test-kv',
description: 'Test KV engine',
config: {
plugin_version: 'v1.16.1+builtin',
max_lease_ttl: '8760h',
},
};
const result = form.toJSON();
assert.strictEqual(result.data.type, 'kv', 'type remains builtin type for builtin plugins');
assert.strictEqual(
result.data.config.plugin_version,
'v1.16.1+builtin',
'plugin_version is included for builtin plugin'
);
assert.strictEqual(result.data.path, 'test-kv', 'other data is preserved');
});
test('toJSON includes plugin_version for external plugin selection', function (assert) {
assert.expect(3);
const form = new MountForm({});
form.type = 'vault-plugin-secrets-kv'; // External plugin type
form.data = {
path: 'test-external-kv',
description: 'Test external KV engine',
config: {
plugin_version: 'v0.25.0',
max_lease_ttl: '8760h',
},
};
const result = form.toJSON();
assert.strictEqual(
result.data.type,
'vault-plugin-secrets-kv',
'type is set to external plugin name for external plugins'
);
assert.strictEqual(
result.data.config.plugin_version,
'v0.25.0',
'plugin_version is included for external plugin'
);
assert.strictEqual(result.data.path, 'test-external-kv', 'other data is preserved');
});
});
module('setPluginVersionData', function () {
test('sets config.plugin_version and preserves builtin type for builtin plugins', function (assert) {
assert.expect(3);
const form = new MountForm({});
form.type = 'kv';
form.data = { config: {} };
const builtinVersionInfo = {
version: 'v1.16.1+builtin',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: true,
sha256: 'abc123',
};
form.setPluginVersionData(builtinVersionInfo);
assert.strictEqual(form.data.config.plugin_version, 'v1.16.1+builtin', 'plugin_version is set');
assert.strictEqual(form.type, 'kv', 'type remains builtin for builtin plugins');
assert.ok(form.data.config, 'config object is preserved');
});
test('sets config.plugin_version and updates type for external plugins', function (assert) {
assert.expect(3);
const form = new MountForm({});
form.type = 'kv'; // Initially set to builtin
form.data = { config: {} };
const externalVersionInfo = {
version: 'v0.25.0',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: false,
sha256: 'def456',
};
form.setPluginVersionData(externalVersionInfo);
assert.strictEqual(form.data.config.plugin_version, 'v0.25.0', 'plugin_version is set');
assert.strictEqual(
form.type,
'vault-plugin-secrets-kv',
'type is updated to plugin name for external plugins'
);
assert.ok(form.data.config, 'config object is preserved');
});
});
module('findVersionByLabel', function () {
test('returns undefined for empty string (default selection)', function (assert) {
assert.expect(1);
const form = new MountForm({});
form.data = { config: {} };
const availableVersions = [
{
version: 'v1.16.1+builtin',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: true,
sha256: 'abc123',
},
{
version: 'v0.25.0',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: false,
sha256: 'def456',
},
];
const result = form.findVersionByLabel('', availableVersions);
assert.strictEqual(result, undefined, 'returns undefined for empty string (default)');
});
test('returns undefined for null/undefined selectedValue', function (assert) {
assert.expect(2);
const form = new MountForm({});
form.data = { config: {} };
const availableVersions = [
{
version: 'v1.16.1+builtin',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: true,
sha256: 'abc123',
},
];
assert.strictEqual(
form.findVersionByLabel(null, availableVersions),
undefined,
'returns undefined for null'
);
assert.strictEqual(
form.findVersionByLabel(undefined, availableVersions),
undefined,
'returns undefined for undefined'
);
});
test('finds matching version info by version string', function (assert) {
assert.expect(2);
const form = new MountForm({});
form.data = { config: {} };
const builtinVersion = {
version: 'v1.16.1+builtin',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: true,
sha256: 'abc123',
};
const externalVersion = {
version: 'v0.25.0',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: false,
sha256: 'def456',
};
const availableVersions = [builtinVersion, externalVersion];
const builtinResult = form.findVersionByLabel('v1.16.1+builtin', availableVersions);
const externalResult = form.findVersionByLabel('v0.25.0', availableVersions);
assert.deepEqual(builtinResult, builtinVersion, 'finds builtin version correctly');
assert.deepEqual(externalResult, externalVersion, 'finds external version correctly');
});
test('returns undefined for non-matching version', function (assert) {
assert.expect(1);
const form = new MountForm({});
form.data = { config: {} };
const availableVersions = [
{
version: 'v1.16.1+builtin',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: true,
sha256: 'abc123',
},
];
const result = form.findVersionByLabel('v999.999.999', availableVersions);
assert.strictEqual(result, undefined, 'returns undefined for non-matching version');
});
});
module('setupPluginVersionField', function () {
test('does nothing when no versions available', function (assert) {
assert.expect(1);
const form = new MountForm({});
form.data = { config: {} };
form.setupPluginVersionField(null);
// Since the field is handled in the template now, just verify the method doesn't throw
assert.ok(true, 'setupPluginVersionField handles null versions gracefully');
});
test('does nothing when only one version available', function (assert) {
assert.expect(1);
const form = new MountForm({});
form.data = { config: {} };
const singleVersion = [
{
version: 'v1.16.1+builtin',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: true,
sha256: 'abc123',
},
];
form.setupPluginVersionField(singleVersion);
// Since the field is handled in the template now, just verify the method doesn't throw
assert.ok(true, 'setupPluginVersionField handles single version gracefully');
});
test('initializes plugin_version config when multiple versions available', function (assert) {
assert.expect(1);
const form = new MountForm({});
form.data = { config: {} };
const multipleVersions = [
{
version: 'v1.16.1+builtin',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: true,
sha256: 'abc123',
},
{
version: 'v0.25.0',
pluginName: 'vault-plugin-secrets-kv',
isBuiltin: false,
sha256: 'def456',
},
];
form.setupPluginVersionField(multipleVersions);
assert.strictEqual(form.data.config.plugin_version, '', 'plugin_version initialized as empty string');
});
});
});

View file

@ -5,14 +5,25 @@
import { module, test } from 'qunit';
import engineDisplayData, { unknownEngineMetadata } from 'vault/helpers/engines-display-data';
import { ALL_ENGINES } from 'vault/utils/all-engines-metadata';
module('Unit | Helper | engines-display-data', function () {
test('it returns metadata for builtin engines', function (assert) {
test('it returns correct display data for known engine types', function (assert) {
// Test keymgmt engine
const keymgmtData = engineDisplayData('keymgmt');
assert.strictEqual(keymgmtData.type, 'keymgmt', 'returns correct type for keymgmt');
assert.strictEqual(keymgmtData.displayName, 'Key Management', 'returns correct displayName for keymgmt');
assert.ok(keymgmtData.requiresEnterprise, 'keymgmt requires enterprise');
// Test aws engine with ALL_ENGINES comparison
const awsData = engineDisplayData('aws');
const expectedAws = ALL_ENGINES.find((e) => e.type === 'aws');
assert.propEqual(awsData, expectedAws, 'Returns correct display data for aws');
// Test enterprise-only engine
const kmipData = engineDisplayData('kmip');
assert.true(kmipData.requiresEnterprise, 'KMIP requires enterprise');
assert.strictEqual(kmipData.displayName, 'KMIP', 'KMIP displayName is correct');
});
test('it returns metadata for external plugins that map to builtins', function (assert) {
@ -51,13 +62,40 @@ module('Unit | Helper | engines-display-data', function () {
const emptyData = engineDisplayData('');
const nullData = engineDisplayData(null);
const undefinedData = engineDisplayData(undefined);
const unknownMetadata = unknownEngineMetadata();
assert.strictEqual(emptyData.type, unknownMetadata.type, 'returns unknown for empty string');
// Test empty string
assert.strictEqual(emptyData.type, 'unknown', 'returns unknown type for empty string');
assert.strictEqual(emptyData.displayName, 'Unknown plugin', 'uses default name for empty string');
assert.propEqual(
emptyData.mountCategory,
['secret', 'auth'],
'mountCategory is correct for empty string'
);
assert.strictEqual(emptyData.glyph, 'lock', 'default glyph is a lock for empty string');
assert.strictEqual(nullData.type, unknownMetadata.type, 'returns unknown for null');
assert.strictEqual(undefinedData.type, unknownMetadata.type, 'returns unknown for undefined');
// Test null
assert.strictEqual(nullData.type, 'unknown', 'returns unknown type for null');
assert.strictEqual(nullData.displayName, 'Unknown plugin', 'uses default name for null');
assert.propEqual(nullData.mountCategory, ['secret', 'auth'], 'mountCategory is correct for null');
assert.strictEqual(nullData.glyph, 'lock', 'default glyph is a lock for null');
// Test undefined
assert.strictEqual(undefinedData.type, 'unknown', 'returns unknown type for undefined');
assert.strictEqual(undefinedData.displayName, 'Unknown plugin', 'uses default name for undefined');
assert.propEqual(
undefinedData.mountCategory,
['secret', 'auth'],
'mountCategory is correct for undefined'
);
assert.strictEqual(undefinedData.glyph, 'lock', 'default glyph is a lock for undefined');
});
test('it returns fallback display data for unknown engine types', function (assert) {
const unknownData = engineDisplayData('not-an-engine');
assert.strictEqual(unknownData.displayName, 'not-an-engine', 'uses passed type as fallback displayName');
assert.strictEqual(unknownData.type, 'not-an-engine', 'returns methodType type');
assert.propEqual(unknownData.mountCategory, ['secret', 'auth'], 'mountCategory is correct');
assert.strictEqual(unknownData.glyph, 'lock', 'default glyph is a lock');
});
test('it handles case sensitivity correctly', function (assert) {

View file

@ -162,6 +162,7 @@ module('Unit | Model | secret-engine', function (hooks) {
'config.auditNonHmacResponseKeys',
'config.passthroughRequestHeaders',
'config.allowedResponseHeaders',
'config.plugin_version',
],
},
]);
@ -188,6 +189,7 @@ module('Unit | Model | secret-engine', function (hooks) {
'config.auditNonHmacResponseKeys',
'config.passthroughRequestHeaders',
'config.allowedResponseHeaders',
'config.plugin_version',
],
},
]);
@ -215,6 +217,7 @@ module('Unit | Model | secret-engine', function (hooks) {
'config.auditNonHmacResponseKeys',
'config.passthroughRequestHeaders',
'config.allowedResponseHeaders',
'config.plugin_version',
],
},
]);
@ -239,6 +242,7 @@ module('Unit | Model | secret-engine', function (hooks) {
'config.auditNonHmacResponseKeys',
'config.passthroughRequestHeaders',
'config.allowedResponseHeaders',
'config.plugin_version',
],
},
]);
@ -262,6 +266,7 @@ module('Unit | Model | secret-engine', function (hooks) {
'config.auditNonHmacResponseKeys',
'config.passthroughRequestHeaders',
'config.allowedResponseHeaders',
'config.plugin_version',
],
},
]);
@ -286,6 +291,7 @@ module('Unit | Model | secret-engine', function (hooks) {
'config.auditNonHmacResponseKeys',
'config.passthroughRequestHeaders',
'config.allowedResponseHeaders',
'config.plugin_version',
],
},
]);
@ -313,6 +319,7 @@ module('Unit | Model | secret-engine', function (hooks) {
'config.auditNonHmacResponseKeys',
'config.passthroughRequestHeaders',
'config.allowedResponseHeaders',
'config.plugin_version',
],
},
]);

View file

@ -7,8 +7,9 @@ import { module, test } from 'qunit';
import {
EXTERNAL_PLUGIN_TO_BUILTIN_MAP,
getBuiltinTypeFromExternalPlugin,
isKnownExternalPlugin,
getEffectiveEngineType,
getExternalPluginNameFromBuiltin,
isKnownExternalPlugin,
} from 'vault/utils/external-plugin-helpers';
module('Unit | Utility | external-plugin-helpers', function () {
@ -118,6 +119,77 @@ module('Unit | Utility | external-plugin-helpers', function () {
});
});
module('getExternalPluginNameFromBuiltin', function () {
test('it returns external plugin name for known builtin types', function (assert) {
assert.strictEqual(
getExternalPluginNameFromBuiltin('keymgmt'),
'vault-plugin-secrets-keymgmt',
'returns vault-plugin-secrets-keymgmt for keymgmt'
);
assert.strictEqual(
getExternalPluginNameFromBuiltin('azure'),
'vault-plugin-secrets-azure',
'returns vault-plugin-secrets-azure for azure'
);
assert.strictEqual(
getExternalPluginNameFromBuiltin('gcp'),
'vault-plugin-secrets-gcp',
'returns vault-plugin-secrets-gcp for gcp'
);
});
test('it returns null for unknown builtin types', function (assert) {
assert.strictEqual(
getExternalPluginNameFromBuiltin('unknown-engine'),
null,
'returns null for unknown builtin type'
);
});
test('it returns null for external plugin names', function (assert) {
assert.strictEqual(
getExternalPluginNameFromBuiltin('vault-plugin-secrets-keymgmt'),
null,
'returns null for external plugin name'
);
});
test('it returns null for empty string', function (assert) {
assert.strictEqual(getExternalPluginNameFromBuiltin(''), null, 'returns null for empty string');
});
test('it handles case sensitivity correctly', function (assert) {
assert.strictEqual(
getExternalPluginNameFromBuiltin('KEYMGMT'),
null,
'returns null for uppercase builtin type'
);
assert.strictEqual(
getExternalPluginNameFromBuiltin('KeyMgmt'),
null,
'returns null for mixed case builtin type'
);
});
test('it works with all mapped builtin types', function (assert) {
// Test that every builtin type in the map can be reverse-looked up
const builtinTypes = Object.values(EXTERNAL_PLUGIN_TO_BUILTIN_MAP);
const uniqueBuiltinTypes = [...new Set(builtinTypes)];
uniqueBuiltinTypes.forEach((builtinType) => {
const externalName = getExternalPluginNameFromBuiltin(builtinType);
assert.ok(externalName, `found external name for builtin type: ${builtinType}`);
assert.true(
externalName.startsWith('vault-plugin-'),
`external name ${externalName} follows expected pattern`
);
});
});
});
module('future extensibility', function () {
test('mapping can be easily extended', function (assert) {
// Test that we can add more mappings (conceptually)
@ -129,5 +201,24 @@ module('Unit | Utility | external-plugin-helpers', function () {
assert.strictEqual(testMap['vault-plugin-secrets-keymgmt'], 'keymgmt', 'existing mapping is preserved');
assert.strictEqual(testMap['vault-plugin-auth-example'], 'example-auth', 'new mapping can be added');
});
test('reverse lookup works with extended mappings', function (assert) {
// Test conceptual extensibility of the reverse lookup
// This verifies that the reverse lookup algorithm is robust
const originalFunction = getExternalPluginNameFromBuiltin('keymgmt');
assert.strictEqual(
originalFunction,
'vault-plugin-secrets-keymgmt',
'reverse lookup works for existing mappings'
);
// Test that non-existent mappings return null as expected
const nonExistentResult = getExternalPluginNameFromBuiltin('hypothetical-auth');
assert.strictEqual(
nonExistentResult,
null,
'reverse lookup correctly returns null for non-mapped types'
);
});
});
});

View file

@ -5,11 +5,12 @@
import { module, test } from 'qunit';
import {
enhanceEnginesWithCatalogData,
categorizeEnginesByStatus,
enhanceEnginesWithCatalogData,
getAllVersionsForEngineType,
MOUNT_CATEGORIES,
PLUGIN_TYPES,
PLUGIN_CATEGORIES,
PLUGIN_TYPES,
} from 'vault/utils/plugin-catalog-helpers';
module('Unit | Utility | plugin-catalog-helpers', function () {
@ -147,6 +148,55 @@ module('Unit | Utility | plugin-catalog-helpers', function () {
assert.false(externalPlugin.builtin, 'external plugin is not builtin');
});
test('it excludes external plugins with builtin mappings from external category', function (assert) {
const staticEngines = [
{
type: 'kv',
displayName: 'KV',
pluginCategory: PLUGIN_CATEGORIES.GENERIC,
mountCategory: [MOUNT_CATEGORIES.SECRET],
},
];
const catalogData = [
{
name: 'kv',
type: PLUGIN_TYPES.SECRET,
builtin: true,
},
{
name: 'vault-plugin-secrets-kv', // This has a builtin mapping
type: PLUGIN_TYPES.SECRET,
builtin: false,
version: '2.1.0',
},
{
name: 'truly-external-plugin', // This does not have a builtin mapping
type: PLUGIN_TYPES.SECRET,
builtin: false,
version: '1.0.0',
},
];
const result = enhanceEnginesWithCatalogData(staticEngines, catalogData);
// Should only add the truly external plugin, not the one with builtin mapping
assert.strictEqual(result.length, 2, 'adds only truly external plugin');
const kvEngine = result.find((engine) => engine.type === 'kv');
const externalKv = result.find((engine) => engine.type === 'vault-plugin-secrets-kv');
const trulyExternal = result.find((engine) => engine.type === 'truly-external-plugin');
assert.ok(kvEngine, 'KV engine is present');
assert.notOk(externalKv, 'external KV plugin is not added as separate engine');
assert.ok(trulyExternal, 'truly external plugin is present');
assert.strictEqual(
trulyExternal.pluginCategory,
PLUGIN_CATEGORIES.EXTERNAL,
'truly external plugin is in external category'
);
});
test('it matches external plugins with existing static engine glyphs', function (assert) {
const staticEngines = [
{
@ -282,4 +332,282 @@ module('Unit | Utility | plugin-catalog-helpers', function () {
assert.strictEqual(PLUGIN_CATEGORIES.EXTERNAL, 'external', 'EXTERNAL category is correct');
});
});
module('getAllVersionsForEngineType', function () {
test('it returns empty array when no catalog data provided', function (assert) {
const result = getAllVersionsForEngineType(undefined, 'kv', 'secret');
assert.deepEqual(
result,
{ versions: [], hasUnversionedPlugins: false },
'returns empty result for undefined catalog data'
);
const result2 = getAllVersionsForEngineType([], 'kv', 'secret');
assert.deepEqual(
result2,
{ versions: [], hasUnversionedPlugins: false },
'returns empty result for empty catalog data'
);
});
test('it returns versions for direct engine type matches', function (assert) {
const catalogData = [
{
name: 'kv',
type: PLUGIN_TYPES.SECRET,
builtin: true,
version: '1.0.0',
},
{
name: 'kv',
type: PLUGIN_TYPES.SECRET,
builtin: true,
version: '2.0.0',
},
];
const result = getAllVersionsForEngineType(catalogData, 'kv', 'secret');
assert.strictEqual(result.versions.length, 2, 'returns both versions');
assert.strictEqual(result.versions[0].version, '1.0.0', 'includes first version');
assert.strictEqual(result.versions[1].version, '2.0.0', 'includes second version');
assert.strictEqual(result.versions[0].pluginName, 'kv', 'includes plugin name');
assert.true(result.versions[0].isBuiltin, 'marks builtin correctly');
assert.false(result.hasUnversionedPlugins, 'no unversioned plugins detected');
});
test('it returns versions for external plugins that map to engine types', function (assert) {
const catalogData = [
{
name: 'kv',
type: PLUGIN_TYPES.SECRET,
builtin: true,
version: '1.0.0',
},
{
name: 'vault-plugin-secrets-kv',
type: PLUGIN_TYPES.SECRET,
builtin: false,
version: '2.1.0',
},
];
const result = getAllVersionsForEngineType(catalogData, 'kv', 'secret');
assert.strictEqual(result.versions.length, 2, 'returns both builtin and external versions');
const builtinVersion = result.versions.find((v) => v.isBuiltin);
const externalVersion = result.versions.find((v) => !v.isBuiltin);
assert.ok(builtinVersion, 'includes builtin version');
assert.ok(externalVersion, 'includes external version');
assert.strictEqual(builtinVersion.pluginName, 'kv', 'builtin uses engine name');
assert.strictEqual(
externalVersion.pluginName,
'vault-plugin-secrets-kv',
'external uses full plugin name'
);
assert.false(result.hasUnversionedPlugins, 'no unversioned plugins detected');
});
test('it excludes external plugins that do not map to the engine type', function (assert) {
const catalogData = [
{
name: 'kv',
type: PLUGIN_TYPES.SECRET,
builtin: true,
version: '1.0.0',
},
{
name: 'vault-plugin-secrets-aws',
type: PLUGIN_TYPES.SECRET,
builtin: false,
version: '1.5.0',
},
];
const result = getAllVersionsForEngineType(catalogData, 'kv', 'secret');
assert.strictEqual(result.versions.length, 1, 'only includes matching plugins');
assert.strictEqual(result.versions[0].pluginName, 'kv', 'includes only KV engine');
assert.false(result.hasUnversionedPlugins, 'no unversioned plugins detected');
});
test('it filters by plugin type correctly', function (assert) {
const catalogData = [
{
name: 'gcp',
type: 'auth',
builtin: true,
version: 'v0.22.0+builtin',
},
{
name: 'gcp',
type: 'secret',
builtin: true,
version: 'v0.23.0+builtin',
},
{
name: 'vault-plugin-secrets-gcp',
type: 'secret',
builtin: false,
version: 'v0.23.0',
},
];
// Test filtering for secret plugins only
const secretResult = getAllVersionsForEngineType(catalogData, 'gcp', 'secret');
assert.strictEqual(secretResult.versions.length, 2, 'returns only secret type plugins');
assert.true(
secretResult.versions.every(
(plugin) => plugin.pluginName === 'gcp' || plugin.pluginName === 'vault-plugin-secrets-gcp'
),
'includes correct secret plugins'
);
assert.false(secretResult.hasUnversionedPlugins, 'no unversioned plugins detected');
// Test filtering for auth plugins only
const authResult = getAllVersionsForEngineType(catalogData, 'gcp', 'auth');
assert.strictEqual(authResult.versions.length, 1, 'returns only auth type plugins');
assert.strictEqual(authResult.versions[0].pluginName, 'gcp', 'includes auth plugin');
assert.false(authResult.hasUnversionedPlugins, 'no unversioned plugins detected');
});
test('it handles invalid catalog data gracefully', function (assert) {
const invalidCatalogData = [
null, // null entry
{ name: 'kv' }, // missing required fields
{ name: 'aws', version: '1.0.0' }, // missing builtin field
{
name: 'pki',
type: PLUGIN_TYPES.SECRET,
builtin: true,
version: '1.0.0',
}, // valid entry
];
const result = getAllVersionsForEngineType(invalidCatalogData, 'pki');
assert.strictEqual(result.versions.length, 1, 'filters out invalid entries');
assert.strictEqual(result.versions[0].pluginName, 'pki', 'includes only valid entry');
assert.false(result.hasUnversionedPlugins, 'no unversioned plugins detected');
});
test('it excludes unversioned plugins but detects them', function (assert) {
const catalogData = [
{
name: 'vault-plugin-secrets-keymgmt',
type: PLUGIN_TYPES.SECRET,
builtin: false,
version: '', // Empty string when plugin registered without version
sha256: '9433b2b37d30abf8f7cbf8c3e616dfc263034789681081ea4ba7918673d80086',
},
{
name: 'vault-plugin-secrets-keymgmt',
type: PLUGIN_TYPES.SECRET,
builtin: false,
version: '1.5.0',
},
];
const result = getAllVersionsForEngineType(catalogData, 'keymgmt', 'secret');
assert.strictEqual(result.versions.length, 1, 'excludes unversioned plugin from versions');
assert.true(result.hasUnversionedPlugins, 'detects presence of unversioned plugins');
const versionedPlugin = result.versions[0];
assert.strictEqual(versionedPlugin.version, '1.5.0', 'includes only versioned plugin');
assert.false(versionedPlugin.isBuiltin, 'versioned plugin is not builtin');
assert.strictEqual(
versionedPlugin.pluginName,
'vault-plugin-secrets-keymgmt',
'correct plugin name for versioned'
);
});
test('it detects unversioned plugins for builtin engines', function (assert) {
const catalogData = [
{
name: 'kv',
type: PLUGIN_TYPES.SECRET,
builtin: true,
version: '1.0.0',
},
{
name: 'kv',
type: PLUGIN_TYPES.SECRET,
builtin: false,
version: '', // Unversioned external kv plugin
},
];
const result = getAllVersionsForEngineType(catalogData, 'kv', 'secret');
assert.strictEqual(result.versions.length, 1, 'only includes versioned plugins');
assert.true(result.hasUnversionedPlugins, 'detects unversioned plugin');
assert.strictEqual(result.versions[0].version, '1.0.0', 'includes builtin version');
assert.true(result.versions[0].isBuiltin, 'included plugin is builtin');
});
test('it handles multiple unversioned plugins for same engine type', function (assert) {
const catalogData = [
{
name: 'vault-plugin-secrets-custom',
type: PLUGIN_TYPES.SECRET,
builtin: false,
version: '', // First unversioned plugin
},
{
name: 'custom',
type: PLUGIN_TYPES.SECRET,
builtin: false,
version: '', // Second unversioned plugin (direct match)
},
{
name: 'custom',
type: PLUGIN_TYPES.SECRET,
builtin: true,
version: '2.0.0', // Versioned plugin
},
];
const result = getAllVersionsForEngineType(catalogData, 'custom', 'secret');
assert.strictEqual(result.versions.length, 1, 'excludes all unversioned plugins');
assert.true(result.hasUnversionedPlugins, 'detects multiple unversioned plugins');
assert.strictEqual(result.versions[0].version, '2.0.0', 'includes only versioned plugin');
});
test('it handles invalid engine type parameters', function (assert) {
const catalogData = [
{
name: 'kv',
type: PLUGIN_TYPES.SECRET,
builtin: true,
version: '1.0.0',
},
];
const result1 = getAllVersionsForEngineType(catalogData, null);
assert.deepEqual(
result1,
{ versions: [], hasUnversionedPlugins: false },
'returns empty result for null engine type'
);
const result2 = getAllVersionsForEngineType(catalogData, '');
assert.deepEqual(
result2,
{ versions: [], hasUnversionedPlugins: false },
'returns empty result for empty engine type'
);
const result3 = getAllVersionsForEngineType(catalogData, undefined);
assert.deepEqual(
result3,
{ versions: [], hasUnversionedPlugins: false },
'returns empty result for undefined engine type'
);
});
});
});

View file

@ -0,0 +1,128 @@
/**
* Copyright IBM Corp. 2016, 2025
* SPDX-License-Identifier: BUSL-1.1
*/
import { module, test } from 'qunit';
import {
areVersionsEqual,
cleanVersion,
compareVersions,
getHighestVersion,
isValidVersion,
isVersionGreater,
parseVersion,
sortVersions,
} from 'vault/utils/version-utils';
module('Unit | Utility | version-utils', function () {
test('cleanVersion removes prefixes and suffixes correctly', function (assert) {
assert.strictEqual(cleanVersion('v1.2.3'), '1.2.3', 'removes v prefix');
assert.strictEqual(cleanVersion('1.2.3+ent'), '1.2.3', 'removes +ent suffix');
assert.strictEqual(cleanVersion('v1.2.3+builtin'), '1.2.3', 'removes v prefix and +builtin suffix');
assert.strictEqual(cleanVersion('v1.2.3-beta1+ent'), '1.2.3', 'removes v prefix and -beta1+ent suffix');
assert.strictEqual(cleanVersion('1.2.3'), '1.2.3', 'leaves clean version unchanged');
});
test('parseVersion converts version strings to numeric arrays', function (assert) {
assert.deepEqual(parseVersion('1.2.3'), [1, 2, 3], 'parses basic version');
assert.deepEqual(parseVersion('v1.0.0+ent'), [1, 0, 0], 'parses version with prefix and suffix');
assert.deepEqual(parseVersion('1.2'), [1, 2], 'parses two-part version');
assert.deepEqual(parseVersion('1.2.3.4'), [1, 2, 3, 4], 'parses four-part version');
assert.deepEqual(parseVersion('1.0.x'), [1, 0, 0], 'handles non-numeric parts as 0');
});
test('compareVersions works correctly', function (assert) {
// Equal versions
assert.strictEqual(compareVersions('1.2.3', '1.2.3'), 0, '1.2.3 equals 1.2.3');
assert.strictEqual(compareVersions('v1.2.3+ent', '1.2.3'), 0, 'v1.2.3+ent equals 1.2.3');
// First version greater
assert.ok(compareVersions('1.2.4', '1.2.3') > 0, '1.2.4 > 1.2.3');
assert.ok(compareVersions('1.3.0', '1.2.9') > 0, '1.3.0 > 1.2.9');
assert.ok(compareVersions('2.0.0', '1.9.9') > 0, '2.0.0 > 1.9.9');
// Second version greater
assert.ok(compareVersions('1.2.3', '1.2.4') < 0, '1.2.3 < 1.2.4');
assert.ok(compareVersions('1.2.9', '1.3.0') < 0, '1.2.9 < 1.3.0');
assert.ok(compareVersions('1.9.9', '2.0.0') < 0, '1.9.9 < 2.0.0');
// Different lengths
assert.ok(compareVersions('1.2.3', '1.2') > 0, '1.2.3 > 1.2');
assert.ok(compareVersions('1.2', '1.2.1') < 0, '1.2 < 1.2.1');
});
test('sortVersions sorts correctly', function (assert) {
const versions = ['v1.0.0+ent', 'v0.18.0+ent', 'v0.19.0+ent', 'v1.1.0+ent'];
// Ascending order (default)
const ascending = sortVersions(versions);
assert.deepEqual(
ascending,
['v0.18.0+ent', 'v0.19.0+ent', 'v1.0.0+ent', 'v1.1.0+ent'],
'sorts ascending'
);
// Descending order
const descending = sortVersions(versions, true);
assert.deepEqual(
descending,
['v1.1.0+ent', 'v1.0.0+ent', 'v0.19.0+ent', 'v0.18.0+ent'],
'sorts descending'
);
// Original array unchanged
assert.deepEqual(
versions,
['v1.0.0+ent', 'v0.18.0+ent', 'v0.19.0+ent', 'v1.1.0+ent'],
'original array unchanged'
);
});
test('getHighestVersion returns the latest version', function (assert) {
const versions = ['v1.0.0+ent', 'v0.18.0+ent', 'v0.19.0+ent', 'v1.1.0+ent'];
assert.strictEqual(getHighestVersion(versions), 'v1.1.0+ent', 'returns highest version');
assert.strictEqual(getHighestVersion([]), null, 'returns null for empty array');
assert.strictEqual(getHighestVersion(['v1.0.0']), 'v1.0.0', 'returns single version');
});
test('isVersionGreater compares versions correctly', function (assert) {
assert.true(isVersionGreater('1.2.4', '1.2.3'), '1.2.4 > 1.2.3');
assert.true(isVersionGreater('v1.0.0+ent', '0.9.0'), 'v1.0.0+ent > 0.9.0');
assert.false(isVersionGreater('1.2.3', '1.2.4'), '1.2.3 not > 1.2.4');
assert.false(isVersionGreater('1.2.3', '1.2.3'), '1.2.3 not > 1.2.3');
});
test('areVersionsEqual compares versions correctly', function (assert) {
assert.true(areVersionsEqual('1.2.3', '1.2.3'), '1.2.3 equals 1.2.3');
assert.true(areVersionsEqual('v1.2.3+ent', '1.2.3'), 'v1.2.3+ent equals 1.2.3');
assert.false(areVersionsEqual('1.2.3', '1.2.4'), '1.2.3 not equal 1.2.4');
});
test('edge cases are handled correctly', function (assert) {
// Empty strings
assert.strictEqual(compareVersions('', ''), 0, 'empty strings are equal');
assert.strictEqual(cleanVersion(''), '', 'empty string returns empty');
// Only prefixes/suffixes
assert.strictEqual(cleanVersion('v'), '', 'only prefix returns empty');
assert.strictEqual(cleanVersion('+ent'), '', 'only suffix returns empty');
});
test('isValidVersion validates version strings correctly', function (assert) {
// Valid versions
assert.true(isValidVersion('0.17'), 'Basic semver is valid');
assert.true(isValidVersion('0.17.0'), 'Full semver is valid');
assert.true(isValidVersion('v0.17.1'), 'Version with v prefix is valid');
assert.true(isValidVersion('1.2.3+ent'), 'Version with build metadata is valid');
assert.true(isValidVersion('2.0.0-beta'), 'Version with pre-release is valid');
// Invalid versions
assert.false(isValidVersion(''), 'Empty string is invalid');
assert.false(isValidVersion(' '), 'Whitespace only is invalid');
assert.false(isValidVersion('null'), 'String "null" is invalid');
assert.false(isValidVersion('invalid'), 'Non-numeric string is invalid');
assert.false(isValidVersion(null), 'null is invalid');
assert.false(isValidVersion(undefined), 'undefined is invalid');
});
});