From 85fa1e4cfd08891712b2912a76f402d7d31e6369 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Tue, 28 Jul 2026 09:38:42 -0600 Subject: [PATCH 1/5] Update postcss dep --- webroot/yarn.lock | 36 ++++-------------------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/webroot/yarn.lock b/webroot/yarn.lock index 6e26d796e..e3bd5188b 100644 --- a/webroot/yarn.lock +++ b/webroot/yarn.lock @@ -8912,16 +8912,6 @@ nanoid@3.1.20, nanoid@^3.1.31: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== -nanoid@^3.3.11: - version "3.3.11" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" - integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== - -nanoid@^3.3.12: - version "3.3.12" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05" - integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== - nanoid@^3.3.16: version "3.3.16" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" @@ -9979,28 +9969,10 @@ postcss@^7.0.0, postcss@^7.0.14, postcss@^7.0.2, postcss@^7.0.21, postcss@^7.0.2 picocolors "^0.2.1" source-map "^0.6.1" -postcss@^8.2.6, postcss@^8.3.5, postcss@^8.4.33, postcss@^8.4.38: - version "8.5.12" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.12.tgz#cd0c0f667f7cb0521e2313234ea6e707a9ec1ddb" - integrity sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA== - dependencies: - nanoid "^3.3.11" - picocolors "^1.1.1" - source-map-js "^1.2.1" - -postcss@^8.5.15: - version "8.5.15" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c" - integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== - dependencies: - nanoid "^3.3.12" - picocolors "^1.1.1" - source-map-js "^1.2.1" - -postcss@^8.5.16: - version "8.5.22" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.22.tgz#086a0d5685a715acf5950ebbcb1538faf3051697" - integrity sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ== +postcss@^8.2.6, postcss@^8.3.5, postcss@^8.4.33, postcss@^8.4.38, postcss@^8.5.15, postcss@^8.5.16: + version "8.5.24" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.24.tgz#01d8b032451e1b9ec41ae66eaf02843f42a720d2" + integrity sha512-8RyVklq0owXUTa4xlpzu4l9AaVKIdQvAcOHZWaMh98HgySsUtxRVf/chRe3dsSLqb6i40BzGRzEUddRaI+9TSw== dependencies: nanoid "^3.3.16" picocolors "^1.1.1" From ef2b43e4ace8292dbebf184cc95d40cba28b2700 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Wed, 29 Jul 2026 16:35:23 -0600 Subject: [PATCH 2/5] Revoke tokens --- .../Page/PageContainer/PageContainer.ts | 2 + webroot/src/locales/en.json | 1 + webroot/src/locales/es.json | 1 + webroot/src/pages/Logout/Logout.less | 13 +++ webroot/src/pages/Logout/Logout.spec.ts | 89 +++++++++++++++++++ webroot/src/pages/Logout/Logout.ts | 17 +++- webroot/src/pages/Logout/Logout.vue | 5 +- .../PrivilegePurchase/PrivilegePurchase.ts | 4 +- webroot/src/store/user/user.actions.ts | 10 +-- webroot/src/store/user/user.spec.ts | 2 +- webroot/src/utils/auth.spec.ts | 86 ++++++++++++++++++ webroot/src/utils/auth.ts | 25 ++++++ webroot/tests/helpers/setup.ts | 28 +++++- 13 files changed, 267 insertions(+), 16 deletions(-) create mode 100644 webroot/src/pages/Logout/Logout.spec.ts create mode 100644 webroot/src/utils/auth.spec.ts diff --git a/webroot/src/components/Page/PageContainer/PageContainer.ts b/webroot/src/components/Page/PageContainer/PageContainer.ts index be5cb1a40..adab9cf2c 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 578f92ebe..fbe62672a 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 c8ddf62d0..d36da7cb3 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/Logout/Logout.less b/webroot/src/pages/Logout/Logout.less index 085cc685e..ea28df301 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 000000000..cfa320dde --- /dev/null +++ b/webroot/src/pages/Logout/Logout.spec.ts @@ -0,0 +1,89 @@ +// +// 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'; + +const chaiMatchPattern = require('chai-match-pattern'); +const chai = require('chai').use(chaiMatchPattern); + +const { expect } = chai; + +describe('Logout page', async () => { + let logoutStub; + + beforeEach(() => { + // Prevent created() from running real logout (clears shared store / redirects) + logoutStub = sinon.stub(Logout.methods, 'logout').resolves(); + }); + + afterEach(() => { + logoutStub.restore(); + }); + + 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 errors inside revokeTokens', async () => { + const wrapper = await mountShallow(Logout); + const component = wrapper.vm; + const axiosPostStub = sinon.stub(axios, 'post').rejects(new Error('network')); + let didThrow = false; + + authStorage.setItem(tokens.staff.REFRESH_TOKEN, 'staff-refresh-token'); + + try { + await component.revokeTokens(AuthTypes.STAFF); + } catch (err) { + didThrow = true; + } + + expect(didThrow).to.equal(false); + + axiosPostStub.restore(); + authStorage.removeItem(tokens.staff.REFRESH_TOKEN); + }); +}); diff --git a/webroot/src/pages/Logout/Logout.ts b/webroot/src/pages/Logout/Logout.ts index 1d6c7900c..9069ab6bb 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 { // @@ -120,9 +124,18 @@ export default class Logout extends Vue { this.unsetAnalyticsUser(); // Not awaiting analytics so it doesn't block other critical steps this.stashWorkingUri(); this.$store.dispatch('user/clearRefreshTokenTimeout'); + await this.revokeTokens(authType); await this.$store.dispatch('user/logoutRequest', authType); } + async revokeTokens(authType: AuthTypes): Promise { + try { + await revokeCognitoRefreshToken(this.appMode, authType); + } catch (err) { + // Continue — do not block cookie logout / local cleanup if revoke fails + } + } + async unsetAnalyticsUser(): Promise { try { await this.$analytics.updateUserAsync({}); diff --git a/webroot/src/pages/Logout/Logout.vue b/webroot/src/pages/Logout/Logout.vue index aba66dd81..08a25430b 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 44b921f25..9423dad8d 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/store/user/user.actions.ts b/webroot/src/store/user/user.actions.ts index 98feb4693..54dbb1451 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 21bd4c0b3..bb52f9fd3 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 000000000..9f42f5b6d --- /dev/null +++ b/webroot/src/utils/auth.spec.ts @@ -0,0 +1,86 @@ +// +// 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; + + beforeEach(() => { + axiosPostStub = sinon.stub(axios, 'post').resolves({ data: {}}); + authStorage.removeItem(tokens.staff.REFRESH_TOKEN); + authStorage.removeItem(tokens.licensee.REFRESH_TOKEN); + }); + + afterEach(() => { + axiosPostStub.restore(); + 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({ + 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.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); + }); +}); diff --git a/webroot/src/utils/auth.ts b/webroot/src/utils/auth.ts index c87ee401e..bcb92cdd3 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,29 @@ export const getHostedLoginUri = (appMode: AppModes, authType: AuthTypes, hosted return loginUri; }; +// =========================== +// = Token Revocation = +// =========================== +// https://docs.aws.amazon.com/cognito/latest/developerguide/revocation-endpoint.html +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); + + await axios.post(`${authDomain}/oauth2/revoke`, params, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + }); + } +}; + // ==================== // = Auto logout = // ==================== @@ -248,6 +272,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 6f7c61c44..378218fbc 100644 --- a/webroot/tests/helpers/setup.ts +++ b/webroot/tests/helpers/setup.ts @@ -156,6 +156,30 @@ beforeEach(() => { failTestOn(['Vue warn', 'unhandledRejection']); }); +// 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(); +}); + // Trap when Mocha stumbles on promises (process as any).on('unhandledRejection', (err) => { if (err) { @@ -213,7 +237,7 @@ const mountShallow = async (component, mountConfig: any = {}) => { // await router.isReady(); - return shallowMount(component, config); + return trackWrapper(shallowMount(component, config)); }; /** @@ -263,7 +287,7 @@ const mountFull = async (component, mountConfig: any = {}) => { // await router.isReady(); - return mount(component, config); + return trackWrapper(mount(component, config)); }; export { From 1ac849c9bb644d897b0ae7ccf597420eb3177d84 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Thu, 30 Jul 2026 12:02:32 -0600 Subject: [PATCH 3/5] Revoke retry + logging --- webroot/src/pages/Logout/Logout.spec.ts | 34 +++++++-- webroot/src/pages/Logout/Logout.ts | 29 ++++--- webroot/src/plugins/Statsig/statsig.plugin.ts | 3 + webroot/src/utils/auth.spec.ts | 75 +++++++++++++++++++ webroot/src/utils/auth.ts | 32 ++++++-- webroot/tests/helpers/setup.ts | 29 ++++--- 6 files changed, 171 insertions(+), 31 deletions(-) diff --git a/webroot/src/pages/Logout/Logout.spec.ts b/webroot/src/pages/Logout/Logout.spec.ts index cfa320dde..5e493d4ec 100644 --- a/webroot/src/pages/Logout/Logout.spec.ts +++ b/webroot/src/pages/Logout/Logout.spec.ts @@ -10,6 +10,7 @@ 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); @@ -18,14 +19,25 @@ 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 () => { @@ -67,23 +79,33 @@ describe('Logout page', async () => { revokeStub.restore(); }); - it('should successfully swallow errors inside revokeTokens', async () => { + 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'); - try { - await component.revokeTokens(AuthTypes.STAFF); - } catch (err) { + 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'); + 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(); - authStorage.removeItem(tokens.staff.REFRESH_TOKEN); + logEventStub.restore(); }); }); diff --git a/webroot/src/pages/Logout/Logout.ts b/webroot/src/pages/Logout/Logout.ts index 9069ab6bb..52e25e0af 100644 --- a/webroot/src/pages/Logout/Logout.ts +++ b/webroot/src/pages/Logout/Logout.ts @@ -39,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; } @@ -121,27 +125,32 @@ 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 { - try { - await revokeCognitoRefreshToken(this.appMode, authType); - } catch (err) { - // Continue — do not block cookie logout / local cleanup if revoke fails - } + await revokeCognitoRefreshToken(this.appMode, authType).catch((err) => Promise.resolve().then(() => { + 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/plugins/Statsig/statsig.plugin.ts b/webroot/src/plugins/Statsig/statsig.plugin.ts index 8b5fcebb9..5b8424ce9 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/utils/auth.spec.ts b/webroot/src/utils/auth.spec.ts index 9f42f5b6d..621679fca 100644 --- a/webroot/src/utils/auth.spec.ts +++ b/webroot/src/utils/auth.spec.ts @@ -23,15 +23,41 @@ 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); }); @@ -49,12 +75,14 @@ describe('auth utils', () => { `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'; @@ -68,19 +96,66 @@ describe('auth utils', () => { `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 bcb92cdd3..7e48e26e3 100644 --- a/webroot/src/utils/auth.ts +++ b/webroot/src/utils/auth.ts @@ -222,6 +222,15 @@ export const getHostedLoginUri = (appMode: AppModes, authType: AuthTypes, hosted // = 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); @@ -232,12 +241,25 @@ export const revokeCognitoRefreshToken = async (appMode: AppModes, authType: Aut params.append('token', refreshToken); params.append('client_id', clientId); - await axios.post(`${authDomain}/oauth2/revoke`, params, { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Accept: 'application/json', - }, + 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(); } }; diff --git a/webroot/tests/helpers/setup.ts b/webroot/tests/helpers/setup.ts index 378218fbc..83dc1f116 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,9 @@ 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). @@ -178,6 +189,7 @@ const unmountAllWrappers = () => { afterEach(() => { unmountAllWrappers(); + sinon.restore(); }); // Trap when Mocha stumbles on promises @@ -187,9 +199,6 @@ afterEach(() => { } }); -// Create stub instance of mock API -const mockApi = sinon.createStubInstance(DataApi); - /** * Shallow-mount a component with mocks. * @param {Component} component The Vue component. From a89244149365c8c7d24cbd410a7cd31272bd2b83 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Mon, 3 Aug 2026 15:39:55 -0600 Subject: [PATCH 4/5] Test improvement --- .../pages/AuthCallback/_mixins/mixins.spec.ts | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/webroot/src/pages/AuthCallback/_mixins/mixins.spec.ts b/webroot/src/pages/AuthCallback/_mixins/mixins.spec.ts index 7c5742a57..e6af467e3 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); From 01c0b7beb75b1d40ca2ae242911f053eb5418d64 Mon Sep 17 00:00:00 2001 From: John Sandoval Date: Fri, 7 Aug 2026 11:16:51 -0600 Subject: [PATCH 5/5] PR review feedback --- webroot/src/pages/Logout/Logout.spec.ts | 2 +- webroot/src/pages/Logout/Logout.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/webroot/src/pages/Logout/Logout.spec.ts b/webroot/src/pages/Logout/Logout.spec.ts index 5e493d4ec..d3a030298 100644 --- a/webroot/src/pages/Logout/Logout.spec.ts +++ b/webroot/src/pages/Logout/Logout.spec.ts @@ -94,7 +94,7 @@ describe('Logout page', async () => { expect(didThrow).to.equal(false); expect(logEventStub.calledOnce).to.equal(true); - expect(logEventStub.firstCall.args[0]).to.equal('cognito_token_revoke_failed'); + 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, diff --git a/webroot/src/pages/Logout/Logout.ts b/webroot/src/pages/Logout/Logout.ts index 52e25e0af..faff91725 100644 --- a/webroot/src/pages/Logout/Logout.ts +++ b/webroot/src/pages/Logout/Logout.ts @@ -134,6 +134,7 @@ export default class Logout extends Vue { 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,