@workos-inc/authkit-nextjs 2.5.0 → 2.7.0

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.
Files changed (53) hide show
  1. package/README.md +124 -29
  2. package/dist/esm/auth.js +18 -5
  3. package/dist/esm/auth.js.map +1 -1
  4. package/dist/esm/components/tokenStore.js +110 -11
  5. package/dist/esm/components/tokenStore.js.map +1 -1
  6. package/dist/esm/components/useAccessToken.js +34 -4
  7. package/dist/esm/components/useAccessToken.js.map +1 -1
  8. package/dist/esm/cookie.js +51 -0
  9. package/dist/esm/cookie.js.map +1 -1
  10. package/dist/esm/get-authorization-url.js +2 -1
  11. package/dist/esm/get-authorization-url.js.map +1 -1
  12. package/dist/esm/middleware.js +2 -2
  13. package/dist/esm/middleware.js.map +1 -1
  14. package/dist/esm/session.js +36 -3
  15. package/dist/esm/session.js.map +1 -1
  16. package/dist/esm/test-helpers.js +57 -0
  17. package/dist/esm/test-helpers.js.map +1 -0
  18. package/dist/esm/types/auth.d.ts +5 -3
  19. package/dist/esm/types/components/tokenStore.d.ts +7 -2
  20. package/dist/esm/types/cookie.d.ts +1 -0
  21. package/dist/esm/types/interfaces.d.ts +3 -0
  22. package/dist/esm/types/middleware.d.ts +1 -1
  23. package/dist/esm/types/session.d.ts +2 -1
  24. package/dist/esm/types/test-helpers.d.ts +3 -0
  25. package/dist/esm/types/workos.d.ts +1 -1
  26. package/dist/esm/workos.js +1 -1
  27. package/package.json +5 -4
  28. package/src/actions.spec.ts +100 -0
  29. package/src/auth.spec.ts +347 -0
  30. package/src/auth.ts +19 -6
  31. package/src/authkit-callback-route.spec.ts +258 -0
  32. package/src/components/authkit-provider.spec.tsx +471 -0
  33. package/src/components/button.spec.tsx +46 -0
  34. package/src/components/impersonation.spec.tsx +134 -0
  35. package/src/components/min-max-button.spec.tsx +60 -0
  36. package/src/components/tokenStore.spec.ts +816 -0
  37. package/src/components/tokenStore.ts +147 -12
  38. package/src/components/useAccessToken.spec.tsx +731 -0
  39. package/src/components/useAccessToken.ts +40 -6
  40. package/src/components/useTokenClaims.spec.tsx +194 -0
  41. package/src/cookie.spec.ts +276 -0
  42. package/src/cookie.ts +56 -0
  43. package/src/get-authorization-url.spec.ts +60 -0
  44. package/src/get-authorization-url.ts +2 -0
  45. package/src/interfaces.ts +3 -0
  46. package/src/jwt.spec.ts +159 -0
  47. package/src/middleware.ts +2 -1
  48. package/src/session.spec.ts +1152 -0
  49. package/src/session.ts +42 -2
  50. package/src/test-helpers.ts +70 -0
  51. package/src/utils.spec.ts +142 -0
  52. package/src/workos.spec.ts +67 -0
  53. package/src/workos.ts +1 -1
@@ -0,0 +1,731 @@
1
+ import '@testing-library/jest-dom';
2
+ import { act, render, waitFor } from '@testing-library/react';
3
+ import React from 'react';
4
+ import { getAccessTokenAction, refreshAccessTokenAction } from '../actions.js';
5
+ import { useAuth } from './authkit-provider.js';
6
+ import { useAccessToken } from './useAccessToken.js';
7
+ import { tokenStore } from './tokenStore.js';
8
+
9
+ jest.mock('../actions.js', () => ({
10
+ getAccessTokenAction: jest.fn(),
11
+ refreshAccessTokenAction: jest.fn(),
12
+ }));
13
+
14
+ jest.mock('./authkit-provider.js', () => {
15
+ const originalModule = jest.requireActual('./authkit-provider.js');
16
+ return {
17
+ ...originalModule,
18
+ useAuth: jest.fn(),
19
+ };
20
+ });
21
+
22
+ describe('useAccessToken', () => {
23
+ beforeEach(() => {
24
+ tokenStore.reset();
25
+ jest.resetAllMocks();
26
+ jest.useFakeTimers();
27
+
28
+ // Reset mock implementations to avoid test interference
29
+ (getAccessTokenAction as jest.Mock).mockReset();
30
+ (refreshAccessTokenAction as jest.Mock).mockReset();
31
+
32
+ (useAuth as jest.Mock).mockImplementation(() => ({
33
+ user: { id: 'user_123' },
34
+ sessionId: 'session_123',
35
+ refreshAuth: jest.fn().mockResolvedValue({}),
36
+ }));
37
+ });
38
+
39
+ afterEach(() => {
40
+ jest.clearAllTimers();
41
+ jest.useRealTimers();
42
+ tokenStore.reset();
43
+ jest.clearAllMocks();
44
+ });
45
+
46
+ const TestComponent = () => {
47
+ const { accessToken, loading, error, refresh } = useAccessToken();
48
+ return (
49
+ <div>
50
+ <div data-testid="token">{accessToken || 'no-token'}</div>
51
+ <div data-testid="loading">{loading.toString()}</div>
52
+ <div data-testid="error">{error?.message || 'no-error'}</div>
53
+ <button data-testid="refresh" onClick={() => refresh().catch(() => {})}>
54
+ Refresh
55
+ </button>
56
+ </div>
57
+ );
58
+ };
59
+
60
+ it('should fetch an access token on mount and show loading state initially', async () => {
61
+ const mockToken =
62
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
63
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(mockToken);
64
+
65
+ const { getByTestId } = render(<TestComponent />);
66
+
67
+ // Loading should be true during initial fetch
68
+ expect(getByTestId('loading')).toHaveTextContent('true');
69
+
70
+ await waitFor(() => {
71
+ expect(getAccessTokenAction).toHaveBeenCalledTimes(1);
72
+ });
73
+
74
+ await waitFor(() => {
75
+ expect(getByTestId('loading')).toHaveTextContent('false');
76
+ expect(getByTestId('token')).toHaveTextContent(mockToken);
77
+ });
78
+ });
79
+
80
+ it('should handle token refresh when an expiring token is received', async () => {
81
+ // Create a token that's about to expire (exp is very close to current time)
82
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
83
+ // Use 25 seconds to ensure it's within the 30-second buffer for short-lived tokens
84
+ const payload = {
85
+ sub: '1234567890',
86
+ sid: 'session_123',
87
+ exp: currentTimeInSeconds + 25,
88
+ iat: currentTimeInSeconds - 35,
89
+ };
90
+ const expiringToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
91
+
92
+ const refreshedToken =
93
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
94
+
95
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(expiringToken);
96
+ (refreshAccessTokenAction as jest.Mock).mockResolvedValueOnce(refreshedToken);
97
+
98
+ const { getByTestId } = render(<TestComponent />);
99
+
100
+ // Loading should be true initially during token fetch
101
+ expect(getByTestId('loading')).toHaveTextContent('true');
102
+
103
+ await waitFor(() => {
104
+ expect(getByTestId('loading')).toHaveTextContent('false');
105
+ expect(getByTestId('token')).toHaveTextContent(refreshedToken);
106
+ });
107
+
108
+ expect(getAccessTokenAction).toHaveBeenCalledTimes(1);
109
+ expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1);
110
+ });
111
+
112
+ it('should handle token refresh on manual refresh and show loading state', async () => {
113
+ const initialToken =
114
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
115
+ const refreshedToken =
116
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
117
+
118
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(initialToken);
119
+ (refreshAccessTokenAction as jest.Mock).mockResolvedValueOnce(refreshedToken);
120
+
121
+ const { getByTestId } = render(<TestComponent />);
122
+
123
+ await waitFor(() => {
124
+ expect(getByTestId('token')).toHaveTextContent(initialToken);
125
+ expect(getByTestId('loading')).toHaveTextContent('false');
126
+ });
127
+
128
+ act(() => {
129
+ getByTestId('refresh').click();
130
+ });
131
+
132
+ // Should show loading for user-initiated refresh
133
+ expect(getByTestId('loading')).toHaveTextContent('true');
134
+
135
+ await waitFor(() => {
136
+ expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1);
137
+ expect(getAccessTokenAction).toHaveBeenCalledTimes(1);
138
+ expect(getByTestId('token')).toHaveTextContent(refreshedToken);
139
+ expect(getByTestId('loading')).toHaveTextContent('false');
140
+ });
141
+ });
142
+
143
+ it('should handle the not loggged in state', async () => {
144
+ (useAuth as jest.Mock).mockImplementation(() => ({
145
+ user: undefined,
146
+ sessionId: undefined,
147
+ refreshAuth: jest.fn().mockResolvedValue({}),
148
+ }));
149
+
150
+ const { getByTestId } = render(<TestComponent />);
151
+
152
+ await waitFor(() => {
153
+ expect(getByTestId('loading')).toHaveTextContent('false');
154
+ expect(getByTestId('token')).toHaveTextContent('no-token');
155
+ });
156
+ });
157
+
158
+ it('should handle errors during token fetch', async () => {
159
+ const error = new Error('Failed to fetch token');
160
+ (getAccessTokenAction as jest.Mock).mockRejectedValueOnce(error);
161
+
162
+ const { getByTestId } = render(<TestComponent />);
163
+
164
+ // Loading should be true initially
165
+ expect(getByTestId('loading')).toHaveTextContent('true');
166
+
167
+ await waitFor(() => {
168
+ expect(getByTestId('loading')).toHaveTextContent('false');
169
+ expect(getByTestId('error')).toHaveTextContent('Failed to fetch token');
170
+ expect(getByTestId('token')).toHaveTextContent('no-token');
171
+ });
172
+ });
173
+
174
+ it('should handle errors during manual refresh', async () => {
175
+ const initialToken =
176
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
177
+ const error = new Error('Failed to refresh token');
178
+
179
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(initialToken);
180
+ (refreshAccessTokenAction as jest.Mock).mockRejectedValueOnce(error);
181
+
182
+ const { getByTestId } = render(<TestComponent />);
183
+
184
+ await waitFor(() => {
185
+ expect(getByTestId('token')).toHaveTextContent(initialToken);
186
+ });
187
+
188
+ await act(async () => {
189
+ getByTestId('refresh').click();
190
+ });
191
+
192
+ await waitFor(() => {
193
+ expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1);
194
+ expect(getByTestId('error')).toHaveTextContent('Failed to refresh token');
195
+ // Token should be preserved on error
196
+ expect(getByTestId('token')).toHaveTextContent(initialToken);
197
+ });
198
+ });
199
+
200
+ it('should reset token state when user is undefined', async () => {
201
+ const mockToken =
202
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
203
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(mockToken);
204
+
205
+ // First render with user
206
+ (useAuth as jest.Mock).mockImplementation(() => ({
207
+ user: { id: 'user_123' },
208
+ sessionId: 'session_123',
209
+ refreshAuth: jest.fn().mockResolvedValue({}),
210
+ }));
211
+
212
+ const { getByTestId, rerender } = render(<TestComponent />);
213
+
214
+ await waitFor(() => {
215
+ expect(getByTestId('token')).toHaveTextContent(mockToken);
216
+ });
217
+
218
+ (useAuth as jest.Mock).mockImplementation(() => ({
219
+ user: undefined,
220
+ sessionId: undefined,
221
+ refreshAuth: jest.fn().mockResolvedValue({}),
222
+ }));
223
+
224
+ rerender(<TestComponent />);
225
+
226
+ await waitFor(() => {
227
+ expect(getByTestId('token')).toHaveTextContent('no-token');
228
+ });
229
+ });
230
+
231
+ it('should handle invalid tokens gracefully', async () => {
232
+ const invalidToken = 'invalid-token';
233
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(invalidToken);
234
+
235
+ const { getByTestId } = render(<TestComponent />);
236
+
237
+ await waitFor(() => {
238
+ expect(getByTestId('loading')).toHaveTextContent('false');
239
+ // Invalid tokens (non-JWT) are stored as opaque tokens, not rejected
240
+ expect(getByTestId('token')).toHaveTextContent(invalidToken);
241
+ });
242
+ });
243
+
244
+ it('should retry fetching when an error occurs without showing loading', async () => {
245
+ const error = new Error('Failed to fetch token');
246
+ const mockToken =
247
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
248
+
249
+ (getAccessTokenAction as jest.Mock).mockRejectedValueOnce(error).mockResolvedValueOnce(mockToken);
250
+
251
+ const { getByTestId } = render(<TestComponent />);
252
+
253
+ await waitFor(() => {
254
+ expect(getByTestId('error')).toHaveTextContent('Failed to fetch token');
255
+ expect(getByTestId('loading')).toHaveTextContent('false');
256
+ expect(getByTestId('token')).toHaveTextContent('no-token');
257
+ });
258
+
259
+ act(() => {
260
+ jest.advanceTimersByTime(5 * 60 * 1000); // RETRY_DELAY
261
+ });
262
+
263
+ // Loading should remain false during retry
264
+ expect(getByTestId('loading')).toHaveTextContent('false');
265
+
266
+ await waitFor(() => {
267
+ expect(getAccessTokenAction).toHaveBeenCalledTimes(2);
268
+ expect(getByTestId('token')).toHaveTextContent(mockToken);
269
+ expect(getByTestId('loading')).toHaveTextContent('false');
270
+ });
271
+ });
272
+
273
+ it('should handle errors when refreshing an expiring token', async () => {
274
+ // Create a token that's about to expire
275
+ const currentTimeInSeconds = Math.floor(Date.now() / 1000);
276
+ // Use 25 seconds to ensure it's within the 30-second buffer for short-lived tokens
277
+ const payload = {
278
+ sub: '1234567890',
279
+ sid: 'session_123',
280
+ exp: currentTimeInSeconds + 25,
281
+ iat: currentTimeInSeconds - 35,
282
+ };
283
+ const expiringToken = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.${btoa(JSON.stringify(payload))}.mock-signature`;
284
+ const error = new Error('Failed to refresh token');
285
+
286
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(expiringToken);
287
+ (refreshAccessTokenAction as jest.Mock).mockRejectedValueOnce(error);
288
+
289
+ const { getByTestId } = render(<TestComponent />);
290
+
291
+ await waitFor(() => {
292
+ expect(getByTestId('loading')).toHaveTextContent('false');
293
+ expect(getByTestId('error')).toHaveTextContent('Failed to refresh token');
294
+ // The expiring token should still be preserved despite the error
295
+ expect(getByTestId('token')).toHaveTextContent(expiringToken);
296
+ });
297
+
298
+ expect(getAccessTokenAction).toHaveBeenCalledTimes(1);
299
+ expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1);
300
+ });
301
+
302
+ it('should handle token with an invalid payload format', async () => {
303
+ const badPayloadToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.invalidpayload.mock-signature';
304
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(badPayloadToken);
305
+
306
+ const { getByTestId } = render(<TestComponent />);
307
+
308
+ await waitFor(() => {
309
+ expect(getByTestId('loading')).toHaveTextContent('false');
310
+ // Invalid payload tokens are still stored as opaque tokens
311
+ expect(getByTestId('token')).toHaveTextContent(badPayloadToken);
312
+ });
313
+ });
314
+
315
+ it('should immediately try to update token when token is undefined', async () => {
316
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined);
317
+
318
+ const { getByTestId } = render(<TestComponent />);
319
+
320
+ await waitFor(() => {
321
+ expect(getByTestId('loading')).toHaveTextContent('false');
322
+ expect(getByTestId('token')).toHaveTextContent('no-token');
323
+ });
324
+
325
+ expect(getAccessTokenAction).toHaveBeenCalledTimes(1);
326
+ });
327
+
328
+ it('should react to sessionId changes', async () => {
329
+ // Clear any previous mocks to ensure clean state
330
+ jest.clearAllMocks();
331
+
332
+ const token1 = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-1';
333
+ const token2 = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMSIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
334
+
335
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(token1).mockResolvedValueOnce(token2);
336
+
337
+ (useAuth as jest.Mock).mockImplementation(() => ({
338
+ user: { id: 'user1' },
339
+ sessionId: 'session1',
340
+ refreshAuth: jest.fn().mockResolvedValue({}),
341
+ }));
342
+
343
+ const { rerender } = render(<TestComponent />);
344
+
345
+ await waitFor(() => {
346
+ expect(getAccessTokenAction).toHaveBeenCalledTimes(1);
347
+ });
348
+
349
+ (useAuth as jest.Mock).mockImplementation(() => ({
350
+ user: { id: 'user1' }, // Same user ID
351
+ sessionId: 'session2',
352
+ refreshAuth: jest.fn().mockResolvedValue({}),
353
+ }));
354
+
355
+ rerender(<TestComponent />);
356
+
357
+ await waitFor(() => {
358
+ expect(getAccessTokenAction).toHaveBeenCalledTimes(2);
359
+ });
360
+ });
361
+
362
+ it('should prevent concurrent token fetches via updateToken', async () => {
363
+ jest.clearAllMocks();
364
+ (getAccessTokenAction as jest.Mock).mockReset();
365
+
366
+ const mockToken =
367
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
368
+
369
+ let fetchCalls = 0;
370
+
371
+ const tokenPromise = new Promise<string>((resolve) => {
372
+ setTimeout(() => {
373
+ resolve(mockToken);
374
+ }, 0);
375
+ });
376
+
377
+ (getAccessTokenAction as jest.Mock).mockImplementation(() => {
378
+ fetchCalls++;
379
+ return tokenPromise;
380
+ });
381
+
382
+ const { getByTestId } = render(<TestComponent />);
383
+
384
+ // Loading should be true initially during fetch
385
+ expect(getByTestId('loading')).toHaveTextContent('true');
386
+
387
+ await waitFor(() => {
388
+ expect(fetchCalls).toBe(1);
389
+ });
390
+
391
+ await waitFor(() => {
392
+ expect(getByTestId('loading')).toHaveTextContent('false');
393
+ expect(getByTestId('token')).toHaveTextContent(mockToken);
394
+ });
395
+
396
+ expect(fetchCalls).toBe(1);
397
+ });
398
+
399
+ it('should prevent concurrent manual refresh operations', async () => {
400
+ jest.clearAllMocks();
401
+
402
+ let refreshCalls = 0;
403
+
404
+ const mockToken =
405
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
406
+ const refreshedToken =
407
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
408
+
409
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
410
+ const refreshPromise = new Promise<string>((resolve) => {
411
+ // Slow promise
412
+ setTimeout(() => resolve(refreshedToken), 10);
413
+ });
414
+
415
+ (refreshAccessTokenAction as jest.Mock).mockImplementation(() => {
416
+ refreshCalls++;
417
+ return refreshPromise;
418
+ });
419
+
420
+ (getAccessTokenAction as jest.Mock).mockImplementation(() => {
421
+ return Promise.resolve(mockToken);
422
+ });
423
+
424
+ const { getByTestId } = render(<TestComponent />);
425
+
426
+ // Wait for initial token
427
+ await waitFor(() => {
428
+ expect(getByTestId('token')).toHaveTextContent(mockToken);
429
+ });
430
+
431
+ // Call refresh twice in succession - should only result in one actual refresh call
432
+ act(() => {
433
+ getByTestId('refresh').click();
434
+ getByTestId('refresh').click();
435
+ });
436
+
437
+ // Wait for refresh to complete
438
+ await waitFor(() => {
439
+ expect(refreshCalls).toBe(1);
440
+ expect(getByTestId('token')).toHaveTextContent(refreshedToken);
441
+ });
442
+
443
+ // Verify that refreshAccessToken was only called once despite two clicks
444
+ expect(refreshCalls).toBe(1);
445
+ });
446
+
447
+ it('should handle non-Error objects thrown during token fetch', async () => {
448
+ // Simulate a string error being thrown
449
+ (getAccessTokenAction as jest.Mock).mockImplementation(() => {
450
+ throw 'String error message';
451
+ });
452
+
453
+ const { getByTestId } = render(<TestComponent />);
454
+
455
+ await waitFor(() => {
456
+ expect(getByTestId('loading')).toHaveTextContent('false');
457
+ expect(getByTestId('error')).toHaveTextContent('String error message');
458
+ expect(getByTestId('token')).toHaveTextContent('no-token');
459
+ });
460
+ });
461
+
462
+ it('should show loading state immediately on first render when user exists but no token', () => {
463
+ // Mock user with no token initially
464
+ (useAuth as jest.Mock).mockImplementation(() => ({
465
+ user: { id: 'user_123' },
466
+ sessionId: 'session_123',
467
+ refreshAuth: jest.fn().mockResolvedValue({}),
468
+ }));
469
+
470
+ (getAccessTokenAction as jest.Mock).mockImplementation(
471
+ () => new Promise((resolve) => setTimeout(() => resolve('token'), 100)),
472
+ );
473
+
474
+ const { getByTestId } = render(<TestComponent />);
475
+
476
+ expect(getByTestId('loading')).toHaveTextContent('true');
477
+ expect(getByTestId('token')).toHaveTextContent('no-token');
478
+ });
479
+
480
+ it('should not show loading when a valid token already exists', async () => {
481
+ const existingToken =
482
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJleGlzdGluZyIsInNpZCI6InNlc3Npb24xMjMiLCJleHAiOjk5OTk5OTk5OTl9.existing';
483
+
484
+ await act(async () => {
485
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(existingToken);
486
+ await tokenStore.getAccessTokenSilently();
487
+ });
488
+
489
+ // Reset the mock to track new calls
490
+ (getAccessTokenAction as jest.Mock).mockClear();
491
+
492
+ const { getByTestId } = render(<TestComponent />);
493
+
494
+ expect(getByTestId('loading')).toHaveTextContent('false');
495
+ expect(getByTestId('token')).toHaveTextContent(existingToken);
496
+
497
+ expect(getAccessTokenAction).not.toHaveBeenCalled();
498
+ });
499
+
500
+ // Additional test cases to increase coverage
501
+ it('should handle concurrent manual refresh attempts', async () => {
502
+ const initialToken =
503
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
504
+ const refreshedToken =
505
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJyZWZyZXNoZWQiLCJzaWQiOiJzZXNzaW9uXzEyMyIsImV4cCI6OTk5OTk5OTk5OX0.mock-signature-2';
506
+
507
+ // Setup a delayed promise for the refresh
508
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
509
+ let resolveRefreshPromise: (value: any) => void;
510
+ const refreshPromise = new Promise((resolve) => {
511
+ resolveRefreshPromise = resolve;
512
+ });
513
+
514
+ (refreshAccessTokenAction as jest.Mock).mockReturnValue(refreshPromise);
515
+ (getAccessTokenAction as jest.Mock).mockResolvedValue(initialToken);
516
+
517
+ const { getByTestId } = render(<TestComponent />);
518
+
519
+ await waitFor(() => {
520
+ expect(getByTestId('token')).toHaveTextContent(initialToken);
521
+ });
522
+
523
+ act(() => {
524
+ getByTestId('refresh').click();
525
+ });
526
+
527
+ act(() => {
528
+ getByTestId('refresh').click();
529
+ });
530
+
531
+ act(() => {
532
+ resolveRefreshPromise!(refreshedToken);
533
+ });
534
+
535
+ await waitFor(() => {
536
+ expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1); // Should only call once
537
+ expect(getByTestId('token')).toHaveTextContent(refreshedToken);
538
+ });
539
+ });
540
+
541
+ it('should clear refresh timeout on unmount', async () => {
542
+ const mockToken =
543
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
544
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(mockToken);
545
+
546
+ const { getByTestId, unmount } = render(<TestComponent />);
547
+
548
+ await waitFor(() => {
549
+ expect(getByTestId('token')).toHaveTextContent(mockToken);
550
+ });
551
+
552
+ unmount();
553
+ });
554
+
555
+ it('should handle edge cases when token data is null', async () => {
556
+ // Create a token that resembles a JWT but with a null payload
557
+ const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.bnVsbA==.mock-signature'; // "null" in base64
558
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(token);
559
+
560
+ const { getByTestId } = render(<TestComponent />);
561
+
562
+ await waitFor(() => {
563
+ expect(getByTestId('loading')).toHaveTextContent('false');
564
+ });
565
+
566
+ // Token with invalid/null payload is still stored as opaque token
567
+ expect(getByTestId('token')).toHaveTextContent(token);
568
+ });
569
+
570
+ it('should handle errors with string messages instead of Error objects', async () => {
571
+ const error = 'String error message';
572
+ const errorObj = new Error(error);
573
+ (getAccessTokenAction as jest.Mock).mockRejectedValueOnce(errorObj);
574
+
575
+ const { getByTestId } = render(<TestComponent />);
576
+
577
+ await waitFor(() => {
578
+ expect(getByTestId('loading')).toHaveTextContent('false');
579
+ expect(getByTestId('error')).toHaveTextContent(error);
580
+ });
581
+ });
582
+
583
+ it('should handle string errors during manual refresh', async () => {
584
+ const initialToken =
585
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
586
+ const stringError = 'String error directly'; // Not wrapped in Error object
587
+
588
+ (getAccessTokenAction as jest.Mock).mockResolvedValueOnce(initialToken);
589
+ // Mock refreshAccessTokenAction to reject with a string, not an Error object
590
+ (refreshAccessTokenAction as jest.Mock).mockImplementation(() => {
591
+ return Promise.reject(stringError); // Directly reject with string
592
+ });
593
+
594
+ const { getByTestId } = render(<TestComponent />);
595
+
596
+ await waitFor(() => {
597
+ expect(getByTestId('token')).toHaveTextContent(initialToken);
598
+ });
599
+
600
+ await act(async () => {
601
+ getByTestId('refresh').click();
602
+ });
603
+
604
+ await waitFor(() => {
605
+ expect(refreshAccessTokenAction).toHaveBeenCalledTimes(1);
606
+ expect(getByTestId('error')).toHaveTextContent(stringError);
607
+ // Token should be preserved on error
608
+ expect(getByTestId('token')).toHaveTextContent(initialToken);
609
+ });
610
+ });
611
+
612
+ it('should bypass refresh when token is unchanged but user or sessionId changed', async () => {
613
+ const token =
614
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
615
+
616
+ (getAccessTokenAction as jest.Mock).mockResolvedValue(token);
617
+
618
+ (useAuth as jest.Mock).mockImplementation(() => ({
619
+ user: { id: 'user_123' },
620
+ sessionId: 'session_123',
621
+ refreshAuth: jest.fn().mockResolvedValue({}),
622
+ }));
623
+
624
+ const { getByTestId, rerender } = render(<TestComponent />);
625
+
626
+ await waitFor(() => {
627
+ expect(getByTestId('token')).toHaveTextContent(token);
628
+ expect(getAccessTokenAction).toHaveBeenCalledTimes(1);
629
+ });
630
+
631
+ (useAuth as jest.Mock).mockImplementation(() => ({
632
+ user: { id: 'user_456' }, // Different user
633
+ sessionId: 'session_123', // Same session
634
+ refreshAuth: jest.fn().mockResolvedValue({}),
635
+ }));
636
+
637
+ rerender(<TestComponent />);
638
+
639
+ await waitFor(() => {
640
+ expect(getAccessTokenAction).toHaveBeenCalledTimes(2);
641
+ });
642
+ });
643
+
644
+ it('should handle getAccessToken when user is not authenticated', async () => {
645
+ (useAuth as jest.Mock).mockImplementation(() => ({
646
+ user: null,
647
+ sessionId: undefined,
648
+ refreshAuth: jest.fn().mockResolvedValue({}),
649
+ }));
650
+
651
+ const TestComponentWithGetAccessToken = () => {
652
+ const { getAccessToken } = useAccessToken();
653
+ const [result, setResult] = React.useState<string | undefined | null>(null);
654
+
655
+ React.useEffect(() => {
656
+ getAccessToken().then((token) => setResult(token || 'no-token'));
657
+ }, [getAccessToken]);
658
+
659
+ return <div data-testid="result">{result === null ? 'loading' : result}</div>;
660
+ };
661
+
662
+ const { getByTestId } = render(<TestComponentWithGetAccessToken />);
663
+
664
+ await waitFor(() => {
665
+ expect(getByTestId('result')).toHaveTextContent('no-token');
666
+ });
667
+ });
668
+
669
+ it('should handle getAccessToken when user is authenticated', async () => {
670
+ const mockToken =
671
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoic2Vzc2lvbl8xMjMiLCJleHAiOjk5OTk5OTk5OTl9.mock-signature';
672
+
673
+ (getAccessTokenAction as jest.Mock).mockResolvedValue(mockToken);
674
+ (useAuth as jest.Mock).mockImplementation(() => ({
675
+ user: { id: 'user_123' },
676
+ sessionId: 'session_123',
677
+ refreshAuth: jest.fn().mockResolvedValue({}),
678
+ }));
679
+
680
+ const TestComponentWithGetAccessToken = () => {
681
+ const { getAccessToken } = useAccessToken();
682
+ const [result, setResult] = React.useState<string | undefined | null>(null);
683
+
684
+ React.useEffect(() => {
685
+ // Wait a bit for initial token load, then call getAccessToken
686
+ const timer = setTimeout(() => {
687
+ getAccessToken().then((token) => setResult(token || 'no-token'));
688
+ }, 100);
689
+ return () => clearTimeout(timer);
690
+ }, [getAccessToken]);
691
+
692
+ return <div data-testid="result">{result === null ? 'loading' : result}</div>;
693
+ };
694
+
695
+ const { getByTestId } = render(<TestComponentWithGetAccessToken />);
696
+
697
+ // Advance timers to trigger getAccessToken call
698
+ act(() => {
699
+ jest.advanceTimersByTime(100);
700
+ });
701
+
702
+ await waitFor(() => {
703
+ expect(getByTestId('result')).toHaveTextContent(mockToken);
704
+ });
705
+ });
706
+
707
+ it('should handle manual refresh when user is not authenticated', async () => {
708
+ (useAuth as jest.Mock).mockImplementation(() => ({
709
+ user: null,
710
+ sessionId: undefined,
711
+ refreshAuth: jest.fn().mockResolvedValue({}),
712
+ }));
713
+
714
+ const TestComponentWithRefresh = () => {
715
+ const { refresh } = useAccessToken();
716
+ const [result, setResult] = React.useState<string | undefined | null>(null);
717
+
718
+ React.useEffect(() => {
719
+ refresh().then((token) => setResult(token || 'no-token'));
720
+ }, [refresh]);
721
+
722
+ return <div data-testid="result">{result === null ? 'loading' : result}</div>;
723
+ };
724
+
725
+ const { getByTestId } = render(<TestComponentWithRefresh />);
726
+
727
+ await waitFor(() => {
728
+ expect(getByTestId('result')).toHaveTextContent('no-token');
729
+ });
730
+ });
731
+ });