diff --git a/webroot/src/components/Page/PageContainer/PageContainer.ts b/webroot/src/components/Page/PageContainer/PageContainer.ts index be5cb1a40a..adab9cf2c4 100644 --- a/webroot/src/components/Page/PageContainer/PageContainer.ts +++ b/webroot/src/components/Page/PageContainer/PageContainer.ts @@ -59,6 +59,7 @@ class PageContainer extends Vue { get shouldPadTop(): boolean { const nonPadTopRouteNames: Array = [ + 'Logout', 'LicensingDetail', 'LicenseeDetailPublic', 'LicenseeVerification', @@ -70,6 +71,7 @@ class PageContainer extends Vue { get includeMainNav(): boolean { const nonMainNavRouteNames: Array = [ + 'Logout', // This is a non-interactive page with background operations and an automatic redirect 'DashboardPublic', // This is a custom splash page with custom button navigation 'LicenseeVerification', // This is a printer-friendly page 'MfaResetConfirmLicensee', // This is a standalone automation page accessed from emailed link diff --git a/webroot/src/locales/en.json b/webroot/src/locales/en.json index 578f92ebed..fbe62672aa 100644 --- a/webroot/src/locales/en.json +++ b/webroot/src/locales/en.json @@ -53,6 +53,7 @@ "status": "Status", "saving": "Saving...", "loading": "Loading...", + "loggingOut": "Logging out...", "viewing": "Viewing", "viewDetails": "View details", "name": "Name", diff --git a/webroot/src/locales/es.json b/webroot/src/locales/es.json index c8ddf62d07..d36da7cb30 100644 --- a/webroot/src/locales/es.json +++ b/webroot/src/locales/es.json @@ -53,6 +53,7 @@ "saveAndClose": "Guardar y cerrar", "saving": "Guardando...", "loading": "Cargando...", + "loggingOut": "Cerrando sesión...", "viewing": "Viendo", "viewDetails": "Ver detalles", "name": "Nombre", diff --git a/webroot/src/pages/AuthCallback/_mixins/mixins.spec.ts b/webroot/src/pages/AuthCallback/_mixins/mixins.spec.ts index 7c5742a57b..e6af467e30 100644 --- a/webroot/src/pages/AuthCallback/_mixins/mixins.spec.ts +++ b/webroot/src/pages/AuthCallback/_mixins/mixins.spec.ts @@ -5,10 +5,18 @@ // Created by InspiringApps on 6/24/2026. // +import sinon from 'sinon'; +import axios from 'axios'; import { mountShallow } from '@tests/helpers/setup'; import AuthCallbackHandlerMixin from '@pages/AuthCallback/_mixins/handler.mixin'; import { AppModes } from '@/app.config'; -import { AuthTypes, AUTH_CSRF_STATE } from '@utils/auth'; +import { + AuthTypes, + AUTH_CSRF_STATE, + AUTH_PKCE_CODE_VERIFIER, + authStorage, + tokens +} from '@utils/auth'; import sessionStorage from '@store/session.storage'; const chaiMatchPattern = require('chai-match-pattern'); @@ -41,13 +49,49 @@ describe('AuthCallbackHandler mixin', async () => { expect(component.stateParam).to.equal('def'); }); it('should successfully get tokens', async () => { + const cognitoAuthDomain = 'https://staff-auth.test.example.com'; + const cognitoClientId = 'test-staff-client-id'; + const tokenResponse = { + access_token: 'access-token', + id_token: 'id-token', + token_type: 'Bearer', + }; + const axiosPostStub = sinon.stub(axios, 'post').resolves({ data: tokenResponse }); const wrapper = await mountShallow(AuthCallbackHandlerMixin); const component = wrapper.vm; - - await component.getTokens(AppModes.JCC, AuthTypes.STAFF, 'http://localhost', 'abc'); - - // If the tokens flow is successful then it ends by redirecting the user with a replaced router history state - expect(component.$router.options.history.state.replaced).to.equal(true); + const routerPushStub = sinon.stub(component.$router, 'push').resolves(); + const dispatchSpy = sinon.spy(component.$store, 'dispatch'); + + // created() fails CSRF and sets isError; reset for the direct getTokens call under test + component.isError = false; + component.$route.query.code = 'auth-code-123'; + sessionStorage.setItem(AUTH_PKCE_CODE_VERIFIER, 'pkce-verifier-123'); + + await component.getTokens(AppModes.JCC, AuthTypes.STAFF, cognitoAuthDomain, cognitoClientId); + + expect(axiosPostStub.calledOnce).to.equal(true); + expect(axiosPostStub.firstCall.args[0]).to.equal(`${cognitoAuthDomain}/oauth2/token`); + expect(axiosPostStub.firstCall.args[1].get('grant_type')).to.equal('authorization_code'); + expect(axiosPostStub.firstCall.args[1].get('client_id')).to.equal(cognitoClientId); + expect(axiosPostStub.firstCall.args[1].get('redirect_uri')).to.equal( + `${component.$envConfig.domain}${component.$route.path}` + ); + expect(axiosPostStub.firstCall.args[1].get('code')).to.equal('auth-code-123'); + expect(axiosPostStub.firstCall.args[1].get('code_verifier')).to.equal('pkce-verifier-123'); + expect(dispatchSpy.calledWith('user/updateAuthTokens', { + tokenResponse, + authType: AuthTypes.STAFF, + })).to.equal(true); + expect(dispatchSpy.calledWith('user/loginSuccess', AuthTypes.STAFF)).to.equal(true); + expect(routerPushStub.calledWith({ name: 'Home' })).to.equal(true); + expect(component.isError).to.equal(false); + + axiosPostStub.restore(); + routerPushStub.restore(); + dispatchSpy.restore(); + authStorage.removeItem(tokens.staff.AUTH_TOKEN); + authStorage.removeItem(tokens.staff.AUTH_TOKEN_TYPE); + authStorage.removeItem(tokens.staff.ID_TOKEN); }); it('should verify a matching csrf state param', async () => { const wrapper = await mountShallow(AuthCallbackHandlerMixin); diff --git a/webroot/src/pages/Logout/Logout.less b/webroot/src/pages/Logout/Logout.less index 085cc685e5..ea28df3015 100644 --- a/webroot/src/pages/Logout/Logout.less +++ b/webroot/src/pages/Logout/Logout.less @@ -4,3 +4,16 @@ // // Created by InspiringApps on 8/12/2024. // + +.logout-container { + display: flex; + flex-grow: 1; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + + .message { + padding-bottom: 6.4rem; + } +} diff --git a/webroot/src/pages/Logout/Logout.spec.ts b/webroot/src/pages/Logout/Logout.spec.ts new file mode 100644 index 0000000000..d3a030298d --- /dev/null +++ b/webroot/src/pages/Logout/Logout.spec.ts @@ -0,0 +1,111 @@ +// +// Logout.spec.ts +// CompactConnect +// +// Created by InspiringApps on 7/29/2026. +// + +import sinon from 'sinon'; +import axios from 'axios'; +import { mountShallow } from '@tests/helpers/setup'; +import Logout from '@pages/Logout/Logout.vue'; +import { authStorage, tokens, AuthTypes } from '@utils/auth'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; + +const chaiMatchPattern = require('chai-match-pattern'); +const chai = require('chai').use(chaiMatchPattern); + +const { expect } = chai; + +describe('Logout page', async () => { + let logoutStub; + let originalCognitoConfig; + + beforeEach(() => { + // Prevent created() from running real logout (clears shared store / redirects) + logoutStub = sinon.stub(Logout.methods, 'logout').resolves(); + + originalCognitoConfig = { + cognitoClientIdStaff: envConfig.cognitoClientIdStaff, + cognitoAuthDomainStaff: envConfig.cognitoAuthDomainStaff, + }; + envConfig.cognitoClientIdStaff = 'test-staff-client-id'; + envConfig.cognitoAuthDomainStaff = 'https://staff-auth.test.example.com'; + }); + + afterEach(() => { + logoutStub.restore(); + envConfig.cognitoClientIdStaff = originalCognitoConfig.cognitoClientIdStaff; + envConfig.cognitoAuthDomainStaff = originalCognitoConfig.cognitoAuthDomainStaff; + authStorage.removeItem(tokens.staff.REFRESH_TOKEN); + }); + + it('should mount the page component', async () => { + const wrapper = await mountShallow(Logout); + + expect(wrapper.exists()).to.equal(true); + expect(wrapper.findComponent(Logout).exists()).to.equal(true); + expect(logoutStub.calledOnce).to.equal(true); + }); + + it('should successfully revoke tokens before logoutRequest in logoutChecklist', async () => { + const wrapper = await mountShallow(Logout); + const component = wrapper.vm; + const revokeStub = sinon.stub(component, 'revokeTokens').resolves(); + const dispatchSpy = sinon.spy(component.$store, 'dispatch'); + + await component.logoutChecklist(false); + + expect(revokeStub.calledOnce).to.equal(true); + expect(revokeStub.firstCall.args[0]).to.equal(AuthTypes.STAFF); + expect(dispatchSpy.calledWith('user/logoutRequest', AuthTypes.STAFF)).to.equal(true); + expect(revokeStub.calledBefore( + dispatchSpy.withArgs('user/logoutRequest', AuthTypes.STAFF) + )).to.equal(true); + + revokeStub.restore(); + dispatchSpy.restore(); + }); + + it('should successfully revoke licensee tokens when logged in as licensee only', async () => { + const wrapper = await mountShallow(Logout); + const component = wrapper.vm; + const revokeStub = sinon.stub(component, 'revokeTokens').resolves(); + + await component.logoutChecklist(true); + + expect(revokeStub.firstCall.args[0]).to.equal(AuthTypes.LICENSEE); + + revokeStub.restore(); + }); + + it('should successfully swallow revoke errors and log to analytics', async () => { + const wrapper = await mountShallow(Logout); + const component = wrapper.vm; + const axiosPostStub = sinon.stub(axios, 'post').rejects(new Error('network')); + const logEventStub = sinon.stub(component.$analytics, 'logEvent').returns(undefined); + let didThrow = false; + + authStorage.setItem(tokens.staff.REFRESH_TOKEN, 'staff-refresh-token'); + + await component.revokeTokens(AuthTypes.STAFF).catch(() => { + didThrow = true; + }); + + expect(didThrow).to.equal(false); + expect(logEventStub.calledOnce).to.equal(true); + expect(logEventStub.firstCall.args[0]).to.equal('cognito_token_revoke_failed'); // https://console.statsig.com/3KcYv8LC2YCc1vsTkVi3Fb/metrics/metrics_catalog/Cognito%20Token%20Revocation%20Failure/event_count_custom?unitType=overall + expect(logEventStub.firstCall.args[1]).to.equal(1); + expect(logEventStub.firstCall.args[2]).to.matchPattern({ + authType: AuthTypes.STAFF, + appMode: component.appMode, + appGroupMode: component.appGroupMode, + errorName: 'Error', + errorCode: undefined, + httpStatus: undefined, + }); + + axiosPostStub.restore(); + logEventStub.restore(); + }); +}); diff --git a/webroot/src/pages/Logout/Logout.ts b/webroot/src/pages/Logout/Logout.ts index 1d6c7900cb..faff91725c 100644 --- a/webroot/src/pages/Logout/Logout.ts +++ b/webroot/src/pages/Logout/Logout.ts @@ -13,12 +13,16 @@ import { AuthTypes, AUTH_TYPE, AUTH_LOGIN_GOTO_PATH, - AUTH_LOGIN_GOTO_PATH_AUTH_TYPE + AUTH_LOGIN_GOTO_PATH_AUTH_TYPE, + revokeCognitoRefreshToken } from '@utils/auth'; +import LoadingSpinner from '@components/LoadingSpinner/LoadingSpinner.vue'; @Component({ name: 'Logout', - components: {} + components: { + LoadingSpinner, + }, }) export default class Logout extends Vue { // @@ -35,6 +39,10 @@ export default class Logout extends Vue { return this.$store.state.appMode; } + get appGroupMode() { + return this.$store.state.appGroupMode; + } + get userStore() { return this.$store.state.user; } @@ -117,18 +125,33 @@ export default class Logout extends Vue { async logoutChecklist(isRemoteLoggedInAsLicenseeOnly): Promise { const authType = (isRemoteLoggedInAsLicenseeOnly) ? AuthTypes.LICENSEE : AuthTypes.STAFF; - this.unsetAnalyticsUser(); // Not awaiting analytics so it doesn't block other critical steps this.stashWorkingUri(); this.$store.dispatch('user/clearRefreshTokenTimeout'); + await this.revokeTokens(authType); + this.unsetAnalyticsUser(); // Not awaiting analytics so it doesn't block other critical steps await this.$store.dispatch('user/logoutRequest', authType); } + async revokeTokens(authType: AuthTypes): Promise { + await revokeCognitoRefreshToken(this.appMode, authType).catch((err) => Promise.resolve().then(() => { + // https://console.statsig.com/3KcYv8LC2YCc1vsTkVi3Fb/metrics/metrics_catalog/Cognito%20Token%20Revocation%20Failure/event_count_custom?unitType=overall + this.$analytics.logEvent('cognito_token_revoke_failed', 1, { + authType, + appMode: this.appMode, + appGroupMode: this.appGroupMode, + errorName: err?.name, + errorCode: err?.code, + httpStatus: err?.response?.status, + }); + }).catch(() => { + // Continue — analytics failures must never block logout + })); + } + async unsetAnalyticsUser(): Promise { - try { - await this.$analytics.updateUserAsync({}); - } catch (err) { + await this.$analytics.updateUserAsync({}).catch(() => { // Continue - } + }); } stashWorkingUri(): void { diff --git a/webroot/src/pages/Logout/Logout.vue b/webroot/src/pages/Logout/Logout.vue index aba66dd81d..08a25430bf 100644 --- a/webroot/src/pages/Logout/Logout.vue +++ b/webroot/src/pages/Logout/Logout.vue @@ -6,7 +6,10 @@ --> diff --git a/webroot/src/pages/PrivilegePurchase/PrivilegePurchase.ts b/webroot/src/pages/PrivilegePurchase/PrivilegePurchase.ts index 44b921f252..9423dad8d4 100644 --- a/webroot/src/pages/PrivilegePurchase/PrivilegePurchase.ts +++ b/webroot/src/pages/PrivilegePurchase/PrivilegePurchase.ts @@ -149,6 +149,8 @@ export default class PrivilegePurchase extends Vue { // Watchers // @Watch('routeName') handlePurchaseFlowNavigation() { - this.handlePurchaseFlowState(); + if (this.licensee?.canPurchasePrivileges() && this.currentCompactType) { + this.handlePurchaseFlowState(); + } } } diff --git a/webroot/src/plugins/Statsig/statsig.plugin.ts b/webroot/src/plugins/Statsig/statsig.plugin.ts index 8b5fcebb92..5b8424ce94 100644 --- a/webroot/src/plugins/Statsig/statsig.plugin.ts +++ b/webroot/src/plugins/Statsig/statsig.plugin.ts @@ -43,6 +43,7 @@ export const getStatsigEnvironment = () => { export type StatsigClientMock = { updateUserAsync: (user: any) => Promise; checkGate: (gateId?: string) => boolean; + logEvent: (eventName?: string, value?: string | number | null, metadata?: Record) => boolean; } export const getStatsigClientMock = async (isLiveFallback = false) => ({ @@ -54,6 +55,8 @@ export const getStatsigClientMock = async (isLiveFallback = false) => ({ return isEnabled; }, + // Returns truthy so chai-match-pattern (which treats functions as predicates) still matches across mock instances + logEvent: () => true, }); export const getStatsigClient = async () => { diff --git a/webroot/src/store/user/user.actions.ts b/webroot/src/store/user/user.actions.ts index 98feb46937..54dbb14513 100644 --- a/webroot/src/store/user/user.actions.ts +++ b/webroot/src/store/user/user.actions.ts @@ -42,7 +42,6 @@ export default { // LOGOUT logoutRequest: ({ commit, dispatch }, authType) => { dispatch('clearSessionStores'); - dispatch('startLoading', null, { root: true }); let tokenType = AuthTypes.STAFF; if (authType === AuthTypes.LICENSEE) { @@ -50,14 +49,7 @@ export default { } dispatch('clearAuthToken', tokenType); commit(MutationTypes.LOGOUT_REQUEST); - - /* istanbul ignore next */ - if (config.isUsingMockApi) { - setTimeout(() => dispatch('endLoading', null, { root: true }), 1000); - dispatch('logoutSuccess'); - } else { - dispatch('logoutSuccess'); - } + dispatch('logoutSuccess'); }, logoutSuccess: ({ commit }) => { commit(MutationTypes.LOGOUT_SUCCESS); diff --git a/webroot/src/store/user/user.spec.ts b/webroot/src/store/user/user.spec.ts index 21bd4c0b39..bb52f9fd3f 100644 --- a/webroot/src/store/user/user.spec.ts +++ b/webroot/src/store/user/user.spec.ts @@ -584,7 +584,7 @@ describe('User Store Actions', async () => { expect(commit.calledOnce).to.equal(true); expect(commit.firstCall.args).to.matchPattern([MutationTypes.LOGOUT_REQUEST]); - expect(dispatch.callCount).to.equal(4); + expect(dispatch.callCount).to.equal(3); }); it('should successfully start logout success', () => { const commit = sinon.spy(); diff --git a/webroot/src/utils/auth.spec.ts b/webroot/src/utils/auth.spec.ts new file mode 100644 index 0000000000..621679fcaf --- /dev/null +++ b/webroot/src/utils/auth.spec.ts @@ -0,0 +1,161 @@ +// +// auth.spec.ts +// CompactConnect +// +// Created by InspiringApps on 7/29/2026. +// + +import sinon from 'sinon'; +import axios from 'axios'; +import { AppModes } from '@/app.config'; +import { + authStorage, + tokens, + AuthTypes, + revokeCognitoRefreshToken +} from '@utils/auth'; +import { config as envConfig } from '@plugins/EnvConfig/envConfig.plugin'; + +const chaiMatchPattern = require('chai-match-pattern'); +const chai = require('chai').use(chaiMatchPattern); + +const { expect } = chai; + +describe('auth utils', () => { + let axiosPostStub; + let originalCognitoConfig; + + beforeEach(() => { + axiosPostStub = sinon.stub(axios, 'post').resolves({ data: {}}); + + // Preserve real env values, then seed stable doubles so tests do not depend on .env / CI secrets + originalCognitoConfig = { + cognitoClientIdStaff: envConfig.cognitoClientIdStaff, + cognitoAuthDomainStaff: envConfig.cognitoAuthDomainStaff, + cognitoClientIdLicensee: envConfig.cognitoClientIdLicensee, + cognitoAuthDomainLicensee: envConfig.cognitoAuthDomainLicensee, + cognitoClientIdStaffCosmo: envConfig.cognitoClientIdStaffCosmo, + cognitoAuthDomainStaffCosmo: envConfig.cognitoAuthDomainStaffCosmo, + }; + envConfig.cognitoClientIdStaff = 'test-staff-client-id'; + envConfig.cognitoAuthDomainStaff = 'https://staff-auth.test.example.com'; + envConfig.cognitoClientIdLicensee = 'test-licensee-client-id'; + envConfig.cognitoAuthDomainLicensee = 'https://licensee-auth.test.example.com'; + envConfig.cognitoClientIdStaffCosmo = 'test-cosmo-client-id'; + envConfig.cognitoAuthDomainStaffCosmo = 'https://cosmo-auth.test.example.com'; + + authStorage.removeItem(tokens.staff.REFRESH_TOKEN); + authStorage.removeItem(tokens.licensee.REFRESH_TOKEN); + }); + + afterEach(() => { + axiosPostStub.restore(); + + envConfig.cognitoClientIdStaff = originalCognitoConfig.cognitoClientIdStaff; + envConfig.cognitoAuthDomainStaff = originalCognitoConfig.cognitoAuthDomainStaff; + envConfig.cognitoClientIdLicensee = originalCognitoConfig.cognitoClientIdLicensee; + envConfig.cognitoAuthDomainLicensee = originalCognitoConfig.cognitoAuthDomainLicensee; + envConfig.cognitoClientIdStaffCosmo = originalCognitoConfig.cognitoClientIdStaffCosmo; + envConfig.cognitoAuthDomainStaffCosmo = originalCognitoConfig.cognitoAuthDomainStaffCosmo; + + authStorage.removeItem(tokens.staff.REFRESH_TOKEN); + authStorage.removeItem(tokens.licensee.REFRESH_TOKEN); + }); + + it('should successfully post refresh token to Cognito /oauth2/revoke for staff', async () => { + const refreshToken = 'staff-refresh-token'; + + authStorage.setItem(tokens.staff.REFRESH_TOKEN, refreshToken); + + await revokeCognitoRefreshToken(AppModes.JCC, AuthTypes.STAFF); + + expect(axiosPostStub.calledOnce).to.equal(true); + expect(axiosPostStub.firstCall.args[0]).to.equal(`${envConfig.cognitoAuthDomainStaff}/oauth2/revoke`); + expect(axiosPostStub.firstCall.args[1].toString()).to.equal( + `token=${refreshToken}&client_id=${envConfig.cognitoClientIdStaff}` + ); + expect(axiosPostStub.firstCall.args[2]).to.matchPattern({ + timeout: 30000, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + }); + }); + + it('should successfully post refresh token to Cognito /oauth2/revoke for licensee', async () => { + const refreshToken = 'licensee-refresh-token'; + + authStorage.setItem(tokens.licensee.REFRESH_TOKEN, refreshToken); + + await revokeCognitoRefreshToken(AppModes.JCC, AuthTypes.LICENSEE); + + expect(axiosPostStub.calledOnce).to.equal(true); + expect(axiosPostStub.firstCall.args[0]).to.equal(`${envConfig.cognitoAuthDomainLicensee}/oauth2/revoke`); + expect(axiosPostStub.firstCall.args[1].toString()).to.equal( + `token=${refreshToken}&client_id=${envConfig.cognitoClientIdLicensee}` + ); + }); + + it('should successfully use cosmetology staff cognito config when app mode is cosmetology', async () => { + authStorage.setItem(tokens.staff.REFRESH_TOKEN, 'cosmo-refresh-token'); + + await revokeCognitoRefreshToken(AppModes.COSMETOLOGY, AuthTypes.STAFF); + + expect(axiosPostStub.calledOnce).to.equal(true); + expect(axiosPostStub.firstCall.args[0]).to.equal(`${envConfig.cognitoAuthDomainStaffCosmo}/oauth2/revoke`); + expect(axiosPostStub.firstCall.args[1].toString()).to.contain( + `client_id=${envConfig.cognitoClientIdStaffCosmo}` + ); + }); + + it('should successfully no-op when refresh token is missing', async () => { + await revokeCognitoRefreshToken(AppModes.JCC, AuthTypes.STAFF); + + expect(axiosPostStub.called).to.equal(false); + }); + + it('should successfully retry retryable revoke failures then succeed', async () => { + const networkError = new Error('network'); + + axiosPostStub.onCall(0).rejects(networkError); + axiosPostStub.onCall(1).rejects(networkError); + axiosPostStub.onCall(2).resolves({ data: {}}); + + authStorage.setItem(tokens.staff.REFRESH_TOKEN, 'staff-refresh-token'); + + await revokeCognitoRefreshToken(AppModes.JCC, AuthTypes.STAFF); + + expect(axiosPostStub.callCount).to.equal(3); + }); + + it('should successfully throw after exhausting retryable revoke attempts', async () => { + const networkError = new Error('network'); + let didThrow = false; + + axiosPostStub.rejects(networkError); + authStorage.setItem(tokens.staff.REFRESH_TOKEN, 'staff-refresh-token'); + + await revokeCognitoRefreshToken(AppModes.JCC, AuthTypes.STAFF).catch(() => { + didThrow = true; + }); + + expect(didThrow).to.equal(true); + expect(axiosPostStub.callCount).to.equal(3); + }); + + it('should successfully not retry non-retryable revoke failures', async () => { + const clientError = Object.assign(new Error('bad request'), { response: { status: 400 }}); + let didThrow = false; + + axiosPostStub.rejects(clientError); + authStorage.setItem(tokens.staff.REFRESH_TOKEN, 'staff-refresh-token'); + + await revokeCognitoRefreshToken(AppModes.JCC, AuthTypes.STAFF).catch(() => { + didThrow = true; + }); + + expect(didThrow).to.equal(true); + expect(axiosPostStub.callCount).to.equal(1); + }); +}); diff --git a/webroot/src/utils/auth.ts b/webroot/src/utils/auth.ts index c87ee401e0..7e48e26e3f 100644 --- a/webroot/src/utils/auth.ts +++ b/webroot/src/utils/auth.ts @@ -10,6 +10,7 @@ import sessionStorage from '@store/session.storage'; import localStorage from '@store/local.storage'; import { v4 as uuidv4 } from 'uuid'; import moment from 'moment'; +import axios from 'axios'; // ==================== // = Auth storage = @@ -217,6 +218,51 @@ export const getHostedLoginUri = (appMode: AppModes, authType: AuthTypes, hosted return loginUri; }; +// =========================== +// = Token Revocation = +// =========================== +// https://docs.aws.amazon.com/cognito/latest/developerguide/revocation-endpoint.html +const REVOKE_TIMEOUT_MS = 30000; +const REVOKE_MAX_ATTEMPTS = 3; + +const isRetryableRevokeError = (err: any): boolean => { + const status = err?.response?.status; + + return !status || status >= 500; +}; + +export const revokeCognitoRefreshToken = async (appMode: AppModes, authType: AuthTypes): Promise => { + const { clientId, authDomain } = getCognitoConfig(appMode, authType); + const refreshToken = authStorage.getItem(tokens[authType]?.REFRESH_TOKEN); + + if (clientId && authDomain && refreshToken) { + const params = new URLSearchParams(); + + params.append('token', refreshToken); + params.append('client_id', clientId); + + const postRevoke = (attempt = 1): Promise => axios.post( + `${authDomain}/oauth2/revoke`, + params, + { + timeout: REVOKE_TIMEOUT_MS, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + } + ).catch((err) => { + if (attempt < REVOKE_MAX_ATTEMPTS && isRetryableRevokeError(err)) { + return postRevoke(attempt + 1); + } + + return Promise.reject(err); + }); + + await postRevoke(); + } +}; + // ==================== // = Auto logout = // ==================== @@ -248,6 +294,7 @@ export default { licenseeLoginScopes, getCognitoConfig, getHostedLoginUri, + revokeCognitoRefreshToken, createAuthCsrfState, consumeAuthCsrfState, createPkceChallenge, diff --git a/webroot/tests/helpers/setup.ts b/webroot/tests/helpers/setup.ts index 6f7c61c44c..83dc1f1161 100644 --- a/webroot/tests/helpers/setup.ts +++ b/webroot/tests/helpers/setup.ts @@ -47,16 +47,21 @@ const cancelAnimationFrameStub = (id: number) => clearTimeout(id as unknown as N (global as any).cancelAnimationFrame = cancelAnimationFrameStub; // Polyfill matchMedia() for tests -window.matchMedia = sinon.stub().callsFake((query) => ({ +// Use plain functions (not sinon spies) so each matchMedia call does not accumulate sandbox fakes. +const matchMediaListener = { + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, +}; + +window.matchMedia = (query) => ({ matches: false, media: query, onchange: null, - addListener: sinon.spy(), - removeListener: sinon.spy(), - addEventListener: sinon.spy(), - removeEventListener: sinon.spy(), - dispatchEvent: sinon.spy(), -})); + ...matchMediaListener, +}); // Polyfill WebCrypto SubtleCrypto for tests (jsdom does not implement crypto.subtle, used for PKCE hashing) try { @@ -131,6 +136,9 @@ const failTestOn = (errorWatchList: Array) => { // // Mocha setup / teardown methods // +// Recreated in beforeEach after sinon.restore() so $api stubs stay valid across tests +let mockApi = sinon.createStubInstance(DataApi); + beforeEach(() => { const { tm: $tm, t: $t } = i18n.global; @@ -154,6 +162,34 @@ beforeEach(() => { // Ensure tests fail on what would otherwise just be vue-test-utils console output failTestOn(['Vue warn', 'unhandledRejection']); + + // Fresh stub instance each test — sinon.restore() in afterEach resets createStubInstance fakes + mockApi = sinon.createStubInstance(DataApi); +}); + +// Track wrappers mounted via helpers so they can be torn down between tests, preventing leftover components from reacting to shared-store changes (e.g. compact router-links). +const mountedWrappers: Array<{ unmount: () => void }> = []; +const trackWrapper = void }>(wrapper: T): T => { + mountedWrappers.push(wrapper); + + return wrapper; +}; + +const unmountAllWrappers = () => { + while (mountedWrappers.length) { + const wrapper = mountedWrappers.pop(); + + try { + wrapper?.unmount(); + } catch (err) { + // Ignore already-unmounted wrappers + } + } +}; + +afterEach(() => { + unmountAllWrappers(); + sinon.restore(); }); // Trap when Mocha stumbles on promises @@ -163,9 +199,6 @@ beforeEach(() => { } }); -// Create stub instance of mock API -const mockApi = sinon.createStubInstance(DataApi); - /** * Shallow-mount a component with mocks. * @param {Component} component The Vue component. @@ -213,7 +246,7 @@ const mountShallow = async (component, mountConfig: any = {}) => { // await router.isReady(); - return shallowMount(component, config); + return trackWrapper(shallowMount(component, config)); }; /** @@ -263,7 +296,7 @@ const mountFull = async (component, mountConfig: any = {}) => { // await router.isReady(); - return mount(component, config); + return trackWrapper(mount(component, config)); }; export {