@axa-fr/react-oidc 7.27.12 → 7.27.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,279 @@
1
+ import { act, render, screen } from '@testing-library/react';
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ let mockEventSubscribers: Array<{ id: string; func: (name: string, data: any) => void }> = [];
5
+ const mockPublishEvent = vi.fn((eventName, data) => {
6
+ mockEventSubscribers.forEach(sub => sub.func(eventName, data));
7
+ });
8
+ const mockSubscribeEvents = vi.fn(func => {
9
+ const id = Math.random().toString();
10
+ mockEventSubscribers.push({ id, func });
11
+ return id;
12
+ });
13
+ const mockRemoveEventSubscription = vi.fn(id => {
14
+ mockEventSubscribers = mockEventSubscribers.filter(e => e.id !== id);
15
+ });
16
+
17
+ vi.mock('@axa-fr/oidc-client', () => ({
18
+ OidcClient: {
19
+ getOrCreate: vi.fn(() => () => ({
20
+ subscribeEvents: mockSubscribeEvents,
21
+ removeEventSubscription: mockRemoveEventSubscription,
22
+ publishEvent: mockPublishEvent,
23
+ configuration: {
24
+ redirect_uri: 'http://localhost/callback',
25
+ silent_redirect_uri: 'http://localhost/silent-callback',
26
+ silent_login_uri: 'http://localhost/silent-login',
27
+ },
28
+ tryKeepExistingSessionAsync: vi.fn().mockResolvedValue(true),
29
+ })),
30
+ get: vi.fn(() => ({
31
+ subscribeEvents: mockSubscribeEvents,
32
+ removeEventSubscription: mockRemoveEventSubscription,
33
+ publishEvent: mockPublishEvent,
34
+ configuration: {
35
+ redirect_uri: 'http://localhost/callback',
36
+ silent_redirect_uri: 'http://localhost/silent-callback',
37
+ silent_login_uri: 'http://localhost/silent-login',
38
+ },
39
+ tryKeepExistingSessionAsync: vi.fn().mockResolvedValue(true),
40
+ })),
41
+ eventNames: {
42
+ service_worker_not_supported_by_browser: 'service_worker_not_supported_by_browser',
43
+ token_acquired: 'token_acquired',
44
+ logout_from_another_tab: 'logout_from_another_tab',
45
+ logout_from_same_tab: 'logout_from_same_tab',
46
+ token_renewed: 'token_renewed',
47
+ token_timer: 'token_timer',
48
+ loginAsync_begin: 'loginAsync_begin',
49
+ loginAsync_error: 'loginAsync_error',
50
+ loginCallbackAsync_begin: 'loginCallbackAsync_begin',
51
+ loginCallbackAsync_end: 'loginCallbackAsync_end',
52
+ loginCallbackAsync_error: 'loginCallbackAsync_error',
53
+ refreshTokensAsync_begin: 'refreshTokensAsync_begin',
54
+ refreshTokensAsync: 'refreshTokensAsync',
55
+ refreshTokensAsync_end: 'refreshTokensAsync_end',
56
+ refreshTokensAsync_error: 'refreshTokensAsync_error',
57
+ refreshTokensAsync_silent_error: 'refreshTokensAsync_silent_error',
58
+ tryKeepExistingSessionAsync_begin: 'tryKeepExistingSessionAsync_begin',
59
+ tryKeepExistingSessionAsync_end: 'tryKeepExistingSessionAsync_end',
60
+ tryKeepExistingSessionAsync_error: 'tryKeepExistingSessionAsync_error',
61
+ silentLoginAsync_begin: 'silentLoginAsync_begin',
62
+ silentLoginAsync: 'silentLoginAsync',
63
+ silentLoginAsync_end: 'silentLoginAsync_end',
64
+ silentLoginAsync_error: 'silentLoginAsync_error',
65
+ syncTokensAsync_begin: 'syncTokensAsync_begin',
66
+ syncTokensAsync_lock_not_available: 'syncTokensAsync_lock_not_available',
67
+ syncTokensAsync_end: 'syncTokensAsync_end',
68
+ syncTokensAsync_error: 'syncTokensAsync_error',
69
+ tokensInvalidAndWaitingActionsToRefresh: 'tokensInvalidAndWaitingActionsToRefresh',
70
+ loadingTimeout_error: 'loadingTimeout_error',
71
+ },
72
+ },
73
+ OidcLocation: class {
74
+ getCurrentHref() {
75
+ return 'http://localhost/';
76
+ }
77
+ getPath() {
78
+ return '/';
79
+ }
80
+ open() {}
81
+ reload() {}
82
+ getOrigin() {
83
+ return 'http://localhost';
84
+ }
85
+ },
86
+ getFetchDefault: vi.fn(() => fetch),
87
+ }));
88
+
89
+ vi.mock('./core/routes/OidcRoutes.js', () => ({
90
+ default: ({ children }: any) => <div data-testid="oidc-routes">{children}</div>,
91
+ }));
92
+
93
+ import { OidcProvider } from './OidcProvider';
94
+
95
+ describe('OidcProvider loading timeout', () => {
96
+ beforeEach(() => {
97
+ vi.useFakeTimers();
98
+ vi.clearAllMocks();
99
+ mockEventSubscribers = [];
100
+ });
101
+
102
+ afterEach(() => {
103
+ vi.useRealTimers();
104
+ });
105
+
106
+ const baseConfiguration = {
107
+ client_id: 'test-client',
108
+ redirect_uri: 'http://localhost/callback',
109
+ silent_redirect_uri: 'http://localhost/silent-callback',
110
+ scope: 'openid',
111
+ authority: 'http://localhost/authority',
112
+ };
113
+
114
+ it('should fire loadingTimeout_error when stuck in initial loading state', async () => {
115
+ render(
116
+ <OidcProvider
117
+ configuration={{ ...baseConfiguration, loading_timeout_ms: 100 }}
118
+ configurationName="default"
119
+ >
120
+ <div>App</div>
121
+ </OidcProvider>,
122
+ );
123
+
124
+ act(() => {
125
+ vi.advanceTimersByTime(100);
126
+ });
127
+
128
+ expect(mockPublishEvent).toHaveBeenCalledWith('loadingTimeout_error', { timeoutMs: 100 });
129
+ });
130
+
131
+ it('should fire loadingTimeout_error when stuck in loginAsync_begin state', async () => {
132
+ render(
133
+ <OidcProvider
134
+ configuration={{ ...baseConfiguration, loading_timeout_ms: 200 }}
135
+ configurationName="default"
136
+ >
137
+ <div>App</div>
138
+ </OidcProvider>,
139
+ );
140
+
141
+ // Simulate loginAsync_begin event
142
+ act(() => {
143
+ mockEventSubscribers.forEach(sub => sub.func('loginAsync_begin', {}));
144
+ });
145
+
146
+ // Reset to track timeout event specifically
147
+ mockPublishEvent.mockClear();
148
+
149
+ act(() => {
150
+ vi.advanceTimersByTime(200);
151
+ });
152
+
153
+ expect(mockPublishEvent).toHaveBeenCalledWith('loadingTimeout_error', { timeoutMs: 200 });
154
+ });
155
+
156
+ it('should render loadingTimeoutComponent when loadingTimeout_error is triggered', async () => {
157
+ const CustomTimeoutComponent = () => <div data-testid="custom-timeout">Timeout!</div>;
158
+
159
+ render(
160
+ <OidcProvider
161
+ configuration={{ ...baseConfiguration, loading_timeout_ms: 50 }}
162
+ configurationName="default"
163
+ loadingTimeoutComponent={CustomTimeoutComponent}
164
+ >
165
+ <div>App</div>
166
+ </OidcProvider>,
167
+ );
168
+
169
+ await act(async () => {
170
+ vi.advanceTimersByTime(50);
171
+ });
172
+
173
+ expect(screen.getByTestId('custom-timeout')).toBeTruthy();
174
+ });
175
+
176
+ it('should NOT fire loadingTimeout_error when loading_timeout_ms is 0 (disabled)', async () => {
177
+ render(
178
+ <OidcProvider
179
+ configuration={{ ...baseConfiguration, loading_timeout_ms: 0 }}
180
+ configurationName="default"
181
+ >
182
+ <div>App</div>
183
+ </OidcProvider>,
184
+ );
185
+
186
+ act(() => {
187
+ vi.advanceTimersByTime(60_000);
188
+ });
189
+
190
+ expect(mockPublishEvent).not.toHaveBeenCalledWith('loadingTimeout_error', expect.anything());
191
+ });
192
+
193
+ it('should NOT fire loadingTimeout_error when loading_timeout_ms is negative (disabled)', async () => {
194
+ render(
195
+ <OidcProvider
196
+ configuration={{ ...baseConfiguration, loading_timeout_ms: -1 }}
197
+ configurationName="default"
198
+ >
199
+ <div>App</div>
200
+ </OidcProvider>,
201
+ );
202
+
203
+ act(() => {
204
+ vi.advanceTimersByTime(60_000);
205
+ });
206
+
207
+ expect(mockPublishEvent).not.toHaveBeenCalledWith('loadingTimeout_error', expect.anything());
208
+ });
209
+
210
+ it('should NOT fire loadingTimeout_error if provider leaves loading state before deadline', async () => {
211
+ render(
212
+ <OidcProvider
213
+ configuration={{ ...baseConfiguration, loading_timeout_ms: 500 }}
214
+ configurationName="default"
215
+ >
216
+ <div>App</div>
217
+ </OidcProvider>,
218
+ );
219
+
220
+ // Advance partway
221
+ act(() => {
222
+ vi.advanceTimersByTime(200);
223
+ });
224
+
225
+ // Simulate successful callback (leaving loading state)
226
+ act(() => {
227
+ mockEventSubscribers.forEach(sub => sub.func('loginCallbackAsync_end', {}));
228
+ });
229
+
230
+ mockPublishEvent.mockClear();
231
+
232
+ // Advance past original timeout
233
+ act(() => {
234
+ vi.advanceTimersByTime(500);
235
+ });
236
+
237
+ expect(mockPublishEvent).not.toHaveBeenCalledWith('loadingTimeout_error', expect.anything());
238
+ });
239
+
240
+ it('should use default timeout of 30000ms when loading_timeout_ms is not configured', async () => {
241
+ render(
242
+ <OidcProvider configuration={baseConfiguration} configurationName="default">
243
+ <div>App</div>
244
+ </OidcProvider>,
245
+ );
246
+
247
+ act(() => {
248
+ vi.advanceTimersByTime(29_999);
249
+ });
250
+
251
+ expect(mockPublishEvent).not.toHaveBeenCalledWith('loadingTimeout_error', expect.anything());
252
+
253
+ act(() => {
254
+ vi.advanceTimersByTime(1);
255
+ });
256
+
257
+ expect(mockPublishEvent).toHaveBeenCalledWith('loadingTimeout_error', { timeoutMs: 30_000 });
258
+ });
259
+
260
+ it('should propagate loadingTimeout_error through onEvent callback', async () => {
261
+ const onEvent = vi.fn();
262
+
263
+ render(
264
+ <OidcProvider
265
+ configuration={{ ...baseConfiguration, loading_timeout_ms: 100 }}
266
+ configurationName="default"
267
+ onEvent={onEvent}
268
+ >
269
+ <div>App</div>
270
+ </OidcProvider>,
271
+ );
272
+
273
+ act(() => {
274
+ vi.advanceTimersByTime(100);
275
+ });
276
+
277
+ expect(onEvent).toHaveBeenCalledWith('default', 'loadingTimeout_error', { timeoutMs: 100 });
278
+ });
279
+ });
@@ -13,6 +13,7 @@ import {
13
13
  Authenticating,
14
14
  CallBackSuccess,
15
15
  Loading,
16
+ LoadingTimeout,
16
17
  SessionLost,
17
18
  } from './core/default-component/index.js';
18
19
  import ServiceWorkerNotSupported from './core/default-component/ServiceWorkerNotSupported.component.js';
@@ -31,6 +32,7 @@ export type OidcProviderProps = {
31
32
  authenticatingComponent?: ComponentType<any>;
32
33
  authenticatingErrorComponent?: ComponentType<any>;
33
34
  loadingComponent?: ComponentType<any>;
35
+ loadingTimeoutComponent?: ComponentType<any>;
34
36
  serviceWorkerNotSupportedComponent?: ComponentType<any>;
35
37
  configurationName?: string;
36
38
  configuration?: OidcConfiguration;
@@ -39,6 +41,7 @@ export type OidcProviderProps = {
39
41
  onLogoutFromAnotherTab?: () => void;
40
42
  onLogoutFromSameTab?: () => void;
41
43
  withCustomHistory?: () => CustomHistory;
44
+ navigateAfterCallback?: (callbackPath: string) => Promise<void>;
42
45
  onEvent?: (configuration: string, name: string, data: any) => void;
43
46
  getFetch?: () => Fetch;
44
47
  location?: ILOidcLocation;
@@ -84,6 +87,8 @@ const Switch = ({ isLoading, loadingComponent, children, configurationName }) =>
84
87
  return <>{children}</>;
85
88
  };
86
89
 
90
+ const DEFAULT_LOADING_TIMEOUT_MS = 30_000;
91
+
87
92
  export const OidcProvider: FC<PropsWithChildren<OidcProviderProps>> = ({
88
93
  children,
89
94
  configuration,
@@ -91,6 +96,7 @@ export const OidcProvider: FC<PropsWithChildren<OidcProviderProps>> = ({
91
96
  callbackSuccessComponent = CallBackSuccess,
92
97
  authenticatingComponent = Authenticating,
93
98
  loadingComponent = Loading,
99
+ loadingTimeoutComponent = LoadingTimeout,
94
100
  serviceWorkerNotSupportedComponent = ServiceWorkerNotSupported,
95
101
  authenticatingErrorComponent = AuthenticatingError,
96
102
  sessionLostComponent = SessionLost,
@@ -98,6 +104,7 @@ export const OidcProvider: FC<PropsWithChildren<OidcProviderProps>> = ({
98
104
  onLogoutFromAnotherTab = null,
99
105
  onLogoutFromSameTab = null,
100
106
  withCustomHistory = null,
107
+ navigateAfterCallback = null,
101
108
  onEvent = null,
102
109
  getFetch = null,
103
110
  location = null,
@@ -155,6 +162,8 @@ export const OidcProvider: FC<PropsWithChildren<OidcProviderProps>> = ({
155
162
  onLogoutFromSameTab();
156
163
  }
157
164
  // setEvent({name, data});
165
+ } else if (name === OidcClient.eventNames.loadingTimeout_error) {
166
+ setEvent({ name, data });
158
167
  } else if (
159
168
  name === OidcClient.eventNames.loginAsync_begin ||
160
169
  name === OidcClient.eventNames.loginCallbackAsync_end ||
@@ -181,9 +190,26 @@ export const OidcProvider: FC<PropsWithChildren<OidcProviderProps>> = ({
181
190
  };
182
191
  }, [configuration, configurationName]);
183
192
 
193
+ useEffect(() => {
194
+ const timeoutMs = configuration?.loading_timeout_ms ?? DEFAULT_LOADING_TIMEOUT_MS;
195
+ if (timeoutMs <= 0) {
196
+ return;
197
+ }
198
+ const isStuck = event.name === '' || event.name === OidcClient.eventNames.loginAsync_begin;
199
+ if (!isStuck) {
200
+ return;
201
+ }
202
+ const timeoutId = setTimeout(() => {
203
+ const oidcInstance = getOidc(configurationName);
204
+ oidcInstance.publishEvent(OidcClient.eventNames.loadingTimeout_error, { timeoutMs });
205
+ }, timeoutMs);
206
+ return () => clearTimeout(timeoutId);
207
+ }, [event.name, configurationName, configuration]);
208
+
184
209
  const SessionLostComponent = sessionLostComponent;
185
210
  const AuthenticatingComponent = authenticatingComponent;
186
211
  const LoadingComponent = loadingComponent;
212
+ const LoadingTimeoutComponent = loadingTimeoutComponent;
187
213
  const ServiceWorkerNotSupportedComponent = serviceWorkerNotSupportedComponent;
188
214
  const AuthenticatingErrorComponent = authenticatingErrorComponent;
189
215
 
@@ -211,6 +237,16 @@ export const OidcProvider: FC<PropsWithChildren<OidcProviderProps>> = ({
211
237
  <AuthenticatingComponent configurationName={configurationName} />
212
238
  </Switch>
213
239
  );
240
+ case OidcClient.eventNames.loadingTimeout_error:
241
+ return (
242
+ <Switch
243
+ loadingComponent={LoadingComponent}
244
+ isLoading={isLoading}
245
+ configurationName={configurationName}
246
+ >
247
+ <LoadingTimeoutComponent configurationName={configurationName} />
248
+ </Switch>
249
+ );
214
250
  case OidcClient.eventNames.loginAsync_error:
215
251
  case OidcClient.eventNames.loginCallbackAsync_error:
216
252
  return (
@@ -250,6 +286,7 @@ export const OidcProvider: FC<PropsWithChildren<OidcProviderProps>> = ({
250
286
  authenticatingComponent={authenticatingComponent}
251
287
  configurationName={configurationName}
252
288
  withCustomHistory={withCustomHistory}
289
+ navigateAfterCallback={navigateAfterCallback}
253
290
  location={location ?? new OidcLocation()}
254
291
  >
255
292
  <OidcSession loadingComponent={LoadingComponent} configurationName={configurationName}>
@@ -0,0 +1,248 @@
1
+ import { act, render, waitFor } from '@testing-library/react';
2
+ import React from 'react';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import CallbackManager, { CallBackSuccess, verifyNavigationCommitted } from './Callback.component';
6
+
7
+ vi.mock('@axa-fr/oidc-client', () => ({
8
+ OidcClient: {
9
+ get: vi.fn(),
10
+ eventNames: {
11
+ loginCallbackAsync_navigated: 'loginCallbackAsync_navigated',
12
+ loginCallbackAsync_navigation_error: 'loginCallbackAsync_navigation_error',
13
+ },
14
+ },
15
+ }));
16
+
17
+ vi.mock('../routes/withRouter.js', () => ({
18
+ getCustomHistory: vi.fn(),
19
+ }));
20
+
21
+ import { OidcClient } from '@axa-fr/oidc-client';
22
+
23
+ import { getCustomHistory } from '../routes/withRouter.js';
24
+
25
+ describe('verifyNavigationCommitted', () => {
26
+ it('should return true when current path matches target path', () => {
27
+ const windowMock = { location: { pathname: '/dashboard' } } as Window;
28
+ expect(verifyNavigationCommitted('/dashboard', windowMock)).toBe(true);
29
+ });
30
+
31
+ it('should return false when current path does not match target path', () => {
32
+ const windowMock = { location: { pathname: '/callback' } } as Window;
33
+ expect(verifyNavigationCommitted('/dashboard', windowMock)).toBe(false);
34
+ });
35
+
36
+ it('should return true when target path is root', () => {
37
+ const windowMock = { location: { pathname: '/anything' } } as Window;
38
+ expect(verifyNavigationCommitted('/', windowMock)).toBe(true);
39
+ });
40
+ });
41
+
42
+ describe('CallBackSuccess', () => {
43
+ it('renders the success message', () => {
44
+ const { getByText } = render(<CallBackSuccess />);
45
+ expect(getByText('Authentication complete')).toBeTruthy();
46
+ expect(getByText('You will be redirected to your application.')).toBeTruthy();
47
+ });
48
+ });
49
+
50
+ describe('CallbackManager', () => {
51
+ let mockPublishEvent: ReturnType<typeof vi.fn>;
52
+ let mockLoginCallbackAsync: ReturnType<typeof vi.fn>;
53
+ let mockReplaceState: ReturnType<typeof vi.fn>;
54
+
55
+ beforeEach(() => {
56
+ vi.useFakeTimers();
57
+ mockPublishEvent = vi.fn();
58
+ mockLoginCallbackAsync = vi.fn();
59
+ mockReplaceState = vi.fn();
60
+
61
+ (OidcClient.get as ReturnType<typeof vi.fn>).mockReturnValue({
62
+ loginCallbackAsync: mockLoginCallbackAsync,
63
+ publishEvent: mockPublishEvent,
64
+ });
65
+
66
+ (getCustomHistory as ReturnType<typeof vi.fn>).mockReturnValue({
67
+ replaceState: mockReplaceState,
68
+ });
69
+ });
70
+
71
+ afterEach(() => {
72
+ vi.useRealTimers();
73
+ vi.clearAllMocks();
74
+ });
75
+
76
+ it('should use navigateAfterCallback when provided and emit navigated event on success', async () => {
77
+ mockLoginCallbackAsync.mockResolvedValue({ callbackPath: '/dashboard' });
78
+ const navigateAfterCallback = vi.fn().mockResolvedValue(undefined);
79
+
80
+ await act(async () => {
81
+ render(
82
+ <CallbackManager
83
+ configurationName="default"
84
+ navigateAfterCallback={navigateAfterCallback}
85
+ />,
86
+ );
87
+ });
88
+
89
+ expect(navigateAfterCallback).toHaveBeenCalledWith('/dashboard');
90
+ expect(mockPublishEvent).toHaveBeenCalledWith('loginCallbackAsync_navigated', {
91
+ configurationName: 'default',
92
+ callbackPath: '/dashboard',
93
+ });
94
+ });
95
+
96
+ it('should emit navigation_error event when navigateAfterCallback rejects', async () => {
97
+ mockLoginCallbackAsync.mockResolvedValue({ callbackPath: '/dashboard' });
98
+ const navError = new Error('Navigation failed');
99
+ const navigateAfterCallback = vi.fn().mockRejectedValue(navError);
100
+
101
+ await act(async () => {
102
+ render(
103
+ <CallbackManager
104
+ configurationName="default"
105
+ navigateAfterCallback={navigateAfterCallback}
106
+ />,
107
+ );
108
+ });
109
+
110
+ expect(mockPublishEvent).toHaveBeenCalledWith('loginCallbackAsync_navigation_error', {
111
+ configurationName: 'default',
112
+ callbackPath: '/dashboard',
113
+ error: navError,
114
+ });
115
+ });
116
+
117
+ it('should render error component when navigateAfterCallback fails', async () => {
118
+ mockLoginCallbackAsync.mockResolvedValue({ callbackPath: '/dashboard' });
119
+ const navigateAfterCallback = vi.fn().mockRejectedValue(new Error('fail'));
120
+
121
+ const ErrorComponent = () => <div>Error occurred</div>;
122
+
123
+ let container;
124
+ await act(async () => {
125
+ const result = render(
126
+ <CallbackManager
127
+ configurationName="default"
128
+ navigateAfterCallback={navigateAfterCallback}
129
+ callBackError={ErrorComponent}
130
+ />,
131
+ );
132
+ container = result.container;
133
+ });
134
+
135
+ expect(container.textContent).toContain('Error occurred');
136
+ });
137
+
138
+ it('should use default history navigation when navigateAfterCallback is not provided', async () => {
139
+ vi.useRealTimers();
140
+ mockLoginCallbackAsync.mockResolvedValue({ callbackPath: '/dashboard' });
141
+
142
+ // Mock window.location to simulate successful navigation
143
+ Object.defineProperty(window, 'location', {
144
+ value: { pathname: '/dashboard' },
145
+ writable: true,
146
+ });
147
+
148
+ await act(async () => {
149
+ render(<CallbackManager configurationName="default" />);
150
+ // Wait for the verification delay to pass
151
+ await new Promise(resolve => setTimeout(resolve, 300));
152
+ });
153
+
154
+ expect(mockReplaceState).toHaveBeenCalledWith('/dashboard');
155
+
156
+ await waitFor(() => {
157
+ expect(mockPublishEvent).toHaveBeenCalledWith('loginCallbackAsync_navigated', {
158
+ configurationName: 'default',
159
+ callbackPath: '/dashboard',
160
+ });
161
+ });
162
+ });
163
+
164
+ it('should emit navigation_error when default navigation does not commit', async () => {
165
+ vi.useRealTimers();
166
+ mockLoginCallbackAsync.mockResolvedValue({ callbackPath: '/dashboard' });
167
+
168
+ // Mock window.location to simulate failed navigation
169
+ Object.defineProperty(window, 'location', {
170
+ value: { pathname: '/callback' },
171
+ writable: true,
172
+ });
173
+
174
+ await act(async () => {
175
+ render(<CallbackManager configurationName="default" />);
176
+ // Wait for the verification delay to pass
177
+ await new Promise(resolve => setTimeout(resolve, 300));
178
+ });
179
+
180
+ await waitFor(() => {
181
+ expect(mockPublishEvent).toHaveBeenCalledWith(
182
+ 'loginCallbackAsync_navigation_error',
183
+ expect.objectContaining({
184
+ configurationName: 'default',
185
+ callbackPath: '/dashboard',
186
+ }),
187
+ );
188
+ });
189
+ });
190
+
191
+ it('should use "/" as fallback when callbackPath is empty', async () => {
192
+ mockLoginCallbackAsync.mockResolvedValue({ callbackPath: '' });
193
+ const navigateAfterCallback = vi.fn().mockResolvedValue(undefined);
194
+
195
+ await act(async () => {
196
+ render(
197
+ <CallbackManager
198
+ configurationName="default"
199
+ navigateAfterCallback={navigateAfterCallback}
200
+ />,
201
+ );
202
+ });
203
+
204
+ expect(navigateAfterCallback).toHaveBeenCalledWith('/');
205
+ });
206
+
207
+ it('should use withCustomHistory when provided and no navigateAfterCallback', async () => {
208
+ mockLoginCallbackAsync.mockResolvedValue({ callbackPath: '/profile' });
209
+ const customReplaceState = vi.fn();
210
+ const withCustomHistory = vi.fn().mockReturnValue({ replaceState: customReplaceState });
211
+
212
+ Object.defineProperty(window, 'location', {
213
+ value: { pathname: '/' },
214
+ writable: true,
215
+ });
216
+
217
+ await act(async () => {
218
+ render(<CallbackManager configurationName="default" withCustomHistory={withCustomHistory} />);
219
+ });
220
+
221
+ expect(withCustomHistory).toHaveBeenCalled();
222
+ expect(customReplaceState).toHaveBeenCalledWith('/profile');
223
+ });
224
+
225
+ it('should set error state when loginCallbackAsync throws', async () => {
226
+ mockLoginCallbackAsync.mockRejectedValue(new Error('login error'));
227
+
228
+ const ErrorComponent = () => <div>Login Error</div>;
229
+
230
+ let container;
231
+ await act(async () => {
232
+ const result = render(
233
+ <CallbackManager configurationName="default" callBackError={ErrorComponent} />,
234
+ );
235
+ container = result.container;
236
+ });
237
+
238
+ expect(container.textContent).toContain('Login Error');
239
+ });
240
+
241
+ it('should render success component by default', () => {
242
+ mockLoginCallbackAsync.mockReturnValue(new Promise(() => {})); // never resolves
243
+
244
+ const { getByText } = render(<CallbackManager configurationName="default" />);
245
+
246
+ expect(getByText('Authentication complete')).toBeTruthy();
247
+ });
248
+ });