@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.
- package/README.md +124 -29
- package/dist/esm/auth.js +18 -5
- package/dist/esm/auth.js.map +1 -1
- package/dist/esm/components/tokenStore.js +110 -11
- package/dist/esm/components/tokenStore.js.map +1 -1
- package/dist/esm/components/useAccessToken.js +34 -4
- package/dist/esm/components/useAccessToken.js.map +1 -1
- package/dist/esm/cookie.js +51 -0
- package/dist/esm/cookie.js.map +1 -1
- package/dist/esm/get-authorization-url.js +2 -1
- package/dist/esm/get-authorization-url.js.map +1 -1
- package/dist/esm/middleware.js +2 -2
- package/dist/esm/middleware.js.map +1 -1
- package/dist/esm/session.js +36 -3
- package/dist/esm/session.js.map +1 -1
- package/dist/esm/test-helpers.js +57 -0
- package/dist/esm/test-helpers.js.map +1 -0
- package/dist/esm/types/auth.d.ts +5 -3
- package/dist/esm/types/components/tokenStore.d.ts +7 -2
- package/dist/esm/types/cookie.d.ts +1 -0
- package/dist/esm/types/interfaces.d.ts +3 -0
- package/dist/esm/types/middleware.d.ts +1 -1
- package/dist/esm/types/session.d.ts +2 -1
- package/dist/esm/types/test-helpers.d.ts +3 -0
- package/dist/esm/types/workos.d.ts +1 -1
- package/dist/esm/workos.js +1 -1
- package/package.json +5 -4
- package/src/actions.spec.ts +100 -0
- package/src/auth.spec.ts +347 -0
- package/src/auth.ts +19 -6
- package/src/authkit-callback-route.spec.ts +258 -0
- package/src/components/authkit-provider.spec.tsx +471 -0
- package/src/components/button.spec.tsx +46 -0
- package/src/components/impersonation.spec.tsx +134 -0
- package/src/components/min-max-button.spec.tsx +60 -0
- package/src/components/tokenStore.spec.ts +816 -0
- package/src/components/tokenStore.ts +147 -12
- package/src/components/useAccessToken.spec.tsx +731 -0
- package/src/components/useAccessToken.ts +40 -6
- package/src/components/useTokenClaims.spec.tsx +194 -0
- package/src/cookie.spec.ts +276 -0
- package/src/cookie.ts +56 -0
- package/src/get-authorization-url.spec.ts +60 -0
- package/src/get-authorization-url.ts +2 -0
- package/src/interfaces.ts +3 -0
- package/src/jwt.spec.ts +159 -0
- package/src/middleware.ts +2 -1
- package/src/session.spec.ts +1152 -0
- package/src/session.ts +42 -2
- package/src/test-helpers.ts +70 -0
- package/src/utils.spec.ts +142 -0
- package/src/workos.spec.ts +67 -0
- 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
|
+
});
|