@lobehub/lobehub 2.0.0-next.20 → 2.0.0-next.21

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,706 @@
1
+ import { DataSyncConfig } from '@lobechat/electron-client-ipc';
2
+ import { BrowserWindow, shell } from 'electron';
3
+ import crypto from 'node:crypto';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import type { App } from '@/core/App';
7
+
8
+ import AuthCtr from '../AuthCtr';
9
+ import RemoteServerConfigCtr from '../RemoteServerConfigCtr';
10
+
11
+ // Mock logger
12
+ vi.mock('@/utils/logger', () => ({
13
+ createLogger: () => ({
14
+ debug: vi.fn(),
15
+ info: vi.fn(),
16
+ warn: vi.fn(),
17
+ error: vi.fn(),
18
+ }),
19
+ }));
20
+
21
+ // Mock electron
22
+ vi.mock('electron', () => ({
23
+ BrowserWindow: {
24
+ getAllWindows: vi.fn(() => []),
25
+ },
26
+ shell: {
27
+ openExternal: vi.fn().mockResolvedValue(undefined),
28
+ },
29
+ safeStorage: {
30
+ isEncryptionAvailable: vi.fn(() => true),
31
+ encryptString: vi.fn((str: string) => Buffer.from(str)),
32
+ decryptString: vi.fn((buffer: Buffer) => buffer.toString()),
33
+ },
34
+ }));
35
+
36
+ // Mock electron-is
37
+ vi.mock('electron-is', () => ({
38
+ macOS: vi.fn(() => false),
39
+ windows: vi.fn(() => false),
40
+ linux: vi.fn(() => false),
41
+ }));
42
+
43
+ // Mock OFFICIAL_CLOUD_SERVER
44
+ vi.mock('@/const/env', () => ({
45
+ OFFICIAL_CLOUD_SERVER: 'https://lobehub-cloud.com',
46
+ isMac: false,
47
+ isWindows: false,
48
+ isLinux: false,
49
+ isDev: false,
50
+ }));
51
+
52
+ // Mock crypto
53
+ let randomBytesCounter = 0;
54
+ vi.mock('node:crypto', () => ({
55
+ default: {
56
+ randomBytes: vi.fn((size: number) => {
57
+ randomBytesCounter++;
58
+ return {
59
+ toString: vi.fn(() => `mock-random-${randomBytesCounter}`),
60
+ };
61
+ }),
62
+ subtle: {
63
+ digest: vi.fn(() => Promise.resolve(new ArrayBuffer(32))),
64
+ },
65
+ },
66
+ }));
67
+
68
+ // Create mock App and RemoteServerConfigCtr
69
+ const mockRemoteServerConfigCtr = {
70
+ clearTokens: vi.fn().mockResolvedValue(undefined),
71
+ getAccessToken: vi.fn().mockResolvedValue('mock-access-token'),
72
+ getRemoteServerConfig: vi.fn().mockResolvedValue({ active: true, storageMode: 'cloud' }),
73
+ getRemoteServerUrl: vi.fn().mockImplementation(async (config?: DataSyncConfig) => {
74
+ if (config?.storageMode === 'selfHost') {
75
+ return config.remoteServerUrl || 'https://mock-server.com';
76
+ }
77
+ return 'https://lobehub-cloud.com'; // OFFICIAL_CLOUD_SERVER
78
+ }),
79
+ getTokenExpiresAt: vi.fn().mockReturnValue(Date.now() + 3600000),
80
+ isTokenExpiringSoon: vi.fn().mockReturnValue(false),
81
+ refreshAccessToken: vi.fn().mockResolvedValue({ success: true }),
82
+ saveTokens: vi.fn().mockResolvedValue(undefined),
83
+ setRemoteServerConfig: vi.fn().mockResolvedValue(true),
84
+ } as unknown as RemoteServerConfigCtr;
85
+
86
+ const mockApp = {
87
+ getController: vi.fn((ControllerClass) => {
88
+ if (ControllerClass === RemoteServerConfigCtr) {
89
+ return mockRemoteServerConfigCtr;
90
+ }
91
+ return null;
92
+ }),
93
+ } as unknown as App;
94
+
95
+ describe('AuthCtr', () => {
96
+ let authCtr: AuthCtr;
97
+ let mockFetch: ReturnType<typeof vi.fn>;
98
+ let mockWindow: any;
99
+
100
+ beforeEach(() => {
101
+ vi.clearAllMocks();
102
+ randomBytesCounter = 0; // Reset counter for each test
103
+
104
+ // Reset shell.openExternal to default successful behavior
105
+ vi.mocked(shell.openExternal).mockResolvedValue(undefined);
106
+
107
+ // Create fresh instance for each test
108
+ authCtr = new AuthCtr(mockApp);
109
+
110
+ // Mock global fetch
111
+ mockFetch = vi.fn();
112
+ global.fetch = mockFetch;
113
+
114
+ // Mock BrowserWindow with send spy
115
+ mockWindow = {
116
+ isDestroyed: vi.fn(() => false),
117
+ webContents: {
118
+ send: vi.fn(),
119
+ },
120
+ };
121
+ vi.mocked(BrowserWindow.getAllWindows).mockReturnValue([mockWindow]);
122
+ });
123
+
124
+ afterEach(() => {
125
+ // Clean up authCtr intervals (using real timers, not fake timers)
126
+ authCtr.cleanup();
127
+ // Clean up any fake timers if used
128
+ vi.clearAllTimers();
129
+ });
130
+
131
+ describe('Basic functionality', () => {
132
+ // Use real timers for all tests since setInterval with async doesn't work well with fake timers
133
+
134
+ describe('requestAuthorization', () => {
135
+ it('should generate PKCE parameters and open authorization URL', async () => {
136
+ const config: DataSyncConfig = {
137
+ active: false,
138
+ storageMode: 'cloud',
139
+ };
140
+
141
+ mockFetch.mockResolvedValue({
142
+ status: 404,
143
+ ok: false,
144
+ });
145
+
146
+ const result = await authCtr.requestAuthorization(config);
147
+
148
+ // Verify success response
149
+ expect(result).toEqual({ success: true });
150
+
151
+ // Verify shell.openExternal was called with correct URL
152
+ expect(shell.openExternal).toHaveBeenCalledWith(
153
+ expect.stringContaining('https://lobehub-cloud.com/oidc/auth'),
154
+ );
155
+
156
+ // Verify URL contains required parameters
157
+ const authUrl = vi.mocked(shell.openExternal).mock.calls[0][0];
158
+ expect(authUrl).toContain('client_id=lobehub-desktop');
159
+ expect(authUrl).toContain('response_type=code');
160
+ expect(authUrl).toContain('code_challenge_method=S256');
161
+ expect(authUrl).toContain('scope=profile%20email%20offline_access');
162
+ });
163
+
164
+ it('should start polling after authorization request', async () => {
165
+ const config: DataSyncConfig = {
166
+ active: false,
167
+ storageMode: 'cloud',
168
+ };
169
+
170
+ mockFetch.mockResolvedValue({
171
+ status: 404,
172
+ ok: false,
173
+ });
174
+
175
+ const result = await authCtr.requestAuthorization(config);
176
+ expect(result.success).toBe(true);
177
+
178
+ // Wait a bit for polling to start
179
+ await new Promise((resolve) => setTimeout(resolve, 3500));
180
+
181
+ // Verify fetch was called for polling
182
+ const pollingCalls = mockFetch.mock.calls.filter((call) =>
183
+ (call[0] as string).includes('/oidc/handoff'),
184
+ );
185
+ expect(pollingCalls.length).toBeGreaterThan(0);
186
+ });
187
+
188
+ it('should use self-hosted server URL when storageMode is selfHost', async () => {
189
+ const config: DataSyncConfig = {
190
+ active: false,
191
+ storageMode: 'selfHost',
192
+ remoteServerUrl: 'https://my-custom-server.com',
193
+ };
194
+
195
+ mockFetch.mockResolvedValue({
196
+ status: 404,
197
+ ok: false,
198
+ });
199
+
200
+ await authCtr.requestAuthorization(config);
201
+
202
+ // Verify shell.openExternal was called with custom URL
203
+ expect(shell.openExternal).toHaveBeenCalledWith(
204
+ expect.stringContaining('https://my-custom-server.com/oidc/auth'),
205
+ );
206
+ });
207
+
208
+ it('should handle authorization request error gracefully', async () => {
209
+ const config: DataSyncConfig = {
210
+ active: false,
211
+ storageMode: 'cloud',
212
+ };
213
+
214
+ vi.mocked(shell.openExternal).mockRejectedValue(new Error('Failed to open browser'));
215
+
216
+ const result = await authCtr.requestAuthorization(config);
217
+
218
+ expect(result.success).toBe(false);
219
+ expect(result.error).toContain('Failed to open browser');
220
+ });
221
+ });
222
+
223
+ describe('polling mechanism', () => {
224
+ it('should poll every 3 seconds', async () => {
225
+ const config: DataSyncConfig = {
226
+ active: false,
227
+ storageMode: 'cloud',
228
+ };
229
+
230
+ mockFetch.mockResolvedValue({
231
+ status: 404,
232
+ ok: false,
233
+ });
234
+
235
+ await authCtr.requestAuthorization(config);
236
+
237
+ // Wait for first poll
238
+ await new Promise((resolve) => setTimeout(resolve, 3100));
239
+
240
+ const firstCallCount = mockFetch.mock.calls.filter((call) =>
241
+ (call[0] as string).includes('/oidc/handoff'),
242
+ ).length;
243
+ expect(firstCallCount).toBeGreaterThanOrEqual(1);
244
+
245
+ // Wait for second poll
246
+ await new Promise((resolve) => setTimeout(resolve, 3000));
247
+
248
+ const secondCallCount = mockFetch.mock.calls.filter((call) =>
249
+ (call[0] as string).includes('/oidc/handoff'),
250
+ ).length;
251
+ expect(secondCallCount).toBeGreaterThanOrEqual(2);
252
+ }, 10000);
253
+
254
+ it('should stop polling when credentials are received', async () => {
255
+ const config: DataSyncConfig = {
256
+ active: false,
257
+ storageMode: 'cloud',
258
+ };
259
+
260
+ let pollCount = 0;
261
+ mockFetch.mockImplementation((url: string) => {
262
+ const urlObj = new URL(url);
263
+
264
+ // Return success on third poll
265
+ if (urlObj.pathname.includes('/oidc/handoff')) {
266
+ pollCount++;
267
+ if (pollCount >= 3) {
268
+ return Promise.resolve({
269
+ status: 200,
270
+ ok: true,
271
+ json: () =>
272
+ Promise.resolve({
273
+ success: true,
274
+ data: {
275
+ payload: {
276
+ code: 'mock-auth-code',
277
+ state: 'mock-random-2', // Second randomBytes call is for state
278
+ },
279
+ },
280
+ }),
281
+ text: () => Promise.resolve('mock response'),
282
+ });
283
+ }
284
+ }
285
+
286
+ // Token exchange endpoint
287
+ if (urlObj.pathname.includes('/oidc/token')) {
288
+ return Promise.resolve({
289
+ status: 200,
290
+ ok: true,
291
+ json: () =>
292
+ Promise.resolve({
293
+ access_token: 'new-access-token',
294
+ refresh_token: 'new-refresh-token',
295
+ expires_in: 3600,
296
+ }),
297
+ text: () => Promise.resolve('mock response'),
298
+ clone: () => ({
299
+ json: () =>
300
+ Promise.resolve({
301
+ access_token: 'new-access-token',
302
+ refresh_token: 'new-refresh-token',
303
+ expires_in: 3600,
304
+ }),
305
+ }),
306
+ });
307
+ }
308
+
309
+ return Promise.resolve({
310
+ status: 404,
311
+ ok: false,
312
+ });
313
+ });
314
+
315
+ await authCtr.requestAuthorization(config);
316
+
317
+ // Wait for polling to complete
318
+ await new Promise((resolve) => setTimeout(resolve, 10000));
319
+
320
+ const pollCountBefore = pollCount;
321
+
322
+ // Wait more time and verify no more polling
323
+ await new Promise((resolve) => setTimeout(resolve, 3500));
324
+ expect(pollCount).toBe(pollCountBefore);
325
+ }, 15000);
326
+
327
+ it('should broadcast authorizationSuccessful when credentials are exchanged', async () => {
328
+ const config: DataSyncConfig = {
329
+ active: false,
330
+ storageMode: 'cloud',
331
+ };
332
+
333
+ mockFetch.mockImplementation((url: string) => {
334
+ const urlObj = new URL(url);
335
+
336
+ if (urlObj.pathname.includes('/oidc/handoff')) {
337
+ return Promise.resolve({
338
+ status: 200,
339
+ ok: true,
340
+ json: () =>
341
+ Promise.resolve({
342
+ success: true,
343
+ data: {
344
+ payload: {
345
+ code: 'mock-auth-code',
346
+ state: 'mock-random-2', // Second randomBytes call is for state
347
+ },
348
+ },
349
+ }),
350
+ text: () => Promise.resolve('mock response'),
351
+ });
352
+ }
353
+
354
+ if (urlObj.pathname.includes('/oidc/token')) {
355
+ return Promise.resolve({
356
+ status: 200,
357
+ ok: true,
358
+ json: () =>
359
+ Promise.resolve({
360
+ access_token: 'new-access-token',
361
+ refresh_token: 'new-refresh-token',
362
+ expires_in: 3600,
363
+ }),
364
+ text: () => Promise.resolve('mock response'),
365
+ clone: () => ({
366
+ json: () =>
367
+ Promise.resolve({
368
+ access_token: 'new-access-token',
369
+ refresh_token: 'new-refresh-token',
370
+ expires_in: 3600,
371
+ }),
372
+ }),
373
+ });
374
+ }
375
+
376
+ return Promise.resolve({ status: 404, ok: false });
377
+ });
378
+
379
+ await authCtr.requestAuthorization(config);
380
+
381
+ // Wait for polling to complete and token exchange
382
+ await new Promise((resolve) => setTimeout(resolve, 4000));
383
+
384
+ // Verify authorizationSuccessful was broadcast
385
+ expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationSuccessful');
386
+ }, 6000);
387
+
388
+ it('should validate state parameter and reject mismatched state', async () => {
389
+ const config: DataSyncConfig = {
390
+ active: false,
391
+ storageMode: 'cloud',
392
+ };
393
+
394
+ mockFetch.mockImplementation((url: string) => {
395
+ const urlObj = new URL(url);
396
+
397
+ if (urlObj.pathname.includes('/oidc/handoff')) {
398
+ return Promise.resolve({
399
+ status: 200,
400
+ ok: true,
401
+ json: () =>
402
+ Promise.resolve({
403
+ success: true,
404
+ data: {
405
+ payload: {
406
+ code: 'mock-auth-code',
407
+ state: 'wrong-state', // Mismatched state
408
+ },
409
+ },
410
+ }),
411
+ });
412
+ }
413
+
414
+ return Promise.resolve({ status: 404, ok: false });
415
+ });
416
+
417
+ await authCtr.requestAuthorization(config);
418
+
419
+ // Wait for polling and state validation
420
+ await new Promise((resolve) => setTimeout(resolve, 4000));
421
+
422
+ // Verify authorizationFailed was broadcast with state error
423
+ expect(mockWindow.webContents.send).toHaveBeenCalledWith('authorizationFailed', {
424
+ error: 'Invalid state parameter',
425
+ });
426
+ }, 6000);
427
+ });
428
+
429
+ describe('token refresh', () => {
430
+ it('should start auto-refresh after successful authorization', async () => {
431
+ const config: DataSyncConfig = {
432
+ active: false,
433
+ storageMode: 'cloud',
434
+ };
435
+
436
+ mockFetch.mockImplementation((url: string) => {
437
+ const urlObj = new URL(url);
438
+
439
+ if (urlObj.pathname.includes('/oidc/handoff')) {
440
+ return Promise.resolve({
441
+ status: 200,
442
+ ok: true,
443
+ json: () =>
444
+ Promise.resolve({
445
+ success: true,
446
+ data: {
447
+ payload: {
448
+ code: 'mock-auth-code',
449
+ state: 'mock-random-2', // Second randomBytes call is for state
450
+ },
451
+ },
452
+ }),
453
+ text: () => Promise.resolve('mock response'),
454
+ });
455
+ }
456
+
457
+ if (urlObj.pathname.includes('/oidc/token')) {
458
+ return Promise.resolve({
459
+ status: 200,
460
+ ok: true,
461
+ json: () =>
462
+ Promise.resolve({
463
+ access_token: 'new-access-token',
464
+ refresh_token: 'new-refresh-token',
465
+ expires_in: 3600,
466
+ }),
467
+ text: () => Promise.resolve('mock response'),
468
+ clone: () => ({
469
+ json: () =>
470
+ Promise.resolve({
471
+ access_token: 'new-access-token',
472
+ refresh_token: 'new-refresh-token',
473
+ expires_in: 3600,
474
+ }),
475
+ }),
476
+ });
477
+ }
478
+
479
+ return Promise.resolve({ status: 404, ok: false });
480
+ });
481
+
482
+ await authCtr.requestAuthorization(config);
483
+
484
+ // Wait for polling and token exchange
485
+ await new Promise((resolve) => setTimeout(resolve, 4000));
486
+
487
+ // Verify saveTokens was called
488
+ expect(mockRemoteServerConfigCtr.saveTokens).toHaveBeenCalledWith(
489
+ 'new-access-token',
490
+ 'new-refresh-token',
491
+ 3600,
492
+ );
493
+
494
+ // Verify remote server was set to active
495
+ expect(mockRemoteServerConfigCtr.setRemoteServerConfig).toHaveBeenCalledWith({
496
+ active: true,
497
+ });
498
+ }, 6000);
499
+ });
500
+ });
501
+
502
+ describe('Scenario: Authorization Timeout and Retry', () => {
503
+ // All scenario tests use real timers
504
+
505
+ it('Step 1: User requests authorization but does not complete it within 5 minutes', async () => {
506
+ const config: DataSyncConfig = {
507
+ active: false,
508
+ storageMode: 'cloud',
509
+ };
510
+
511
+ // Mock: User never completes authorization, so polling always returns 404
512
+ mockFetch.mockResolvedValue({
513
+ status: 404,
514
+ ok: false,
515
+ });
516
+
517
+ // User clicks "Connect to Cloud" button
518
+ await authCtr.requestAuthorization(config);
519
+
520
+ // Wait for some polling to happen
521
+ await new Promise((resolve) => setTimeout(resolve, 10000));
522
+
523
+ const handoffCallsBeforeTimeout = mockFetch.mock.calls.filter((call) =>
524
+ (call[0] as string).includes('/oidc/handoff'),
525
+ ).length;
526
+ expect(handoffCallsBeforeTimeout).toBeGreaterThan(0);
527
+
528
+ // Verify polling is active by checking calls increased
529
+ const callsBefore = handoffCallsBeforeTimeout;
530
+ await new Promise((resolve) => setTimeout(resolve, 3500));
531
+ const callsAfter = mockFetch.mock.calls.filter((call) =>
532
+ (call[0] as string).includes('/oidc/handoff'),
533
+ ).length;
534
+ expect(callsAfter).toBeGreaterThan(callsBefore);
535
+ }, 15000); // Increase test timeout
536
+
537
+ it('Step 2: User clicks retry button after previous attempt', async () => {
538
+ const config: DataSyncConfig = {
539
+ active: false,
540
+ storageMode: 'cloud',
541
+ };
542
+
543
+ mockFetch.mockResolvedValue({
544
+ status: 404,
545
+ ok: false,
546
+ });
547
+
548
+ // First attempt
549
+ await authCtr.requestAuthorization(config);
550
+ await new Promise((resolve) => setTimeout(resolve, 3500));
551
+
552
+ // Reset mock to track retry
553
+ mockFetch.mockClear();
554
+
555
+ // User clicks retry button - should start fresh authorization
556
+ await authCtr.requestAuthorization(config);
557
+
558
+ // Verify: New polling started
559
+ await new Promise((resolve) => setTimeout(resolve, 3500));
560
+
561
+ const handoffCalls = mockFetch.mock.calls.filter((call) =>
562
+ (call[0] as string).includes('/oidc/handoff'),
563
+ );
564
+ expect(handoffCalls.length).toBeGreaterThan(0);
565
+ }, 10000);
566
+
567
+ it('Step 3: Retry generates new state parameter (not reusing old state)', async () => {
568
+ const config: DataSyncConfig = {
569
+ active: false,
570
+ storageMode: 'cloud',
571
+ };
572
+
573
+ const capturedStates: string[] = [];
574
+
575
+ mockFetch.mockImplementation((url: string) => {
576
+ const urlObj = new URL(url);
577
+ const stateParam = urlObj.searchParams.get('id');
578
+ if (stateParam && !capturedStates.includes(stateParam)) {
579
+ capturedStates.push(stateParam);
580
+ }
581
+ return Promise.resolve({ status: 404, ok: false });
582
+ });
583
+
584
+ // First authorization attempt
585
+ await authCtr.requestAuthorization(config);
586
+ await new Promise((resolve) => setTimeout(resolve, 3500));
587
+ const firstState = capturedStates[0];
588
+
589
+ // Clear for second attempt tracking
590
+ const firstAttemptStates = [...capturedStates];
591
+ capturedStates.length = 0;
592
+
593
+ // Retry - should generate NEW state
594
+ await authCtr.requestAuthorization(config);
595
+ await new Promise((resolve) => setTimeout(resolve, 3500));
596
+ const secondState = capturedStates[0];
597
+
598
+ // CRITICAL: States must be different
599
+ expect(firstState).toBeDefined();
600
+ expect(secondState).toBeDefined();
601
+ expect(secondState).not.toBe(firstState);
602
+ expect(firstAttemptStates).not.toContain(secondState);
603
+ }, 10000);
604
+
605
+ it('Step 4: User completes authorization on retry successfully', async () => {
606
+ const config: DataSyncConfig = {
607
+ active: false,
608
+ storageMode: 'cloud',
609
+ };
610
+
611
+ // First attempt - incomplete
612
+ mockFetch.mockResolvedValue({ status: 404, ok: false });
613
+ await authCtr.requestAuthorization(config);
614
+ await new Promise((resolve) => setTimeout(resolve, 3500));
615
+
616
+ // Second attempt - user completes it this time
617
+ mockFetch.mockImplementation((url: string) => {
618
+ const urlObj = new URL(url);
619
+
620
+ // Handoff returns credentials immediately
621
+ if (urlObj.pathname.includes('/oidc/handoff')) {
622
+ return Promise.resolve({
623
+ status: 200,
624
+ ok: true,
625
+ json: () =>
626
+ Promise.resolve({
627
+ success: true,
628
+ data: {
629
+ payload: {
630
+ code: 'authorization-code',
631
+ state: 'mock-random-4', // Matches second request's state (3rd and 4th randomBytes calls)
632
+ },
633
+ },
634
+ }),
635
+ text: () => Promise.resolve('mock response'),
636
+ });
637
+ }
638
+
639
+ // Token exchange succeeds
640
+ if (urlObj.pathname.includes('/oidc/token')) {
641
+ return Promise.resolve({
642
+ status: 200,
643
+ ok: true,
644
+ json: () =>
645
+ Promise.resolve({
646
+ access_token: 'access-token',
647
+ refresh_token: 'refresh-token',
648
+ expires_in: 3600,
649
+ }),
650
+ text: () => Promise.resolve('mock response'),
651
+ clone: () => ({
652
+ json: () =>
653
+ Promise.resolve({
654
+ access_token: 'access-token',
655
+ refresh_token: 'refresh-token',
656
+ expires_in: 3600,
657
+ }),
658
+ }),
659
+ });
660
+ }
661
+
662
+ return Promise.resolve({ status: 404, ok: false });
663
+ });
664
+
665
+ await authCtr.requestAuthorization(config);
666
+
667
+ // Wait longer for polling and token exchange
668
+ await new Promise((resolve) => setTimeout(resolve, 4000));
669
+
670
+ // Verify: Success message shown
671
+ const successCall = mockWindow.webContents.send.mock.calls.find(
672
+ (call: any[]) => call[0] === 'authorizationSuccessful',
673
+ );
674
+ expect(successCall).toBeDefined();
675
+
676
+ // Verify: Tokens saved
677
+ expect(mockRemoteServerConfigCtr.saveTokens).toHaveBeenCalled();
678
+ }, 12000);
679
+
680
+ it('Edge case: Rapid retry clicks should not create multiple polling intervals', async () => {
681
+ const config: DataSyncConfig = {
682
+ active: false,
683
+ storageMode: 'cloud',
684
+ };
685
+
686
+ mockFetch.mockResolvedValue({ status: 404, ok: false });
687
+
688
+ // User rapidly clicks retry multiple times
689
+ await authCtr.requestAuthorization(config);
690
+ await authCtr.requestAuthorization(config);
691
+ await authCtr.requestAuthorization(config);
692
+
693
+ // Wait for some polling to happen
694
+ await new Promise((resolve) => setTimeout(resolve, 9000));
695
+
696
+ // Count handoff requests
697
+ const handoffCalls = mockFetch.mock.calls.filter((call) =>
698
+ (call[0] as string).includes('/oidc/handoff'),
699
+ );
700
+
701
+ // Should have ~3 calls (one per 3-second interval), not ~9 (3 intervals running)
702
+ // Allow some tolerance for timing
703
+ expect(handoffCalls.length).toBeLessThanOrEqual(5);
704
+ }, 10000);
705
+ });
706
+ });