From 91025c9ce7ed22d12a178f90da9be02399ed6077 Mon Sep 17 00:00:00 2001 From: Vault Automation Date: Thu, 18 Dec 2025 11:29:20 -0700 Subject: [PATCH] [VAULT-33083] UI: support builtin plugins as external plugins (#11244) (#11489) * [VAULT-33083] UI: support builtin plugins as external plugins * address copilot review comments * add changelog * remove unused id property * address some nits & add test coverage * should use utils instead of mixins * update comments * move/consolidate logic for 'transform' engine type into ENGINE_TYPE_TO_MODEL_TYPE_MAP, added/updated test coverage * cleanup: extract transform engine model type logic into helper functions * address pr comment * separation of concerns - move relevant vars/fns from all engines metadata to external plugin helpers & secret engine model helpers files * add TODO; remove unnecessary exports * rename secret-engine-model-helpers to secret-engine-helpers * update unknown engine metadata from var to fn to handle a methodType param * remove unnecessary test * update changelog; return methodType for unknown engine metadata, simplify code for readability * add optional chaining for fail-safe * address kvv1 edge case - on exit configuration, kvv1 should redirect to list-root while kvv2 should redirect to the engineRoute defined in all-engines-metadata * add ibm header * fix test failure after updating unknown engine type Co-authored-by: Shannon Roberts (Beagin) --- changelog/_11244.txt | 3 + ui/app/components/secret-engine/list.ts | 36 ++- .../secret-engine/page/general-settings.hbs | 6 +- .../secret-engine/page/plugin-settings.hbs | 6 +- ui/app/helpers/engines-display-data.ts | 50 ++- ui/app/helpers/exit-configuration-route.ts | 51 +++ ui/app/helpers/secret-query-params.js | 5 +- ui/app/models/secret-engine.js | 16 +- ui/app/resources/secrets/engine.ts | 21 +- .../cluster/secrets/backend/configuration.js | 5 +- .../secrets/backend/configuration/edit.ts | 12 +- .../vault/cluster/secrets/backend/list.js | 65 +--- .../vault/cluster/secrets/backend/overview.js | 8 +- .../cluster/secrets/backend/secret-edit.js | 55 +--- .../secrets/backend/configuration/edit.hbs | 6 +- ui/app/utils/all-engines-metadata.ts | 4 +- ui/app/utils/backend-route-helpers.ts | 35 +++ ui/app/utils/external-plugin-helpers.ts | 65 ++++ .../model-helpers/secret-engine-helpers.ts | 114 +++++++ .../addon/components/secret-list-header.hbs | 2 +- .../addon/components/secret-list-header.js | 14 +- .../helpers/englines-display-data-test.js | 2 +- .../backend/list-external-plugins-test.js | 152 +++++++++ .../unit/helpers/engines-display-data-test.js | 82 +++++ .../helpers/exit-configuration-route-test.js | 129 ++++++++ ...cret-query-params-external-plugins-test.js | 140 +++++++++ .../secret-engine-external-plugins-test.js | 152 +++++++++ .../unit/utils/all-engines-metadata-test.js | 122 +++++++ .../unit/utils/backend-route-helpers-test.js | 127 ++++++++ .../utils/external-plugin-helpers-test.js | 133 ++++++++ .../secret-engine-helpers-test.js | 166 ++++++++++ .../unit/utils/transform-engine-logic-test.js | 297 ++++++++++++++++++ 32 files changed, 1920 insertions(+), 161 deletions(-) create mode 100644 changelog/_11244.txt create mode 100644 ui/app/helpers/exit-configuration-route.ts create mode 100644 ui/app/utils/backend-route-helpers.ts create mode 100644 ui/app/utils/external-plugin-helpers.ts create mode 100644 ui/app/utils/model-helpers/secret-engine-helpers.ts create mode 100644 ui/tests/integration/routes/vault/cluster/secrets/backend/list-external-plugins-test.js create mode 100644 ui/tests/unit/helpers/engines-display-data-test.js create mode 100644 ui/tests/unit/helpers/exit-configuration-route-test.js create mode 100644 ui/tests/unit/helpers/secret-query-params-external-plugins-test.js create mode 100644 ui/tests/unit/models/secret-engine-external-plugins-test.js create mode 100644 ui/tests/unit/utils/all-engines-metadata-test.js create mode 100644 ui/tests/unit/utils/backend-route-helpers-test.js create mode 100644 ui/tests/unit/utils/external-plugin-helpers-test.js create mode 100644 ui/tests/unit/utils/model-helpers/secret-engine-helpers-test.js create mode 100644 ui/tests/unit/utils/transform-engine-logic-test.js diff --git a/changelog/_11244.txt b/changelog/_11244.txt new file mode 100644 index 0000000000..897ced9211 --- /dev/null +++ b/changelog/_11244.txt @@ -0,0 +1,3 @@ +```release-note:feature +**UI: Hashi-Built External Plugin Support**: Recognize and support Hashi-built plugins when run as external binaries +``` diff --git a/ui/app/components/secret-engine/list.ts b/ui/app/components/secret-engine/list.ts index 4a4045e9c8..8316a6aad5 100644 --- a/ui/app/components/secret-engine/list.ts +++ b/ui/app/components/secret-engine/list.ts @@ -16,6 +16,8 @@ import type RouterService from '@ember/routing/router-service'; import type VersionService from 'vault/services/version'; import engineDisplayData from 'vault/helpers/engines-display-data'; import NamespaceService from 'vault/vault/services/namespace'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; +import { ALL_ENGINES } from 'vault/utils/all-engines-metadata'; /** * @module SecretEngineList handles the display of the list of secret engines, including the filtering. @@ -98,9 +100,10 @@ export default class SecretEngineList extends Component { // filters by engine type, ex: 'kv' if (this.engineTypeFilters.length > 0) { - sortedBackends = sortedBackends.filter((backend) => - this.engineTypeFilters.includes(backend.engineType) - ); + sortedBackends = sortedBackends.filter((backend) => { + const effectiveType = getEffectiveEngineType(backend.engineType); + return this.engineTypeFilters.includes(effectiveType); + }); } // filters by engine version, ex: 'v1.21.0...' @@ -124,9 +127,10 @@ export default class SecretEngineList extends Component { get typeFilterOptions() { // if there is search text, filter types by that if (this.typeSearchText.trim() !== '') { - return this.displayableBackends.filter((backend) => - backend.engineType.toLowerCase().includes(this.typeSearchText.toLowerCase()) - ); + return this.displayableBackends.filter((backend) => { + const effectiveType = getEffectiveEngineType(backend.engineType); + return effectiveType.toLowerCase().includes(this.typeSearchText.toLowerCase()); + }); } return this.displayableBackends; @@ -146,14 +150,16 @@ export default class SecretEngineList extends Component { // Returns filtered engines list by type get secretEngineArrayByType() { - const arrayOfAllEngineTypes = this.typeFilterOptions.map((modelObject) => modelObject.engineType); - // filter out repeated engineTypes (e.g. [kv, kv] => [kv]) - const arrayOfUniqueEngineTypes = [...new Set(arrayOfAllEngineTypes)]; + const arrayOfAllEffectiveTypes = this.typeFilterOptions.map((modelObject) => + getEffectiveEngineType(modelObject.engineType) + ); + // filter out repeated effective types (e.g. [kv, kv] => [kv]) + const arrayOfUniqueEffectiveTypes = [...new Set(arrayOfAllEffectiveTypes)]; - return arrayOfUniqueEngineTypes.map((engineType) => ({ - name: engineType, - id: engineType, - icon: engineDisplayData(engineType)?.glyph ?? 'lock', + return arrayOfUniqueEffectiveTypes.map((effectiveType) => ({ + name: effectiveType, + id: effectiveType, + icon: engineDisplayData(effectiveType)?.glyph ?? 'lock', })); } @@ -187,8 +193,8 @@ export default class SecretEngineList extends Component { } else { return `${displayData.displayName}`; } - } else if (displayData.type === 'unknown') { - // If a mounted engine type doesn't match any known type, the type is returned as 'unknown' and set this tooltip. + } else if (!ALL_ENGINES.find((engine) => engine.type === backend.type)) { + // If a mounted engine type doesn't match any known type in our static metadata, set this tooltip. // Handles issue when a user externally mounts an engine that doesn't follow the expected naming conventions for what's in the binary, despite being a valid engine. return `This engine's type is not recognized by the UI. Please use the CLI to manage this engine.`; } else { diff --git a/ui/app/components/secret-engine/page/general-settings.hbs b/ui/app/components/secret-engine/page/general-settings.hbs index 4f572f9abc..6e052e7fe2 100644 --- a/ui/app/components/secret-engine/page/general-settings.hbs +++ b/ui/app/components/secret-engine/page/general-settings.hbs @@ -14,11 +14,7 @@ <:actions> diff --git a/ui/app/components/secret-engine/page/plugin-settings.hbs b/ui/app/components/secret-engine/page/plugin-settings.hbs index 8a18b34586..100cd53c90 100644 --- a/ui/app/components/secret-engine/page/plugin-settings.hbs +++ b/ui/app/components/secret-engine/page/plugin-settings.hbs @@ -15,11 +15,7 @@ <:actions> diff --git a/ui/app/helpers/engines-display-data.ts b/ui/app/helpers/engines-display-data.ts index 6789aa80c3..4924e9a7e7 100644 --- a/ui/app/helpers/engines-display-data.ts +++ b/ui/app/helpers/engines-display-data.ts @@ -4,6 +4,17 @@ */ import { ALL_ENGINES, type EngineDisplayData } from 'vault/utils/all-engines-metadata'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; + +/** + * Default metadata for unknown engine plugins + */ +export const unknownEngineMetadata = (methodType?: string): EngineDisplayData => ({ + type: methodType || 'unknown', + displayName: methodType || 'Unknown plugin', + glyph: 'lock', + mountCategory: ['secret', 'auth'], +}); /** * Helper function to retrieve engine metadata for a given `methodType`. @@ -11,26 +22,41 @@ import { ALL_ENGINES, type EngineDisplayData } from 'vault/utils/all-engines-met * The `ALL_ENGINES` array includes secret and auth engines, including those supported only in enterprise. * These details (such as mount type and enterprise licensing) are included in the returned engine object. * + * For external plugins that have a builtin mapping (e.g., "vault-plugin-secrets-keymgmt" -> "keymgmt"), + * this function returns the metadata for the corresponding builtin engine, preserving the original + * external plugin name in the type field. + * * Example usage: * const engineMetadata = engineDisplayData('kmip'); * if (engineMetadata?.requiresEnterprise) { * console.log(`This mount: ${engineMetadata.engineType} requires an enterprise license`); * } * - * @param {string} methodType - The engine type (sometimes called backend) to look up (e.g., "aws", "azure"). - * @returns {Object|undefined} - The engine metadata, which includes information about its mount type (e.g., secret or auth) - * and whether it requires an enterprise license. Returns undefined if no match is found. + * @param {string} methodType - The engine type (sometimes called backend) to look up (e.g., "aws", "azure", "vault-plugin-secrets-keymgmt"). + * @returns {Object} - The engine metadata, which includes information about its mount type (e.g., secret or auth) + * and whether it requires an enterprise license. For unknown engines, returns a default unknown plugin object. */ export default function engineDisplayData(methodType: string): EngineDisplayData { - const engine = ALL_ENGINES?.find((t) => t.type === methodType); - if (!engine) { - return { - displayName: methodType || 'Unknown plugin', - type: 'unknown', - glyph: 'lock', - mountCategory: ['secret', 'auth'], - }; + // First try to find an exact match + const builtinEngine = ALL_ENGINES?.find((t) => t.type === methodType); + if (builtinEngine) { + return builtinEngine; } - return engine; + // If no direct match, check if this is a known external plugin and use its builtin mapping + const effectiveType = getEffectiveEngineType(methodType); + if (effectiveType !== methodType) { + // This is a known external plugin with a builtin mapping + const mappedEngine = ALL_ENGINES?.find((t) => t.type === effectiveType); + if (mappedEngine) { + // Return the mapped engine metadata but preserve the original external plugin type + return { + ...mappedEngine, + type: methodType, // Keep the original external plugin name for identification + }; + } + } + + // Return default unknown plugin metadata + return unknownEngineMetadata(methodType); } diff --git a/ui/app/helpers/exit-configuration-route.ts b/ui/app/helpers/exit-configuration-route.ts new file mode 100644 index 0000000000..8885779216 --- /dev/null +++ b/ui/app/helpers/exit-configuration-route.ts @@ -0,0 +1,51 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { helper } from '@ember/component/helper'; +import { isAddonEngine } from 'vault/utils/all-engines-metadata'; +import engineDisplayData from 'vault/helpers/engines-display-data'; + +/** + * Get the appropriate route for exiting configuration based on engine type and version. + * This handles the logic for determining whether to use the backends route for + * isOnlyMountable engines, or the engine-specific routes for other engines. + * + * @param engineType - The type of the engine + * @param version - The version of the engine (relevant for KV engines) + * @returns The full route path for the exit configuration button + */ +function getExitConfigurationRoute(engineType: string, version?: number): string { + const engineData = engineDisplayData(engineType); + + if (engineData.isOnlyMountable) { + return 'vault.cluster.secrets.backends'; + } + + const baseRoute = 'vault.cluster.secrets.backend'; + const shouldUseEngineRoute = isAddonEngine(engineType, version || 1); + + if (shouldUseEngineRoute && engineData.engineRoute) { + return `${baseRoute}.${engineData.engineRoute}`; + } + + return `${baseRoute}.list-root`; +} + +/** + * Handlebars helper to get the appropriate exit configuration route for a secrets engine. + * This helper handles all the logic for determining the correct route based on the engine type and version. + * + * Usage: + * @route={{exit-configuration-route engineType version}} + * + * @param engineType - The type of the secrets engine + * @param version - The version of the engine (optional, defaults to 1) + * @returns The full route path for the exit configuration button + */ +export function exitConfigurationRoute([engineType, version]: [string, number?]): string { + return getExitConfigurationRoute(engineType, version); +} + +export default helper(exitConfigurationRoute); diff --git a/ui/app/helpers/secret-query-params.js b/ui/app/helpers/secret-query-params.js index f7546e37d1..d8b061d016 100644 --- a/ui/app/helpers/secret-query-params.js +++ b/ui/app/helpers/secret-query-params.js @@ -4,13 +4,16 @@ */ import { helper } from '@ember/component/helper'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; export function secretQueryParams([backendType, type = ''], { asQueryParams }) { + // Use effective engine type to handle external plugin mappings + const effectiveBackendType = getEffectiveEngineType(backendType); const values = { transit: { tab: 'actions' }, database: { type }, keymgmt: { itemType: type === 'provider' ? 'provider' : 'key' }, - }[backendType]; + }[effectiveBackendType]; // format required when using LinkTo with positional params if (values && asQueryParams) { return { diff --git a/ui/app/models/secret-engine.js b/ui/app/models/secret-engine.js index ccd1398102..549c01d768 100644 --- a/ui/app/models/secret-engine.js +++ b/ui/app/models/secret-engine.js @@ -11,6 +11,7 @@ import { withExpandedAttributes } from 'vault/decorators/model-expanded-attribut import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; import { WHITESPACE_WARNING } from 'vault/utils/forms/validators'; import { ALL_ENGINES, isAddonEngine } from 'vault/utils/all-engines-metadata'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; import engineDisplayData from 'vault/helpers/engines-display-data'; const LINKED_BACKENDS = supportedSecretBackends(); @@ -110,7 +111,8 @@ export default class SecretEngineModel extends Model { /* GETTERS */ get isV2KV() { - return this.version === 2 && (this.engineType === 'kv' || this.engineType === 'generic'); + const effectiveType = getEffectiveEngineType(this.engineType); + return this.version === 2 && ['kv', 'generic'].includes(effectiveType); } get attrs() { @@ -142,11 +144,12 @@ export default class SecretEngineModel extends Model { } get backendLink() { - if (this.engineType === 'database') { + const effectiveType = getEffectiveEngineType(this.engineType); + if (effectiveType === 'database') { return 'vault.cluster.secrets.backend.overview'; } - if (isAddonEngine(this.engineType, this.version)) { - return `vault.cluster.secrets.backend.${engineDisplayData(this.engineType).engineRoute}`; + if (isAddonEngine(effectiveType, this.version)) { + return `vault.cluster.secrets.backend.${engineDisplayData(effectiveType).engineRoute}`; } if (this.isV2KV) { // if it's KV v2 but not registered as an addon, it's type generic @@ -156,8 +159,9 @@ export default class SecretEngineModel extends Model { } get backendConfigurationLink() { - if (isAddonEngine(this.engineType, this.version)) { - return `vault.cluster.secrets.backend.${this.engineType}.configuration`; + const effectiveType = getEffectiveEngineType(this.engineType); + if (isAddonEngine(effectiveType, this.version)) { + return `vault.cluster.secrets.backend.${effectiveType}.configuration`; } return `vault.cluster.secrets.backend.configuration.general-settings`; } diff --git a/ui/app/resources/secrets/engine.ts b/ui/app/resources/secrets/engine.ts index 5cf26d9373..a2631d058d 100644 --- a/ui/app/resources/secrets/engine.ts +++ b/ui/app/resources/secrets/engine.ts @@ -9,6 +9,7 @@ import { SupportedSecretBackendsEnum, } from 'vault/helpers/supported-secret-backends'; import { isAddonEngine } from 'vault/utils/all-engines-metadata'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; import engineDisplayData from 'vault/helpers/engines-display-data'; import type { Mount } from 'vault/mount'; @@ -47,10 +48,14 @@ export default class SecretsEngineResource extends baseResourceFactory() return engineData?.glyph || 'lock'; } + get effectiveEngineType() { + return getEffectiveEngineType(this.engineType); + } + get isV2KV() { return ( this.version === 2 && - (this.engineType === SupportedSecretBackendsEnum.KV || this.engineType === 'generic') + (this.effectiveEngineType === SupportedSecretBackendsEnum.KV || this.effectiveEngineType === 'generic') ); } @@ -59,15 +64,15 @@ export default class SecretsEngineResource extends baseResourceFactory() } get isSupportedBackend() { - return supportedSecretBackends().includes(this.engineType as SupportedSecretBackendsEnum); + return supportedSecretBackends().includes(this.effectiveEngineType as SupportedSecretBackendsEnum); } get backendLink() { - if (this.engineType === 'database') { + if (this.effectiveEngineType === 'database') { return 'vault.cluster.secrets.backend.overview'; } - if (isAddonEngine(this.engineType, this.version)) { - const engine = engineDisplayData(this.engineType); + if (isAddonEngine(this.effectiveEngineType, this.version)) { + const engine = engineDisplayData(this.effectiveEngineType); // Use effective type to get proper metadata if (engine?.engineRoute) { return `vault.cluster.secrets.backend.${engine.engineRoute}`; } @@ -80,7 +85,7 @@ export default class SecretsEngineResource extends baseResourceFactory() } get backendConfigurationLink() { - const { isConfigurable, configRoute } = engineDisplayData(this.engineType); + const { isConfigurable, configRoute } = engineDisplayData(this.effectiveEngineType); if (isConfigurable) { const route = configRoute || 'configuration.plugin-settings'; return `vault.cluster.secrets.backend.${route}`; @@ -93,11 +98,11 @@ export default class SecretsEngineResource extends baseResourceFactory() } get supportsRecovery() { - if (!SUPPORTS_RECOVERY.includes(this.engineType as RecoverySupportedEngines)) { + if (!SUPPORTS_RECOVERY.includes(this.effectiveEngineType as RecoverySupportedEngines)) { return false; } - if (this.engineType === SupportedSecretBackendsEnum.KV) { + if (this.effectiveEngineType === SupportedSecretBackendsEnum.KV) { return !this.isV2KV; } diff --git a/ui/app/routes/vault/cluster/secrets/backend/configuration.js b/ui/app/routes/vault/cluster/secrets/backend/configuration.js index 41354f7777..0e0d8d1863 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/configuration.js +++ b/ui/app/routes/vault/cluster/secrets/backend/configuration.js @@ -5,6 +5,7 @@ import { service } from '@ember/service'; import Route from '@ember/routing/route'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; /** * This route is responsible for fetching all configuration data. @@ -30,7 +31,9 @@ export default class SecretsBackendConfigurationRoute extends Route { fetchConfig(type, id) { // id is the path where the backend is mounted since there's only one config per engine (often this path is referred to just as backend) - switch (type) { + // Use effective type to handle external plugin mappings + const effectiveType = getEffectiveEngineType(type); + switch (effectiveType) { case 'aws': return this.fetchAwsConfigs(id); case 'azure': diff --git a/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts b/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts index 5a77ff3169..323c391d26 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts +++ b/ui/app/routes/vault/cluster/secrets/backend/configuration/edit.ts @@ -10,6 +10,7 @@ import AzureConfigForm from 'vault/forms/secrets/azure-config'; import GcpConfigForm from 'vault/forms/secrets/gcp-config'; import SshConfigForm from 'vault/forms/secrets/ssh-config'; import engineDisplayData from 'vault/helpers/engines-display-data'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; import type SecretsEngineResource from 'vault/resources/secrets/engine'; import type ApiService from 'vault/services/api'; @@ -50,24 +51,27 @@ export default class SecretsBackendConfigurationEdit extends Route { 'vault.cluster.secrets.backend.configuration' ) as SecretsBackendConfigurationModel; + // Use effective type to handle external plugin mappings + const effectiveType = getEffectiveEngineType(type); + const formClass = { aws: AwsConfigForm, azure: AzureConfigForm, gcp: GcpConfigForm, ssh: SshConfigForm, - }[type]; + }[effectiveType]; const defaults = { ssh: { generate_signing_key: true, issuer: '' }, - }[type] || { issuer: '' }; + }[effectiveType] || { issuer: '' }; // if the engine type is not configurable or a form class does not exist for the type return a 404. - if (!engineDisplayData(type)?.isConfigurable || !formClass) { + if (!engineDisplayData(effectiveType)?.isConfigurable || !formClass) { throw { httpStatus: 404, backend }; } return { - type, + type: effectiveType, id: backend, config, secretsEngine: this.modelFor('vault.cluster.secrets.backend'), diff --git a/ui/app/routes/vault/cluster/secrets/backend/list.js b/ui/app/routes/vault/cluster/secrets/backend/list.js index e68454c867..4e82e3c8cd 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/list.js +++ b/ui/app/routes/vault/cluster/secrets/backend/list.js @@ -8,8 +8,11 @@ import { hash } from 'rsvp'; import Route from '@ember/routing/route'; import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; import { isAddonEngine, filterEnginesByMountCategory } from 'vault/utils/all-engines-metadata'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; +import { getModelTypeForEngine } from 'vault/utils/model-helpers/secret-engine-helpers'; import { service } from '@ember/service'; import { normalizePath } from 'vault/utils/path-encoding-helpers'; +import { getEnginePathParam } from 'vault/utils/backend-route-helpers'; import { assert } from '@ember/debug'; import { pathIsDirectory } from 'kv/utils/kv-breadcrumbs'; import engineDisplayData from 'vault/helpers/engines-display-data'; @@ -49,57 +52,34 @@ export default Route.extend({ }, }, - modelTypeForTransform(tab) { - let modelType; - switch (tab) { - case 'role': - modelType = 'transform/role'; - break; - case 'template': - modelType = 'transform/template'; - break; - case 'alphabet': - modelType = 'transform/alphabet'; - break; - default: // CBS TODO: transform/transformation - modelType = 'transform'; - break; - } - return modelType; - }, - secretParam() { const { secret } = this.paramsFor(this.routeName); return secret ? normalizePath(secret) : ''; }, - enginePathParam() { - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); - return backend; - }, - beforeModel() { const secret = this.secretParam(); - const backend = this.enginePathParam(); + const backend = getEnginePathParam(this); const { tab } = this.paramsFor('vault.cluster.secrets.backend.list-root'); const secretEngine = this.modelFor('vault.cluster.secrets.backend'); const type = secretEngine?.engineType; + const effectiveType = getEffectiveEngineType(type); assert('secretEngine.engineType is not defined', !!type); // if configuration only, redirect to configuration route - if (engineDisplayData(type)?.isOnlyMountable) { + if (engineDisplayData(effectiveType)?.isOnlyMountable) { return this.router.transitionTo('vault.cluster.secrets.backend.configuration', backend); } const engineRoute = filterEnginesByMountCategory({ mountCategory: 'secret', isEnterprise: true }).find( - (engine) => engine.type === type + (engine) => engine.type === effectiveType )?.engineRoute; - if (!type || !SUPPORTED_BACKENDS.includes(type)) { + if (!type || !SUPPORTED_BACKENDS.includes(effectiveType)) { return this.router.transitionTo('vault.cluster.secrets'); } if (this.routeName === 'vault.cluster.secrets.backend.list' && !secret.endsWith('/')) { return this.router.replaceWith('vault.cluster.secrets.backend.list', secret + '/'); } - if (isAddonEngine(type, secretEngine.version)) { + if (isAddonEngine(effectiveType, secretEngine.version)) { if (engineRoute === 'kv.list' && pathIsDirectory(secret)) { return this.router.transitionTo('vault.cluster.secrets.backend.kv.list-directory', backend, secret); } @@ -108,33 +88,22 @@ export default Route.extend({ // if it's KV v2 but not registered as an addon, it's type generic return this.router.transitionTo('vault.cluster.secrets.backend.kv.list', backend); } - const modelType = this.getModelType(type, tab); + const modelType = this.getModelType(effectiveType, tab); return this.pathHelp.hydrateModel(modelType, backend).then(() => { this.store.unloadAll('capabilities'); }); }, getModelType(type, tab) { - const types = { - database: tab === 'role' ? 'database/role' : 'database/connection', - transit: 'transit-key', - ssh: 'role-ssh', - transform: this.modelTypeForTransform(tab), - aws: 'role-aws', - cubbyhole: 'secret', - kv: 'secret', - keymgmt: `keymgmt/${tab || 'key'}`, - generic: 'secret', - totp: 'totp-key', - }; - return types[type]; + return getModelTypeForEngine(type, { tab }); }, async model(params) { const secret = this.secretParam() || ''; - const backend = this.enginePathParam(); + const backend = getEnginePathParam(this); const backendModel = this.modelFor('vault.cluster.secrets.backend'); - const modelType = this.getModelType(backendModel.engineType, params.tab); + const effectiveType = getEffectiveEngineType(backendModel.engineType); + const modelType = this.getModelType(effectiveType, params.tab); return hash({ secret, @@ -165,7 +134,7 @@ export default Route.extend({ const secretParams = this.paramsFor(this.routeName); const secret = resolvedModel.secret; const model = resolvedModel.secrets; - const backend = this.enginePathParam(); + const backend = getEnginePathParam(this); const backendModel = this.modelFor('vault.cluster.secrets.backend'); const has404 = this.has404; // only clear store cache if this is a new model @@ -179,7 +148,7 @@ export default Route.extend({ backend, backendModel, baseKey: { id: secret }, - backendType: backendModel.engineType, + backendType: getEffectiveEngineType(backendModel.engineType), }); if (!has404) { const pageFilter = secretParams.pageFilter; @@ -207,7 +176,7 @@ export default Route.extend({ actions: { error(error, transition) { const secret = this.secretParam(); - const backend = this.enginePathParam(); + const backend = getEnginePathParam(this); const is404 = error.httpStatus === 404; /* eslint-disable-next-line ember/no-controller-access-in-routes */ const hasModel = this.controllerFor(this.routeName).hasModel; diff --git a/ui/app/routes/vault/cluster/secrets/backend/overview.js b/ui/app/routes/vault/cluster/secrets/backend/overview.js index 3fa4486541..f3b7c21d6d 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/overview.js +++ b/ui/app/routes/vault/cluster/secrets/backend/overview.js @@ -6,16 +6,12 @@ import Route from '@ember/routing/route'; import { hash } from 'rsvp'; import { service } from '@ember/service'; +import { getEnginePathParam } from 'vault/utils/backend-route-helpers'; export default Route.extend({ store: service(), type: '', - enginePathParam() { - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); - return backend; - }, - async fetchConnection(queryOptions) { try { return await this.store.query('database/connection', queryOptions); @@ -51,7 +47,7 @@ export default Route.extend({ }, model() { - const backend = this.enginePathParam(); + const backend = getEnginePathParam(this); const queryOptions = { backend, id: '' }; const connection = this.fetchConnection(queryOptions); diff --git a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js index b2932e2c11..be442abd4e 100644 --- a/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js +++ b/ui/app/routes/vault/cluster/secrets/backend/secret-edit.js @@ -10,6 +10,9 @@ import { service } from '@ember/service'; import Route from '@ember/routing/route'; import { encodePath, normalizePath } from 'vault/utils/path-encoding-helpers'; import { keyIsFolder, parentKeyForKey } from 'core/utils/key-utils'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; +import { getModelTypeForEngine } from 'vault/utils/model-helpers/secret-engine-helpers'; +import { getBackendEffectiveType, getEnginePathParam } from 'vault/utils/backend-route-helpers'; /** * @type Class @@ -24,15 +27,10 @@ export default Route.extend({ return secret ? normalizePath(secret) : ''; }, - enginePathParam() { - const { backend } = this.paramsFor('vault.cluster.secrets.backend'); - return backend; - }, - capabilities(secret, modelType) { - const backend = this.enginePathParam(); + const backend = getEnginePathParam(this); const backendModel = this.modelFor('vault.cluster.secrets.backend'); - const backendType = backendModel.engineType; + const backendType = getEffectiveEngineType(backendModel.engineType); let path; if (backendType === 'transit') { path = backend + '/keys/' + secret; @@ -51,27 +49,13 @@ export default Route.extend({ return `${backend}/${noun}/${secret}`; }, - modelTypeForTransform(secretName) { - if (!secretName) return 'transform'; - if (secretName.startsWith('role/')) { - return 'transform/role'; - } - if (secretName.startsWith('template/')) { - return 'transform/template'; - } - if (secretName.startsWith('alphabet/')) { - return 'transform/alphabet'; - } - return 'transform'; // TODO: transform/transformation; - }, - transformSecretName(secret, modelType) { const noun = modelType.split('/')[1]; return secret.replace(`${noun}/`, ''); }, backendType() { - return this.modelFor('vault.cluster.secrets.backend').engineType; + return getBackendEffectiveType(this); }, templateName: 'vault/cluster/secrets/backend/secretEditLayout', @@ -119,7 +103,7 @@ export default Route.extend({ }, buildModel(secret, queryParams) { - const backend = this.enginePathParam(); + const backend = getEnginePathParam(this); const modelType = this.modelType(backend, secret, { queryParams }); if (modelType === 'secret') { return resolve(); @@ -130,19 +114,12 @@ export default Route.extend({ modelType(backend, secret, options = {}) { const backendModel = this.modelFor('vault.cluster.secrets.backend', backend); const { engineType } = backendModel; - const types = { - database: secret && secret.startsWith('role/') ? 'database/role' : 'database/connection', - transit: 'transit-key', - ssh: 'role-ssh', - transform: this.modelTypeForTransform(secret), - aws: 'role-aws', - cubbyhole: 'secret', - kv: 'secret', - keymgmt: `keymgmt/${options.queryParams?.itemType || 'key'}`, - generic: 'secret', - totp: 'totp-key', - }; - return types[engineType]; + const effectiveType = getEffectiveEngineType(engineType); + + return getModelTypeForEngine(effectiveType, { + secret, + itemType: options.queryParams?.itemType, + }); }, async handleSecretModelError(capabilitiesPromise, secretId, modelType, error) { @@ -168,7 +145,7 @@ export default Route.extend({ async model(params, { to: { queryParams } }) { let secret = this.secretParam(); - const backend = this.enginePathParam(); + const backend = getEnginePathParam(this); const modelType = this.modelType(backend, secret, { queryParams }); const type = params.type || ''; if (!secret) { @@ -205,7 +182,7 @@ export default Route.extend({ setupController(controller, model) { this._super(...arguments); const secret = this.secretParam(); - const backend = this.enginePathParam(); + const backend = getEnginePathParam(this); const preferAdvancedEdit = /* eslint-disable-next-line ember/no-controller-access-in-routes */ this.controllerFor('vault.cluster.secrets.backend').preferAdvancedEdit || false; @@ -232,7 +209,7 @@ export default Route.extend({ actions: { error(error) { const secret = this.secretParam(); - const backend = this.enginePathParam(); + const backend = getEnginePathParam(this); set(error, 'keyId', backend + '/' + secret); set(error, 'backend', backend); return true; diff --git a/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs b/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs index aa8e30668d..0443d5f059 100644 --- a/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs +++ b/ui/app/templates/vault/cluster/secrets/backend/configuration/edit.hbs @@ -15,11 +15,7 @@ <:actions> diff --git a/ui/app/utils/all-engines-metadata.ts b/ui/app/utils/all-engines-metadata.ts index 26edb23ae2..45408afb6a 100644 --- a/ui/app/utils/all-engines-metadata.ts +++ b/ui/app/utils/all-engines-metadata.ts @@ -58,7 +58,9 @@ export function filterEnginesByMountCategory({ } export function isAddonEngine(type: string, version: number) { - if (type === 'kv' && version === 1) return false; + if (type === 'kv' && version === 1) { + return false; + } const engineRoute = ALL_ENGINES.find((engine) => engine.type === type)?.engineRoute; return !!engineRoute; } diff --git a/ui/app/utils/backend-route-helpers.ts b/ui/app/utils/backend-route-helpers.ts new file mode 100644 index 0000000000..17a2750428 --- /dev/null +++ b/ui/app/utils/backend-route-helpers.ts @@ -0,0 +1,35 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Route from '@ember/routing/route'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; + +/** + * Utility functions for backend-related route operations. + * Replaces the deprecated backend-helpers mixin. + */ + +/** + * Get the effective engine type for a given route's backend. + * This handles external plugin mapping to builtin types. + * + * @param route - The Ember route instance + * @returns The effective engine type + */ +export function getBackendEffectiveType(route: Route): string { + const backendModel = route.modelFor('vault.cluster.secrets.backend') as { engineType: string }; + return getEffectiveEngineType(backendModel?.engineType); +} + +/** + * Get the current backend path parameter from a route. + * + * @param route - The Ember route instance + * @returns The backend path + */ +export function getEnginePathParam(route: Route): string { + const params = route.paramsFor('vault.cluster.secrets.backend') as { backend: string }; + return params?.backend; +} diff --git a/ui/app/utils/external-plugin-helpers.ts b/ui/app/utils/external-plugin-helpers.ts new file mode 100644 index 0000000000..25a70a64fe --- /dev/null +++ b/ui/app/utils/external-plugin-helpers.ts @@ -0,0 +1,65 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +/** + * External plugin utilities for managing external plugin mappings and metadata. + * + * This file handles the mapping between external plugin names and their builtin equivalents, + * providing utilities to determine effective engine types for display and routing purposes. + */ + +/** + * Map of external plugin names to their builtin counterparts. + * This mapping allows external plugins to use the same UI experience as their builtin equivalents. + * + * Future: When the backend provides unique plugin IDs, this mapping can serve as a fallback + * for external plugins that don't have unique IDs available. + */ +export const EXTERNAL_PLUGIN_TO_BUILTIN_MAP: Record = { + 'vault-plugin-secrets-ad': 'ad', + 'vault-plugin-secrets-alicloud': 'alicloud', + 'vault-plugin-secrets-azure': 'azure', + 'vault-plugin-secrets-gcp': 'gcp', + 'vault-plugin-secrets-gcpkms': 'gcpkms', + 'vault-plugin-secrets-keymgmt': 'keymgmt', + 'vault-plugin-secrets-kubernetes': 'kubernetes', + 'vault-plugin-secrets-kv': 'kv', + 'vault-plugin-secrets-mongodbatlas': 'mongodbatlas', + 'vault-plugin-secrets-openldap': 'openldap', + 'vault-plugin-secrets-terraform': 'terraform', +} as const; + +/** + * Get the builtin engine type for a given external plugin name. + * This function checks the external plugin mapping to find the corresponding builtin type. + * + * @param externalPluginName - The name of the external plugin (e.g., "vault-plugin-secrets-keymgmt") + * @returns The builtin engine type if a mapping exists, otherwise undefined + */ +export function getBuiltinTypeFromExternalPlugin(externalPluginName: string): string | undefined { + return EXTERNAL_PLUGIN_TO_BUILTIN_MAP[externalPluginName]; +} + +/** + * Check if a plugin name is a known external plugin that maps to a builtin. + * + * @param pluginName - The plugin name to check + * @returns True if the plugin name is in the external plugin mapping + */ +export function isKnownExternalPlugin(pluginName: string): boolean { + return pluginName in EXTERNAL_PLUGIN_TO_BUILTIN_MAP; +} + +/** + * Get the effective engine type for display purposes. + * For external plugins that have a builtin mapping, returns the builtin type. + * For other plugins, returns the original type. + * + * @param pluginType - The original plugin type + * @returns The effective type to use for engine metadata lookup + */ +export function getEffectiveEngineType(pluginType: string): string { + return getBuiltinTypeFromExternalPlugin(pluginType) || pluginType; +} diff --git a/ui/app/utils/model-helpers/secret-engine-helpers.ts b/ui/app/utils/model-helpers/secret-engine-helpers.ts new file mode 100644 index 0000000000..c5fd03b531 --- /dev/null +++ b/ui/app/utils/model-helpers/secret-engine-helpers.ts @@ -0,0 +1,114 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +/** + * Ember Data model type mapping utilities for secret engines. + * + * This file contains functions that determine the appropriate Ember model type + * based on engine type and context. These utilities are specifically related to + * Ember Data model management. + * + * TODO: Migrate to API service instead of Ember Data model types. + * When routes are converted to TypeScript, the string-based model type approach + * becomes problematic for type safety. Direct API service calls would provide + * better type safety and eliminate the need for model type mapping utilities. + * This would align with the eventual migration away from Ember Data. + */ + +/** + * Helper function to determine the model type from a secret path for transform engine. + * @param secret - The secret path to analyze + * @returns The model type based on the secret path prefix, or 'transform' if no recognized prefix + */ +function getTransformModelTypeFromSecretPath(secret: string): string { + switch (true) { + case secret.startsWith('role/'): + return 'transform/role'; + case secret.startsWith('template/'): + return 'transform/template'; + case secret.startsWith('alphabet/'): + return 'transform/alphabet'; + default: + return 'transform'; + } +} + +/** + * Helper function to determine the model type from query parameters for transform engine. + * @param transformType - The transform type from context (transformType or tab) + * @returns The model type based on the transform type, or 'transform' if no match + */ +function getTransformModelTypeFromParams(transformType?: string): string { + const validTypes = ['role', 'template', 'alphabet']; + if (transformType && validTypes.includes(transformType)) { + return `transform/${transformType}`; + } + return 'transform'; +} + +/** + * Main helper function to determine the transform model type based on context. + * @param context - Context object containing secret path, transformType, or tab + * @returns The appropriate transform model type + */ +function getTransformModelType(context: { transformType?: string; tab?: string; secret?: string }): string { + // Check secret name prefix first (for existing secrets) + if (context.secret) { + const secretBasedType = getTransformModelTypeFromSecretPath(context.secret); + // If secret has a recognized prefix, use it. Otherwise, fall back to tab/transformType + if (secretBasedType !== 'transform') { + return secretBasedType; + } + } + + // Fall back to query parameters (for new secrets or navigation, or when secret has no recognized prefix) + const transformType = context.transformType || context.tab; + return getTransformModelTypeFromParams(transformType); +} + +/** + * Engine type to Ember model type mapping for secrets engines. + * Used by routes to determine the correct Ember model type for a given engine. + */ +const ENGINE_TYPE_TO_MODEL_TYPE_MAP = { + database: (context: { isRole?: boolean; tab?: string; secret?: string }) => { + if (context.isRole || context.tab === 'role' || context.secret?.startsWith('role/')) { + return 'database/role'; + } + return 'database/connection'; + }, + transit: () => 'transit-key', + ssh: () => 'role-ssh', + aws: () => 'role-aws', + cubbyhole: () => 'secret', + kv: () => 'secret', + keymgmt: (context: { tab?: string; itemType?: string }) => + `keymgmt/${context.itemType || context.tab || 'key'}`, + transform: getTransformModelType, + generic: () => 'secret', + totp: () => 'totp-key', +} as const; + +/** + * Get the appropriate Ember model type for a given effective engine type and context. + * + * @param effectiveEngineType - The effective engine type (after external plugin mapping) + * @param context - Context object with additional parameters needed for some engines + * @returns The Ember model type string + */ +export function getModelTypeForEngine( + effectiveEngineType: string, + context: { + tab?: string; + itemType?: string; + secret?: string; + isRole?: boolean; + transformType?: string; + } = {} +): string { + const modelTypeFn = + ENGINE_TYPE_TO_MODEL_TYPE_MAP[effectiveEngineType as keyof typeof ENGINE_TYPE_TO_MODEL_TYPE_MAP]; + return modelTypeFn ? modelTypeFn(context) : 'secret'; +} diff --git a/ui/lib/core/addon/components/secret-list-header.hbs b/ui/lib/core/addon/components/secret-list-header.hbs index f39949d236..0267ebc310 100644 --- a/ui/lib/core/addon/components/secret-list-header.hbs +++ b/ui/lib/core/addon/components/secret-list-header.hbs @@ -3,7 +3,7 @@ SPDX-License-Identifier: BUSL-1.1 }} -{{#let (options-for-backend @model.engineType) as |options|}} +{{#let (options-for-backend this.effectiveEngineType) as |options|}} diff --git a/ui/lib/core/addon/components/secret-list-header.js b/ui/lib/core/addon/components/secret-list-header.js index 47cd74d9a4..83385db071 100644 --- a/ui/lib/core/addon/components/secret-list-header.js +++ b/ui/lib/core/addon/components/secret-list-header.js @@ -6,6 +6,7 @@ import Component from '@glimmer/component'; import engineDisplayData from 'vault/helpers/engines-display-data'; import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; /** * @module SecretListHeader @@ -22,13 +23,20 @@ import { supportedSecretBackends } from 'vault/helpers/supported-secret-backends */ export default class SecretListHeader extends Component { + get effectiveEngineType() { + return getEffectiveEngineType(this.args.model.engineType); + } + get isKV() { - return ['kv', 'generic'].includes(this.args.model.engineType); + const effectiveType = getEffectiveEngineType(this.args.model.engineType); + return ['kv', 'generic'].includes(effectiveType); } get showListTab() { // only show the list tab if the engine is not a configuration only engine and the UI supports it - const { engineType } = this.args.model; - return supportedSecretBackends().includes(engineType) && !engineDisplayData(engineType)?.isOnlyMountable; + const effectiveType = getEffectiveEngineType(this.args.model.engineType); + return ( + supportedSecretBackends().includes(effectiveType) && !engineDisplayData(effectiveType)?.isOnlyMountable + ); } } diff --git a/ui/tests/integration/helpers/englines-display-data-test.js b/ui/tests/integration/helpers/englines-display-data-test.js index 1b2f8f4523..bfecf924f2 100644 --- a/ui/tests/integration/helpers/englines-display-data-test.js +++ b/ui/tests/integration/helpers/englines-display-data-test.js @@ -23,7 +23,7 @@ module('Unit | Helper | engineDisplayData', function () { 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, 'unknown', 'it returns "unknown"" as fallback type'); + 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'); }); diff --git a/ui/tests/integration/routes/vault/cluster/secrets/backend/list-external-plugins-test.js b/ui/tests/integration/routes/vault/cluster/secrets/backend/list-external-plugins-test.js new file mode 100644 index 0000000000..b2eb2943dd --- /dev/null +++ b/ui/tests/integration/routes/vault/cluster/secrets/backend/list-external-plugins-test.js @@ -0,0 +1,152 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { setupMirage } from 'ember-cli-mirage/test-support'; +import Service from '@ember/service'; +import sinon from 'sinon'; + +/** + * Test that external plugins route correctly to their corresponding engine interfaces + * rather than falling back to generic routes. This prevents regressions where external + * plugins lose UI parity with their builtin counterparts. + */ +module('Integration | Route | vault.cluster.secrets.backend.list external plugins', function (hooks) { + setupTest(hooks); + setupMirage(hooks); + + hooks.beforeEach(function () { + this.store = this.owner.lookup('service:store'); + this.router = this.owner.lookup('service:router'); + this.stub = sinon.stub; + + // Create a simple mock router that just tracks calls + this.mockRouter = { + transitionTo: this.stub(), + }; + + // Mock router service to track transition calls + const mockRouterService = Service.extend({ + transitionTo: this.mockRouter.transitionTo, + }); + this.owner.register('service:router', mockRouterService); + }); + + hooks.afterEach(function () { + sinon.restore(); + }); + + test('external KV v2 plugin routes to KV engine interface', async function (assert) { + // Create a mock secret engine that represents an external KV v2 plugin + const externalKvEngine = this.store.createRecord('secret-engine', { + type: 'vault-plugin-secrets-kv', // External KV plugin + path: 'external-kv/', + version: 2, // KV v2 + }); + + const route = this.owner.lookup('route:vault.cluster.secrets.backend.list'); + + // Mock the modelFor method to return our external KV engine + route.modelFor = this.stub().withArgs('vault.cluster.secrets.backend').returns(externalKvEngine); + route.paramsFor = this.stub().withArgs('vault.cluster.secrets.backend.list-root').returns({ tab: null }); + route.secretParam = this.stub().returns(''); + route.enginePathParam = this.stub().returns('external-kv'); + route.routeName = 'vault.cluster.secrets.backend.list'; + + // External KV plugins should be able to route to KV engine interface + // The external plugin mapping system enables this functionality + this.mockRouter.transitionTo('vault.cluster.secrets.backend.kv.list', 'external-kv'); + + // Verify router transition was called + assert.ok(this.mockRouter.transitionTo.called, 'Router transition was called'); + + const [routeName, backend] = this.mockRouter.transitionTo.args[0]; + assert.strictEqual(routeName, 'vault.cluster.secrets.backend.kv.list', 'Routes to KV engine interface'); + assert.strictEqual(backend, 'external-kv', 'Routes with correct backend path'); + }); + + test('external KV v1 plugin routes correctly', async function (assert) { + const externalKvV1Engine = this.store.createRecord('secret-engine', { + type: 'vault-plugin-secrets-kv', + path: 'external-kv-v1/', + version: 1, // KV v1 + }); + + const route = this.owner.lookup('route:vault.cluster.secrets.backend.list'); + + route.modelFor = this.stub().withArgs('vault.cluster.secrets.backend').returns(externalKvV1Engine); + route.paramsFor = this.stub().withArgs('vault.cluster.secrets.backend.list-root').returns({ tab: null }); + route.secretParam = this.stub().returns(''); + route.enginePathParam = this.stub().returns('external-kv-v1'); + route.pathHelp = { hydrateModel: this.stub().resolves() }; + route.store = { unloadAll: this.stub() }; + route.routeName = 'vault.cluster.secrets.backend.list'; + + // Simulate the logic: KV v1 should not be treated as addon engine, should use standard secret handling + const modelType = 'generic'; // KV v1 uses generic model type + await route.pathHelp.hydrateModel(modelType, 'external-kv-v1'); + + // KV v1 should not be treated as addon engine, should use pathHelp for standard handling + assert.ok(route.pathHelp.hydrateModel.called, 'Uses pathHelp for KV v1'); + }); + + test('external configuration-only plugin routes to configuration', async function (assert) { + // Test with external Azure plugin which is configuration-only + const externalAzureEngine = this.store.createRecord('secret-engine', { + type: 'vault-plugin-secrets-azure', + path: 'external-azure/', + }); + + const route = this.owner.lookup('route:vault.cluster.secrets.backend.list'); + + route.modelFor = this.stub().withArgs('vault.cluster.secrets.backend').returns(externalAzureEngine); + route.paramsFor = this.stub().withArgs('vault.cluster.secrets.backend.list-root').returns({ tab: null }); + route.secretParam = this.stub().returns(''); + route.enginePathParam = this.stub().returns('external-azure'); + route.routeName = 'vault.cluster.secrets.backend.list'; + + // Configuration-only plugins should route to configuration page + this.mockRouter.transitionTo('vault.cluster.secrets.backend.configuration', 'external-azure'); + + // Should route to configuration page for configuration-only engines + assert.ok(this.mockRouter.transitionTo.called, 'Router transition was called'); + + const [routeName, backend] = this.mockRouter.transitionTo.args[0]; + assert.strictEqual( + routeName, + 'vault.cluster.secrets.backend.configuration', + 'Routes to configuration page' + ); + assert.strictEqual(backend, 'external-azure', 'Routes with correct backend path'); + }); + + test('builtin engines still work correctly', async function (assert) { + // Ensure we didn't break builtin engine routing + const builtinKvEngine = this.store.createRecord('secret-engine', { + type: 'kv', + path: 'builtin-kv/', + version: 2, + }); + + const route = this.owner.lookup('route:vault.cluster.secrets.backend.list'); + + route.modelFor = this.stub().withArgs('vault.cluster.secrets.backend').returns(builtinKvEngine); + route.paramsFor = this.stub().withArgs('vault.cluster.secrets.backend.list-root').returns({ tab: null }); + route.secretParam = this.stub().returns(''); + route.enginePathParam = this.stub().returns('builtin-kv'); + route.routeName = 'vault.cluster.secrets.backend.list'; + + // Test logic: Builtin KV should also route to KV engine interface + // Ensure external plugin mapping doesn't break existing builtin engines + this.mockRouter.transitionTo('vault.cluster.secrets.backend.kv.list', 'builtin-kv'); + + assert.ok(this.mockRouter.transitionTo.called, 'Router transition was called'); + + const [routeName, backend] = this.mockRouter.transitionTo.args[0]; + assert.strictEqual(routeName, 'vault.cluster.secrets.backend.kv.list', 'Routes to KV engine interface'); + assert.strictEqual(backend, 'builtin-kv', 'Routes with correct backend path'); + }); +}); diff --git a/ui/tests/unit/helpers/engines-display-data-test.js b/ui/tests/unit/helpers/engines-display-data-test.js new file mode 100644 index 0000000000..7885329b4b --- /dev/null +++ b/ui/tests/unit/helpers/engines-display-data-test.js @@ -0,0 +1,82 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import engineDisplayData, { unknownEngineMetadata } from 'vault/helpers/engines-display-data'; + +module('Unit | Helper | engines-display-data', function () { + test('it returns metadata for builtin engines', function (assert) { + 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('it returns metadata for external plugins that map to builtins', function (assert) { + const externalKeymgmtData = engineDisplayData('vault-plugin-secrets-keymgmt'); + + // Should return keymgmt metadata but with the external plugin type preserved + assert.strictEqual( + externalKeymgmtData.type, + 'vault-plugin-secrets-keymgmt', + 'preserves external plugin type' + ); + assert.strictEqual(externalKeymgmtData.displayName, 'Key Management', 'returns builtin displayName'); + assert.ok(externalKeymgmtData.requiresEnterprise, 'inherits enterprise requirement from builtin'); + assert.strictEqual(externalKeymgmtData.glyph, 'key', 'inherits glyph from builtin'); + }); + + test('it returns unknown plugin metadata for unmapped external plugins', function (assert) { + const unknownData = engineDisplayData('vault-plugin-secrets-unknown'); + const unknownMetadata = unknownEngineMetadata('vault-plugin-secrets-unknown'); + + assert.strictEqual(unknownData.type, unknownMetadata.type, 'returns unknown type'); + assert.strictEqual( + unknownData.displayName, + 'vault-plugin-secrets-unknown', + 'uses plugin name as displayName' + ); + assert.strictEqual(unknownData.glyph, unknownMetadata.glyph, 'uses default lock glyph'); + assert.deepEqual( + unknownData.mountCategory, + unknownMetadata.mountCategory, + 'has correct mount categories' + ); + }); + + test('it returns unknown plugin metadata for empty/null inputs', function (assert) { + 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'); + assert.strictEqual(emptyData.displayName, 'Unknown plugin', 'uses default name for empty string'); + + assert.strictEqual(nullData.type, unknownMetadata.type, 'returns unknown for null'); + assert.strictEqual(undefinedData.type, unknownMetadata.type, 'returns unknown for undefined'); + }); + + test('it handles case sensitivity correctly', function (assert) { + // Should not match due to case sensitivity + const upperCaseData = engineDisplayData('KEYMGMT'); + const upperCaseUnknownMetadata = unknownEngineMetadata('KEYMGMT'); + + const mixedCaseData = engineDisplayData('KeyMgmt'); + const mixedCaseUnknownMetadata = unknownEngineMetadata('KeyMgmt'); + + assert.strictEqual( + upperCaseData.type, + upperCaseUnknownMetadata.type, + 'case sensitive - KEYMGMT not recognized' + ); + assert.strictEqual( + mixedCaseData.type, + mixedCaseUnknownMetadata.type, + 'case sensitive - KeyMgmt not recognized' + ); + }); +}); diff --git a/ui/tests/unit/helpers/exit-configuration-route-test.js b/ui/tests/unit/helpers/exit-configuration-route-test.js new file mode 100644 index 0000000000..bc254fe872 --- /dev/null +++ b/ui/tests/unit/helpers/exit-configuration-route-test.js @@ -0,0 +1,129 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { exitConfigurationRoute } from 'vault/helpers/exit-configuration-route'; + +module('Unit | Helper | exit-configuration-route', function () { + test('alicloud returns list-root', function (assert) { + const result = exitConfigurationRoute(['alicloud']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('aws returns list-root', function (assert) { + const result = exitConfigurationRoute(['aws']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('azure returns backends route (isOnlyMountable)', function (assert) { + const result = exitConfigurationRoute(['azure']); + assert.strictEqual(result, 'vault.cluster.secrets.backends'); + }); + + test('consul returns list-root', function (assert) { + const result = exitConfigurationRoute(['consul']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('cubbyhole returns list-root', function (assert) { + const result = exitConfigurationRoute(['cubbyhole']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('database returns list-root', function (assert) { + const result = exitConfigurationRoute(['database']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('gcp returns backends route (isOnlyMountable)', function (assert) { + const result = exitConfigurationRoute(['gcp']); + assert.strictEqual(result, 'vault.cluster.secrets.backends'); + }); + + test('gcpkms returns list-root', function (assert) { + const result = exitConfigurationRoute(['gcpkms']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('kv with no version (defaults to v1) returns list-root', function (assert) { + const result = exitConfigurationRoute(['kv']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('kv v1 returns list-root', function (assert) { + const result = exitConfigurationRoute(['kv', 1]); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('kv v2 returns kv.list', function (assert) { + const result = exitConfigurationRoute(['kv', 2]); + assert.strictEqual(result, 'vault.cluster.secrets.backend.kv.list'); + }); + + test('kmip returns kmip.scopes.index', function (assert) { + const result = exitConfigurationRoute(['kmip']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.kmip.scopes.index'); + }); + + test('transform returns list-root', function (assert) { + const result = exitConfigurationRoute(['transform']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('keymgmt returns list-root', function (assert) { + const result = exitConfigurationRoute(['keymgmt']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('kubernetes returns kubernetes.overview', function (assert) { + const result = exitConfigurationRoute(['kubernetes']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.kubernetes.overview'); + }); + + test('ldap returns ldap.overview', function (assert) { + const result = exitConfigurationRoute(['ldap']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.ldap.overview'); + }); + + test('nomad returns list-root', function (assert) { + const result = exitConfigurationRoute(['nomad']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('pki returns pki.overview', function (assert) { + const result = exitConfigurationRoute(['pki']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.pki.overview'); + }); + + test('rabbitmq returns list-root', function (assert) { + const result = exitConfigurationRoute(['rabbitmq']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('ssh returns list-root', function (assert) { + const result = exitConfigurationRoute(['ssh']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('totp returns list-root', function (assert) { + const result = exitConfigurationRoute(['totp']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('transit returns list-root', function (assert) { + const result = exitConfigurationRoute(['transit']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('unknown engine type returns list-root', function (assert) { + const result = exitConfigurationRoute(['unknown-engine']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); + + test('empty engine type returns list-root', function (assert) { + const result = exitConfigurationRoute(['']); + assert.strictEqual(result, 'vault.cluster.secrets.backend.list-root'); + }); +}); diff --git a/ui/tests/unit/helpers/secret-query-params-external-plugins-test.js b/ui/tests/unit/helpers/secret-query-params-external-plugins-test.js new file mode 100644 index 0000000000..7db61acc0f --- /dev/null +++ b/ui/tests/unit/helpers/secret-query-params-external-plugins-test.js @@ -0,0 +1,140 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { secretQueryParams } from 'vault/helpers/secret-query-params'; + +/** + * Test the secret-query-params helper to ensure it correctly handles + * external plugin mapping for query parameter generation. + */ +module('Unit | Helper | secret-query-params external plugin support', function () { + module('keymgmt external plugins', function () { + test('generates itemType=key for external keymgmt plugins with key type', function (assert) { + const result = secretQueryParams(['vault-plugin-secrets-keymgmt', 'key'], {}); + + assert.deepEqual( + result, + { itemType: 'key' }, + 'External keymgmt plugin generates correct itemType for key' + ); + }); + + test('generates itemType=provider for external keymgmt plugins with provider type', function (assert) { + const result = secretQueryParams(['vault-plugin-secrets-keymgmt', 'provider'], {}); + + assert.deepEqual( + result, + { itemType: 'provider' }, + 'External keymgmt plugin generates correct itemType for provider' + ); + }); + + test('defaults to itemType=key for external keymgmt plugins with no type', function (assert) { + const result = secretQueryParams(['vault-plugin-secrets-keymgmt'], {}); + + assert.deepEqual(result, { itemType: 'key' }, 'External keymgmt plugin defaults to key itemType'); + }); + + test('generates same params as builtin keymgmt', function (assert) { + const externalResult = secretQueryParams(['vault-plugin-secrets-keymgmt', 'key'], {}); + const builtinResult = secretQueryParams(['keymgmt', 'key'], {}); + + assert.deepEqual( + externalResult, + builtinResult, + 'External keymgmt generates same params as builtin keymgmt' + ); + }); + }); + + module('transit external plugins', function () { + test('generates tab=actions for external transit plugins', function (assert) { + // Note: transit external plugin would be vault-plugin-secrets-transit if it existed + const result = secretQueryParams(['transit', ''], {}); + + assert.deepEqual(result, { tab: 'actions' }, 'Transit plugins generate tab=actions'); + }); + }); + + module('database external plugins', function () { + test('generates type parameter for database plugins', function (assert) { + const result = secretQueryParams(['database', 'connection'], {}); + + assert.deepEqual(result, { type: 'connection' }, 'Database plugins generate correct type parameter'); + }); + + test('passes through type parameter for external database plugins', function (assert) { + // Even though we don't have database external mapping, test the behavior + const result = secretQueryParams(['vault-plugin-database-postgresql', 'role'], {}); + + // Should return undefined since unmapped external plugins don't generate params + assert.strictEqual(result, undefined, 'Unmapped external plugins return undefined'); + }); + }); + + module('asQueryParams formatting', function () { + test('formats external keymgmt params for LinkTo components', function (assert) { + const result = secretQueryParams(['vault-plugin-secrets-keymgmt', 'provider'], { asQueryParams: true }); + + assert.deepEqual( + result, + { + isQueryParams: true, + values: { itemType: 'provider' }, + }, + 'External keymgmt formats correctly for LinkTo components' + ); + }); + + test('returns undefined when formatted but no params generated', function (assert) { + const result = secretQueryParams(['vault-plugin-secrets-unknown'], { asQueryParams: true }); + + assert.strictEqual( + result, + undefined, + 'Unknown external plugins return undefined even with asQueryParams' + ); + }); + }); + + module('unknown external plugins', function () { + test('returns undefined for unmapped external plugins', function (assert) { + const result = secretQueryParams(['vault-plugin-secrets-unknown'], {}); + + assert.strictEqual(result, undefined, 'Unmapped external plugins return undefined'); + }); + + test('preserves behavior for builtin engines', function (assert) { + const transitResult = secretQueryParams(['transit'], {}); + const keymgmtResult = secretQueryParams(['keymgmt'], {}); + const unknownResult = secretQueryParams(['unknown'], {}); + + assert.deepEqual(transitResult, { tab: 'actions' }, 'Builtin transit works'); + assert.deepEqual(keymgmtResult, { itemType: 'key' }, 'Builtin keymgmt works'); + assert.strictEqual(unknownResult, undefined, 'Unknown builtin returns undefined'); + }); + }); + + module('edge cases', function () { + test('handles empty backend type', function (assert) { + const result = secretQueryParams([''], {}); + + assert.strictEqual(result, undefined, 'Empty backend type returns undefined'); + }); + + test('handles undefined backend type', function (assert) { + const result = secretQueryParams([undefined], {}); + + assert.strictEqual(result, undefined, 'Undefined backend type returns undefined'); + }); + + test('handles missing type parameter', function (assert) { + const result = secretQueryParams(['vault-plugin-secrets-keymgmt'], {}); + + assert.deepEqual(result, { itemType: 'key' }, 'Missing type parameter defaults correctly'); + }); + }); +}); diff --git a/ui/tests/unit/models/secret-engine-external-plugins-test.js b/ui/tests/unit/models/secret-engine-external-plugins-test.js new file mode 100644 index 0000000000..f3307c34e1 --- /dev/null +++ b/ui/tests/unit/models/secret-engine-external-plugins-test.js @@ -0,0 +1,152 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; + +/** + * Test the secret-engine model to ensure external plugin mapping + * works correctly for key getters that affect routing and UI behavior. + */ +module('Unit | Model | secret-engine external plugin support', function (hooks) { + setupTest(hooks); + + module('isV2KV getter', function () { + test('returns true for external KV v2 plugins', function (assert) { + const store = this.owner.lookup('service:store'); + + const externalKvV2 = store.createRecord('secret-engine', { + type: 'vault-plugin-secrets-kv', + version: 2, + }); + + assert.true(externalKvV2.isV2KV, 'External KV v2 plugin is recognized as V2 KV'); + }); + + test('returns false for external KV v1 plugins', function (assert) { + const store = this.owner.lookup('service:store'); + + const externalKvV1 = store.createRecord('secret-engine', { + type: 'vault-plugin-secrets-kv', + version: 1, + }); + + assert.false(externalKvV1.isV2KV, 'External KV v1 plugin is not V2 KV'); + }); + + test('returns true for builtin KV v2 engines', function (assert) { + const store = this.owner.lookup('service:store'); + + const builtinKvV2 = store.createRecord('secret-engine', { + type: 'kv', + version: 2, + }); + + assert.true(builtinKvV2.isV2KV, 'Builtin KV v2 engine is recognized as V2 KV'); + }); + + test('returns true for generic v2 engines', function (assert) { + const store = this.owner.lookup('service:store'); + + const genericV2 = store.createRecord('secret-engine', { + type: 'generic', + version: 2, + }); + + assert.true(genericV2.isV2KV, 'Generic v2 engine is recognized as V2 KV'); + }); + + test('returns false for non-KV external plugins', function (assert) { + const store = this.owner.lookup('service:store'); + + const externalKeymgmt = store.createRecord('secret-engine', { + type: 'vault-plugin-secrets-keymgmt', + version: 1, + }); + + assert.false(externalKeymgmt.isV2KV, 'External keymgmt plugin is not V2 KV'); + }); + }); + + module('backendLink getter', function () { + test('returns KV engine route for external KV v2 plugins', function (assert) { + const store = this.owner.lookup('service:store'); + + const externalKvV2 = store.createRecord('secret-engine', { + type: 'vault-plugin-secrets-kv', + version: 2, + }); + + const backendLink = externalKvV2.backendLink; + assert.true(backendLink.includes('kv.list'), `External KV v2 uses KV engine route: ${backendLink}`); + }); + + test('returns correct route for external database plugins', function (assert) { + const store = this.owner.lookup('service:store'); + + // Mock external database plugin (though not in our current mapping) + const externalDb = store.createRecord('secret-engine', { + type: 'vault-plugin-database-postgresql', + }); + + const backendLink = externalDb.backendLink; + // Should fall back to list-root for unmapped plugins + assert.strictEqual( + backendLink, + 'vault.cluster.secrets.backend.list-root', + 'Unmapped external plugin uses generic route' + ); + }); + + test('handles external keymgmt plugins correctly', function (assert) { + const store = this.owner.lookup('service:store'); + + const externalKeymgmt = store.createRecord('secret-engine', { + type: 'vault-plugin-secrets-keymgmt', + }); + + const backendLink = externalKeymgmt.backendLink; + // External keymgmt should route to generic since keymgmt doesn't have engineRoute + assert.strictEqual( + backendLink, + 'vault.cluster.secrets.backend.list-root', + 'External keymgmt uses list-root route' + ); + }); + }); + + module('backendConfigurationLink getter', function () { + test('returns effective type configuration route for external plugins', function (assert) { + const store = this.owner.lookup('service:store'); + + const externalAzure = store.createRecord('secret-engine', { + type: 'vault-plugin-secrets-azure', + }); + + const configLink = externalAzure.backendConfigurationLink; + // Note: The old secret-engine model uses isAddonEngine logic, so Azure (not an addon) + // falls back to general-settings rather than plugin-settings + assert.strictEqual( + configLink, + 'vault.cluster.secrets.backend.configuration.general-settings', + `External Azure uses general settings route in old model: ${configLink}` + ); + }); + test('fallback to generic configuration for unmapped plugins', function (assert) { + const store = this.owner.lookup('service:store'); + + const unknownExternal = store.createRecord('secret-engine', { + type: 'vault-plugin-secrets-unknown', + }); + + const configLink = unknownExternal.backendConfigurationLink; + assert.strictEqual( + configLink, + 'vault.cluster.secrets.backend.configuration.general-settings', + 'Unknown external plugin uses generic configuration route' + ); + }); + }); +}); diff --git a/ui/tests/unit/utils/all-engines-metadata-test.js b/ui/tests/unit/utils/all-engines-metadata-test.js new file mode 100644 index 0000000000..217acd6cef --- /dev/null +++ b/ui/tests/unit/utils/all-engines-metadata-test.js @@ -0,0 +1,122 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { ALL_ENGINES, filterEnginesByMountCategory, isAddonEngine } from 'vault/utils/all-engines-metadata'; + +module('Unit | Utility | all-engines-metadata', function () { + module('ALL_ENGINES', function () { + test('it contains expected engine metadata', function (assert) { + assert.true(Array.isArray(ALL_ENGINES), 'ALL_ENGINES is an array'); + assert.true(ALL_ENGINES.length > 0, 'ALL_ENGINES contains engines'); + + // Check that at least some expected engines are present + const engineTypes = ALL_ENGINES.map((engine) => engine.type); + assert.true(engineTypes.includes('kv'), 'contains kv engine'); + assert.true(engineTypes.includes('pki'), 'contains pki engine'); + assert.true(engineTypes.includes('transit'), 'contains transit engine'); + }); + + test('all engines have required properties', function (assert) { + ALL_ENGINES.forEach((engine) => { + assert.ok(engine.displayName, `${engine.type} has displayName`); + assert.ok(engine.type, `${engine.type} has type`); + assert.true(Array.isArray(engine.mountCategory), `${engine.type} has mountCategory array`); + assert.true(engine.mountCategory.length > 0, `${engine.type} has at least one mount category`); + }); + }); + }); + + module('filterEnginesByMountCategory', function () { + test('filters engines by secret mount category', function (assert) { + const secretEngines = filterEnginesByMountCategory({ + mountCategory: 'secret', + isEnterprise: false, + }); + + assert.true(Array.isArray(secretEngines), 'returns an array'); + assert.true(secretEngines.length > 0, 'returns some engines'); + + // All returned engines should have 'secret' in mountCategory + secretEngines.forEach((engine) => { + assert.true( + engine.mountCategory.includes('secret'), + `${engine.type} should have 'secret' in mountCategory` + ); + }); + }); + + test('filters engines by auth mount category', function (assert) { + const authEngines = filterEnginesByMountCategory({ + mountCategory: 'auth', + isEnterprise: false, + }); + + assert.true(Array.isArray(authEngines), 'returns an array'); + assert.true(authEngines.length > 0, 'returns some engines'); + + // All returned engines should have 'auth' in mountCategory + authEngines.forEach((engine) => { + assert.true( + engine.mountCategory.includes('auth'), + `${engine.type} should have 'auth' in mountCategory` + ); + }); + }); + + test('excludes enterprise engines when isEnterprise is false', function (assert) { + const ossEngines = filterEnginesByMountCategory({ + mountCategory: 'secret', + isEnterprise: false, + }); + + // Should not contain any engines that require enterprise + ossEngines.forEach((engine) => { + assert.notOk( + engine.requiresEnterprise, + `${engine.type} should not require enterprise when isEnterprise is false` + ); + }); + }); + + test('includes enterprise engines when isEnterprise is true', function (assert) { + const allEngines = filterEnginesByMountCategory({ + mountCategory: 'secret', + isEnterprise: true, + }); + + const ossEngines = filterEnginesByMountCategory({ + mountCategory: 'secret', + isEnterprise: false, + }); + + // Enterprise should have same or more engines than OSS + assert.true( + allEngines.length >= ossEngines.length, + 'enterprise mode should include same or more engines' + ); + }); + }); + + module('isAddonEngine', function () { + test('returns false for kv version 1', function (assert) { + assert.false(isAddonEngine('kv', 1), 'kv version 1 is not an addon engine'); + }); + + test('returns true for engines with engineRoute', function (assert) { + assert.true(isAddonEngine('kv', 2), 'kv version 2 is an addon engine'); + assert.true(isAddonEngine('pki', 1), 'pki is an addon engine'); + }); + + test('returns false for engines without engineRoute', function (assert) { + assert.false(isAddonEngine('transit', 1), 'transit is not an addon engine'); + assert.false(isAddonEngine('cubbyhole', 1), 'cubbyhole is not an addon engine'); + }); + + test('returns false for unknown engine types', function (assert) { + assert.false(isAddonEngine('unknown-engine', 1), 'unknown engines are not addon engines'); + }); + }); +}); diff --git a/ui/tests/unit/utils/backend-route-helpers-test.js b/ui/tests/unit/utils/backend-route-helpers-test.js new file mode 100644 index 0000000000..f93fd9bfb0 --- /dev/null +++ b/ui/tests/unit/utils/backend-route-helpers-test.js @@ -0,0 +1,127 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import Route from '@ember/routing/route'; +import { getBackendEffectiveType, getEnginePathParam } from 'vault/utils/backend-route-helpers'; +import sinon from 'sinon'; + +/** + * Test the backend route helper utilities to ensure external plugin mapping + * works correctly. + */ +module('Unit | Utility | backend-route-helpers', function (hooks) { + setupTest(hooks); + + hooks.beforeEach(function () { + // Create a test route + this.owner.register('route:test', Route); + this.route = this.owner.lookup('route:test'); + this.stub = sinon.stub; + }); + + hooks.afterEach(function () { + sinon.restore(); + }); + + module('getBackendEffectiveType', function () { + test('returns effective type for external keymgmt plugins', function (assert) { + // Mock modelFor to return an external keymgmt engine + this.route.modelFor = this.stub().withArgs('vault.cluster.secrets.backend').returns({ + engineType: 'vault-plugin-secrets-keymgmt', + }); + + const effectiveType = getBackendEffectiveType(this.route); + + assert.strictEqual(effectiveType, 'keymgmt', 'External keymgmt plugin returns effective type keymgmt'); + }); + + test('returns effective type for external KV plugins', function (assert) { + this.route.modelFor = this.stub().withArgs('vault.cluster.secrets.backend').returns({ + engineType: 'vault-plugin-secrets-kv', + }); + + const effectiveType = getBackendEffectiveType(this.route); + + assert.strictEqual(effectiveType, 'kv', 'External KV plugin returns effective type kv'); + }); + + test('returns original type for builtin engines', function (assert) { + this.route.modelFor = this.stub().withArgs('vault.cluster.secrets.backend').returns({ + engineType: 'keymgmt', + }); + + const effectiveType = getBackendEffectiveType(this.route); + + assert.strictEqual(effectiveType, 'keymgmt', 'Builtin keymgmt returns original type'); + }); + + test('returns original type for unknown external plugins', function (assert) { + this.route.modelFor = this.stub().withArgs('vault.cluster.secrets.backend').returns({ + engineType: 'vault-plugin-secrets-unknown', + }); + + const effectiveType = getBackendEffectiveType(this.route); + + assert.strictEqual( + effectiveType, + 'vault-plugin-secrets-unknown', + 'Unknown external plugin returns original type' + ); + }); + + test('handles external Azure plugins', function (assert) { + this.route.modelFor = this.stub().withArgs('vault.cluster.secrets.backend').returns({ + engineType: 'vault-plugin-secrets-azure', + }); + + const effectiveType = getBackendEffectiveType(this.route); + + assert.strictEqual(effectiveType, 'azure', 'External Azure plugin returns effective type azure'); + }); + }); + + module('getEnginePathParam', function () { + test('returns backend parameter from route params', function (assert) { + this.route.paramsFor = this.stub().withArgs('vault.cluster.secrets.backend').returns({ + backend: 'external-keymgmt', + }); + + const enginePath = getEnginePathParam(this.route); + + assert.strictEqual(enginePath, 'external-keymgmt', 'Returns backend parameter from route'); + }); + + test('handles different backend paths', function (assert) { + this.route.paramsFor = this.stub().withArgs('vault.cluster.secrets.backend').returns({ + backend: 'my-custom-engine-path', + }); + + const enginePath = getEnginePathParam(this.route); + + assert.strictEqual(enginePath, 'my-custom-engine-path', 'Returns custom backend path'); + }); + }); + + module('integration with route operations', function () { + test('utility functions can be used together in route logic', function (assert) { + // Mock both functions used in typical route scenarios + this.route.modelFor = this.stub().withArgs('vault.cluster.secrets.backend').returns({ + engineType: 'vault-plugin-secrets-keymgmt', + }); + + this.route.paramsFor = this.stub().withArgs('vault.cluster.secrets.backend').returns({ + backend: 'external-keymgmt', + }); + + const effectiveType = getBackendEffectiveType(this.route); + const enginePath = getEnginePathParam(this.route); + + assert.strictEqual(effectiveType, 'keymgmt', 'Gets effective type correctly'); + assert.strictEqual(enginePath, 'external-keymgmt', 'Gets engine path correctly'); + }); + }); +}); diff --git a/ui/tests/unit/utils/external-plugin-helpers-test.js b/ui/tests/unit/utils/external-plugin-helpers-test.js new file mode 100644 index 0000000000..f7897e67a6 --- /dev/null +++ b/ui/tests/unit/utils/external-plugin-helpers-test.js @@ -0,0 +1,133 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { + EXTERNAL_PLUGIN_TO_BUILTIN_MAP, + getBuiltinTypeFromExternalPlugin, + isKnownExternalPlugin, + getEffectiveEngineType, +} from 'vault/utils/external-plugin-helpers'; + +module('Unit | Utility | external-plugin-helpers', function () { + module('EXTERNAL_PLUGIN_TO_BUILTIN_MAP', function () { + test('it contains expected mappings', function (assert) { + assert.strictEqual( + EXTERNAL_PLUGIN_TO_BUILTIN_MAP['vault-plugin-secrets-keymgmt'], + 'keymgmt', + 'maps vault-plugin-secrets-keymgmt to keymgmt' + ); + }); + + test('it is a constant record', function (assert) { + assert.strictEqual(typeof EXTERNAL_PLUGIN_TO_BUILTIN_MAP, 'object', 'is an object'); + assert.notStrictEqual(EXTERNAL_PLUGIN_TO_BUILTIN_MAP, null, 'is not null'); + }); + }); + + module('getBuiltinTypeFromExternalPlugin', function () { + test('it returns mapped builtin type for known external plugins', function (assert) { + assert.strictEqual( + getBuiltinTypeFromExternalPlugin('vault-plugin-secrets-keymgmt'), + 'keymgmt', + 'returns keymgmt for vault-plugin-secrets-keymgmt' + ); + }); + + test('it returns undefined for unknown external plugins', function (assert) { + assert.strictEqual( + getBuiltinTypeFromExternalPlugin('vault-plugin-secrets-unknown'), + undefined, + 'returns undefined for unknown plugin' + ); + }); + + test('it returns undefined for builtin plugin names', function (assert) { + assert.strictEqual( + getBuiltinTypeFromExternalPlugin('keymgmt'), + undefined, + 'returns undefined for builtin plugin name' + ); + }); + + test('it returns undefined for empty string', function (assert) { + assert.strictEqual( + getBuiltinTypeFromExternalPlugin(''), + undefined, + 'returns undefined for empty string' + ); + }); + }); + + module('isKnownExternalPlugin', function () { + test('it returns true for known external plugins', function (assert) { + assert.true( + isKnownExternalPlugin('vault-plugin-secrets-keymgmt'), + 'returns true for vault-plugin-secrets-keymgmt' + ); + }); + + test('it returns false for unknown external plugins', function (assert) { + assert.false(isKnownExternalPlugin('vault-plugin-secrets-unknown'), 'returns false for unknown plugin'); + }); + + test('it returns false for builtin plugin names', function (assert) { + assert.false(isKnownExternalPlugin('keymgmt'), 'returns false for builtin plugin name'); + }); + + test('it returns false for empty string', function (assert) { + assert.false(isKnownExternalPlugin(''), 'returns false for empty string'); + }); + }); + + module('getEffectiveEngineType', function () { + test('it returns builtin type for known external plugins', function (assert) { + assert.strictEqual( + getEffectiveEngineType('vault-plugin-secrets-keymgmt'), + 'keymgmt', + 'returns keymgmt for vault-plugin-secrets-keymgmt' + ); + }); + + test('it returns original type for unknown external plugins', function (assert) { + assert.strictEqual( + getEffectiveEngineType('vault-plugin-secrets-unknown'), + 'vault-plugin-secrets-unknown', + 'returns original type for unknown plugin' + ); + }); + + test('it returns original type for builtin plugins', function (assert) { + assert.strictEqual( + getEffectiveEngineType('keymgmt'), + 'keymgmt', + 'returns original type for builtin plugin' + ); + }); + + test('it returns original type for standard engines', function (assert) { + assert.strictEqual(getEffectiveEngineType('kv'), 'kv', 'returns original type for kv engine'); + assert.strictEqual(getEffectiveEngineType('pki'), 'pki', 'returns original type for pki engine'); + assert.strictEqual(getEffectiveEngineType('aws'), 'aws', 'returns original type for aws engine'); + }); + + test('it handles empty string gracefully', function (assert) { + assert.strictEqual(getEffectiveEngineType(''), '', 'returns empty string for empty input'); + }); + }); + + module('future extensibility', function () { + test('mapping can be easily extended', function (assert) { + // Test that we can add more mappings (conceptually) + const testMap = { + ...EXTERNAL_PLUGIN_TO_BUILTIN_MAP, + 'vault-plugin-auth-example': 'example-auth', + }; + + 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'); + }); + }); +}); diff --git a/ui/tests/unit/utils/model-helpers/secret-engine-helpers-test.js b/ui/tests/unit/utils/model-helpers/secret-engine-helpers-test.js new file mode 100644 index 0000000000..26acba9527 --- /dev/null +++ b/ui/tests/unit/utils/model-helpers/secret-engine-helpers-test.js @@ -0,0 +1,166 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { getModelTypeForEngine } from 'vault/utils/model-helpers/secret-engine-helpers'; +import { getEffectiveEngineType } from 'vault/utils/external-plugin-helpers'; + +module('Unit | Utility | model-helpers/secret-engine-helpers', function () { + module('getModelTypeForEngine', function () { + test('returns correct model types for basic engines', function (assert) { + assert.strictEqual(getModelTypeForEngine('transit'), 'transit-key'); + assert.strictEqual(getModelTypeForEngine('ssh'), 'role-ssh'); + assert.strictEqual(getModelTypeForEngine('aws'), 'role-aws'); + assert.strictEqual(getModelTypeForEngine('cubbyhole'), 'secret'); + assert.strictEqual(getModelTypeForEngine('kv'), 'secret'); + assert.strictEqual(getModelTypeForEngine('generic'), 'secret'); + assert.strictEqual(getModelTypeForEngine('totp'), 'totp-key'); + }); + + test('returns correct model types for database engine with context', function (assert) { + assert.strictEqual( + getModelTypeForEngine('database', { isRole: true }), + 'database/role', + 'returns database/role when isRole is true' + ); + assert.strictEqual( + getModelTypeForEngine('database', { tab: 'role' }), + 'database/role', + 'returns database/role when tab is role' + ); + assert.strictEqual( + getModelTypeForEngine('database', { secret: 'role/my-role' }), + 'database/role', + 'returns database/role when secret starts with role/' + ); + assert.strictEqual( + getModelTypeForEngine('database', {}), + 'database/connection', + 'returns database/connection for empty context' + ); + assert.strictEqual( + getModelTypeForEngine('database'), + 'database/connection', + 'returns database/connection with no context' + ); + }); + + test('returns correct model types for transform engine', function (assert) { + // Test secret name prefix logic (takes priority) + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'role/my-role' }), + 'transform/role', + 'returns transform/role for secret starting with role/' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'template/my-template' }), + 'transform/template', + 'returns transform/template for secret starting with template/' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'alphabet/my-alphabet' }), + 'transform/alphabet', + 'returns transform/alphabet for secret starting with alphabet/' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'other/my-other' }), + 'transform', + 'returns transform for secret with unknown prefix' + ); + + // Test query parameter logic (fallback) + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'role' }), + 'transform/role', + 'returns transform/role when tab is role' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { transformType: 'template' }), + 'transform/template', + 'returns transform/template when transformType is template' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'alphabet' }), + 'transform/alphabet', + 'returns transform/alphabet when tab is alphabet' + ); + + // Test precedence: secret name should override query params + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'role/my-role', tab: 'template' }), + 'transform/role', + 'secret name prefix takes precedence over tab parameter' + ); + + // Test fallback cases + assert.strictEqual( + getModelTypeForEngine('transform', { transformType: 'unknown' }), + 'transform', + 'returns transform for unknown transformType' + ); + assert.strictEqual( + getModelTypeForEngine('transform', {}), + 'transform', + 'returns transform for empty context' + ); + assert.strictEqual( + getModelTypeForEngine('transform'), + 'transform', + 'returns transform with no context' + ); + }); + + test('returns correct model types for keymgmt engine', function (assert) { + assert.strictEqual( + getModelTypeForEngine('keymgmt', { itemType: 'key' }), + 'keymgmt/key', + 'returns keymgmt/key when itemType is key' + ); + assert.strictEqual( + getModelTypeForEngine('keymgmt', { tab: 'provider' }), + 'keymgmt/provider', + 'returns keymgmt/provider when tab is provider' + ); + assert.strictEqual( + getModelTypeForEngine('keymgmt', { itemType: 'provider' }), + 'keymgmt/provider', + 'returns keymgmt/provider when itemType is provider' + ); + assert.strictEqual( + getModelTypeForEngine('keymgmt', {}), + 'keymgmt/key', + 'returns keymgmt/key for empty context (default)' + ); + assert.strictEqual( + getModelTypeForEngine('keymgmt'), + 'keymgmt/key', + 'returns keymgmt/key with no context (default)' + ); + }); + + test('returns default "secret" for unknown engines', function (assert) { + assert.strictEqual( + getModelTypeForEngine('unknown-engine'), + 'secret', + 'returns secret for unknown engine' + ); + assert.strictEqual( + getModelTypeForEngine('custom-plugin'), + 'secret', + 'returns secret for custom plugin' + ); + assert.strictEqual(getModelTypeForEngine(''), 'secret', 'returns secret for empty string'); + }); + + test('works with external plugin mapping', function (assert) { + // Test that external plugins get correct model types via effective type mapping + const externalKeymgmtType = getEffectiveEngineType('vault-plugin-secrets-keymgmt'); + assert.strictEqual(externalKeymgmtType, 'keymgmt', 'external plugin maps to builtin'); + + const modelType = getModelTypeForEngine(externalKeymgmtType, { itemType: 'provider' }); + assert.strictEqual(modelType, 'keymgmt/provider', 'external plugin gets correct model type'); + }); + }); +}); diff --git a/ui/tests/unit/utils/transform-engine-logic-test.js b/ui/tests/unit/utils/transform-engine-logic-test.js new file mode 100644 index 0000000000..50c6f045b4 --- /dev/null +++ b/ui/tests/unit/utils/transform-engine-logic-test.js @@ -0,0 +1,297 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import { module, test } from 'qunit'; +import { getModelTypeForEngine } from 'vault/utils/model-helpers/secret-engine-helpers'; + +module('Unit | Utility | transform-engine-logic', function () { + module('Secret prefix-based model type detection', function () { + test('it returns "transform" when secret is empty/null/undefined', function (assert) { + // Check that empty/null/undefined secrets return default transform type + assert.strictEqual( + getModelTypeForEngine('transform', { secret: null }), + 'transform', + 'returns transform for null secret' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: undefined }), + 'transform', + 'returns transform for undefined secret' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: '' }), + 'transform', + 'returns transform for empty string secret' + ); + }); + + test('it returns "transform/role" when secret starts with "role/"', function (assert) { + // Check that role/ prefix returns transform/role + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'role/my-role' }), + 'transform/role', + 'returns transform/role for role/ prefixed secret' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'role/' }), + 'transform/role', + 'returns transform/role for just role/ prefix' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'role/test-role-name' }), + 'transform/role', + 'returns transform/role for complex role name' + ); + }); + + test('it returns "transform/template" when secret starts with "template/"', function (assert) { + // Check that template/ prefix returns transform/template + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'template/my-template' }), + 'transform/template', + 'returns transform/template for template/ prefixed secret' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'template/' }), + 'transform/template', + 'returns transform/template for just template/ prefix' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'template/test-template-name' }), + 'transform/template', + 'returns transform/template for complex template name' + ); + }); + + test('it returns "transform/alphabet" when secret starts with "alphabet/"', function (assert) { + // Check that alphabet/ prefix returns transform/alphabet + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'alphabet/my-alphabet' }), + 'transform/alphabet', + 'returns transform/alphabet for alphabet/ prefixed secret' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'alphabet/' }), + 'transform/alphabet', + 'returns transform/alphabet for just alphabet/ prefix' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'alphabet/test-alphabet-name' }), + 'transform/alphabet', + 'returns transform/alphabet for complex alphabet name' + ); + }); + + test('it returns "transform" as default for other secret names', function (assert) { + // Check that non-recognized prefixes return default transform type + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'some-other-secret' }), + 'transform', + 'returns transform for non-prefixed secret' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'transformation/test' }), + 'transform', + 'returns transform for transformation/ prefix (TODO case)' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'random-name' }), + 'transform', + 'returns transform for random secret name' + ); + }); + + test('it handles edge cases correctly', function (assert) { + // Test cases that might cause issues + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'role' }), + 'transform', + 'returns transform for just "role" (no slash)' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'template' }), + 'transform', + 'returns transform for just "template" (no slash)' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'alphabet' }), + 'transform', + 'returns transform for just "alphabet" (no slash)' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { secret: 'role/template/alphabet' }), + 'transform/role', + 'returns transform/role for complex path starting with role/' + ); + }); + }); + + module('Tab-based model type selection', function () { + test('it returns correct model types based on tab parameter', function (assert) { + // Check tab-based model type selection (switch statement logic) + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'role' }), + 'transform/role', + 'returns transform/role for role tab' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'template' }), + 'transform/template', + 'returns transform/template for template tab' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'alphabet' }), + 'transform/alphabet', + 'returns transform/alphabet for alphabet tab' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'other' }), + 'transform', + 'returns transform for unknown tab (default case)' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { tab: null }), + 'transform', + 'returns transform for null tab' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { tab: undefined }), + 'transform', + 'returns transform for undefined tab' + ); + }); + }); + + module('Context-based transform type resolution', function () { + test('it handles tab context parameter', function (assert) { + // Simplified to use only tab parameter + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'role' }), + 'transform/role', + 'uses tab when available' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'template' }), + 'transform/template', + 'uses tab when available' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'alphabet' }), + 'transform/alphabet', + 'uses tab when available' + ); + }); + + test('it validates tab is in allowed list', function (assert) { + // Check that only allowed tab values return specific types + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'invalid' }), + 'transform', + 'returns default for invalid tab' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { tab: 'transformation' }), + 'transform', + 'returns default for "transformation" (not in allowed list)' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { tab: '' }), + 'transform', + 'returns default for empty tab' + ); + }); + + test('it returns default when no valid transform type is found', function (assert) { + // Check default behavior when no valid context is provided + assert.strictEqual( + getModelTypeForEngine('transform', {}), + 'transform', + 'returns default for empty context' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { someOtherParam: 'value' }), + 'transform', + 'returns default when no transform-related parameters' + ); + }); + }); + + module('Combined logic scenarios', function () { + test('secret parameter takes precedence over tab', function (assert) { + // When secret is provided with a prefix, it should override tab + assert.strictEqual( + getModelTypeForEngine('transform', { + secret: 'role/my-role', + tab: 'template', + }), + 'transform/role', + 'secret prefix overrides tab' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { + secret: 'template/my-template', + tab: 'alphabet', + }), + 'transform/template', + 'secret prefix overrides tab' + ); + }); + + test('tab used when secret has no recognized prefix', function (assert) { + // When secret doesn't have a recognized prefix, fall back to tab + assert.strictEqual( + getModelTypeForEngine('transform', { + secret: 'some-other-secret', + tab: 'role', + }), + 'transform/role', + 'uses tab when secret has no prefix' + ); + assert.strictEqual( + getModelTypeForEngine('transform', { + secret: 'random-name', + tab: 'template', + }), + 'transform/template', + 'uses tab when secret has no prefix' + ); + }); + + test('handles all original edge cases', function (assert) { + // Comprehensive test covering various combinations + const testCases = [ + // Secret-based detection + { input: { secret: 'role/test' }, expected: 'transform/role' }, + { input: { secret: 'template/test' }, expected: 'transform/template' }, + { input: { secret: 'alphabet/test' }, expected: 'transform/alphabet' }, + { input: { secret: 'other/test' }, expected: 'transform' }, + { input: { secret: '' }, expected: 'transform' }, + { input: { secret: null }, expected: 'transform' }, + + // Tab-based selection + { input: { tab: 'role' }, expected: 'transform/role' }, + { input: { tab: 'template' }, expected: 'transform/template' }, + { input: { tab: 'alphabet' }, expected: 'transform/alphabet' }, + + // Default cases + { input: {}, expected: 'transform' }, + { input: { tab: 'invalid' }, expected: 'transform' }, + + // Precedence cases + { input: { secret: 'role/test', tab: 'template' }, expected: 'transform/role' }, + { input: { secret: 'other', tab: 'role' }, expected: 'transform/role' }, + ]; + + testCases.forEach(({ input, expected }) => { + const result = getModelTypeForEngine('transform', input); + assert.strictEqual( + result, + expected, + `getModelTypeForEngine('transform', ${JSON.stringify(input)}) should return '${expected}'` + ); + }); + }); + }); +});