From 0211c18468e042e60d25b35a65227775c0a1f3f5 Mon Sep 17 00:00:00 2001 From: Pasindu Yeshan Date: Thu, 30 Jul 2026 22:18:00 +0530 Subject: [PATCH] Fix duplicate /users/me requests during console sign-in Initiate sign-in from an effect instead of render, and guard it with a ref so it only fires once per unauthenticated episode. On failure the guard stays closed until the episode genuinely ends (sign-in, fallback, or redirect), instead of releasing immediately and letting a re-render retry it. Also stop re-exporting createPackageComponentLogger from @thunderid/react's public API; consumers that need it (react-router, matching vue) import it from @thunderid/browser directly. Fixes thunder-id/thunderid#4115 --- packages/react-router/.gitignore | 4 + packages/react-router/package.json | 3 + .../src/components/ProtectedRoute.tsx | 125 +++++-- .../__tests__/ProtectedRoute.test.tsx | 348 ++++++++++++++++++ packages/react-router/tsconfig.spec.json | 2 + .../contexts/ThunderID/ThunderIDProvider.tsx | 62 ++-- .../__tests__/ThunderIDProvider.test.tsx | 234 ++++++++++++ pnpm-lock.yaml | 6 + 8 files changed, 718 insertions(+), 66 deletions(-) create mode 100644 packages/react-router/src/components/__tests__/ProtectedRoute.test.tsx create mode 100644 packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx diff --git a/packages/react-router/.gitignore b/packages/react-router/.gitignore index 0ccb8df..1bbc2a7 100644 --- a/packages/react-router/.gitignore +++ b/packages/react-router/.gitignore @@ -139,3 +139,7 @@ dist vite.config.js.timestamp-* vite.config.ts.timestamp-* .vite/ + +# Vitest browser screenshots and attachments +.vitest-attachments/ +**/__screenshots__/ diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 0369ee1..b081b5b 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -47,6 +47,8 @@ }, "devDependencies": { "@playwright/test": "catalog:", + "@testing-library/react": "catalog:", + "@thunderid/browser": "workspace:^", "@thunderid/eslint-plugin": "catalog:", "@thunderid/prettier-config": "catalog:", "@thunderid/react": "workspace:^", @@ -64,6 +66,7 @@ "vitest": "catalog:" }, "peerDependencies": { + "@thunderid/browser": ">=0.1.0", "@thunderid/react": ">=0.1.0", "react": ">=16.8.0", "react-router": ">=6.30.1" diff --git a/packages/react-router/src/components/ProtectedRoute.tsx b/packages/react-router/src/components/ProtectedRoute.tsx index 54136da..752770f 100644 --- a/packages/react-router/src/components/ProtectedRoute.tsx +++ b/packages/react-router/src/components/ProtectedRoute.tsx @@ -16,10 +16,16 @@ * under the License. */ +import {createPackageComponentLogger} from '@thunderid/browser'; import {ThunderIDRuntimeError, navigate, useThunderID} from '@thunderid/react'; -import {FC, ReactElement, ReactNode} from 'react'; +import {FC, ReactElement, ReactNode, RefObject, useEffect, useRef} from 'react'; import {Navigate} from 'react-router'; +const logger: ReturnType = createPackageComponentLogger( + '@thunderid/react-router', + 'ProtectedRoute', +); + /** * Props for the ProtectedRoute component. */ @@ -46,10 +52,13 @@ export interface ProtectedRouteProps { * @param defaultSignIn - The default signIn method from useThunderID hook * @param signInOptions - Merged sign-in options (context + component props) */ - onSignIn?: (defaultSignIn: (options?: Record) => void, signInOptions?: Record) => void; + onSignIn?: ( + defaultSignIn: (options?: Record) => Promise, + signInOptions?: Record, + ) => void | Promise; /** * URL to redirect to when the user is not authenticated. - * Required unless a fallback element is provided. + * When neither this nor a fallback element is provided, sign-in is initiated instead. */ redirectTo?: string; /** @@ -92,8 +101,8 @@ export interface ProtectedRouteProps { * It checks authentication status and either renders the protected content, * shows a loading state, redirects, or shows a fallback. * - * Either a `redirectTo` prop or a `fallback` prop must be provided to handle - * unauthenticated users. + * For unauthenticated users it renders the `fallback`, redirects to `redirectTo`, or, when neither + * is provided, initiates sign-in and renders the `loader` until the redirect happens. * * @example Basic usage with redirect * ```tsx @@ -161,6 +170,76 @@ const ProtectedRoute: FC = ({ }: ProtectedRouteProps) => { const {isSignedIn, isLoading, signIn, signInOptions, tokenRequest, signInUrl} = useThunderID(); + const hasInitiatedSignInRef: RefObject = useRef(false); + + const needsSignIn: boolean = !isSignedIn && !fallback && !redirectTo; + const shouldInitiateSignIn: boolean = !isLoading && needsSignIn; + + // Sign-in must be initiated from an effect, never from render: it updates the provider's state, + // and a render-phase update makes React discard the render and retry it, re-running the sign-in + // on every retry. + useEffect(() => { + if (!needsSignIn) { + // Reset so a later sign-out initiates sign-in again instead of rendering the loader forever. + // Keyed off `needsSignIn`, not `shouldInitiateSignIn`: the in-flight sign-in toggles the + // provider's loading state, and resetting on that would re-arm the guard and sign in twice. + hasInitiatedSignInRef.current = false; + return; + } + + if (!shouldInitiateSignIn || hasInitiatedSignInRef.current) { + return; + } + + hasInitiatedSignInRef.current = true; + + // Logged rather than thrown: a rejection here cannot reach React, and CallbackRoute treats + // async auth failures the same way. + const reportSignInFailure = (error: unknown): void => { + const signInError: ThunderIDRuntimeError = new ThunderIDRuntimeError( + 'Sign-in failed in ProtectedRoute.', + 'ProtectedRoute-SignInError-001', + 'react-router', + error, + ); + + logger.error(signInError.message, signInError); + }; + + if (signInUrl) { + navigate(signInUrl); + return; + } + if (onSignIn) { + // onSignIn may be async; wrapping the call in Promise.resolve().then() catches both a + // synchronous throw and a rejected Promise through the same error path as default sign-in. + Promise.resolve() + .then(() => onSignIn(signIn, overriddenSignInOptions)) + .catch(reportSignInFailure); + return; + } + + const mergedParams: Record | undefined = (overriddenTokenRequest ?? tokenRequest)?.params; + + signIn( + overriddenSignInOptions ?? signInOptions, + undefined, + undefined, + undefined, + mergedParams && Object.keys(mergedParams).length > 0 ? {params: mergedParams} : undefined, + ).catch(reportSignInFailure); + }, [ + needsSignIn, + shouldInitiateSignIn, + signInUrl, + onSignIn, + signIn, + signInOptions, + overriddenSignInOptions, + tokenRequest, + overriddenTokenRequest, + ]); + // Always wait for loading to finish before making authentication decisions if (isLoading) { return loader; @@ -178,40 +257,8 @@ const ProtectedRoute: FC = ({ return ; } - if (!isSignedIn) { - if (signInUrl) { - navigate(signInUrl); - } else if (onSignIn) { - onSignIn(signIn, overriddenSignInOptions); - } else { - (async (): Promise => { - try { - const mergedParams = (overriddenTokenRequest ?? tokenRequest)?.params; - await signIn( - overriddenSignInOptions ?? signInOptions, - undefined, - undefined, - undefined, - mergedParams && Object.keys(mergedParams).length > 0 ? {params: mergedParams} : undefined, - ); - } catch (error) { - throw new ThunderIDRuntimeError( - 'Sign-in failed in ProtectedRoute.', - 'ProtectedRoute-SignInError-001', - 'react-router', - `An error occurred during sign-in: ${(error as Error).message}`, - ); - } - })(); - } - } - - throw new ThunderIDRuntimeError( - 'ProtectedRoute misconfiguration.', - 'ProtectedRoute-Misconfiguration-001', - 'react-router', - 'The internal handler failed to process the state. Please try with a fallback or redirectTo prop.', - ); + // The effect above is taking the user to sign-in; render the loader until that resolves. + return loader; }; export default ProtectedRoute; diff --git a/packages/react-router/src/components/__tests__/ProtectedRoute.test.tsx b/packages/react-router/src/components/__tests__/ProtectedRoute.test.tsx new file mode 100644 index 0000000..bda07a3 --- /dev/null +++ b/packages/react-router/src/components/__tests__/ProtectedRoute.test.tsx @@ -0,0 +1,348 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {cleanup, render, screen, waitFor} from '@testing-library/react'; +import {ReactElement} from 'react'; +import {MemoryRouter, Route, Routes} from 'react-router'; +import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import ProtectedRoute, {ProtectedRouteProps} from '../ProtectedRoute'; + +type SignInMock = Mock<(...args: unknown[]) => Promise>>; + +/** The slice of the ThunderID context {@link ProtectedRoute} reads. */ +interface ThunderIDState { + isLoading: boolean; + isSignedIn: boolean; + signIn: SignInMock; + signInOptions?: Record; + signInUrl?: string; + tokenRequest?: {params?: Record}; +} + +interface Mocks { + logger: {error: Mock<(message: string, ...args: unknown[]) => void>}; + navigate: Mock<(url: string) => void>; + state: ThunderIDState; +} + +const mocks: Mocks = vi.hoisted(() => ({}) as Mocks); + +vi.mock('@thunderid/browser', () => ({ + createPackageComponentLogger: () => ({ + error: (message: string, ...args: unknown[]): void => mocks.logger.error(message, ...args), + }), +})); + +vi.mock('@thunderid/react', () => ({ + ThunderIDRuntimeError: class extends Error { + public code: string; + + public details: unknown; + + public origin: string; + + public constructor(message: string, code: string, origin: string, details?: unknown) { + super(message); + this.code = code; + this.origin = origin; + this.details = details; + } + }, + navigate: (url: string): void => mocks.navigate(url), + useThunderID: (): ThunderIDState => mocks.state, +})); + +/** + * Number of `signIn()` calls already recorded at the moment the loader rendered. + * + * The loader is part of the tree {@link ProtectedRoute} returns, so it renders in the same pass. + * A non-zero value means `signIn()` ran during render rather than from an effect. + */ +let signInCallsAtLoaderRender: number | null = null; + +function Loader({onRender}: {onRender: (signInCalls: number) => void}): ReactElement { + onRender(mocks.state.signIn.mock.calls.length); + + return
; +} + +function renderRoute(props: Partial = {}): {rerender: () => void} { + const loader: ReactElement = ( + { + signInCallsAtLoaderRender = signInCalls; + }} + /> + ); + + // Built fresh per render: React bails out of re-rendering an identical element reference, which + // would make every rerender() below a no-op. + const buildTree = (): ReactElement => ( + + + +
secret
+ + } + /> + } /> +
+
+ ); + + const result: {rerender: (ui: ReactElement) => void} = render(buildTree()); + + return {rerender: () => result.rerender(buildTree())}; +} + +beforeEach(() => { + signInCallsAtLoaderRender = null; + mocks.logger = {error: vi.fn()}; + mocks.navigate = vi.fn(); + mocks.state = { + isLoading: false, + isSignedIn: false, + signIn: vi.fn(() => Promise.resolve({sub: 'user-1'})), + }; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('ProtectedRoute authenticated states', () => { + it('renders the loader while authentication status is being determined', () => { + mocks.state.isLoading = true; + + renderRoute(); + + expect(screen.getByTestId('loader')).toBeDefined(); + expect(mocks.state.signIn).not.toHaveBeenCalled(); + }); + + it('renders the children when the user is signed in', () => { + mocks.state.isSignedIn = true; + + renderRoute(); + + expect(screen.getByTestId('protected')).toBeDefined(); + expect(mocks.state.signIn).not.toHaveBeenCalled(); + }); + + it('never calls signIn while navigating between already-authenticated protected routes', () => { + // Regression check: navigating to a new protected route mounts a fresh ProtectedRoute + // instance (fresh refs). An already-signed-in user must never trigger sign-in, no matter how + // many protected routes they move through. + mocks.state.isSignedIn = true; + + renderRoute(); + + expect(screen.getByTestId('protected')).toBeDefined(); + expect(mocks.state.signIn).not.toHaveBeenCalled(); + + // Simulates leaving the route entirely and landing on a different protected route: the old + // ProtectedRoute unmounts and a fresh instance (fresh refs) mounts in its place. + cleanup(); + + const {rerender} = renderRoute(); + + expect(screen.getByTestId('protected')).toBeDefined(); + expect(mocks.state.signIn).not.toHaveBeenCalled(); + + // A later re-render on that same route (e.g. an unrelated parent state change) must not + // trigger sign-in either. + rerender(); + + expect(screen.getByTestId('protected')).toBeDefined(); + expect(mocks.state.signIn).not.toHaveBeenCalled(); + }); +}); + +describe('ProtectedRoute unauthenticated states', () => { + it('renders the fallback instead of initiating sign-in', () => { + renderRoute({fallback:
}); + + expect(screen.getByTestId('fallback')).toBeDefined(); + expect(mocks.state.signIn).not.toHaveBeenCalled(); + }); + + it('redirects when redirectTo is provided instead of initiating sign-in', async () => { + renderRoute({redirectTo: '/signin'}); + + await waitFor(() => expect(screen.getByTestId('signin-page')).toBeDefined()); + expect(mocks.state.signIn).not.toHaveBeenCalled(); + }); + + it('navigates to signInUrl instead of initiating sign-in', async () => { + mocks.state.signInUrl = 'https://localhost:8090/gate/signin'; + + renderRoute(); + + await waitFor(() => expect(mocks.navigate).toHaveBeenCalledWith('https://localhost:8090/gate/signin')); + expect(mocks.state.signIn).not.toHaveBeenCalled(); + }); + + it('delegates to onSignIn instead of calling signIn directly', async () => { + const onSignIn: Mock<() => void> = vi.fn(); + + renderRoute({onSignIn, signInOptions: {prompt: 'login'}}); + + await waitFor(() => expect(onSignIn).toHaveBeenCalledTimes(1)); + expect(onSignIn).toHaveBeenCalledWith(mocks.state.signIn, {prompt: 'login'}); + expect(mocks.state.signIn).not.toHaveBeenCalled(); + }); + + it('reports a rejected custom onSignIn handler through the same error path as default sign-in', async () => { + const onSignIn: Mock<() => Promise> = vi.fn(() => Promise.reject(new Error('custom handler boom'))); + + renderRoute({onSignIn}); + + await waitFor(() => expect(mocks.logger.error).toHaveBeenCalledTimes(1)); + + const [, reported] = mocks.logger.error.mock.calls[0] as [string, {code: string; details: unknown}]; + + expect(reported.code).toBe('ProtectedRoute-SignInError-001'); + expect((reported.details as Error).message).toBe('custom handler boom'); + }); +}); + +describe('ProtectedRoute default sign-in initiation', () => { + it('renders the loader instead of throwing when no fallback or redirectTo is given', () => { + // Regression: this state used to throw ProtectedRoute-Misconfiguration-001 unconditionally, + // even though sign-in had just been initiated. + expect(() => renderRoute()).not.toThrow(); + + expect(screen.getByTestId('loader')).toBeDefined(); + }); + + it('initiates sign-in from an effect, not during render', async () => { + renderRoute(); + + await waitFor(() => expect(mocks.state.signIn).toHaveBeenCalled()); + + // The loader rendered in the same pass that would have issued a render-phase signIn(). + expect(signInCallsAtLoaderRender).toBe(0); + }); + + it('initiates sign-in only once across repeated re-renders', async () => { + const {rerender} = renderRoute(); + + await waitFor(() => expect(mocks.state.signIn).toHaveBeenCalled()); + + rerender(); + rerender(); + rerender(); + + await waitFor(() => expect(screen.getByTestId('loader')).toBeDefined()); + + expect(mocks.state.signIn).toHaveBeenCalledTimes(1); + }); + + it('does not re-initiate sign-in while the pending sign-in toggles the loading state', async () => { + mocks.state.signIn = vi.fn( + () => + new Promise>(() => { + // Never settles, mirroring a sign-in that ends in a full-page redirect. + }), + ); + + const {rerender} = renderRoute(); + + await waitFor(() => expect(mocks.state.signIn).toHaveBeenCalledTimes(1)); + + // The in-flight signIn() flips the provider into loading and back out again before the browser + // unloads for the redirect. The guard must survive that. + mocks.state.isLoading = true; + rerender(); + mocks.state.isLoading = false; + rerender(); + + await waitFor(() => expect(screen.getByTestId('loader')).toBeDefined()); + + expect(mocks.state.signIn).toHaveBeenCalledTimes(1); + }); + + it('does not retry a failed sign-in on its own', async () => { + mocks.state.signIn = vi.fn(() => Promise.reject(new Error('boom'))); + + const {rerender} = renderRoute(); + + await waitFor(() => expect(mocks.logger.error).toHaveBeenCalledTimes(1)); + + // Toggling isLoading (as the provider does around every signIn() call) must not re-arm the + // guard: the failed episode stays failed until it genuinely ends. + mocks.state.isLoading = true; + rerender(); + mocks.state.isLoading = false; + rerender(); + + expect(mocks.state.signIn).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('loader')).toBeDefined(); + }); + + it('re-initiates sign-in once the failed episode ends and a new one begins', async () => { + mocks.state.signIn = vi.fn(() => Promise.reject(new Error('boom'))); + + const {rerender} = renderRoute(); + + await waitFor(() => expect(mocks.state.signIn).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(mocks.logger.error).toHaveBeenCalledTimes(1)); + + // Episode ends (signed in) and a new one begins (signed out again). + mocks.state.isSignedIn = true; + rerender(); + mocks.state.isSignedIn = false; + rerender(); + + await waitFor(() => expect(mocks.state.signIn).toHaveBeenCalledTimes(2)); + }); + + it('forwards signInOptions and tokenRequest params to signIn', async () => { + mocks.state.signInOptions = {prompt: 'login'}; + mocks.state.tokenRequest = {params: {resource: 'https://api.example.com'}}; + + renderRoute(); + + await waitFor(() => expect(mocks.state.signIn).toHaveBeenCalled()); + + expect(mocks.state.signIn).toHaveBeenCalledWith({prompt: 'login'}, undefined, undefined, undefined, { + params: {resource: 'https://api.example.com'}, + }); + }); + + it('reports a failed sign-in as a ThunderIDRuntimeError without rejecting', async () => { + mocks.state.signIn = vi.fn(() => Promise.reject(new Error('boom'))); + + renderRoute(); + + await waitFor(() => expect(mocks.logger.error).toHaveBeenCalledTimes(1)); + + const [, reported] = mocks.logger.error.mock.calls[0] as [string, {code: string; details: unknown; origin: string}]; + + expect(reported.code).toBe('ProtectedRoute-SignInError-001'); + expect(reported.origin).toBe('react-router'); + expect((reported.details as Error).message).toBe('boom'); + + // The failure is reported, not thrown, so the route keeps rendering. + expect(screen.getByTestId('loader')).toBeDefined(); + }); +}); diff --git a/packages/react-router/tsconfig.spec.json b/packages/react-router/tsconfig.spec.json index d39f597..9f1f71a 100644 --- a/packages/react-router/tsconfig.spec.json +++ b/packages/react-router/tsconfig.spec.json @@ -10,7 +10,9 @@ "vitest.config.ts", "jest.config.js", "**/*.test.ts", + "**/*.test.tsx", "**/*.spec.ts", + "**/*.spec.tsx", "**/*.test.js", "**/*.spec.js", "**/*.d.ts" diff --git a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx index ea7260f..d5c4356 100644 --- a/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx +++ b/packages/react/src/contexts/ThunderID/ThunderIDProvider.tsx @@ -103,6 +103,10 @@ const ThunderIDProvider: FC> = ({ const [isUpdatingSession, setIsUpdatingSession] = useState(false); const [wellKnown, setWellKnown] = useState(null); + // Holds the in-flight `updateSession()` promise so concurrent callers coalesce onto a single + // `/users/me` request instead of issuing one each. + const pendingSessionUpdateRef: RefObject | null> = useRef | null>(null); + useEffect(() => { setBaseUrl(initialBaseUrl ?? ''); }, [initialBaseUrl]); @@ -116,7 +120,7 @@ const ThunderIDProvider: FC> = ({ })(); }, []); - async function updateSession(): Promise { + async function performSessionUpdate(): Promise { try { // Set flag to prevent loading state tracking from interfering setIsUpdatingSession(true); @@ -164,6 +168,25 @@ const ThunderIDProvider: FC> = ({ } } + /** + * Single-flight wrapper around {@link performSessionUpdate}. + * + * Concurrent callers share the in-flight request instead of each issuing their own `/users/me`. + * This is not a cache: once the request settles the ref is cleared, so a later caller (e.g. + * revalidation after a profile update) fetches again. + */ + async function updateSession(): Promise { + if (pendingSessionUpdateRef.current) { + return pendingSessionUpdateRef.current; + } + + pendingSessionUpdateRef.current = performSessionUpdate().finally(() => { + pendingSessionUpdateRef.current = null; + }); + + return pendingSessionUpdateRef.current; + } + async function signIn(...args: any): Promise { // Check if this is a V2 embedded flow request BEFORE calling signIn // This allows us to skip session checks entirely for V2 flows @@ -216,35 +239,20 @@ const ThunderIDProvider: FC> = ({ await updateSession(); }); - // User is already authenticated. Skip... - const isAlreadySignedIn: boolean = await client.isSignedIn(); - - // Start auto-refresh with a soft failure. - const scheduleAutoRefresh = async (): Promise => { - try { - await client.startAutoRefreshToken(); - } catch (error) { - logger.warn('Failed to schedule automatic token refresh.', error); - } - }; - - // Restore session state and kick off the refresh timer. - const resumeSession = async (): Promise => { - await updateSession(); - await scheduleAutoRefresh(); - }; - - if (isAlreadySignedIn) { - await resumeSession(); + // Start auto-refresh with a soft failure. This also covers the case where the access token + // expired while the refresh token is still valid — startAutoRefreshToken() calls + // refreshAccessToken() immediately when timeUntilRefresh <= 0 — so the sign-in check below + // sees the refreshed state. + try { + await client.startAutoRefreshToken(); + } catch (error) { + logger.warn('Failed to schedule automatic token refresh.', error); } - // The access token may have expired while the refresh token is still valid. - // Attempt a silent refresh — startAutoRefreshToken() calls refreshAccessToken() - // immediately when timeUntilRefresh <= 0, then re-check sign-in status. - await scheduleAutoRefresh(); - + // User is already authenticated. Restore the session and skip the sign-in path. if (await client.isSignedIn()) { - await resumeSession(); + await updateSession(); + return; } diff --git a/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx b/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx new file mode 100644 index 0000000..b557bb5 --- /dev/null +++ b/packages/react/src/contexts/ThunderID/__tests__/ThunderIDProvider.test.tsx @@ -0,0 +1,234 @@ +/** + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import {cleanup, render, waitFor} from '@testing-library/react'; +import {Mock, afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import ThunderIDProvider from '../ThunderIDProvider'; + +type Noop = Mock<() => void>; +type SignInHookHandler = () => Promise; +type UserProfileResponse = Record; + +/** + * Minimal stand-in for {@link ThunderIDReactClient}, covering only what {@link ThunderIDProvider} + * calls during mount and session sync. + */ +interface MockClient { + clearSession: Noop; + exchangeToken: Noop; + getAccessToken: Noop; + getConfiguration: Mock<() => Promise<{afterSignInUrl: string; baseUrl: string}>>; + getDecodedIdToken: Mock<() => Promise>>; + getDiscoveryResponse: Mock<() => Promise>; + getIdToken: Noop; + getStorageManager: Noop; + /** Callbacks registered through `client.on(...)`, keyed by event name. */ + handlers: Record; + initialize: Mock<() => Promise>; + isInitialized: Mock<() => Promise>; + isLoading: Mock<() => boolean>; + isSignedIn: Mock<() => Promise>; + on: Mock<(event: string, callback: SignInHookHandler) => Promise>; + reInitialize: Noop; + recover: Noop; + request: Noop; + requestAll: Noop; + signIn: Mock<() => Promise>>; + signInSilently: Noop; + signOut: Noop; + signUp: Noop; + /** Mutable sign-in state the mocked `isSignedIn()` reports. */ + signedIn: boolean; + startAutoRefreshToken: Mock<() => Promise>; +} + +interface Mocks { + client: MockClient; + getUsersMe: Mock<() => Promise>; +} + +const mocks: Mocks = vi.hoisted(() => ({}) as Mocks); + +vi.mock('../../../ThunderIDReactClient', () => ({ + default: vi.fn(function MockThunderIDReactClient(): MockClient { + return mocks.client; + }), +})); + +vi.mock('../../../api/getUsersMe', () => ({ + default: (): Promise => mocks.getUsersMe(), +})); + +interface Deferred { + promise: Promise; + resolve: (value: UserProfileResponse) => void; +} + +function createDeferred(): Deferred { + let resolve!: (value: UserProfileResponse) => void; + const promise: Promise = new Promise( + (res: (value: UserProfileResponse) => void) => { + resolve = res; + }, + ); + + return {promise, resolve}; +} + +function createMockClient(): MockClient { + const handlers: Record = {}; + const client: MockClient = { + clearSession: vi.fn(), + exchangeToken: vi.fn(), + getAccessToken: vi.fn(), + getConfiguration: vi.fn(() => + Promise.resolve({afterSignInUrl: window.location.origin, baseUrl: 'https://localhost:8090'}), + ), + getDecodedIdToken: vi.fn(() => Promise.resolve({sub: 'user-1'})), + getDiscoveryResponse: vi.fn(() => Promise.resolve(null)), + getIdToken: vi.fn(), + getStorageManager: vi.fn(), + handlers, + initialize: vi.fn(() => Promise.resolve(true)), + isInitialized: vi.fn(() => Promise.resolve(true)), + isLoading: vi.fn(() => false), + isSignedIn: vi.fn(() => Promise.resolve(client.signedIn)), + on: vi.fn((event: string, callback: SignInHookHandler) => { + handlers[event] = callback; + + return Promise.resolve(); + }), + reInitialize: vi.fn(), + recover: vi.fn(), + request: vi.fn(), + requestAll: vi.fn(), + signIn: vi.fn(() => Promise.resolve({sub: 'user-1'})), + signInSilently: vi.fn(), + signOut: vi.fn(), + signUp: vi.fn(), + signedIn: false, + startAutoRefreshToken: vi.fn(() => Promise.resolve()), + }; + + return client; +} + +function renderProvider(): void { + render( + +
+ , + ); +} + +const settle = (ms = 250): Promise => + new Promise((resolve: () => void) => { + setTimeout(resolve, ms); + }); + +beforeEach(() => { + mocks.client = createMockClient(); + mocks.getUsersMe = vi.fn(() => Promise.resolve({sub: 'user-1', userName: 'alice'})); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe('ThunderIDProvider mount bootstrap', () => { + it('fetches the user profile exactly once when the user is already signed in', async () => { + mocks.client.signedIn = true; + + renderProvider(); + + await waitFor(() => expect(mocks.getUsersMe).toHaveBeenCalled()); + await settle(); + + expect(mocks.getUsersMe).toHaveBeenCalledTimes(1); + }); + + it('schedules the auto refresh timer exactly once per mount', async () => { + mocks.client.signedIn = true; + + renderProvider(); + + await waitFor(() => expect(mocks.client.startAutoRefreshToken).toHaveBeenCalled()); + await settle(); + + expect(mocks.client.startAutoRefreshToken).toHaveBeenCalledTimes(1); + }); + + it('attempts the silent refresh before deciding the user is not signed in', () => { + // The access token expired while the refresh token is still valid: startAutoRefreshToken() + // refreshes it, and the sign-in check that follows must observe the refreshed state. + mocks.client.startAutoRefreshToken = vi.fn(() => { + mocks.client.signedIn = true; + + return Promise.resolve(); + }); + + renderProvider(); + + return waitFor(() => expect(mocks.getUsersMe).toHaveBeenCalled()) + .then(() => settle()) + .then(() => { + expect(mocks.client.startAutoRefreshToken).toHaveBeenCalledTimes(1); + expect(mocks.getUsersMe).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('ThunderIDProvider updateSession single-flight', () => { + it('coalesces concurrent session updates onto a single /users/me request', async () => { + renderProvider(); + + await waitFor(() => expect(mocks.client.handlers['sign-in']).toBeDefined()); + + const gate: Deferred = createDeferred(); + + mocks.getUsersMe = vi.fn(() => gate.promise); + mocks.client.signedIn = true; + + const first: Promise = mocks.client.handlers['sign-in'](); + const second: Promise = mocks.client.handlers['sign-in'](); + + await settle(50); + expect(mocks.getUsersMe).toHaveBeenCalledTimes(1); + + gate.resolve({sub: 'user-1', userName: 'alice'}); + + await Promise.all([first, second]); + + expect(mocks.getUsersMe).toHaveBeenCalledTimes(1); + }); + + it('re-fetches for a session update issued after the previous one settled', async () => { + renderProvider(); + + await waitFor(() => expect(mocks.client.handlers['sign-in']).toBeDefined()); + + mocks.client.signedIn = true; + + await mocks.client.handlers['sign-in'](); + expect(mocks.getUsersMe).toHaveBeenCalledTimes(1); + + await mocks.client.handlers['sign-in'](); + expect(mocks.getUsersMe).toHaveBeenCalledTimes(2); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df33611..ec60377 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -734,6 +734,12 @@ importers: '@playwright/test': specifier: 'catalog:' version: 1.60.0 + '@testing-library/react': + specifier: 'catalog:' + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@thunderid/browser': + specifier: workspace:^ + version: link:../browser '@thunderid/eslint-plugin': specifier: 'catalog:' version: 0.0.2(@typescript-eslint/utils@8.61.1(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.4(jiti@2.7.0))(typescript@5.9.3)(vitest@4.1.8)(vue-eslint-parser@10.4.1(eslint@9.39.4(jiti@2.7.0)))