From 2752d77e73ba235841dedd2ad7ed8769a875ce21 Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Wed, 6 May 2026 11:35:30 -0700 Subject: [PATCH] fix(settings): handle invalid session token on sign out Because: - If the session token no longer exists on the server, sessionDestroy returns 401/errno 110 and the avatar-menu toast strands the user with stale local state. This commit: - Swallows errno 110 in Session.destroy() so local cleanup and redirect proceed. Other errors still surface the toast. - Adds tests for Session.destroy(). Closes #FXA-13685 --- .../fxa-settings/src/models/Session.test.ts | 88 +++++++++++++++++++ packages/fxa-settings/src/models/Session.ts | 11 ++- 2 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 packages/fxa-settings/src/models/Session.test.ts diff --git a/packages/fxa-settings/src/models/Session.test.ts b/packages/fxa-settings/src/models/Session.test.ts new file mode 100644 index 00000000000..1cd44ac4d3e --- /dev/null +++ b/packages/fxa-settings/src/models/Session.test.ts @@ -0,0 +1,88 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Session } from './Session'; +import { + sessionToken as mockSessionToken, + clearSignedInAccountUid, +} from '../lib/cache'; +import { dispatchStorageEvent } from '../lib/account-storage'; +import { ERRNO } from '@fxa/accounts/errors'; +import AuthClient from 'fxa-auth-client/browser'; + +jest.mock('../lib/cache', () => { + const actual = jest.requireActual('../lib/cache'); + return { + ...actual, + sessionToken: jest.fn(), + clearSignedInAccountUid: jest.fn(), + }; +}); + +jest.mock('../lib/account-storage', () => ({ + ...jest.requireActual('../lib/account-storage'), + dispatchStorageEvent: jest.fn(), +})); + +describe('Session', () => { + describe('destroy', () => { + let session: Session; + let mockAuthClient: jest.Mocked; + const mockedSessionToken = jest.mocked(mockSessionToken); + const mockedClearSignedInAccountUid = jest.mocked(clearSignedInAccountUid); + const mockedDispatchStorageEvent = jest.mocked(dispatchStorageEvent); + + beforeEach(() => { + jest.clearAllMocks(); + mockAuthClient = { sessionDestroy: jest.fn() } as any; + session = new Session(mockAuthClient); + }); + + it('destroys the server session and clears local state', async () => { + mockedSessionToken.mockReturnValue('valid-token'); + mockAuthClient.sessionDestroy.mockResolvedValue({}); + + await session.destroy(); + + expect(mockAuthClient.sessionDestroy).toHaveBeenCalledWith('valid-token'); + expect(mockedClearSignedInAccountUid).toHaveBeenCalled(); + expect(mockedDispatchStorageEvent).toHaveBeenCalledWith('isSignedIn'); + }); + + it('treats errno 110 (INVALID_TOKEN) as already-destroyed and clears local state', async () => { + mockedSessionToken.mockReturnValue('stale-token'); + mockAuthClient.sessionDestroy.mockRejectedValue({ + code: 401, + errno: ERRNO.INVALID_TOKEN, + message: 'Invalid authentication token in request signature', + }); + + await expect(session.destroy()).resolves.toBeUndefined(); + + expect(mockedClearSignedInAccountUid).toHaveBeenCalled(); + expect(mockedDispatchStorageEvent).toHaveBeenCalledWith('isSignedIn'); + }); + + it('rethrows non-INVALID_TOKEN errors and skips local cleanup', async () => { + mockedSessionToken.mockReturnValue('valid-token'); + const networkError = new Error('Network request failed'); + mockAuthClient.sessionDestroy.mockRejectedValue(networkError); + + await expect(session.destroy()).rejects.toThrow('Network request failed'); + + expect(mockedClearSignedInAccountUid).not.toHaveBeenCalled(); + expect(mockedDispatchStorageEvent).not.toHaveBeenCalled(); + }); + + it('skips the server call when there is no session token', async () => { + mockedSessionToken.mockReturnValue(undefined); + + await session.destroy(); + + expect(mockAuthClient.sessionDestroy).not.toHaveBeenCalled(); + expect(mockedClearSignedInAccountUid).toHaveBeenCalled(); + expect(mockedDispatchStorageEvent).toHaveBeenCalledWith('isSignedIn'); + }); + }); +}); diff --git a/packages/fxa-settings/src/models/Session.ts b/packages/fxa-settings/src/models/Session.ts index 6f687808a46..7c2b59f5394 100644 --- a/packages/fxa-settings/src/models/Session.ts +++ b/packages/fxa-settings/src/models/Session.ts @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import AuthClient from 'fxa-auth-client/browser'; +import { ERRNO } from '@fxa/accounts/errors'; import { sessionToken, clearSignedInAccountUid, @@ -102,7 +103,15 @@ export class Session implements SessionData { async destroy() { const token = sessionToken(); if (token) { - await this.authClient.sessionDestroy(token); + try { + await this.authClient.sessionDestroy(token); + } catch (e) { + // Token already invalid on the server: + // treat as already-destroyed so local cleanup + redirect still run. + if ((e as { errno?: number })?.errno !== ERRNO.INVALID_TOKEN) { + throw e; + } + } } clearSignedInAccountUid(); dispatchStorageEvent('isSignedIn');