diff --git a/changelog/_11659.txt b/changelog/_11659.txt new file mode 100644 index 0000000000..b696a29545 --- /dev/null +++ b/changelog/_11659.txt @@ -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. +``` \ No newline at end of file diff --git a/ui/app/components/mount/secrets-engine-form.hbs b/ui/app/components/mount/secrets-engine-form.hbs index 8ac9c632e3..58187b73f4 100644 --- a/ui/app/components/mount/secrets-engine-form.hbs +++ b/ui/app/components/mount/secrets-engine-form.hbs @@ -13,20 +13,105 @@
+ {{! Plugin registration type (built-in vs external) }} + + Plugin registration type + {{#each this.pluginTypeOptions as |option|}} + + + {{option.label}} + {{#if option.showBadge}} + + {{/if}} + {{option.description}} + {{#if option.showAlert}} + + + No external plugins for this engine are currently registered in your plugin catalog. + + + {{/if}} + + {{/each}} + + + {{! Plugin version selection (only shows for external plugins) }} + {{#if this.shouldShowPluginVersionField}} +
+ + 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}} + + + {{#each this.filteredVersionOptions as |version|}} + + {{/each}} + + {{#if (get this.formValidations "config.plugin_version.errors.length")}} + + {{#each (get this.formValidations "config.plugin_version.errors") as |error|}} + {{error}} + {{/each}} + + {{/if}} + + + {{! Warning when selected version differs from pinned version }} + {{#if this.shouldShowPinWarning}} + + Version differs from pinned + + 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. + + + {{/if}} +
+ {{/if}} + - + <:identityTokenKey> 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 * @@ -38,10 +64,43 @@ export default class MountSecretsEngineFormComponent extends Component { @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 { { 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 { } } + // 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 { @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 { @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 { 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); + } + } } diff --git a/ui/app/controllers/vault/cluster/secrets/enable/create.ts b/ui/app/controllers/vault/cluster/secrets/enable/create.ts index 829b1bf79c..cb927d1f04 100644 --- a/ui/app/controllers/vault/cluster/secrets/enable/create.ts +++ b/ui/app/controllers/vault/cluster/secrets/enable/create.ts @@ -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 { diff --git a/ui/app/controllers/vault/cluster/secrets/enable/index.js b/ui/app/controllers/vault/cluster/secrets/enable/index.js index ecb13b2181..0dbd309f52 100644 --- a/ui/app/controllers/vault/cluster/secrets/enable/index.js +++ b/ui/app/controllers/vault/cluster/secrets/enable/index.js @@ -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 { diff --git a/ui/app/forms/mount.ts b/ui/app/forms/mount.ts index 1ae87b8792..74b0b5e556 100644 --- a/ui/app/forms/mount.ts +++ b/ui/app/forms/mount.ts @@ -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 extends Form { @@ -79,9 +86,83 @@ export default class MountForm 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 { constructor(...args: ConstructorParameters) { @@ -20,6 +23,59 @@ export default class SecretsEngineForm extends MountForm { 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 diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index 549c01d768..a4c33fe905 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -207,6 +207,7 @@ export default class SecretEngineModel extends Model { 'config.auditNonHmacResponseKeys', 'config.passthroughRequestHeaders', 'config.allowedResponseHeaders', + 'config.plugin_version', ]; switch (this.engineType) { diff --git a/ui/app/routes/vault/cluster/secrets/enable/create.ts b/ui/app/routes/vault/cluster/secrets/enable/create.ts index fa9c63694c..cb061832bd 100644 --- a/ui/app/routes/vault/cluster/secrets/enable/create.ts +++ b/ui/app/routes/vault/cluster/secrets/enable/create.ts @@ -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, + }; } } diff --git a/ui/app/utils/external-plugin-helpers.ts b/ui/app/utils/external-plugin-helpers.ts index 25a70a64fe..3ab6854165 100644 --- a/ui/app/utils/external-plugin-helpers.ts +++ b/ui/app/utils/external-plugin-helpers.ts @@ -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; +} diff --git a/ui/app/utils/plugin-catalog-helpers.ts b/ui/app/utils/plugin-catalog-helpers.ts index f85815e074..eb91bc76f9 100644 --- a/ui/app/utils/plugin-catalog-helpers.ts +++ b/ui/app/utils/plugin-catalog-helpers.ts @@ -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 }; +} diff --git a/ui/app/utils/version-utils.ts b/ui/app/utils/version-utils.ts new file mode 100644 index 0000000000..042ea83f6c --- /dev/null +++ b/ui/app/utils/version-utils.ts @@ -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'; + } +} diff --git a/ui/tests/acceptance/auth/config-form-test.js b/ui/tests/acceptance/auth/config-form-test.js index 91cf999357..8069a069c1 100644 --- a/ui/tests/acceptance/auth/config-form-test.js +++ b/ui/tests/acceptance/auth/config-form-test.js @@ -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: [ diff --git a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js index c9c6f17e01..78e705c0ac 100644 --- a/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js +++ b/ui/tests/acceptance/secrets/backend/kv/kv-v2-workflow-navigation-test.js @@ -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) => { diff --git a/ui/tests/integration/components/mount-backend-form-test.js b/ui/tests/integration/components/mount-backend-form-test.js index 8c7b25abf9..5bc05e1b8e 100644 --- a/ui/tests/integration/components/mount-backend-form-test.js +++ b/ui/tests/integration/components/mount-backend-form-test.js @@ -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`` ); @@ -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` + + `); + + // 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'); + }); + }); + }); }); diff --git a/ui/tests/integration/components/mount/secrets-engine-form-test.js b/ui/tests/integration/components/mount/secrets-engine-form-test.js index 7a834647e2..1db4473256 100644 --- a/ui/tests/integration/components/mount/secrets-engine-form-test.js +++ b/ui/tests/integration/components/mount/secrets-engine-form-test.js @@ -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`` ); @@ -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`` ); @@ -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`` @@ -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`` @@ -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`` @@ -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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + // 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + 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`` + ); + + // 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`` + ); + + // 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`` + ); + + // 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`` + ); + + 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'); + }); + }); }); diff --git a/ui/tests/integration/helpers/englines-display-data-test.js b/ui/tests/integration/helpers/englines-display-data-test.js deleted file mode 100644 index bfecf924f2..0000000000 --- a/ui/tests/integration/helpers/englines-display-data-test.js +++ /dev/null @@ -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'); - }); -}); diff --git a/ui/tests/unit/components/mount/secrets-engine-form-test.js b/ui/tests/unit/components/mount/secrets-engine-form-test.js new file mode 100644 index 0000000000..e386b34307 --- /dev/null +++ b/ui/tests/unit/components/mount/secrets-engine-form-test.js @@ -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'); + }); +}); diff --git a/ui/tests/unit/forms/mount-test.js b/ui/tests/unit/forms/mount-test.js new file mode 100644 index 0000000000..8a0acf8160 --- /dev/null +++ b/ui/tests/unit/forms/mount-test.js @@ -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'); + }); + }); +}); diff --git a/ui/tests/unit/helpers/engines-display-data-test.js b/ui/tests/unit/helpers/engines-display-data-test.js index 7885329b4b..cefcd797c5 100644 --- a/ui/tests/unit/helpers/engines-display-data-test.js +++ b/ui/tests/unit/helpers/engines-display-data-test.js @@ -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) { diff --git a/ui/tests/unit/models/secret-engine-test.js b/ui/tests/unit/models/secret-engine-test.js index 4cfdd981bc..9fee14dfef 100644 --- a/ui/tests/unit/models/secret-engine-test.js +++ b/ui/tests/unit/models/secret-engine-test.js @@ -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', ], }, ]); diff --git a/ui/tests/unit/utils/external-plugin-helpers-test.js b/ui/tests/unit/utils/external-plugin-helpers-test.js index f7897e67a6..ef09a0acb6 100644 --- a/ui/tests/unit/utils/external-plugin-helpers-test.js +++ b/ui/tests/unit/utils/external-plugin-helpers-test.js @@ -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' + ); + }); }); }); diff --git a/ui/tests/unit/utils/plugin-catalog-helpers-test.js b/ui/tests/unit/utils/plugin-catalog-helpers-test.js index 4536b2f95d..08dea88be2 100644 --- a/ui/tests/unit/utils/plugin-catalog-helpers-test.js +++ b/ui/tests/unit/utils/plugin-catalog-helpers-test.js @@ -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' + ); + }); + }); }); diff --git a/ui/tests/unit/utils/version-utils-test.js b/ui/tests/unit/utils/version-utils-test.js new file mode 100644 index 0000000000..9c90fa7ce2 --- /dev/null +++ b/ui/tests/unit/utils/version-utils-test.js @@ -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'); + }); +});