diff --git a/DESIGN.md b/DESIGN.md index ed45af41a..ad6c61eb5 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -117,6 +117,19 @@ Central manager only ever sends the boolean, never the other organizations' ids; an expired session — and let still-mounted pages rebuild their queries after the sign-out's cache clear and fetch again on the dead session. The guard redirects every signed-out dashboard load, so the spinner only covers the moment before it does (`endedSessionRouteFailures.test.tsx`, HarperFast/studio#1546). +- **Signing out in one tab signs out every tab.** `signOutAllLocally` rewrites `Studio:AppSignOut`, and each other tab + holding the app sign-in runs `clearAuthStateAfterSignOutElsewhere` on that `storage` event (`installCrossTabSignOut`). + Without it, a tab learns the session ended only from its next central-manager request, whose 403 toasts as a + permission refusal. `signOutAllLocally` runs once the shared session has ended: after `/Logout/` succeeds, on a 401, + after a password change. The sign-out sweep does not announce, because it runs before `/Logout/`: a failed logout + would sign the other tabs out of a live session, and one signed back in meanwhile would be ended by that `/Logout/`. + The value names the user whose session ended (the sweep remembers them, since it clears the user before `/Logout/`), + and a tab follows only when that is its own app user: a late 401 or event from user U's session must not sign out a + tab that has since signed in as V. A tab whose check is still out remembers the user, and if the check comes back as + them, asks again: central manager may have confirmed them just before the session ended. + Tabs follow that key, not the marker record, because a tab also drops the app sign-in from the record when its own + check merely fails (a timeout, a 5xx). A tab that follows writes no `localStorage`: its copy of the markers is a + snapshot from its own load, and writing it back could undo the other tab's next sign-in (`crossTabSignOut.test.tsx`). - **Data fetching**: TanStack React Query 5. Query keys must be instance-scoped when the request targets a specific instance. - **Polling**: every `refetchInterval` on a query that can reject against an instance or a cluster goes through [`pollUnlessForbidden`](src/react-query/pollUnlessForbidden.ts), which stops the timer on a 403 and backs off a sustained 400 or 5xx; a raw number polls a failing endpoint at full rate for as long as the page is open. When the interval depends on the data, compute it and hand it over per tick: `(query) => pollUnlessForbidden(base(query))(query)` (the back-off state is per query, not per wrapper). - **Forms**: react-hook-form with a zod `zodResolver`. A form whose submit button waits on `formState.isValid` must not use the default `onSubmit` mode, which keeps `isValid` current but shows errors only after a submit the disabled button never allows, so the user is blocked with no reason given (HarperFast/studio#1550). Use `mode: 'onTouched'`: a field's error appears once it is first left and then follows each change, while `isValid` still tracks every keystroke (`onBlur` recomputes `isValid` only on blur, so the button lags the text). Enter neither submits past a disabled button nor leaves the field, so give the `
` `onKeyDown={revealFieldErrorOnEnter(form)}`, which treats Enter in a text input as leaving it. A refine that compares two fields reports on one of them; re-check that field from the other's `onChange` once it has been touched (`getFieldState(name).isTouched`, then `trigger(name)`), because `rules={{ deps }}` fires on the other field's first blur, before the user reaches it. react-hook-form only updates the `formState` fields a render has read, and `disabled={!isDirty || !isValid}` skips `isValid` while the form is pristine, so where one change can complete the form (a single field, a paste, a checkbox) the button stays disabled on a valid form. Read the fields before the JSX (`const { isDirty, isValid } = form.formState;`) and pin it with a test that makes one change and checks the button. diff --git a/src/features/auth/handlers/clearAuthStateLocally.ts b/src/features/auth/handlers/clearAuthStateLocally.ts index c70a54680..f909c06d1 100644 --- a/src/features/auth/handlers/clearAuthStateLocally.ts +++ b/src/features/auth/handlers/clearAuthStateLocally.ts @@ -9,15 +9,26 @@ import { queryClient } from '@/react-query/queryClient'; * same-tab sign-in as another user. */ export function clearAuthStateLocally(): void { - for ( - const step of [ - () => authStore.signOutAllLocally(), - () => queryClient.getMutationCache().clear(), - () => queryClient.getQueryCache().clear(), - clearLocalStorage, - clearSessionStorage, - ] - ) { + runEvery([ + () => authStore.signOutAllLocally(), + () => queryClient.getMutationCache().clear(), + () => queryClient.getQueryCache().clear(), + clearLocalStorage, + clearSessionStorage, + ]); +} + +export function clearAuthStateAfterSignOutElsewhere(): void { + runEvery([ + () => authStore.forgetAppSignInEndedElsewhere(), + () => queryClient.getMutationCache().clear(), + () => queryClient.getQueryCache().clear(), + clearSessionStorage, + ]); +} + +function runEvery(steps: Array<() => void>): void { + for (const step of steps) { try { step(); } catch (error) { diff --git a/src/features/auth/handlers/signOutHandlers.test.ts b/src/features/auth/handlers/signOutHandlers.test.ts index 56440a143..36a8db922 100644 --- a/src/features/auth/handlers/signOutHandlers.test.ts +++ b/src/features/auth/handlers/signOutHandlers.test.ts @@ -2,7 +2,7 @@ import { authStore, OverallAppSignIn } from '@/features/auth/store/authStore'; import { queryClient } from '@/react-query/queryClient'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { clearAuthStateLocally } from './clearAuthStateLocally'; +import { clearAuthStateAfterSignOutElsewhere, clearAuthStateLocally } from './clearAuthStateLocally'; import { logoutOnSuccess } from './logoutOnSuccess'; type SetArgs = Parameters; @@ -105,3 +105,36 @@ describe.each( expect(consoleError).toHaveBeenCalledWith(failure); }); }); + +describe('clearAuthStateAfterSignOutElsewhere', () => { + it('signs this tab out and wipes both caches and its sessionStorage, but leaves the shared localStorage', () => { + seedSignedInTab(); + // The tab that signed out cleared localStorage; what is there now is that tab's, a new sign-in perhaps. + localStorage.clear(); + localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn })); + + clearAuthStateAfterSignOutElsewhere(); + + expect(authStore.getConnectionById(OverallAppSignIn).user).toBeNull(); + expect(authStore.getConnectionById('ins-a').user).toBeNull(); + expectCachesEmpty(); + expect(sessionStorage.length).toBe(0); + expect(localStorage.length).toBe(1); + expect(localStorage.getItem('Studio:PotentiallyAuthenticated')).toBe(JSON.stringify({ OverallAppSignIn })); + }); + + it('still wipes the caches and sessionStorage when the store teardown throws', () => { + seedSignedInTab(); + const failure = new Error('teardown failed'); + vi.spyOn(authStore, 'forgetAppSignInEndedElsewhere').mockImplementation(() => { + throw failure; + }); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + clearAuthStateAfterSignOutElsewhere(); + + expectCachesEmpty(); + expect(sessionStorage.length).toBe(0); + expect(consoleError).toHaveBeenCalledWith(failure); + }); +}); diff --git a/src/features/auth/store/appSignOutElsewhere.test.ts b/src/features/auth/store/appSignOutElsewhere.test.ts new file mode 100644 index 000000000..ee1e6cabd --- /dev/null +++ b/src/features/auth/store/appSignOutElsewhere.test.ts @@ -0,0 +1,199 @@ +/** @vitest-environment jsdom */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { createInstanceAuthenticationTokens, getInstanceUserInfo } = vi.hoisted(() => ({ + createInstanceAuthenticationTokens: vi.fn(), + getInstanceUserInfo: vi.fn(), +})); +vi.mock('@/config/apiClient', () => ({ apiClient: { get: vi.fn(), defaults: { baseURL: 'https://cm.example.com' } } })); +vi.mock('@/integrations/api/instance/auth/createInstanceAuthenticationTokens', () => ({ + createInstanceAuthenticationTokens, + refreshInstanceOperationToken: vi.fn(), +})); +vi.mock('@/integrations/api/instance/status/getInstanceUserInfo', () => ({ getInstanceUserInfo })); + +import { apiClient } from '@/config/apiClient'; +import { authStore, OverallAppSignIn } from './authStore'; + +type SetArgs = Parameters; + +const MARKER = 'Studio:PotentiallyAuthenticated'; +const SIGNAL = 'Studio:AppSignOut'; +const DIRECT_URL = 'https://ins-direct.example.com:9925/'; + +function signIn() { + authStore.setUserForIdAndKey(OverallAppSignIn, OverallAppSignIn, { id: 'usr_a' } as SetArgs[2]); +} + +function storageEventFromAnotherTab(key: string | null, newValue: string | null) { + window.dispatchEvent(new StorageEvent('storage', { key, newValue, storageArea: localStorage })); +} + +function signOutOf(user: unknown) { + return JSON.stringify({ user, at: '1' }); +} + +function announcedUser() { + const value = localStorage.getItem(SIGNAL); + return value === null ? undefined : (JSON.parse(value) as { user: unknown }).user; +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); +}); + +afterEach(() => { + authStore.signOutAllLocally(); + vi.resetAllMocks(); + vi.restoreAllMocks(); +}); + +describe('authStore.onAppSignedOutElsewhere', () => { + it('fires for another tab’s sign-out of this tab’s user, and for nothing else it writes', () => { + signIn(); + const signedOut = vi.fn(); + const stop = authStore.onAppSignedOutElsewhere(signedOut); + + // A cluster sign-out, a failed sign-in check dropping the app marker, an explorer clear, a storage wipe. + storageEventFromAnotherTab(MARKER, JSON.stringify({ OverallAppSignIn })); + storageEventFromAnotherTab(MARKER, '{}'); + storageEventFromAnotherTab('Studio:ExplorerAuthEpoch', JSON.stringify({ '*': 1 })); + storageEventFromAnotherTab(null, null); + // Someone else's session, a sign-out that knew no user, and values no sign-out writes. + storageEventFromAnotherTab(SIGNAL, signOutOf('usr_v')); + storageEventFromAnotherTab(SIGNAL, signOutOf(null)); + storageEventFromAnotherTab(SIGNAL, '1'); + storageEventFromAnotherTab(SIGNAL, 'null'); + storageEventFromAnotherTab(SIGNAL, '{'); + expect(signedOut).not.toHaveBeenCalled(); + + storageEventFromAnotherTab(SIGNAL, signOutOf('usr_a')); + expect(signedOut).toHaveBeenCalledOnce(); + stop(); + }); + + it('does nothing in a tab that holds no app sign-in', () => { + const signedOut = vi.fn(); + const stop = authStore.onAppSignedOutElsewhere(signedOut); + + storageEventFromAnotherTab(SIGNAL, signOutOf('usr_a')); + storageEventFromAnotherTab(SIGNAL, signOutOf(null)); + + expect(signedOut).not.toHaveBeenCalled(); + stop(); + }); + + it.each([ + ['the user it named', 'usr_a', 2], + ['someone else', 'usr_v', 1], + ])('asks again when a check that was out during a sign-out of %s comes back as them', async (_, ended, checks) => { + signIn(); + let answer: (response: { data: { id: string } }) => void = () => undefined; + vi.mocked(apiClient.get) + .mockReturnValueOnce(new Promise((resolve) => (answer = resolve))) + .mockReturnValue(new Promise(() => undefined)); + const check = authStore.reloadUser(OverallAppSignIn); + const signedOut = vi.fn(); + const stop = authStore.onAppSignedOutElsewhere(signedOut); + + storageEventFromAnotherTab(SIGNAL, signOutOf(ended)); + answer({ data: { id: 'usr_a' } }); + await check; + + expect(signedOut).not.toHaveBeenCalled(); + expect(apiClient.get).toHaveBeenCalledTimes(checks); + stop(); + }); + + it('does not ask again on a later check in a tab that was signed out when it heard', async () => { + const stop = authStore.onAppSignedOutElsewhere(vi.fn()); + storageEventFromAnotherTab(SIGNAL, signOutOf('usr_a')); + signIn(); + vi.mocked(apiClient.get).mockResolvedValueOnce({ data: { id: 'usr_a' } }).mockReturnValue( + new Promise(() => undefined), + ); + + await authStore.reloadUser(OverallAppSignIn); + + expect(apiClient.get).toHaveBeenCalledOnce(); + stop(); + }); + + it('still names the ended user when the teardown throws', () => { + signIn(); + const failure = new Error('explorer listener failed'); + const stop = authStore.onExplorerAuthInvalidated(OverallAppSignIn, () => { + throw failure; + }); + + expect(() => authStore.signOutAllLocally()).toThrow(failure); + stop(); + + expect(announcedUser()).toBe('usr_a'); + }); + + it('names the ended user from every local sign-out, and is not written by the sweep before `/Logout/`', async () => { + signIn(); + await authStore.signOutFromPotentiallyAuthenticatedInstances(); + expect(localStorage.getItem(SIGNAL)).toBeNull(); + // `logoutOnSuccess`, after the sweep has already cleared the user. + authStore.signOutAllLocally(); + expect(announcedUser()).toBe('usr_a'); + + signIn(); + authStore.signOutAllLocally(); + const first = localStorage.getItem(SIGNAL); + signIn(); + authStore.signOutAllLocally(); + expect(announcedUser()).toBe('usr_a'); + expect(localStorage.getItem(SIGNAL)).not.toBe(first); + + // A tab with no user to name: nothing for another tab to match. + authStore.signOutAllLocally(); + expect(announcedUser()).toBeNull(); + }); +}); + +describe('authStore.forgetAppSignInEndedElsewhere', () => { + it('signs every entity out in memory without writing the storage the tabs share', () => { + signIn(); + authStore.setUserForIdAndKey('ins-a', 'https://ins-a.example.com', { username: 'u' } as SetArgs[2]); + // What the other tab wrote after signing out: a sign-in, perhaps as someone else. + localStorage.clear(); + localStorage.setItem(MARKER, JSON.stringify({ OverallAppSignIn })); + const writes = [ + vi.spyOn(Storage.prototype, 'setItem'), + vi.spyOn(Storage.prototype, 'removeItem'), + vi.spyOn(Storage.prototype, 'clear'), + ]; + + authStore.forgetAppSignInEndedElsewhere(); + + expect(authStore.isKnownSignedOut(OverallAppSignIn)).toBe(true); + expect(authStore.getConnectionById('ins-a').user).toBeNull(); + expect(authStore.getOperationsUrl('ins-a')).toBeUndefined(); + const localWrites = writes.flatMap((spy) => spy.mock.contexts).filter((storage) => storage === localStorage); + expect(localWrites).toEqual([]); + expect(localStorage.getItem(MARKER)).toBe(JSON.stringify({ OverallAppSignIn })); + }); + + it('drops a direct-connect token, and a sign-in check still in flight', async () => { + signIn(); + createInstanceAuthenticationTokens.mockResolvedValue({ operationToken: 'jwt', refreshToken: 'refresh' }); + getInstanceUserInfo.mockResolvedValueOnce({ username: 'direct' }); + await authStore.establishFabricConnectAuth({ id: 'ins-direct', operationsUrl: DIRECT_URL }); + expect(authStore.getOperationToken('ins-direct')).toBe('jwt'); + let answerCheck: (user: { username: string }) => void = () => undefined; + getInstanceUserInfo.mockReturnValueOnce(new Promise((resolve) => (answerCheck = resolve))); + const check = authStore.reloadUser('ins-direct'); + + authStore.forgetAppSignInEndedElsewhere(); + answerCheck({ username: 'direct' }); + + await expect(check).resolves.toBeNull(); + expect(authStore.getOperationToken('ins-direct')).toBeUndefined(); + expect(authStore.getConnectionById('ins-direct').user).toBeNull(); + }); +}); diff --git a/src/features/auth/store/authStore.ts b/src/features/auth/store/authStore.ts index c4005c46a..c9cf5cb36 100644 --- a/src/features/auth/store/authStore.ts +++ b/src/features/auth/store/authStore.ts @@ -99,6 +99,12 @@ class AuthStore { /** Same-tab epoch writes fire no `storage` event, so in-process subscribers are notified directly. */ private readonly explorerInvalidationListeners = new Set<() => void>(); + private readonly appSignOutKey = 'Studio:AppSignOut'; + // The sweep clears the app user before `/Logout/`, and the announcement after it must still name them. + private sweptAppUser: string | null = null; + // Central manager may have confirmed that user before the session ended, so the check is asked again. + private signOutHeardDuringCheck: string | null = null; + constructor() { this.potentiallyAuthenticated = JSON.parse(localStorage.getItem(this.potentiallyAuthenticatedKey) || '{}'); } @@ -633,6 +639,7 @@ class AuthStore { } public async signOutFromPotentiallyAuthenticatedInstances() { + this.sweptAppUser = this.appUser() ?? this.sweptAppUser; // Dooms connects for entities not yet in `potentiallyAuthenticated`, which the loop skips. this.bumpSignOutGeneration('*'); forgetAllEntitySettings(); @@ -719,17 +726,74 @@ class AuthStore { * per-instance logouts. */ public signOutAllLocally(): void { + const endedUser = this.appUser() ?? this.sweptAppUser; + this.sweptAppUser = null; + try { + this.bumpSignOutGeneration('*'); + forgetAllEntitySettings(); + // Snapshot keys first — signOutLocally mutates the flags as it goes. + for (const id of Object.keys(this.potentiallyAuthenticated)) { + this.signOutLocally(id); + } + this.dropFabricConnectState(); + this.setUserForEntity(OverallAppSignIn, null); + // Broadcast a global clear so other tabs' explorers drop their credentials even for entities not + // in this tab's potentiallyAuthenticated set. + this.bumpExplorerAuthEpochAll(); + } finally { + this.announceAppSignOut(endedUser); + } + } + + private appUser(): string | null { + const user = this.allConnections[OverallAppSignIn]?.user; + if (!user) { + return null; + } + return 'id' in user ? user.id : user.username; + } + + private announceAppSignOut(user: string | null): void { + try { + // A new value every time, since storage fires an event only for a change. + localStorage.setItem(this.appSignOutKey, JSON.stringify({ user, at: `${Date.now()}:${Math.random()}` })); + } catch (error) { + console.debug('Could not tell the other tabs about this sign-out', error); + } + } + + public onAppSignedOutElsewhere(onSignedOut: () => void): () => void { + const handler = (event: StorageEvent) => { + if (event.key !== this.appSignOutKey || !event.newValue) { + return; + } + const ended = signedOutUser(event.newValue); + const user = this.appUser(); + if (user !== null) { + if (ended === user) { + onSignedOut(); + } + } else if (typeof ended === 'string' && this.allConnections[OverallAppSignIn]?.isLoading) { + this.signOutHeardDuringCheck = ended; + } + }; + window.addEventListener('storage', handler); + return () => window.removeEventListener('storage', handler); + } + + public forgetAppSignInEndedElsewhere(): void { this.bumpSignOutGeneration('*'); forgetAllEntitySettings(); - // Snapshot keys first — signOutLocally mutates the flags as it goes. + const entityIds = new Set([...Object.keys(this.potentiallyAuthenticated), ...Object.keys(this.allConnections)]); + entityIds.delete(OverallAppSignIn); for (const id of Object.keys(this.potentiallyAuthenticated)) { - this.signOutLocally(id); + delete this.potentiallyAuthenticated[id]; } this.dropFabricConnectState(); - this.setUserForEntity(OverallAppSignIn, null); - // Broadcast a global clear so other tabs' explorers drop their credentials even for entities not - // in this tab's potentiallyAuthenticated set. - this.bumpExplorerAuthEpochAll(); + for (const id of entityIds) { + this.updateConnectionIfChanged(id, false, null); + } + this.updateConnectionIfChanged(OverallAppSignIn, false, null); } private calculateKeyFromEntity(entity: EntityTypes): AuthenticatedConnectionKey | undefined { @@ -852,6 +916,10 @@ class AuthStore { console.error(error); user = null; } + const signOutHeard = id === OverallAppSignIn ? this.signOutHeardDuringCheck : null; + if (id === OverallAppSignIn) { + this.signOutHeardDuringCheck = null; + } if (this.getSignOutGeneration(id) !== signOutGeneration) { return null; } @@ -861,6 +929,9 @@ class AuthStore { this.flagKeyAsSignedOut(id); } this.updateConnectionIfChanged(id, false, user); + if (signOutHeard !== null && signOutHeard === this.appUser()) { + void this.reloadUser(id); + } return user; } } @@ -872,6 +943,14 @@ class SignedOutWhileConnectingError extends Error { } } +function signedOutUser(announcement: string): unknown { + try { + return (JSON.parse(announcement) as { user?: unknown } | null)?.user; + } catch { + return undefined; + } +} + // `console.debug`, never `console.error`: the RUM SDK reports `console.error` as an error, which would // put a failure the sign-out deliberately carries past into Error Tracking. Message only: the full // Axios error carries the request credentials. diff --git a/src/lib/installCrossTabSignOut.ts b/src/lib/installCrossTabSignOut.ts new file mode 100644 index 000000000..7b11c9e00 --- /dev/null +++ b/src/lib/installCrossTabSignOut.ts @@ -0,0 +1,12 @@ +import { clearAuthStateAfterSignOutElsewhere } from '@/features/auth/handlers/clearAuthStateLocally'; +import { authStore } from '@/features/auth/store/authStore'; + +let installed = false; + +export function installCrossTabSignOut(): void { + if (installed) { + return; + } + authStore.onAppSignedOutElsewhere(clearAuthStateAfterSignOutElsewhere); + installed = true; +} diff --git a/src/main.tsx b/src/main.tsx index f29b2a000..ae16180fe 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,7 @@ import { scrubLegacySettings } from '@/features/instance/apis/explorer/settings' import { reactRootErrorHandlers } from '@/integrations/datadog/reportReactError'; import { installApiUnauthorizedRedirect } from '@/lib/installApiUnauthorizedRedirect'; import { installBrowserTranslationDomGuard } from '@/lib/installBrowserTranslationDomGuard'; +import { installCrossTabSignOut } from '@/lib/installCrossTabSignOut'; import { installStaleDeployReload } from '@/lib/installStaleDeployReload'; import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; @@ -18,6 +19,7 @@ installStaleDeployReload(); // Redirect to /sign-in when a CM call returns 401 (lost/expired session), instead // of leaving the SPA on a stale user while every data call fails. installApiUnauthorizedRedirect(); +installCrossTabSignOut(); // Remove the pre-sessionStorage API-explorer credential map (plaintext Basic passwords / Bearer // tokens) at boot, so an upgraded user who never reopens the explorer is still scrubbed. scrubLegacySettings(); diff --git a/src/router/__tests__/crossTabSignOut.test.tsx b/src/router/__tests__/crossTabSignOut.test.tsx new file mode 100644 index 000000000..a14771ea5 --- /dev/null +++ b/src/router/__tests__/crossTabSignOut.test.tsx @@ -0,0 +1,464 @@ +// @vitest-environment jsdom +import { type QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { AxiosError, type InternalAxiosRequestConfig } from 'axios'; +import { toast } from 'sonner'; +import { afterEach, beforeEach, describe, expect, it, MockInstance, vi } from 'vitest'; + +const ORGANIZATION_ID = 'org-qmvwv3tdm45a9d4a'; +const CLUSTER_ID = 'clu-crosstab'; +const PAGE = `/${ORGANIZATION_ID}/cluster`; +const cloudUser = { id: 'usr_a', email: 'a@example.com', roles: {} }; + +const { routers } = vi.hoisted(() => ({ + routers: new Set<{ history: { destroy: () => void } }>(), +})); + +vi.mock('sonner', () => ({ toast: { error: vi.fn(), dismiss: vi.fn() } })); +vi.mock('@tanstack/react-router-devtools', () => ({ TanStackRouterDevtools: () => null })); +vi.mock('@/features/notifications/NotificationsSubscriptionManager', () => ({ + NotificationsSubscriptionManager: () => null, +})); +vi.mock('@/components/NotificationBanner', () => ({ NotificationBanner: () => null })); +vi.mock('@/components/Navbar', () => ({ Navbar: () => null })); +vi.mock('@/features/search/GlobalSearch', () => ({ + GlobalSearch: ({ children }: { children: unknown }) => children, + GlobalSearchTrigger: () => null, +})); +vi.mock('@/features/auth/AuthLayout', async () => { + const { Outlet } = await import('@tanstack/react-router'); + return { AuthLayout: () => }; +}); +vi.mock('@/features/auth/SignIn', () => ({ SignIn: () =>

sign-in page

})); + +/** Registered again after each `resetModules`, which a `vi.mock` factory's result outlives. */ +function mockAppModules() { + vi.doMock('@/integrations/datadog/datadog', async (importOriginal) => ({ + ...await importOriginal(), + useOnRouteLoadTracker: () => undefined, + })); + vi.doMock('@/router/useNewRouter', async (importOriginal) => { + const { useNewRouter } = await importOriginal(); + return { + useNewRouter: (...args: Parameters) => { + const router = useNewRouter(...args); + routers.add(router); + return router; + }, + }; + }); + // The real root, sign-in, dashboard and organization routes, under a page that polls its cluster the way + // the cluster pages do. + vi.doMock('@/router/rootRouteTree', async () => { + const { createRoute } = await import('@tanstack/react-router'); + const { useQuery } = await import('@tanstack/react-query'); + const { useCloudAuth } = await import('@/hooks/useAuth'); + const { getClusterInfoQueryOptions } = await import('@/features/cluster/queries/getClusterInfoQuery'); + const { rootRoute } = await import('@/router/rootRoute'); + const { authRouteTree } = await import('@/features/auth/routes'); + const { dashboardLayout } = await import('@/router/dashboardRoute'); + const { orgsLayoutRoute } = await import('@/features/organizations/routes'); + const { orgLayoutRoute } = await import('@/features/organization/routes'); + + function ClusterPage() { + useCloudAuth(); + const { data } = useQuery(getClusterInfoQueryOptions(CLUSTER_ID, true)); + return

{data ? `cluster ${data.name}` : 'loading cluster'}

; + } + const clusterRoute = createRoute({ + getParentRoute: () => orgLayoutRoute, + path: '/cluster', + component: ClusterPage, + }); + return { + rootRouteTree: rootRoute.addChildren([ + authRouteTree, + dashboardLayout.addChildren([orgsLayoutRoute.addChildren([orgLayoutRoute.addChildren([clusterRoute])])]), + ]), + }; + }); +} + +mockAppModules(); +await Promise.all([import('@/lib/installApiUnauthorizedRedirect'), import('@/AppRouted')]); + +/** + * The central-manager session every tab shares through its cookie. Central manager answers a request with no + * session 403 `Not allowed` (`checkUser` → `notAllowed()`, HarperFast/central-manager#938), exactly as it + * answers one the user may not make; only `/User/current` says 401. + */ +let session: 'live' | 'ended' | 'ended-with-401' | 'cluster-forbidden'; +const requests: string[] = []; +const requestsAfterSessionEnded: string[] = []; +/** While set, this tab's `/User/current` answers are decided when sent but delivered only once it settles. */ +let userCurrentHeldUntil: Promise | null = null; + +function respond(config: InternalAxiosRequestConfig, status: number, data: unknown) { + const response = { status, statusText: String(status), data, headers: {}, config }; + if (status < 300) { + return response; + } + throw new AxiosError( + `Request failed with status code ${status}`, + AxiosError.ERR_BAD_RESPONSE, + config, + undefined, + response, + ); +} + +function notAllowed(config: InternalAxiosRequestConfig) { + return respond(config, 403, { + type: 'error:ClientError', + code: 'ClientError', + title: 'Not allowed', + status: 403, + instance: config.url, + }); +} + +function centralManager(config: InternalAxiosRequestConfig) { + requests.push(config.url ?? ''); + if (session !== 'live' && session !== 'cluster-forbidden') { + requestsAfterSessionEnded.push(config.url ?? ''); + return session === 'ended-with-401' || config.url === '/User/current' + ? respond(config, 401, { code: 'ClientError', title: 'Invalid cookie' }) + : notAllowed(config); + } + switch (config.url) { + case '/User/current': + return respond(config, 200, cloudUser); + case `/Organization/${ORGANIZATION_ID}`: + return respond(config, 200, { id: ORGANIZATION_ID, name: 'Acme' }); + case `/Cluster/${CLUSTER_ID}`: + return session === 'cluster-forbidden' + ? notAllowed(config) + : respond(config, 200, { id: CLUSTER_ID, name: 'Prod', status: 'RUNNING' }); + } + throw new Error(`unexpected request ${config.url}`); +} + +/** This tab: the cluster page, signed in, wired as `main.tsx` wires it. */ +async function openClusterPage() { + vi.resetModules(); + mockAppModules(); + const [ + { apiClient }, + { authStore }, + { installApiUnauthorizedRedirect }, + { installCrossTabSignOut }, + { queryClient }, + { AppRouted }, + ] = await Promise.all([ + import('@/config/apiClient'), + import('@/features/auth/store/authStore'), + import('@/lib/installApiUnauthorizedRedirect'), + import('@/lib/installCrossTabSignOut'), + import('@/react-query/queryClient'), + import('@/AppRouted'), + ]); + apiClient.defaults.adapter = async (config) => { + const held = config.url === '/User/current' ? userCurrentHeldUntil : null; + if (!held) { + return centralManager(config); + } + let answer: () => ReturnType; + try { + const response = centralManager(config); + answer = () => response; + } catch (error) { + answer = () => { + throw error; + }; + } + await held; + return answer(); + }; + installApiUnauthorizedRedirect(); + installCrossTabSignOut(); + render( + + + , + ); + await screen.findByText('cluster Prod'); + return { authStore, queryClient }; +} + +type StorageChange = Pick; + +/** + * Another tab of the same browser, signed in as its page load would sign it in: its own copy of the app's + * modules, sharing this tab's `localStorage` and central-manager session but not its `sessionStorage`. + * `record` returns the changes `step` made to `localStorage`; `deliver` hands them to this tab as the + * `storage` events a browser fires in every other tab; `run` does both. + */ +async function openAnotherTab() { + vi.resetModules(); + const [{ authStore }, { apiClient }, { installApiUnauthorizedRedirect }, { logoutOnSuccess }, { + clearAuthStateLocally, + }] = await Promise.all([ + import('@/features/auth/store/authStore'), + import('@/config/apiClient'), + import('@/lib/installApiUnauthorizedRedirect'), + import('@/features/auth/handlers/logoutOnSuccess'), + import('@/features/auth/handlers/clearAuthStateLocally'), + ]); + apiClient.defaults.adapter = async (config) => centralManager(config); + installApiUnauthorizedRedirect(); + await authStore.reloadUser('OverallAppSignIn'); + const record = (step: () => unknown) => localStorageChanges(step); + return { + authStore, + apiClient, + logoutOnSuccess, + clearAuthStateLocally, + record, + run: async (step: () => unknown) => deliver(await record(step)), + }; +} + +function deliver(changes: StorageChange[]) { + for (const change of changes) { + window.dispatchEvent(new StorageEvent('storage', { ...change, storageArea: localStorage })); + } +} + +async function localStorageChanges(step: () => unknown): Promise { + const changes: StorageChange[] = []; + const { clear, removeItem, setItem } = Storage.prototype; + const spies = [ + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(function(this: Storage, key, value) { + if (this !== localStorage) { + return; + } + const oldValue = this.getItem(key); + setItem.call(this, key, value); + if (oldValue !== value) { + changes.push({ key, oldValue, newValue: value }); + } + }), + vi.spyOn(Storage.prototype, 'removeItem').mockImplementation(function(this: Storage, key) { + if (this !== localStorage) { + return; + } + const oldValue = this.getItem(key); + removeItem.call(this, key); + if (oldValue !== null) { + changes.push({ key, oldValue, newValue: null }); + } + }), + vi.spyOn(Storage.prototype, 'clear').mockImplementation(function(this: Storage) { + if (this !== localStorage) { + return; + } + const hadItems = this.length > 0; + clear.call(this); + if (hadItems) { + changes.push({ key: null, oldValue: null, newValue: null }); + } + }), + ]; + try { + await step(); + } finally { + for (const spy of spies) { + spy.mockRestore(); + } + } + return changes; +} + +/** This tab's next poll of its page data, as its 10s `refetchInterval` or a refocus would send it. */ +async function nextPoll(queryClient: QueryClient) { + await queryClient.refetchQueries({ type: 'active' }); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); +} + +/** Needs no wait: a sign-out reaches the store synchronously, and its cache clear leaves the next poll nothing to send. */ +async function expectStillSignedIn(thisTab: Awaited>, user: object = cloudUser) { + expect(thisTab.authStore.getConnectionById('OverallAppSignIn').user).toEqual(user); + const polled = requests.length; + await nextPoll(thisTab.queryClient); + expect(requests.slice(polled)).toContain(`/Cluster/${CLUSTER_ID}`); + expect(screen.getByText('cluster Prod')).toBeTruthy(); + expect(toast.error).not.toHaveBeenCalled(); +} + +let consoleError: MockInstance; +let addEventListener: MockInstance; + +function reportedAuthorizationFailures() { + return consoleError.mock.calls.filter(([error]) => error instanceof AxiosError && error.response?.status === 403); +} + +beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + localStorage.setItem('Studio:PotentiallyAuthenticated', JSON.stringify({ OverallAppSignIn: 'OverallAppSignIn' })); + window.history.replaceState(null, '', `#${PAGE}`); + session = 'live'; + requests.length = 0; + requestsAfterSessionEnded.length = 0; + userCurrentHeldUntil = null; + // Recorded so each test's `storage` listeners come down with it, rather than acting on its modules later. + addEventListener = vi.spyOn(window, 'addEventListener'); + consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + vi.spyOn(console, 'debug').mockImplementation(() => undefined); +}); + +afterEach(() => { + cleanup(); + for (const router of routers) { + router.history.destroy(); + } + routers.clear(); + for (const [type, listener, options] of addEventListener.mock.calls) { + if (type === 'storage' && listener) { + window.removeEventListener(type, listener, options); + } + } + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +describe('a sign-out in another tab', () => { + it('signs this tab out once that tab’s `/Logout/` succeeds, before its next poll can toast', async () => { + const { queryClient } = await openClusterPage(); + const otherTab = await openAnotherTab(); + + await otherTab.run(() => otherTab.authStore.signOutFromPotentiallyAuthenticatedInstances()); + session = 'ended'; + await otherTab.run(() => otherTab.logoutOnSuccess()); + await nextPoll(queryClient); + + expect(toast.error).not.toHaveBeenCalled(); + expect(requestsAfterSessionEnded).toEqual([]); + expect(reportedAuthorizationFailures()).toEqual([]); + await screen.findByText('sign-in page'); + }); + + it('leaves this tab signed in when that tab’s `/Logout/` fails', async () => { + const thisTab = await openClusterPage(); + const otherTab = await openAnotherTab(); + + await otherTab.run(() => otherTab.authStore.signOutFromPotentiallyAuthenticatedInstances()); + + await expectStillSignedIn(thisTab); + }); + + it('signs this tab out when another tab found the session ended', async () => { + const { queryClient } = await openClusterPage(); + const otherTab = await openAnotherTab(); + + session = 'ended'; + await otherTab.run(() => otherTab.clearAuthStateLocally()); + await nextPoll(queryClient); + + expect(toast.error).not.toHaveBeenCalled(); + expect(requestsAfterSessionEnded).toEqual([]); + await screen.findByText('sign-in page'); + }); + + it('asks again when its own check, out during the sign-out, comes back as the user it ended', async () => { + const { authStore, queryClient } = await openClusterPage(); + const otherTab = await openAnotherTab(); + let releaseUserCurrent: () => void = () => undefined; + userCurrentHeldUntil = new Promise((resolve) => (releaseUserCurrent = resolve)); + const checksBefore = requests.filter((url) => url === '/User/current').length; + + // Central manager answers this check before the other tab's `/Logout/`; the answer arrives after it. + const check = authStore.reloadUser('OverallAppSignIn'); + await waitFor(() => expect(requests.filter((url) => url === '/User/current')).toHaveLength(checksBefore + 1)); + await otherTab.run(() => otherTab.authStore.signOutFromPotentiallyAuthenticatedInstances()); + session = 'ended'; + await otherTab.run(() => otherTab.logoutOnSuccess()); + userCurrentHeldUntil = null; + releaseUserCurrent(); + await check; + await screen.findByText('sign-in page'); + await nextPoll(queryClient); + + expect(toast.error).not.toHaveBeenCalled(); + expect(requestsAfterSessionEnded).toEqual(['/User/current']); + }); + + it('leaves this tab signed in when a 401 in another tab ends a different user’s session', async () => { + const thisTab = await openClusterPage(); + const otherTab = await openAnotherTab(); + otherTab.authStore.setUserForIdAndKey('OverallAppSignIn', 'OverallAppSignIn', { id: 'usr_u' } as never); + otherTab.apiClient.defaults.adapter = async (config) => + respond(config, 401, { code: 'ClientError', title: 'Invalid cookie' }); + + await otherTab.run(() => + otherTab.apiClient.get(`/Organization/${ORGANIZATION_ID}` as '/Organization/{id}').catch(() => undefined) + ); + expect(otherTab.authStore.isKnownSignedOut('OverallAppSignIn')).toBe(true); + + await expectStillSignedIn(thisTab); + }); + + it('leaves this tab signed in when a sign-out reaches it after it signed in as someone else', async () => { + const thisTab = await openClusterPage(); + const otherTab = await openAnotherTab(); + const signedInSince = { id: 'usr_v', email: 'v@example.com', roles: {} }; + + const changes = [ + ...await otherTab.record(() => otherTab.authStore.signOutFromPotentiallyAuthenticatedInstances()), + ...await otherTab.record(() => otherTab.logoutOnSuccess()), + ]; + thisTab.authStore.setUserForIdAndKey('OverallAppSignIn', 'OverallAppSignIn', signedInSince as never); + deliver(changes); + + await expectStillSignedIn(thisTab, signedInSince); + }); + + it('leaves this tab signed in when another tab only signs in or out of a cluster', async () => { + const thisTab = await openClusterPage(); + const otherTab = await openAnotherTab(); + + await otherTab.run(() => + otherTab.authStore.setUserForIdAndKey('clu-other', 'https://clu-other.example.com', { username: 'u' } as never) + ); + await otherTab.run(() => otherTab.authStore.signOutLocally('clu-other')); + + await expectStillSignedIn(thisTab); + }); + + it('leaves this tab signed in when another tab’s sign-in check merely fails', async () => { + const thisTab = await openClusterPage(); + const otherTab = await openAnotherTab(); + // A reload during a central-manager blip: that tab reads the failed check as signed out, and drops the app + // sign-in from the marker record both tabs share. + otherTab.apiClient.defaults.adapter = async (config) => { + throw new AxiosError('timeout of 60000ms exceeded', AxiosError.ECONNABORTED, config); + }; + + await otherTab.run(() => otherTab.authStore.reloadUser('OverallAppSignIn')); + expect(localStorage.getItem('Studio:PotentiallyAuthenticated')).toBe('{}'); + + await expectStillSignedIn(thisTab); + }); +}); + +describe('a session that ends with no other tab signing out', () => { + it('still toasts a refusal central manager gives this tab while it is signed in', async () => { + const { queryClient } = await openClusterPage(); + + session = 'cluster-forbidden'; + await nextPoll(queryClient); + + expect(toast.error).toHaveBeenCalledWith('ClientError', expect.objectContaining({ description: 'Not allowed' })); + expect(screen.getByText('cluster Prod')).toBeTruthy(); + }); + + it('signs this tab out without a toast when central manager answers its poll 401', async () => { + const { queryClient } = await openClusterPage(); + + session = 'ended-with-401'; + await nextPoll(queryClient); + + await screen.findByText('sign-in page'); + expect(toast.error).not.toHaveBeenCalled(); + }); +});