diff --git a/ui/app/components/mfa/mfa-form.ts b/ui/app/components/mfa/mfa-form.ts index ded17a6366..c74a662b29 100644 --- a/ui/app/components/mfa/mfa-form.ts +++ b/ui/app/components/mfa/mfa-form.ts @@ -6,9 +6,11 @@ import Ember from 'ember'; import Component from '@glimmer/component'; import { service } from '@ember/service'; -import { tracked } from '@glimmer/tracking'; +import { tracked } from 'tracked-built-ins'; +import type { TrackedSet } from 'tracked-built-ins'; import { action } from '@ember/object'; import { task, timeout } from 'ember-concurrency'; +import { waitFor } from '@ember/test-waiters'; import errorMessage from 'vault/utils/error-message'; import MfaConstraint from 'vault/resources/mfa/constraint'; @@ -54,7 +56,7 @@ export default class MfaForm extends Component { @tracked error = ''; // Self-enrollment is per MFA method, not per login enforcement (constraint) // Track method IDs used to fetch a QR code so we don't re-request if a user just enrolled. - @tracked enrolledMethods = new Set(); + enrolledMethods: TrackedSet = tracked(Set); constructor(owner: unknown, args: Args) { super(owner, args); @@ -124,7 +126,7 @@ export default class MfaForm extends Component { await this.args.loginAndTransition.unlinked().perform(response); } catch (error) { // Reset enrolled methods if there's an error - this.enrolledMethods = new Set(); + this.enrolledMethods.clear(); const errorMsg = errorMessage(error); const codeUsed = errorMsg.includes('code already used'); const rateLimit = errorMsg.includes('maximum TOTP validation attempts'); @@ -141,30 +143,32 @@ export default class MfaForm extends Component { } }); - fetchQrCode = task(async (mfa_method_id: string, constraint: MfaConstraint) => { - // Self-enrollment is an enterprise only feature - if (this.version.isCommunity) return; + fetchQrCode = task( + waitFor(async (mfa_method_id: string, constraint: MfaConstraint) => { + // Self-enrollment is an enterprise only feature + if (this.version.isCommunity) return; - const adapter = this.store.adapterFor('application'); - const { mfaRequirement } = this.args.authData; - try { - const { data } = await adapter.ajax('/v1/identity/mfa/method/totp/self-enroll', 'POST', { - unauthenticated: true, - data: { mfa_method_id, mfa_request_id: mfaRequirement.mfa_request_id }, - }); - if (data?.url) { - // Set QR code which recomputes currentSelfEnrollConstraint and renders it for the user to scan - constraint.qrCode = data.url; - // Add mfa_method_id to list of already enrolled methods for client-side tracking - this.enrolledMethods.add(mfa_method_id); - return; + const adapter = this.store.adapterFor('application'); + const { mfaRequirement } = this.args.authData; + try { + const { data } = await adapter.ajax('/v1/identity/mfa/method/totp/self-enroll', 'POST', { + unauthenticated: true, + data: { mfa_method_id, mfa_request_id: mfaRequirement.mfa_request_id }, + }); + if (data?.url) { + // Set QR code which recomputes currentSelfEnrollConstraint and renders it for the user to scan + constraint.qrCode = data.url; + // Add mfa_method_id to list of already enrolled methods for client-side tracking + this.enrolledMethods.add(mfa_method_id); + return; + } + // Not sure it's realistic to get here without the endpoint throwing an error, but just in case! + this.error = 'There was a problem generating the QR code. Please try again.'; + } catch (error) { + this.error = errorMessage(error); } - // Not sure it's realistic to get here without the endpoint throwing an error, but just in case! - this.error = 'There was a problem generating the QR code. Please try again.'; - } catch (error) { - this.error = errorMessage(error); - } - }); + }) + ); newCodeDelay = task(async (errorMessage) => { let delay; diff --git a/ui/app/controllers/vault.js b/ui/app/controllers/vault.js index 2c83a2cd26..cf2af5ec71 100644 --- a/ui/app/controllers/vault.js +++ b/ui/app/controllers/vault.js @@ -4,8 +4,9 @@ */ import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; export default class VaultController extends Controller { queryParams = [{ redirectTo: 'redirect_to' }]; - redirectTo = ''; + @tracked redirectTo = ''; } diff --git a/ui/app/initializers/enable-engines.js b/ui/app/initializers/enable-engines.js deleted file mode 100644 index c47ad9cff3..0000000000 --- a/ui/app/initializers/enable-engines.js +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import config from '../config/environment'; - -export function initialize(/* application */) { - // attach mount hooks to the environment config - // context will be the router DSL - config.addRootMounts = function () { - this.mount('replication'); - }; -} - -export default { - initialize, -}; diff --git a/ui/app/mixins/cluster-route.js b/ui/app/mixins/cluster-route.js deleted file mode 100644 index 5d948cc605..0000000000 --- a/ui/app/mixins/cluster-route.js +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import { service } from '@ember/service'; -import Mixin from '@ember/object/mixin'; -import RSVP from 'rsvp'; -import { - INIT, - UNSEAL, - AUTH, - CLUSTER, - CLUSTER_INDEX, - OIDC_CALLBACK, - OIDC_PROVIDER, - NS_OIDC_PROVIDER, - DR_REPLICATION_SECONDARY, - DR_REPLICATION_SECONDARY_DETAILS, - EXCLUDED_REDIRECT_URLS, - REDIRECT, -} from 'vault/lib/route-paths'; - -export default Mixin.create({ - auth: service(), - store: service(), - router: service(), - - transitionToTargetRoute(transition = {}) { - const targetRoute = this.targetRouteName(transition); - if ( - targetRoute && - targetRoute !== this.routeName && - targetRoute !== transition.targetName && - targetRoute !== this.router.currentRouteName - ) { - // there may be query params so check for inclusion rather than exact match - const isExcluded = EXCLUDED_REDIRECT_URLS.find((url) => this.router.currentURL?.includes(url)); - if ( - // only want to redirect if we're going to authenticate - targetRoute === AUTH && - transition.targetName !== CLUSTER_INDEX && - !isExcluded - ) { - return this.router.transitionTo(targetRoute, { - queryParams: { redirect_to: this.router.currentURL }, - }); - } - return this.router.transitionTo(targetRoute); - } - - return RSVP.resolve(); - }, - - beforeModel(transition) { - return this.transitionToTargetRoute(transition); - }, - - clusterModel() { - return this.modelFor(CLUSTER) || this.store.peekRecord('cluster', 'vault'); - }, - - authToken() { - return this.auth.currentToken; - }, - - hasKeyData() { - /* eslint-disable-next-line ember/no-controller-access-in-routes */ - return !!this.controllerFor(INIT).keyData; - }, - - targetRouteName(transition) { - const cluster = this.clusterModel(); - const isAuthed = this.authToken(); - if (cluster.needsInit) { - return INIT; - } - if (this.hasKeyData() && this.routeName !== UNSEAL && this.routeName !== AUTH) { - return INIT; - } - if (cluster.sealed) { - return UNSEAL; - } - if (cluster?.dr?.isSecondary) { - if (transition && transition.targetName === DR_REPLICATION_SECONDARY_DETAILS) { - return DR_REPLICATION_SECONDARY_DETAILS; - } - if (this.router.currentRouteName === DR_REPLICATION_SECONDARY_DETAILS) { - return DR_REPLICATION_SECONDARY_DETAILS; - } - - return DR_REPLICATION_SECONDARY; - } - if (!isAuthed) { - if ((transition && transition.targetName === OIDC_PROVIDER) || this.routeName === OIDC_PROVIDER) { - return OIDC_PROVIDER; - } - if ((transition && transition.targetName === NS_OIDC_PROVIDER) || this.routeName === NS_OIDC_PROVIDER) { - return NS_OIDC_PROVIDER; - } - if ((transition && transition.targetName === OIDC_CALLBACK) || this.routeName === OIDC_CALLBACK) { - return OIDC_CALLBACK; - } - return AUTH; - } - if ( - (!cluster.needsInit && this.routeName === INIT) || - (!cluster.sealed && this.routeName === UNSEAL) || - (!cluster?.dr?.isSecondary && this.routeName === DR_REPLICATION_SECONDARY) - ) { - return CLUSTER; - } - if (isAuthed && this.routeName === AUTH) { - // if you're already authed and you wanna go to auth, you probably want to redirect - return REDIRECT; - } - return null; - }, -}); diff --git a/ui/app/mixins/model-boundary-route.js b/ui/app/mixins/model-boundary-route.js deleted file mode 100644 index 3cd425839f..0000000000 --- a/ui/app/mixins/model-boundary-route.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -// meant for use mixed-in to a Route file -// -// When a route is deactivated, this mixin clears the Ember Data store of -// models of type specified by the required param `modelType`. -// -// example: -// Using this as with a modelType of `datacenter` on the infrastructure -// route will cause all `datacenter` models to get unloaded when the -// infrastructure route is navigated away from. - -import Route from '@ember/routing/route'; - -import { isPresent } from '@ember/utils'; -import { warn } from '@ember/debug'; -import { on } from '@ember/object/evented'; -import Mixin from '@ember/object/mixin'; - -export default Mixin.create({ - modelType: null, - modelTypes: null, - - verifyProps: on('init', function () { - var modelType = this.modelType; - var modelTypes = this.modelTypes; - warn( - 'No `modelType` or `modelTypes` specified for `' + - this.toString() + - '`. Check to make sure you still need to use the `model-boundary-route` mixin.', - isPresent(modelType) || isPresent(modelTypes), - { id: 'model-boundary-init' } - ); - - warn( - 'Expected `model-boundary-route` to be used on an Ember.Route, not `' + this.toString() + '`.', - this instanceof Route, - { id: 'mode-boundary-is-route' } - ); - }), - - clearModelCache: on('deactivate', function () { - var modelType = this.modelType; - var modelTypes = this.modelTypes; - - if (!modelType && !modelTypes) { - warn( - 'Attempted to clear store clear store cache when leaving `' + - this.routeName + - '`, but no `modelType` or `modelTypes` was specified.', - isPresent(modelType), - { id: 'model-boundary-clear' } - ); - return; - } - if (this.store.isDestroyed || this.store.isDestroying) { - // Prevent unload attempt after test teardown, resulting in test failure - return; - } - - if (modelType) { - this.store.unloadAll(modelType); - } - if (modelTypes) { - modelTypes.forEach((type) => { - this.store.unloadAll(type); - }); - } - }), -}); diff --git a/ui/app/router.js b/ui/app/router.js index 02e258f550..5610b2eff5 100644 --- a/ui/app/router.js +++ b/ui/app/router.js @@ -223,10 +223,7 @@ Router.map(function () { this.route('replication-dr-promote', function () { this.route('details'); }); - if (config.addRootMounts) { - config.addRootMounts.call(this); - } - + this.mount('replication'); this.route('not-found', { path: '/*path' }); }); this.route('not-found', { path: '/*path' }); diff --git a/ui/app/routes/vault/cluster.js b/ui/app/routes/vault/cluster.js index b4800af72d..593e2f16ce 100644 --- a/ui/app/routes/vault/cluster.js +++ b/ui/app/routes/vault/cluster.js @@ -4,19 +4,37 @@ */ import { service } from '@ember/service'; -import { computed } from '@ember/object'; -import { reject } from 'rsvp'; import Route from '@ember/routing/route'; +import { reject } from 'rsvp'; import { task, timeout } from 'ember-concurrency'; import Ember from 'ember'; import getStorage from '../../lib/token-storage'; import localStorage from 'vault/lib/local-storage'; -import ClusterRoute from 'vault/mixins/cluster-route'; -import ModelBoundaryRoute from 'vault/mixins/model-boundary-route'; +import clearModelCache from 'vault/utils/shared-model-boundary'; import { assert } from '@ember/debug'; import { v4 as uuidv4 } from 'uuid'; +import { + INIT, + UNSEAL, + AUTH, + CLUSTER_INDEX, + OIDC_CALLBACK, + OIDC_PROVIDER, + NS_OIDC_PROVIDER, + DR_REPLICATION_SECONDARY, + DR_REPLICATION_SECONDARY_DETAILS, + EXCLUDED_REDIRECT_URLS, +} from 'vault/lib/route-paths'; + +const CLUSTER_STATE = { + UNINITIALIZED: 'uninitialized', + SEALED: 'sealed', + DR_SECONDARY: 'dr-secondary', + ACTIVE: 'active', +}; + const POLL_INTERVAL_MS = 10000; export const getManagedNamespace = (nsParam, root) => { @@ -29,34 +47,33 @@ export const getManagedNamespace = (nsParam, root) => { return `${root}/${nsParam}`; }; -export default Route.extend(ModelBoundaryRoute, ClusterRoute, { - auth: service(), - api: service(), - analytics: service(), - currentCluster: service(), - customMessages: service(), - flagsService: service('flags'), - namespaceService: service('namespace'), - permissions: service(), - router: service(), - store: service(), - version: service(), - modelTypes: computed(function () { - return ['node', 'secret', 'secret-engine']; - }), +export default class ClusterRoute extends Route { + @service auth; + @service api; + @service analytics; + @service currentCluster; + @service customMessages; + @service('flags') flagsService; + @service('namespace') namespaceService; + @service permissions; + @service router; + @service store; + @service version; - queryParams: { + modelTypes = ['node', 'secret', 'secret-engine']; + + queryParams = { namespaceQueryParam: { refreshModel: true, }, - }, + }; getClusterId(params) { const { cluster_name } = params; const records = this.store.peekAll('cluster'); const cluster = records.find((record) => record.name === cluster_name); return cluster?.id ?? null; - }, + } async beforeModel() { const params = this.paramsFor(this.routeName); @@ -98,7 +115,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, { } else { return reject({ httpStatus: 404, message: 'not found', path: params.cluster_name }); } - }, + } model(params) { // if a user's browser settings block localStorage they will be unable to use Vault. The method will throw the error and the rest of the application will not load. @@ -106,9 +123,10 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, { const id = this.getClusterId(params); return this.store.findRecord('cluster', id); - }, + } - poll: task(function* () { + poll = task({ keepLatest: true }, async () => { + // eslint-disable-next-line no-constant-condition while (true) { // In test mode, polling causes acceptance tests to hang due to never-settling promises. // To avoid this, polling is disabled during tests. @@ -117,22 +135,20 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, { if (Ember.testing) { return; } - yield timeout(POLL_INTERVAL_MS); + await timeout(POLL_INTERVAL_MS); try { /* eslint-disable-next-line ember/no-controller-access-in-routes */ - yield this.controller.model.reload(); - yield this.transitionToTargetRoute(); + await this.controller.model.reload(); + await this.transitionToTargetRoute(); } catch (e) { // we want to keep polling here } } - }) - .cancelOn('deactivate') - .keepLatest(), + }); // Note: do not make this afterModel hook async, it will break the DR secondary flow. afterModel(model, transition) { - this._super(...arguments); + super.afterModel(...arguments); this.currentCluster.setCluster(model); if (model.needsInit && this.auth.currentToken) { @@ -156,7 +172,7 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, { this.addAnalyticsService(model); return this.transitionToTargetRoute(transition); - }, + } async addAnalyticsService(model) { // identify user for analytics service @@ -187,19 +203,91 @@ export default Route.extend(ModelBoundaryRoute, ClusterRoute, { console.error('unable to start analytics', e); } } - }, + } + + deactivate() { + clearModelCache(this.store, this.modelTypes); + this.poll.cancelAll(); + } setupController() { - this._super(...arguments); + super.setupController(...arguments); this.poll.perform(); - }, + } - actions: { - error(e) { - if (e.httpStatus === 503 && e.errors[0] === 'Vault is sealed') { - this.refresh(); + error = (e) => { + if (e.httpStatus === 503 && e.errors[0] === 'Vault is sealed') { + this.refresh(); + } + return true; + }; + + get clusterState() { + const cluster = this.modelFor(this.routeName); + switch (true) { + case cluster.needsInit: + return CLUSTER_STATE.UNINITIALIZED; + case cluster.sealed: + return CLUSTER_STATE.SEALED; + case cluster.dr?.isSecondary: + return CLUSTER_STATE.DR_SECONDARY; + default: + return CLUSTER_STATE.ACTIVE; + } + } + + get allowedRedirectRoutes() { + const state = this.clusterState; + const isAuthenticated = !!this.auth.currentToken; + + switch (true) { + case state === CLUSTER_STATE.UNINITIALIZED: + return [INIT]; + case state === CLUSTER_STATE.SEALED: + // here we allow init so that we don't redirect users who haven't downloaded keys after init + return [UNSEAL, INIT]; + case state === CLUSTER_STATE.DR_SECONDARY: + return [DR_REPLICATION_SECONDARY, DR_REPLICATION_SECONDARY_DETAILS]; + case isAuthenticated === false: + return [AUTH, OIDC_PROVIDER, NS_OIDC_PROVIDER, OIDC_CALLBACK]; + default: + return [null]; + } + } + + targetRouteName(transition) { + const currentRouteName = this.router.currentRouteName; + const allowedRoutes = this.allowedRedirectRoutes; + + // if the target route or current route is in the allowed routes, go there, otherwise choose the first allowed route + return ( + allowedRoutes?.find((r) => transition?.targetName === r || currentRouteName === r) || allowedRoutes[0] + ); + } + + transitionToTargetRoute(transition = {}) { + const targetRoute = this.targetRouteName(transition); + if ( + targetRoute && + targetRoute !== this.routeName && + targetRoute !== transition.targetName && + targetRoute !== this.router.currentRouteName + ) { + // there may be query params so check for inclusion rather than exact match + const isExcluded = EXCLUDED_REDIRECT_URLS.find((url) => this.router.currentURL?.includes(url)); + if ( + // only want to redirect if we're going to authenticate + targetRoute === AUTH && + transition.targetName !== CLUSTER_INDEX && + !isExcluded + ) { + return this.router.transitionTo(targetRoute, { + queryParams: { redirect_to: this.router.currentURL }, + }); } - return true; - }, - }, -}); + return this.router.transitionTo(targetRoute); + } + + return Promise.resolve(); + } +} diff --git a/ui/app/routes/vault/cluster/access.js b/ui/app/routes/vault/cluster/access.js index 799307b6ce..5296046120 100644 --- a/ui/app/routes/vault/cluster/access.js +++ b/ui/app/routes/vault/cluster/access.js @@ -3,16 +3,20 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { computed } from '@ember/object'; +import { service } from '@ember/service'; import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; -import ModelBoundaryRoute from 'vault/mixins/model-boundary-route'; +import clearModelCache from 'vault/utils/shared-model-boundary'; + +export default class AccessRoute extends Route { + @service store; + + modelTypes = ['capabilities', 'control-group', 'identity/group', 'identity/group-alias', 'identity/alias']; -export default Route.extend(ModelBoundaryRoute, ClusterRoute, { - modelTypes: computed(function () { - return ['capabilities', 'control-group', 'identity/group', 'identity/group-alias', 'identity/alias']; - }), model() { return {}; - }, -}); + } + + deactivate() { + clearModelCache(this.store, this.modelTypes); + } +} diff --git a/ui/app/routes/vault/cluster/access/leases.js b/ui/app/routes/vault/cluster/access/leases.js index 4c966d9718..b159842d01 100644 --- a/ui/app/routes/vault/cluster/access/leases.js +++ b/ui/app/routes/vault/cluster/access/leases.js @@ -3,14 +3,13 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; import { service } from '@ember/service'; +import Route from '@ember/routing/route'; -export default Route.extend(ClusterRoute, { - store: service(), +export default class LeasesRoute extends Route { + @service store; model() { return this.store.findRecord('capabilities', 'sys/leases/lookup/'); - }, -}); + } +} diff --git a/ui/app/routes/vault/cluster/auth.js b/ui/app/routes/vault/cluster/auth.js index 6422ae0003..8e0e0ddf7d 100644 --- a/ui/app/routes/vault/cluster/auth.js +++ b/ui/app/routes/vault/cluster/auth.js @@ -4,13 +4,14 @@ */ import { service } from '@ember/service'; -import ClusterRouteBase from './cluster-route-base'; +import Route from '@ember/routing/route'; import config from 'vault/config/environment'; import { supportedTypes } from 'vault/utils/auth-form-helpers'; import { sanitizePath } from 'core/utils/sanitize-path'; import AuthMethodResource from 'vault/resources/auth/method'; +import { REDIRECT } from 'vault/lib/route-paths'; -export default class AuthRoute extends ClusterRouteBase { +export default class AuthRoute extends Route { queryParams = { authMount: { replace: true, refreshModel: true }, wrapped_token: { refreshModel: true }, @@ -20,13 +21,15 @@ export default class AuthRoute extends ClusterRouteBase { @service auth; @service flashMessages; @service namespace; + @service router; @service store; @service version; beforeModel() { - return super.beforeModel().then(() => { - return this.version.fetchFeatures(); - }); + if (this.auth.currentToken) { + return this.router.replaceWith(REDIRECT); + } + return this.version.fetchFeatures(); } async model(params) { @@ -62,13 +65,14 @@ export default class AuthRoute extends ClusterRouteBase { redirect(model, transition) { if (model?.unwrapResponse) { // handles the transition + /* eslint-disable-next-line ember/no-controller-access-in-routes */ return this.controllerFor('vault.cluster.auth').loginAndTransition.perform(model.unwrapResponse); } const hasQueryParam = transition.to?.queryParams?.with; const isInvalid = !model.directLinkData; if (hasQueryParam && isInvalid) { // redirect user and clear out the query param if it's invalid - this.router.replaceWith(this.routeName, { queryParams: { authMount: null } }); + return this.router.replaceWith(this.routeName, { queryParams: { authMount: '' } }); } } @@ -80,6 +84,7 @@ export default class AuthRoute extends ClusterRouteBase { return await this.auth.authSuccess(clusterId, authData); } catch (e) { const { message } = await this.api.parseError(e); + /* eslint-disable-next-line ember/no-controller-access-in-routes */ this.controllerFor('vault.cluster.auth').unwrapTokenError = message; } } @@ -125,7 +130,7 @@ export default class AuthRoute extends ClusterRouteBase { /* In older versions of Vault, the "with" query param could refer to either the auth mount path or the type - (which may be the same, since the default mount path *is* the type). + (which may be the same, since the default mount path *is* the type). For backward compatibility, we handle both scenarios. → If `authMount` matches a visible auth mount the method will assume that mount path to login and render as the default in the login form. → If `authMount` matches a supported auth type (and the mount does not have `listing_visibility="unauth"`), that type is preselected in the login form. diff --git a/ui/app/routes/vault/cluster/cluster-route-base.js b/ui/app/routes/vault/cluster/cluster-route-base.js index 427a688df5..06825a358a 100644 --- a/ui/app/routes/vault/cluster/cluster-route-base.js +++ b/ui/app/routes/vault/cluster/cluster-route-base.js @@ -7,17 +7,16 @@ // all of the CLUSTER_ROUTES that are states before you can use vault // import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; /** * @type Class */ -export default Route.extend(ClusterRoute, { +export default class ClusterBaseRoute extends Route { model() { return this.modelFor('vault.cluster'); - }, + } resetController(controller) { controller.reset && controller.reset(); - }, -}); + } +} diff --git a/ui/app/routes/vault/cluster/dashboard.js b/ui/app/routes/vault/cluster/dashboard.js index 27baf00dac..e30fb9cc39 100644 --- a/ui/app/routes/vault/cluster/dashboard.js +++ b/ui/app/routes/vault/cluster/dashboard.js @@ -3,14 +3,12 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Route from '@ember/routing/route'; import { service } from '@ember/service'; -// eslint-disable-next-line ember/no-mixins -import ClusterRoute from 'vault/mixins/cluster-route'; import { action } from '@ember/object'; +import Route from '@ember/routing/route'; import SecretsEngineResource from 'vault/resources/secrets/engine'; -export default class VaultClusterDashboardRoute extends Route.extend(ClusterRoute) { +export default class VaultClusterDashboardRoute extends Route { @service store; @service namespace; @service version; diff --git a/ui/app/routes/vault/cluster/init.js b/ui/app/routes/vault/cluster/init.js index edcd34b8d1..dffd1b2b03 100644 --- a/ui/app/routes/vault/cluster/init.js +++ b/ui/app/routes/vault/cluster/init.js @@ -3,6 +3,18 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import ClusterRoute from './cluster-route-base'; +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { CLUSTER } from 'vault/lib/route-paths'; -export default ClusterRoute; +export default class InitRoute extends Route { + @service router; + + beforeModel() { + const cluster = this.modelFor(CLUSTER); + // if it doesn't need init, nav to cluster route + if (!cluster.needsInit) { + return this.router.replaceWith(CLUSTER); + } + } +} diff --git a/ui/app/routes/vault/cluster/license.js b/ui/app/routes/vault/cluster/license.js index 3868c8c77a..bcf3716bfd 100644 --- a/ui/app/routes/vault/cluster/license.js +++ b/ui/app/routes/vault/cluster/license.js @@ -3,22 +3,21 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; import { service } from '@ember/service'; +import Route from '@ember/routing/route'; -export default Route.extend(ClusterRoute, { - store: service(), - version: service(), - router: service(), +export default class LicenseRoute extends Route { + @service store; + @service version; + @service router; beforeModel() { if (this.version.isCommunity) { this.router.transitionTo('vault.cluster'); } - }, + } model() { return this.store.queryRecord('license', {}); - }, -}); + } +} diff --git a/ui/app/routes/vault/cluster/logout.js b/ui/app/routes/vault/cluster/logout.js index 4d0fbee7d8..ba9c4cd6e2 100644 --- a/ui/app/routes/vault/cluster/logout.js +++ b/ui/app/routes/vault/cluster/logout.js @@ -4,25 +4,23 @@ */ import Ember from 'ember'; -import { computed } from '@ember/object'; import { service } from '@ember/service'; import Route from '@ember/routing/route'; -import ModelBoundaryRoute from 'vault/mixins/model-boundary-route'; +import clearModelCache from 'vault/utils/shared-model-boundary'; -export default Route.extend(ModelBoundaryRoute, { - auth: service(), - controlGroup: service(), - flashMessages: service(), - console: service(), - permissions: service(), - namespaceService: service('namespace'), - router: service(), - version: service(), - customMessages: service(), +export default class LogoutRoute extends Route { + @service auth; + @service store; + @service controlGroup; + @service flashMessages; + @service console; + @service permissions; + @service('namespace') namespaceService; + @service router; + @service version; + @service customMessages; - modelTypes: computed(function () { - return ['secret', 'secret-engine']; - }), + modelTypes = ['secret', 'secret-engine']; beforeModel({ to: { queryParams } }) { const ns = this.namespaceService.path; @@ -49,5 +47,8 @@ export default Route.extend(ModelBoundaryRoute, { const { cluster_name } = this.paramsFor('vault.cluster'); location.assign(this.router.urlFor('vault.cluster.auth', cluster_name, { queryParams })); } - }, -}); + } + deactivate() { + clearModelCache(this.store, this.modelTypes); + } +} diff --git a/ui/app/routes/vault/cluster/policies.js b/ui/app/routes/vault/cluster/policies.js index a80ee8d840..a4cedb17ec 100644 --- a/ui/app/routes/vault/cluster/policies.js +++ b/ui/app/routes/vault/cluster/policies.js @@ -5,18 +5,17 @@ import { service } from '@ember/service'; import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; - const ALLOWED_TYPES = ['acl', 'egp', 'rgp']; -export default Route.extend(ClusterRoute, { - version: service(), +export default class PoliciesRoute extends Route { + @service version; + @service router; beforeModel() { return this.version.fetchFeatures().then(() => { - return this._super(...arguments); + return super.beforeModel(...arguments); }); - }, + } model(params) { const policyType = params.type; @@ -24,5 +23,5 @@ export default Route.extend(ClusterRoute, { return this.router.transitionTo(this.routeName, ALLOWED_TYPES[0]); } return {}; - }, -}); + } +} diff --git a/ui/app/routes/vault/cluster/policies/index.js b/ui/app/routes/vault/cluster/policies/index.js index 79a7f134f1..4398e3ff1d 100644 --- a/ui/app/routes/vault/cluster/policies/index.js +++ b/ui/app/routes/vault/cluster/policies/index.js @@ -5,10 +5,9 @@ import { service } from '@ember/service'; import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; import ListRoute from 'core/mixins/list-route'; -export default Route.extend(ClusterRoute, ListRoute, { +export default Route.extend(ListRoute, { pagination: service(), version: service(), diff --git a/ui/app/routes/vault/cluster/policy.js b/ui/app/routes/vault/cluster/policy.js index 694683178e..93a3731ca5 100644 --- a/ui/app/routes/vault/cluster/policy.js +++ b/ui/app/routes/vault/cluster/policy.js @@ -5,17 +5,17 @@ import { service } from '@ember/service'; import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; const ALLOWED_TYPES = ['acl', 'egp', 'rgp']; -export default Route.extend(ClusterRoute, { - version: service(), +export default class PolicyRoute extends Route { + @service version; + @service router; beforeModel() { return this.version.fetchFeatures().then(() => { - return this._super(...arguments); + return super.beforeModel(...arguments); }); - }, + } model(params) { const policyType = params.type; if (!ALLOWED_TYPES.includes(policyType)) { @@ -25,5 +25,5 @@ export default Route.extend(ClusterRoute, { return this.router.transitionTo('vault.cluster.policies', policyType); } return {}; - }, -}); + } +} diff --git a/ui/app/routes/vault/cluster/replication-dr-promote.js b/ui/app/routes/vault/cluster/replication-dr-promote.js new file mode 100644 index 0000000000..d904ad47b9 --- /dev/null +++ b/ui/app/routes/vault/cluster/replication-dr-promote.js @@ -0,0 +1,20 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { CLUSTER } from 'vault/lib/route-paths'; + +export default class ReplicationDrPromote extends Route { + @service router; + + beforeModel() { + const cluster = this.modelFor(CLUSTER); + // if it's not a dr secondary, go back to cluster + if (!cluster?.dr?.isSecondary) { + return this.router.replaceWith(CLUSTER); + } + } +} diff --git a/ui/app/routes/vault/cluster/replication-dr-promote/details.js b/ui/app/routes/vault/cluster/replication-dr-promote/details.js index 36db679ae1..d4a28e870c 100644 --- a/ui/app/routes/vault/cluster/replication-dr-promote/details.js +++ b/ui/app/routes/vault/cluster/replication-dr-promote/details.js @@ -4,12 +4,11 @@ */ import { service } from '@ember/service'; -import Base from '../cluster-route-base'; +import Route from '@ember/routing/route'; -export default Base.extend({ - replicationMode: service(), +export default class ReplicationDetailsRoute extends Route { + @service replicationMode; beforeModel() { - this._super(...arguments); this.replicationMode.setMode('dr'); - }, -}); + } +} diff --git a/ui/app/routes/vault/cluster/replication-dr-promote/index.js b/ui/app/routes/vault/cluster/replication-dr-promote/index.js index 36db679ae1..15fd6845c5 100644 --- a/ui/app/routes/vault/cluster/replication-dr-promote/index.js +++ b/ui/app/routes/vault/cluster/replication-dr-promote/index.js @@ -4,12 +4,11 @@ */ import { service } from '@ember/service'; -import Base from '../cluster-route-base'; +import Route from '@ember/routing/route'; -export default Base.extend({ - replicationMode: service(), +export default class ReplicationIndexRoute extends Route { + @service replicationMode; beforeModel() { - this._super(...arguments); this.replicationMode.setMode('dr'); - }, -}); + } +} diff --git a/ui/app/routes/vault/cluster/secrets.js b/ui/app/routes/vault/cluster/secrets.js deleted file mode 100644 index 6ba3f5eabf..0000000000 --- a/ui/app/routes/vault/cluster/secrets.js +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; - -export default Route.extend(ClusterRoute); diff --git a/ui/app/routes/vault/cluster/settings.js b/ui/app/routes/vault/cluster/settings.js index 925101c1da..e9fa73fb89 100644 --- a/ui/app/routes/vault/cluster/settings.js +++ b/ui/app/routes/vault/cluster/settings.js @@ -4,10 +4,9 @@ */ import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; -export default Route.extend(ClusterRoute, { +export default class SettingsRoute extends Route { model() { return {}; - }, -}); + } +} diff --git a/ui/app/routes/vault/cluster/storage.js b/ui/app/routes/vault/cluster/storage.js index a312830ccc..37279f2013 100644 --- a/ui/app/routes/vault/cluster/storage.js +++ b/ui/app/routes/vault/cluster/storage.js @@ -4,22 +4,15 @@ */ import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; import { service } from '@ember/service'; -export default Route.extend(ClusterRoute, { - store: service(), +export default class StorageRoute extends Route { + @service store; model() { // findAll method will return all records in store as well as response from server // when removing a peer via the cli, stale records would continue to appear until refresh // query method will only return records from response return this.store.query('server', {}); - }, - - actions: { - doRefresh() { - this.refresh(); - }, - }, -}); + } +} diff --git a/ui/app/routes/vault/cluster/tools.js b/ui/app/routes/vault/cluster/tools.js index 23ae6296b0..98f3447790 100644 --- a/ui/app/routes/vault/cluster/tools.js +++ b/ui/app/routes/vault/cluster/tools.js @@ -4,10 +4,9 @@ */ import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; -export default Route.extend(ClusterRoute, { +export default class ToolsRoute extends Route { model() { return this.modelFor('vault.cluster'); - }, -}); + } +} diff --git a/ui/app/routes/vault/cluster/unseal.js b/ui/app/routes/vault/cluster/unseal.js index 0aac55a8af..9821f80ef0 100644 --- a/ui/app/routes/vault/cluster/unseal.js +++ b/ui/app/routes/vault/cluster/unseal.js @@ -3,6 +3,18 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import ClusterRoute from './cluster-route-base'; +import Route from '@ember/routing/route'; +import { service } from '@ember/service'; +import { CLUSTER } from 'vault/lib/route-paths'; -export default ClusterRoute.extend({}); +export default class UnsealRoute extends Route { + @service router; + + beforeModel() { + const cluster = this.modelFor(CLUSTER); + // if it's not sealed, we don't need to unseal + if (!cluster.sealed) { + return this.router.replaceWith(CLUSTER); + } + } +} diff --git a/ui/app/utils/shared-model-boundary.js b/ui/app/utils/shared-model-boundary.js new file mode 100644 index 0000000000..17d7bf7753 --- /dev/null +++ b/ui/app/utils/shared-model-boundary.js @@ -0,0 +1,18 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +export default function clearModelCache(store, modelType) { + if (!modelType) { + return; + } + const modelTypes = Array.isArray(modelType) ? modelType : [modelType]; + if (store.isDestroyed || store.isDestroying) { + // Prevent unload attempt after test teardown, resulting in test failure + return; + } + modelTypes.forEach((type) => { + store.unloadAll(type); + }); +} diff --git a/ui/lib/replication/addon/routes/application.js b/ui/lib/replication/addon/routes/application.js index 9c81eb11f9..289102e9c6 100644 --- a/ui/lib/replication/addon/routes/application.js +++ b/ui/lib/replication/addon/routes/application.js @@ -4,16 +4,14 @@ */ import { service } from '@ember/service'; -import { setProperties } from '@ember/object'; import Route from '@ember/routing/route'; -import ClusterRoute from 'vault/mixins/cluster-route'; -export default Route.extend(ClusterRoute, { - version: service(), - store: service(), - auth: service(), - router: service('app-router'), - capabilities: service(), +export default class ApplicationRoute extends Route { + @service version; + @service store; + @service auth; + @service('app-router') router; + @service capabilities; async fetchCapabilities() { const enablePath = (type, cluster) => `sys/replication/${type}/${cluster}/enable`; @@ -29,21 +27,19 @@ export default Route.extend(ClusterRoute, { canEnablePrimaryPerformance: perms[enablePath('performance', 'secondary')].canUpdate, canEnableSecondaryPerformance: perms[enablePath('performance', 'secondary')].canUpdate, }; - }, + } beforeModel() { if (this.auth.activeCluster.replicationRedacted) { // disallow replication access if endpoints are redacted return this.router.transitionTo('vault.cluster'); } - return this.version.fetchFeatures().then(() => { - return this._super(...arguments); - }); - }, + return this.version.fetchFeatures(); + } model() { return this.auth.activeCluster; - }, + } async afterModel(model) { const { @@ -53,17 +49,10 @@ export default Route.extend(ClusterRoute, { canEnableSecondaryPerformance, } = await this.fetchCapabilities(); - setProperties(model, { - canEnablePrimaryDr, - canEnableSecondaryDr, - canEnablePrimaryPerformance, - canEnableSecondaryPerformance, - }); + model.canEnablePrimaryDr = canEnablePrimaryDr; + model.canEnableSecondaryDr = canEnableSecondaryDr; + model.canEnablePrimaryPerformance = canEnablePrimaryPerformance; + model.canEnableSecondaryPerformance = canEnableSecondaryPerformance; return model; - }, - actions: { - refresh() { - this.refresh(); - }, - }, -}); + } +} diff --git a/ui/lib/replication/addon/routes/mode/index.js b/ui/lib/replication/addon/routes/mode/index.js index 2bd8cffec4..05876ff9b5 100644 --- a/ui/lib/replication/addon/routes/mode/index.js +++ b/ui/lib/replication/addon/routes/mode/index.js @@ -30,12 +30,4 @@ export default Route.extend({ return cluster; }); }, - - afterModel(model) { - const replicationMode = this.paramsFor('mode').replication_mode; - const cluster = model[replicationMode]; - if (!cluster.isPrimary || cluster.replicationDisabled || cluster.replicationUnsupported) { - return this.router.transitionTo('vault.cluster.replication.mode', replicationMode); - } - }, }); diff --git a/ui/tests/acceptance/auth/auth-login-test.js b/ui/tests/acceptance/auth/auth-login-test.js index 528e243955..94a20c0776 100644 --- a/ui/tests/acceptance/auth/auth-login-test.js +++ b/ui/tests/acceptance/auth/auth-login-test.js @@ -291,7 +291,7 @@ module('Acceptance | auth login', function (hooks) { assert.expect(6); this.authType = 'oidc'; this.expectedPayload = { - redirect_uri: 'http://localhost:7357/ui/vault/auth/custom-oidc/oidc/callback', + redirect_uri: `${window.location.origin}/ui/vault/auth/custom-oidc/oidc/callback`, role: 'some-dev', }; this.server.post('/auth/custom-oidc/oidc/auth_url', (schema, req) => { diff --git a/ui/tests/acceptance/enterprise-replication-test.js b/ui/tests/acceptance/enterprise-replication-test.js index ee95cab4f9..f9bbec1032 100644 --- a/ui/tests/acceptance/enterprise-replication-test.js +++ b/ui/tests/acceptance/enterprise-replication-test.js @@ -3,7 +3,16 @@ * SPDX-License-Identifier: BUSL-1.1 */ -import { click, fillIn, findAll, currentURL, visit, settled, waitFor } from '@ember/test-helpers'; +import { + click, + fillIn, + findAll, + currentURL, + currentRouteName, + visit, + settled, + waitFor, +} from '@ember/test-helpers'; import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; @@ -84,7 +93,7 @@ module('Acceptance | Enterprise | replication', function (hooks) { assert.ok(findAll('[data-test-demote-warning]').length, 'displays the demotion warning'); }); - test('DR primary: shows empty state when secondary mode is not enabled and we navigated to the secondary details page', async function (assert) { + test('DR primary: redirects to cluster route when navigating to the secondary details page', async function (assert) { // enable dr replication await visit('/vault/replication/dr'); @@ -94,16 +103,7 @@ module('Acceptance | Enterprise | replication', function (hooks) { await settled(); // eslint-disable-line await pollCluster(this.owner); await visit('/vault/replication-dr-promote/details'); - - assert - .dom('[data-test-component="empty-state"]') - .exists('Empty state is shown when no secondary is configured'); - assert - .dom('[data-test-empty-state-message]') - .hasText( - 'This Disaster Recovery secondary has not been enabled. You can do so from the Disaster Recovery Primary.', - 'Renders the correct message for when a primary is enabled but no secondary is configured and we have navigated to the secondary details page.' - ); + assert.strictEqual(currentRouteName(), 'vault.cluster.dashboard', 'redirects to the cluster route'); }); test('DR primary: runs analytics service when enabled', async function (assert) { @@ -126,22 +126,6 @@ module('Acceptance | Enterprise | replication', function (hooks) { addAnalyticsSpy.restore(); }); - test('DR secondary: shows empty state when replication is not enabled', async function (assert) { - await visit('/vault/replication-dr-promote/details'); - - assert.dom('[data-test-component="empty-state"]').exists(); - assert - .dom(GENERAL.emptyStateTitle) - .includesText('Disaster Recovery secondary not set up', 'shows the correct title of the empty state'); - - assert - .dom(GENERAL.emptyStateMessage) - .hasText( - 'This cluster has not been enabled as a Disaster Recovery Secondary. You can do so by enabling replication and adding a secondary from the Disaster Recovery Primary.', - 'renders default message specific to when no replication is enabled' - ); - }); - test('Performance primary: add secondary and delete config', async function (assert) { const secondaryName = `performanceSecondary`; const mode = 'deny'; diff --git a/ui/tests/acceptance/enterprise-sidebar-nav-test.js b/ui/tests/acceptance/enterprise-sidebar-nav-test.js index 5d77c24272..e0d0437f0b 100644 --- a/ui/tests/acceptance/enterprise-sidebar-nav-test.js +++ b/ui/tests/acceptance/enterprise-sidebar-nav-test.js @@ -5,7 +5,7 @@ import { module, test } from 'qunit'; import { setupApplicationTest } from 'ember-qunit'; -import { click, currentURL, visit } from '@ember/test-helpers'; +import { click, currentURL } from '@ember/test-helpers'; import { setupMirage } from 'ember-cli-mirage/test-support'; import { login } from 'vault/tests/helpers/auth/auth-helpers'; @@ -16,12 +16,12 @@ module('Acceptance | Enterprise | sidebar navigation', function (hooks) { setupApplicationTest(hooks); setupMirage(hooks); - hooks.beforeEach(function () { - return login(); + hooks.beforeEach(async function () { + await login(); }); // common links are tested in the sidebar-nav test and will not be covered here - test('it should render enterprise only navigation links', async function (assert) { + test(`it should render enterprise only navigation links`, async function (assert) { assert.dom(panel('Cluster')).exists('Cluster nav panel renders'); await click(link('Secrets Sync')); @@ -41,12 +41,9 @@ module('Acceptance | Enterprise | sidebar navigation', function (hooks) { 'Replication performance route renders' ); - // for some reason clicking this link would cause the testing browser locally - // to navigate to 'vault/replication/dr' and halt the test runner - assert - .dom(link('Disaster Recovery')) - .hasAttribute('href', '/ui/vault/replication/dr', 'Replication dr route renders'); - await visit('/vault'); + await click(link('Disaster Recovery')); + assert.strictEqual(currentURL(), '/vault/replication/dr', 'Replication DR route renders'); + await click(link('Back to main navigation')); await click(link('Client Count')); assert.dom(panel('Client Count')).exists('Client Count nav panel renders'); diff --git a/ui/tests/integration/components/open-api-explorer/swagger-ui-test.js b/ui/tests/integration/components/open-api-explorer/swagger-ui-test.js index 29c268c756..5c1fd2c3ec 100644 --- a/ui/tests/integration/components/open-api-explorer/swagger-ui-test.js +++ b/ui/tests/integration/components/open-api-explorer/swagger-ui-test.js @@ -51,7 +51,10 @@ module('Integration | Component | open-api-explorer | swagger-ui', function (hoo this.totalApiPaths = Object.keys(this.openApiResponse.paths).length; - this.renderComponent = () => render(hbs``, { owner: this.engine }); + this.renderComponent = async () => { + await render(hbs``, { owner: this.engine }); + await waitFor(SELECTORS.searchInput); + }; }); test('it renders', async function (assert) { diff --git a/ui/tests/integration/routes/cluster-route-test.js b/ui/tests/integration/routes/cluster-route-test.js new file mode 100644 index 0000000000..b5516791e2 --- /dev/null +++ b/ui/tests/integration/routes/cluster-route-test.js @@ -0,0 +1,125 @@ +/** + * Copyright IBM Corp. 2016, 2025 + * SPDX-License-Identifier: BUSL-1.1 + */ + +import ClusterRoute from 'vault/routes/vault/cluster'; +import { INIT, UNSEAL, AUTH, CLUSTER, CLUSTER_INDEX, DR_REPLICATION_SECONDARY } from 'vault/lib/route-paths'; +import { setupTest } from 'ember-qunit'; +import { module, test } from 'qunit'; +import sinon from 'sinon'; + +module('Integration | Route | ClusterRoute', function (hooks) { + setupTest(hooks); + + function createClusterRoute( + context, + clusterModel = {}, + methods = { + auth: { currentToken: null }, + } + ) { + context.owner.register('route:cluster-route-test', ClusterRoute); + const instance = context.owner.lookup('route:cluster-route-test'); + for (const key of Object.keys(methods)) { + if (typeof methods[key] === 'function') { + sinon.stub(instance, key).callsFake(methods[key]); + } + } + if (methods.auth) { + instance.auth = methods.auth; + } + instance.modelFor = () => clusterModel; + instance.router = { transitionTo: () => {} }; + return instance; + } + + const INIT_TESTS = [ + { + clusterState: { needsInit: true }, + expected: INIT, + description: 'forwards to INIT when cluster needs init', + }, + { + clusterState: { needsInit: false, sealed: true }, + expected: UNSEAL, + description: 'forwards to UNSEAL if sealed and initialized', + }, + { + clusterState: { needsInit: false, sealed: false }, + expected: AUTH, + description: 'forwards to AUTH if unsealed and initialized', + }, + { + clusterState: { dr: { isSecondary: true } }, + expected: DR_REPLICATION_SECONDARY, + description: 'forwards to DR_REPLICATION_SECONDARY if is a dr secondary', + }, + ]; + + for (const { clusterState, expected, description } of INIT_TESTS) { + test(`#targetRouteName init case: ${expected}`, function (assert) { + const subject = createClusterRoute(this, clusterState); + subject.routeName = CLUSTER; + assert.strictEqual(subject.targetRouteName(), expected, description); + }); + } + + test('#targetRouteName happy path when not authed forwards to AUTH', function (assert) { + const subject = createClusterRoute( + this, + { needsInit: false, sealed: false, dr: { isSecondary: false } }, + { auth: { currentToken: null } } + ); + subject.router.currentRouteName = INIT; + assert.strictEqual(subject.targetRouteName(), AUTH, 'forwards when inited and navigating to INIT'); + + subject.router.currentRouteName = UNSEAL; + assert.strictEqual(subject.targetRouteName(), AUTH, 'forwards when unsealed and navigating to UNSEAL'); + + subject.router.currentRouteName = AUTH; + assert.strictEqual( + subject.targetRouteName(), + AUTH, + 'forwards when non-authenticated and navigating to AUTH' + ); + + subject.router.currentRouteName = DR_REPLICATION_SECONDARY; + assert.strictEqual( + subject.targetRouteName(), + AUTH, + 'forwards when not a DR secondary and navigating to DR_REPLICATION_SECONDARY' + ); + }); + + test('#transitionToTargetRoute', function (assert) { + const redirectRouteURL = '/vault/secrets-engines/secret/create'; + const subject = createClusterRoute(this, { needsInit: false, sealed: false }); + subject.router.currentURL = redirectRouteURL; + const spy = sinon.stub(subject.router, 'transitionTo'); + subject.transitionToTargetRoute(); + assert.ok( + spy.calledWithExactly(AUTH, { queryParams: { redirect_to: redirectRouteURL } }), + 'calls transitionTo with the expected args' + ); + + spy.restore(); + }); + + test('#transitionToTargetRoute with auth as a target', function (assert) { + const subject = createClusterRoute(this, { needsInit: false, sealed: false }); + const spy = sinon.stub(subject.router, 'transitionTo'); + // in this case it's already transitioning to the AUTH route so we don't need to call transitionTo again + subject.transitionToTargetRoute({ targetName: AUTH }); + assert.ok(spy.notCalled, 'transitionTo is not called'); + spy.restore(); + }); + + test('#transitionToTargetRoute with auth target, coming from cluster route', function (assert) { + const subject = createClusterRoute(this, { needsInit: false, sealed: false }); + const spy = sinon.stub(subject.router, 'transitionTo'); + subject.transitionToTargetRoute({ targetName: CLUSTER_INDEX }); + assert.ok(spy.calledWithExactly(AUTH), 'calls transitionTo without redirect_to'); + spy.restore(); + }); +}); diff --git a/ui/tests/unit/mixins/cluster-route-test.js b/ui/tests/unit/mixins/cluster-route-test.js deleted file mode 100644 index 5f609b01dc..0000000000 --- a/ui/tests/unit/mixins/cluster-route-test.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * Copyright IBM Corp. 2016, 2025 - * SPDX-License-Identifier: BUSL-1.1 - */ - -import EmberObject from '@ember/object'; -import ClusterRouteMixin from 'vault/mixins/cluster-route'; -import { - INIT, - UNSEAL, - AUTH, - CLUSTER, - CLUSTER_INDEX, - DR_REPLICATION_SECONDARY, - REDIRECT, -} from 'vault/lib/route-paths'; -import { module, test } from 'qunit'; -import sinon from 'sinon'; - -module('Unit | Mixin | cluster route', function () { - function createClusterRoute( - clusterModel = {}, - methods = { - router: { transitionTo: () => {} }, - hasKeyData: () => false, - authToken: () => null, - transitionTo: () => {}, - } - ) { - const ClusterRouteObject = EmberObject.extend( - ClusterRouteMixin, - Object.assign(methods, { clusterModel: () => clusterModel }) - ); - return ClusterRouteObject.create(); - } - - test('#targetRouteName init', function (assert) { - let subject = createClusterRoute({ needsInit: true }); - subject.routeName = CLUSTER; - assert.strictEqual(subject.targetRouteName(), INIT, 'forwards to INIT when cluster needs init'); - - subject = createClusterRoute({ needsInit: false, sealed: true }); - subject.routeName = CLUSTER; - assert.strictEqual(subject.targetRouteName(), UNSEAL, 'forwards to UNSEAL if sealed and initialized'); - - subject = createClusterRoute({ needsInit: false }); - subject.routeName = CLUSTER; - assert.strictEqual(subject.targetRouteName(), AUTH, 'forwards to AUTH if unsealed and initialized'); - - subject = createClusterRoute({ dr: { isSecondary: true } }); - subject.routeName = CLUSTER; - assert.strictEqual( - subject.targetRouteName(), - DR_REPLICATION_SECONDARY, - 'forwards to DR_REPLICATION_SECONDARY if is a dr secondary' - ); - }); - - test('#targetRouteName when #hasDataKey is true', function (assert) { - let subject = createClusterRoute( - { needsInit: false, sealed: true }, - { hasKeyData: () => true, authToken: () => null } - ); - - subject.routeName = CLUSTER; - assert.strictEqual( - subject.targetRouteName(), - INIT, - 'still land on INIT if there are keys on the controller' - ); - - subject.routeName = UNSEAL; - assert.strictEqual(subject.targetRouteName(), UNSEAL, 'allowed to proceed to unseal'); - - subject = createClusterRoute( - { needsInit: false, sealed: false }, - { hasKeyData: () => true, authToken: () => null } - ); - - subject.routeName = AUTH; - assert.strictEqual(subject.targetRouteName(), AUTH, 'allowed to proceed to auth'); - }); - - test('#targetRouteName happy path forwards to CLUSTER route', function (assert) { - const subject = createClusterRoute( - { needsInit: false, sealed: false, dr: { isSecondary: false } }, - { hasKeyData: () => false, authToken: () => 'a token' } - ); - subject.routeName = INIT; - assert.strictEqual(subject.targetRouteName(), CLUSTER, 'forwards when inited and navigating to INIT'); - - subject.routeName = UNSEAL; - assert.strictEqual(subject.targetRouteName(), CLUSTER, 'forwards when unsealed and navigating to UNSEAL'); - - subject.routeName = AUTH; - assert.strictEqual( - subject.targetRouteName(), - REDIRECT, - 'forwards when authenticated and navigating to AUTH' - ); - - subject.routeName = DR_REPLICATION_SECONDARY; - assert.strictEqual( - subject.targetRouteName(), - CLUSTER, - 'forwards when not a DR secondary and navigating to DR_REPLICATION_SECONDARY' - ); - }); - - test('#targetRouteName happy path when not authed forwards to AUTH', function (assert) { - const subject = createClusterRoute( - { needsInit: false, sealed: false, dr: { isSecondary: false } }, - { hasKeyData: () => false, authToken: () => null } - ); - subject.routeName = INIT; - assert.strictEqual(subject.targetRouteName(), AUTH, 'forwards when inited and navigating to INIT'); - - subject.routeName = UNSEAL; - assert.strictEqual(subject.targetRouteName(), AUTH, 'forwards when unsealed and navigating to UNSEAL'); - - subject.routeName = AUTH; - assert.strictEqual( - subject.targetRouteName(), - AUTH, - 'forwards when non-authenticated and navigating to AUTH' - ); - - subject.routeName = DR_REPLICATION_SECONDARY; - assert.strictEqual( - subject.targetRouteName(), - AUTH, - 'forwards when not a DR secondary and navigating to DR_REPLICATION_SECONDARY' - ); - }); - - test('#transitionToTargetRoute', function (assert) { - const redirectRouteURL = '/vault/secrets-engines/secret/create'; - const subject = createClusterRoute({ needsInit: false, sealed: false }); - subject.router.currentURL = redirectRouteURL; - const spy = sinon.spy(subject.router, 'transitionTo'); - subject.transitionToTargetRoute(); - assert.ok( - spy.calledWithExactly(AUTH, { queryParams: { redirect_to: redirectRouteURL } }), - 'calls transitionTo with the expected args' - ); - - spy.restore(); - }); - - test('#transitionToTargetRoute with auth as a target', function (assert) { - const subject = createClusterRoute({ needsInit: false, sealed: false }); - const spy = sinon.spy(subject, 'transitionTo'); - // in this case it's already transitioning to the AUTH route so we don't need to call transitionTo again - subject.transitionToTargetRoute({ targetName: AUTH }); - assert.ok(spy.notCalled, 'transitionTo is not called'); - spy.restore(); - }); - - test('#transitionToTargetRoute with auth target, coming from cluster route', function (assert) { - const subject = createClusterRoute({ needsInit: false, sealed: false }); - const spy = sinon.spy(subject.router, 'transitionTo'); - subject.transitionToTargetRoute({ targetName: CLUSTER_INDEX }); - assert.ok(spy.calledWithExactly(AUTH), 'calls transitionTo without redirect_to'); - spy.restore(); - }); -});