From 76b4c35d59f1d217af066851796feee23068fc2b Mon Sep 17 00:00:00 2001 From: Matt Sykes Date: Wed, 10 Jun 2026 15:48:29 -0400 Subject: [PATCH] Prevent error redirect loops with beforeEach circuit breaker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This adds a circuit breaker for uncaught failures in beforeEach. The first failure still goes through the normal ERROR/RESTART recovery path; a second consecutive failure redirects to the static error page instead of looping. - New before-each-error-recovery module tracks recovery attempts in sessionStorage (with an in-memory fallback). - ERROR/RESTART routes skip normal guard logic so the counter can accumulate across bounce-backs. - The counter clears only after a fully successful navigation. - redirectToStaticErrorPage centralizes hard-error bailout: logs, clears poisoned sessionStorage keys (submittedState, heritage redirect count, recovery count, externalParameterState), then navigates to /fmg/static/error. - The static error page RESTART flow clears the same sessionStorage keys so bad state does not re-enter the funnel. - Session storage key strings are centralized in session-storage.js. - Unit tests cover recovery counting, bailout clearing, and navigation fallbacks. Expected impact: Stops repeated ERROR → RESTART → ERROR cycles (including corrupt submittedState JSON.parse failures and recurring automation errors in us-east-1/us-east-2) by bailing out to the static error page after one failed recovery attempt. --- public/static/error/index.html | 26 ++ src/constants/session-storage.js | 4 + .../methods/before-each-error-recovery.js | 51 ++++ .../before-each-error-recovery.spec.js | 80 ++++++ src/router/methods/before-each.js | 41 ++- src/router/methods/before-each.spec.js | 257 ++++++++++++++++++ src/router/methods/error.js | 35 ++- src/router/methods/error.spec.js | 125 ++++++++- src/router/methods/route-logic/error.js | 2 + src/store/index.js | 15 +- 10 files changed, 625 insertions(+), 11 deletions(-) create mode 100644 src/router/methods/before-each-error-recovery.js create mode 100644 src/router/methods/before-each-error-recovery.spec.js create mode 100644 src/router/methods/before-each.spec.js diff --git a/public/static/error/index.html b/public/static/error/index.html index 53bb9fc08..da15bd7a0 100644 --- a/public/static/error/index.html +++ b/public/static/error/index.html @@ -258,10 +258,36 @@ body { return match?.value; } + // Refactor opportunity: sessionStorage keys and cookie names below are duplicated from the + // Vue app because this static page is not webpack-bundled. Canonical sources: + // - src/constants/session-storage.js (sessionStorageKeyConstants) + // - src/constants/cookie-names.js (cookieNames; env-suffixed funnel cookies) + // - src/router/methods/error.js (clearBailoutSessionStorage — keep in sync on bailout keys) + // Options: build-time inject into this file, shared plain JS under public/static/, or generate + // from constants during CI. Until then, update all three places when keys change. function clearApplicationData() { window.localStorage.removeItem('vuex'); + // Keys match sessionStorageKeyConstants in src/constants/session-storage.js and + // clearBailoutSessionStorage() in src/router/methods/error.js — keep in sync. + const sessionStorageKeysToClear = [ + 'submittedState', + 'heritageSuppressRedirectCount', + 'beforeEachErrorRecoveryCount', + 'externalParameterState', + ]; + // Clear sessionStorage keys that can re-poison the funnel on re-entry. + sessionStorageKeysToClear.forEach((key) => { + try { + window.sessionStorage.removeItem(key); + } catch { + // sessionStorage unavailable + } + }); + const environmentData = getCurrentEnvironmentData(); + // Funnel cookie prefixes match cookie-names.js; suffix is environmentData.name here + // (equivalent to applicationConfig.CURRENT_ENVIRONMENT in the Vue app). const cookieNames = [ `FunnelUserId-${environmentData?.name}`, `FunnelSessionKey-${environmentData?.name}`, diff --git a/src/constants/session-storage.js b/src/constants/session-storage.js index 75cd1b578..9481c53d8 100644 --- a/src/constants/session-storage.js +++ b/src/constants/session-storage.js @@ -2,4 +2,8 @@ export const sessionStorageKeyConstants = { SUBMITTED_STATE: "submittedState", /** Counts SuppressConceptFunnel → heritage redirects to avoid an infinite loop in one tab session. */ HERITAGE_SUPPRESS_REDIRECT_COUNT: "heritageSuppressRedirectCount", + /** Counts beforeEach uncaught-exception recovery attempts to avoid an infinite ERROR redirect loop. */ + BEFORE_EACH_ERROR_RECOVERY_COUNT: "beforeEachErrorRecoveryCount", + /** Persists deep-link / querystring funnel entry state across navigations. */ + EXTERNAL_PARAMETER_STATE: "externalParameterState", }; diff --git a/src/router/methods/before-each-error-recovery.js b/src/router/methods/before-each-error-recovery.js new file mode 100644 index 000000000..b925c510f --- /dev/null +++ b/src/router/methods/before-each-error-recovery.js @@ -0,0 +1,51 @@ +import { sessionStorageKeyConstants } from "@/constants/session-storage"; + +const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT; + +/** Used when sessionStorage is missing, throws, or does not persist (e.g. some bots). */ +let inMemoryRecoveryCount = 0; + +export function getBeforeEachErrorRecoveryCount() { + try { + const stored = window.sessionStorage?.getItem(recoveryKey); + if (stored !== null && stored !== undefined) { + return Number(stored) || 0; + } + } catch { + // sessionStorage unavailable; use in-memory fallback + } + + return inMemoryRecoveryCount; +} + +export function incrementBeforeEachErrorRecoveryCount() { + const nextCount = getBeforeEachErrorRecoveryCount() + 1; + inMemoryRecoveryCount = nextCount; + + try { + window.sessionStorage?.setItem(recoveryKey, String(nextCount)); + } catch { + // in-memory count already updated + } + + return nextCount; +} + +export function clearBeforeEachErrorRecoveryCount() { + inMemoryRecoveryCount = 0; + + try { + window.sessionStorage?.removeItem(recoveryKey); + } catch { + // in-memory count already cleared + } +} + +export function hasBeforeEachErrorRecoveryBeenAttempted() { + return getBeforeEachErrorRecoveryCount() >= 1; +} + +/** Test-only: resets in-memory and sessionStorage recovery state between unit tests. */ +export function resetBeforeEachErrorRecoveryStateForTests() { + clearBeforeEachErrorRecoveryCount(); +} diff --git a/src/router/methods/before-each-error-recovery.spec.js b/src/router/methods/before-each-error-recovery.spec.js new file mode 100644 index 000000000..e916ff356 --- /dev/null +++ b/src/router/methods/before-each-error-recovery.spec.js @@ -0,0 +1,80 @@ +import { + clearBeforeEachErrorRecoveryCount, + getBeforeEachErrorRecoveryCount, + hasBeforeEachErrorRecoveryBeenAttempted, + incrementBeforeEachErrorRecoveryCount, + resetBeforeEachErrorRecoveryStateForTests, +} from "./before-each-error-recovery"; +import { sessionStorageKeyConstants } from "@/constants/session-storage"; + +describe("router/methods/before-each-error-recovery", () => { + const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT; + let originalSessionStorage; + + beforeEach(() => { + resetBeforeEachErrorRecoveryStateForTests(); + originalSessionStorage = window.sessionStorage; + }); + + afterEach(() => { + Object.defineProperty(window, "sessionStorage", { + configurable: true, + value: originalSessionStorage, + }); + resetBeforeEachErrorRecoveryStateForTests(); + }); + + it("reads and writes recovery count via sessionStorage when available", () => { + incrementBeforeEachErrorRecoveryCount(); + + expect(window.sessionStorage.getItem(recoveryKey)).toBe("1"); + expect(getBeforeEachErrorRecoveryCount()).toBe(1); + expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true); + }); + + it("clears sessionStorage and in-memory count", () => { + incrementBeforeEachErrorRecoveryCount(); + clearBeforeEachErrorRecoveryCount(); + + expect(window.sessionStorage.getItem(recoveryKey)).toBeNull(); + expect(getBeforeEachErrorRecoveryCount()).toBe(0); + expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(false); + }); + + it("uses in-memory count when sessionStorage getItem throws", () => { + Object.defineProperty(window, "sessionStorage", { + configurable: true, + value: { + getItem: () => { + throw new Error("sessionStorage blocked"); + }, + setItem: jest.fn(), + removeItem: jest.fn(), + }, + }); + + incrementBeforeEachErrorRecoveryCount(); + incrementBeforeEachErrorRecoveryCount(); + + expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true); + expect(getBeforeEachErrorRecoveryCount()).toBe(2); + }); + + it("uses in-memory count when sessionStorage setItem throws", () => { + Object.defineProperty(window, "sessionStorage", { + configurable: true, + value: { + getItem: () => null, + setItem: () => { + throw new Error("sessionStorage blocked"); + }, + removeItem: jest.fn(), + }, + }); + + incrementBeforeEachErrorRecoveryCount(); + + expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true); + expect(getBeforeEachErrorRecoveryCount()).toBe(1); + }); +}); diff --git a/src/router/methods/before-each.js b/src/router/methods/before-each.js index 568cf312c..84e653551 100644 --- a/src/router/methods/before-each.js +++ b/src/router/methods/before-each.js @@ -9,7 +9,16 @@ import { routeData, FUNNEL_START_PAGE } from "@/router/constants/routes"; import { runExperiments } from "@/router/methods/helpers/run-experiments"; import { navigateToHeritageFunnel } from "@/helpers/heritage-integration/navigation-helper"; -import { handleSoftError, handleHardError } from "@/router/methods/error"; +import { + handleSoftError, + handleHardError, + redirectToStaticErrorPage, +} from "@/router/methods/error"; +import { + clearBeforeEachErrorRecoveryCount, + hasBeforeEachErrorRecoveryBeenAttempted, + incrementBeforeEachErrorRecoveryCount, +} from "@/router/methods/before-each-error-recovery"; import { checkPagePrerequisites } from "@/router/methods/page-prerequisites"; import { checkLogParam } from "@/helpers/debug-log-helper"; import { debugLog } from "@/helpers/debug-log-helper"; @@ -19,8 +28,21 @@ import router from "@/router"; const MAX_HERITAGE_SUPPRESS_REDIRECTS_PER_SESSION = 5; +function isRecoveryRoute(routeName) { + return routeName === routeData.ERROR.name || routeName === routeData.RESTART.name; +} + export async function beforeEach(to, from) { try { + if (isRecoveryRoute(to.name)) { + debugLog(`--- before-each.js recovery route ${to.name} ---`); + // Do NOT clear the recovery counter here. The counter must accumulate across + // consecutive failures that bounce through the ERROR/RESTART route so the + // circuit breaker can trip and fall back to the static error page. It is only + // cleared after a fully successful guard pass (end of try block). + return; + } + await analyticsMixin.methods.validateSession(); // prettier-ignore @@ -63,7 +85,7 @@ export async function beforeEach(to, from) { } } - // Ensure session has not expired + // Ensure session has not expired - not primary check for session expiration and uses FunnelSessionInfo-{environment} cookie. if (getFunnelCookie() !== null && !isSavedSessionStillActive()) { const errorPayload = { cause: "expired session", @@ -141,18 +163,31 @@ export async function beforeEach(to, from) { { debugLog("--- before-each.js end ---"); } + + clearBeforeEachErrorRecoveryCount(); } catch (error) { const errorPayload = { cause: "Uncaught exception in `beforeEach`.", currentPage: from?.name, nextPage: to?.name, + errorMessage: error?.message, + errorStack: error?.stack, }; console.log(error); + global.$logger?.logError("beforeEach uncaught exception", errorPayload); + + if (to.name === routeData.ERROR.name || hasBeforeEachErrorRecoveryBeenAttempted()) { + clearBeforeEachErrorRecoveryCount(); + await redirectToStaticErrorPage(errorPayload); + return false; + } + + incrementBeforeEachErrorRecoveryCount(); // Eject user from Vue app in this scenario and clear localstorage. await handleHardError(errorPayload); - return; + return false; } } diff --git a/src/router/methods/before-each.spec.js b/src/router/methods/before-each.spec.js new file mode 100644 index 000000000..caac0ef6f --- /dev/null +++ b/src/router/methods/before-each.spec.js @@ -0,0 +1,257 @@ +import analyticsMixin from "@/mixins/analytics-mixin"; +import { runExperiments } from "@/router/methods/helpers/run-experiments"; +import { handleHardError, redirectToStaticErrorPage } from "@/router/methods/error"; +import { sessionStorageKeyConstants } from "@/constants/session-storage"; +import { routeData } from "@/router/constants/routes"; +import { debugLog } from "@/helpers/debug-log-helper"; + +jest.mock("@/mixins/analytics-mixin", () => ({ + __esModule: true, + default: { + methods: { + validateSession: jest.fn(() => Promise.resolve()), + }, + }, +})); + +jest.mock("@/router/methods/helpers/run-experiments", () => ({ + runExperiments: jest.fn(() => Promise.resolve()), +})); + +jest.mock("@/helpers/heritage-integration/cookie-helper", () => ({ + getFunnelCookie: jest.fn(() => null), + updateOrCreateFunnelCookie: jest.fn(), +})); + +jest.mock("@/helpers/heritage-integration/session-helper", () => ({ + isSavedSessionStillActive: jest.fn(() => true), + getDateForSavedSessionTimeout: jest.fn(() => "2099-01-01T00:00:00.000Z"), +})); + +jest.mock("@/helpers/heritage-integration/navigation-helper", () => ({ + navigateToHeritageFunnel: jest.fn(), +})); + +jest.mock("@/router/methods/helpers/handle-heritage-return", () => ({ + handleHeritageReturn: jest.fn(() => Promise.resolve()), +})); + +jest.mock("@/router/methods/page-prerequisites", () => ({ + checkPagePrerequisites: jest.fn(() => Promise.resolve(true)), +})); + +jest.mock("@/helpers/debug-log-helper", () => ({ + debugLog: jest.fn(), + checkLogParam: jest.fn(), +})); + +jest.mock("@/router/methods/error", () => ({ + handleSoftError: jest.fn(() => Promise.resolve()), + handleHardError: jest.fn(() => Promise.resolve()), + redirectToStaticErrorPage: jest.fn(() => Promise.resolve()), +})); + +jest.mock("@/router", () => ({ + __esModule: true, + default: { + push: jest.fn(), + }, +})); + +import { beforeEach as routerBeforeEach } from "./before-each"; +import { + hasBeforeEachErrorRecoveryBeenAttempted, + incrementBeforeEachErrorRecoveryCount, + resetBeforeEachErrorRecoveryStateForTests, +} from "./before-each-error-recovery"; + +describe("router/methods/before-each", () => { + const recoveryKey = sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT; + + beforeEach(() => { + jest.clearAllMocks(); + resetBeforeEachErrorRecoveryStateForTests(); + window.sessionStorage.removeItem(recoveryKey); + window.sessionStorage.removeItem(sessionStorageKeyConstants.SUBMITTED_STATE); + analyticsMixin.methods.validateSession.mockResolvedValue(undefined); + debugLog.mockImplementation(() => undefined); + global.$logger = { + logError: jest.fn(), + }; + }); + + it("skips validateSession and runExperiments when navigating to ERROR", async () => { + await routerBeforeEach({ name: routeData.ERROR.name }, { name: routeData.VEHICLE.name }); + + expect(analyticsMixin.methods.validateSession).not.toHaveBeenCalled(); + expect(runExperiments).not.toHaveBeenCalled(); + }); + + it("skips validateSession and runExperiments when navigating to RESTART", async () => { + await routerBeforeEach({ name: routeData.RESTART.name }, { name: routeData.VEHICLE.name }); + + expect(analyticsMixin.methods.validateSession).not.toHaveBeenCalled(); + expect(runExperiments).not.toHaveBeenCalled(); + }); + + it("clears recovery counter after successful funnel navigation", async () => { + incrementBeforeEachErrorRecoveryCount(); + expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true); + + await routerBeforeEach( + { name: routeData.VEHICLE_DAMAGE.name }, + { name: routeData.VEHICLE.name } + ); + + expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(false); + expect(window.sessionStorage.getItem(recoveryKey)).toBeNull(); + }); + + it("preserves recovery counter when passing through the ERROR recovery route", async () => { + incrementBeforeEachErrorRecoveryCount(); + expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true); + + await routerBeforeEach({ name: routeData.ERROR.name }, { name: routeData.VEHICLE.name }); + + // The counter must survive the ERROR bounce so consecutive failures can trip the + // circuit breaker; it is only cleared after a fully successful guard pass. + expect(hasBeforeEachErrorRecoveryBeenAttempted()).toBe(true); + expect(window.sessionStorage.getItem(recoveryKey)).toBe("1"); + }); + + it("runs validateSession on a normal funnel page", async () => { + await routerBeforeEach( + { name: routeData.VEHICLE_DAMAGE.name }, + { name: routeData.VEHICLE.name } + ); + + expect(analyticsMixin.methods.validateSession).toHaveBeenCalled(); + expect(runExperiments).toHaveBeenCalledWith(routeData.VEHICLE_DAMAGE.name); + }); + + it("calls handleHardError once on uncaught exception", async () => { + analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure")); + + const result = await routerBeforeEach( + { name: routeData.VEHICLE_DAMAGE.name }, + { name: routeData.VEHICLE.name } + ); + + expect(handleHardError).toHaveBeenCalledTimes(1); + expect(handleHardError).toHaveBeenCalledWith( + expect.objectContaining({ + cause: "Uncaught exception in `beforeEach`.", + errorMessage: "session failure", + errorStack: expect.any(String), + }) + ); + expect(global.$logger.logError).toHaveBeenCalledWith( + "beforeEach uncaught exception", + expect.objectContaining({ errorMessage: "session failure" }) + ); + expect(redirectToStaticErrorPage).not.toHaveBeenCalled(); + expect(window.sessionStorage.getItem(recoveryKey)).toBe("1"); + expect(result).toBe(false); + }); + + it("redirects to static error page when exception occurs on ERROR route", async () => { + debugLog.mockImplementation(() => { + throw new Error("recovery route failure"); + }); + + const result = await routerBeforeEach( + { name: routeData.ERROR.name }, + { name: routeData.VEHICLE.name } + ); + + expect(global.$logger.logError).toHaveBeenCalledWith( + "beforeEach uncaught exception", + expect.objectContaining({ + errorMessage: "recovery route failure", + nextPage: routeData.ERROR.name, + }) + ); + expect(redirectToStaticErrorPage).toHaveBeenCalledWith( + expect.objectContaining({ + cause: "Uncaught exception in `beforeEach`.", + errorMessage: "recovery route failure", + nextPage: routeData.ERROR.name, + }) + ); + expect(handleHardError).not.toHaveBeenCalled(); + expect(window.sessionStorage.getItem(recoveryKey)).toBeNull(); + expect(result).toBe(false); + }); + + it("redirects to static error page on second uncaught exception in session", async () => { + analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure")); + window.sessionStorage.setItem(recoveryKey, "1"); + + const result = await routerBeforeEach( + { name: routeData.VEHICLE_DAMAGE.name }, + { name: routeData.VEHICLE.name } + ); + + expect(global.$logger.logError).toHaveBeenCalledWith( + "beforeEach uncaught exception", + expect.objectContaining({ cause: "Uncaught exception in `beforeEach`." }) + ); + expect(redirectToStaticErrorPage).toHaveBeenCalled(); + expect(handleHardError).not.toHaveBeenCalled(); + expect(window.sessionStorage.getItem(recoveryKey)).toBeNull(); + expect(result).toBe(false); + }); + + it("redirects to static error page on second failure when sessionStorage throws", async () => { + analyticsMixin.methods.validateSession.mockRejectedValue(new Error("session failure")); + + const storageBlockedError = new Error("sessionStorage blocked"); + const underlyingStorage = window.sessionStorage; + Object.defineProperty(window, "sessionStorage", { + configurable: true, + value: { + getItem(key) { + if (key === recoveryKey) { + throw storageBlockedError; + } + return underlyingStorage.getItem(key); + }, + setItem(key, value) { + if (key === recoveryKey) { + throw storageBlockedError; + } + underlyingStorage.setItem(key, value); + }, + removeItem(key) { + if (key === recoveryKey) { + throw storageBlockedError; + } + underlyingStorage.removeItem(key); + }, + }, + }); + + await routerBeforeEach( + { name: routeData.VEHICLE_DAMAGE.name }, + { name: routeData.VEHICLE.name } + ); + expect(handleHardError).toHaveBeenCalledTimes(1); + expect(redirectToStaticErrorPage).not.toHaveBeenCalled(); + + jest.clearAllMocks(); + + const result = await routerBeforeEach( + { name: routeData.VEHICLE_DAMAGE.name }, + { name: routeData.VEHICLE.name } + ); + + expect(redirectToStaticErrorPage).toHaveBeenCalled(); + expect(handleHardError).not.toHaveBeenCalled(); + expect(result).toBe(false); + + Object.defineProperty(window, "sessionStorage", { + configurable: true, + value: underlyingStorage, + }); + }); +}); diff --git a/src/router/methods/error.js b/src/router/methods/error.js index 15cabb9a0..031f2367b 100644 --- a/src/router/methods/error.js +++ b/src/router/methods/error.js @@ -3,9 +3,41 @@ import { routeData } from "@/router/constants/routes"; import router from "@/router"; import store from "@/store"; import { storeActions } from "@/constants/store-actions"; +import { sessionStorageKeyConstants } from "@/constants/session-storage"; import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; +import { clearBeforeEachErrorRecoveryCount } from "@/router/methods/before-each-error-recovery"; import experimentMixin from "../../mixins/experiment-mixin"; +const STATIC_ERROR_PAGE_PATH = "/fmg/static/error"; + +/** Clears sessionStorage keys that can re-poison the funnel after a bailout. */ +export function clearBailoutSessionStorage() { + clearBeforeEachErrorRecoveryCount(); + + try { + window.sessionStorage?.removeItem(sessionStorageKeyConstants.SUBMITTED_STATE); + window.sessionStorage?.removeItem( + sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT + ); + window.sessionStorage?.removeItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE); + } catch { + // sessionStorage unavailable + } +} + +export async function redirectToStaticErrorPage(errorPayload) { + await logErrorToDataLayerAndLogger("redirectToStaticErrorPage", errorPayload, null); + clearBailoutSessionStorage(); + + // Prefer top-level navigation so the static page loads outside any iframe wrapper. + // Assigning window.top.location throws SecurityError when cross-origin; fall back to this frame. + try { + window.top.location = STATIC_ERROR_PAGE_PATH; + } catch { + window.location = STATIC_ERROR_PAGE_PATH; + } +} + export async function handleSoftError(errorPayload, forceRestart = false) { if (forceRestart) { await store.dispatch(storeActions.RESET_STATE); @@ -27,8 +59,7 @@ export async function handleHardError(errorPayload) { ); if (isInStaticErrorExperiment) { - logErrorToDataLayerAndLogger("handleHardError", errorPayload, null); - window.top.location = "/fmg/static/error"; + await redirectToStaticErrorPage(errorPayload); return; } else { await handleSoftError(errorPayload, true); diff --git a/src/router/methods/error.spec.js b/src/router/methods/error.spec.js index c48d63fda..262347fc6 100644 --- a/src/router/methods/error.spec.js +++ b/src/router/methods/error.spec.js @@ -1,10 +1,11 @@ -import { handleSoftError, handleHardError } from "./error"; +import { handleSoftError, handleHardError, redirectToStaticErrorPage } from "./error"; import router from "@/router"; import store from "@/store"; import analyticsMixin from "@/mixins/analytics-mixin"; import experimentMixin from "../../mixins/experiment-mixin"; import { deleteFunnelCookie } from "@/helpers/heritage-integration/cookie-helper"; import { storeActions } from "@/constants/store-actions"; +import { sessionStorageKeyConstants } from "@/constants/session-storage"; import { routeData } from "@/router/constants/routes"; jest.mock("@/router", () => ({ @@ -98,6 +99,126 @@ describe("router/methods/error", () => { }); }); + describe("redirectToStaticErrorPage", () => { + let topLocationValue; + + beforeEach(() => { + topLocationValue = undefined; + Object.defineProperty(window, "top", { + configurable: true, + value: {}, + }); + Object.defineProperty(window.top, "location", { + configurable: true, + set(value) { + topLocationValue = value; + }, + get() { + return topLocationValue; + }, + }); + }); + + it("logs and sets static error location", async () => { + await redirectToStaticErrorPage(errorPayload); + + expect(analyticsMixin.methods.pushPageErrorToDataLayer).toHaveBeenCalledWith( + errorPayload + ); + expect(global.$logger.logError).toHaveBeenCalledWith( + "redirectToStaticErrorPage: test cause", + errorPayload + ); + expect(topLocationValue).toBe("/fmg/static/error"); + }); + + it("clears bailout sessionStorage keys before navigating away", async () => { + window.sessionStorage.setItem(sessionStorageKeyConstants.SUBMITTED_STATE, "not-json"); + window.sessionStorage.setItem( + sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT, + "3" + ); + window.sessionStorage.setItem( + sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT, + "1" + ); + window.sessionStorage.setItem( + sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE, + "{}" + ); + + await redirectToStaticErrorPage(errorPayload); + + expect( + window.sessionStorage.getItem(sessionStorageKeyConstants.SUBMITTED_STATE) + ).toBeNull(); + expect( + window.sessionStorage.getItem( + sessionStorageKeyConstants.HERITAGE_SUPPRESS_REDIRECT_COUNT + ) + ).toBeNull(); + expect( + window.sessionStorage.getItem( + sessionStorageKeyConstants.BEFORE_EACH_ERROR_RECOVERY_COUNT + ) + ).toBeNull(); + expect( + window.sessionStorage.getItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE) + ).toBeNull(); + }); + + it("falls back to window.location when window.top.location setter throws", async () => { + let windowLocationValue; + Object.defineProperty(window, "location", { + configurable: true, + set(value) { + windowLocationValue = value; + }, + get() { + return windowLocationValue; + }, + }); + Object.defineProperty(window.top, "location", { + configurable: true, + set() { + throw new Error("cross-origin frame"); + }, + get() { + return undefined; + }, + }); + + await redirectToStaticErrorPage(errorPayload); + + expect(windowLocationValue).toBe("/fmg/static/error"); + expect(topLocationValue).toBeUndefined(); + }); + + it("awaits logging before navigating away", async () => { + const callOrder = []; + analyticsMixin.methods.pushPageErrorToDataLayer.mockImplementation(() => { + callOrder.push("datalayer"); + }); + global.$logger.logError.mockImplementation(() => { + callOrder.push("logger"); + }); + Object.defineProperty(window.top, "location", { + configurable: true, + set(value) { + callOrder.push("navigate"); + topLocationValue = value; + }, + get() { + return topLocationValue; + }, + }); + + await redirectToStaticErrorPage(errorPayload); + + expect(callOrder).toEqual(["datalayer", "logger", "navigate"]); + }); + }); + describe("handleHardError", () => { let topLocationValue; @@ -131,7 +252,7 @@ describe("router/methods/error", () => { errorPayload ); expect(global.$logger.logError).toHaveBeenCalledWith( - "handleHardError: test cause", + "redirectToStaticErrorPage: test cause", errorPayload ); expect(topLocationValue).toBe("/fmg/static/error"); diff --git a/src/router/methods/route-logic/error.js b/src/router/methods/route-logic/error.js index 162e5b914..93cfc3cff 100644 --- a/src/router/methods/route-logic/error.js +++ b/src/router/methods/route-logic/error.js @@ -25,6 +25,8 @@ export async function errorBeforeEnter(to, from) { // If we've already encountered one error, clear session to avoid more. // Otherwise mark that we encountered an error here. + // Note: an earlier breaker also runs in before-each.js via before-each-error-recovery.js + // (consecutive uncaught guard failures before this route handler runs). if (store.getters.applicationUser.hasAlreadyTriggeredError) { const errorPayload = { cause: "Successive errors triggered.", diff --git a/src/store/index.js b/src/store/index.js index 3f2cf5e0d..542f68e02 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -3733,7 +3733,7 @@ export const actions = { context.commit(storeMutations.UPDATE_EXPERIMENTS, experiments); //clear externalParameter session storage - window.sessionStorage.removeItem("externalParameterState"); + window.sessionStorage.removeItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE); //restore affiliate cookies context.commit(storeMutations.UPDATE_AFFILIATE_COOKIES, affiliateCookies); @@ -4447,14 +4447,21 @@ function createExternalParameterDefaultState() { saveExternalParameterState(externalParameterDefaultState); } function getExternalParameterDefaultState() { - const externalParameterState = window.sessionStorage.getItem("externalParameterState"); + const externalParameterState = window.sessionStorage.getItem( + sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE + ); if (externalParameterState === null) { createExternalParameterDefaultState(); } - return JSON.parse(window.sessionStorage.getItem("externalParameterState")); + return JSON.parse( + window.sessionStorage.getItem(sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE) + ); } function saveExternalParameterState(externalParameterState) { - window.sessionStorage.setItem("externalParameterState", JSON.stringify(externalParameterState)); + window.sessionStorage.setItem( + sessionStorageKeyConstants.EXTERNAL_PARAMETER_STATE, + JSON.stringify(externalParameterState) + ); } const timeSlotCallFlags = {