forked from BasedHardware/omi
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathuseAuthToken.test.ts
More file actions
291 lines (241 loc) · 10.6 KB
/
Copy pathuseAuthToken.test.ts
File metadata and controls
291 lines (241 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
// Mock useAuth
const mockUser = {
getIdToken: vi.fn(),
};
vi.mock('@/components/auth-provider', () => ({
useAuth: vi.fn(() => ({ user: mockUser, loading: false })),
}));
import { useAuth } from '@/components/auth-provider';
import { useAuthToken, authenticatedFetcher, useAuthFetch } from '../useAuthToken';
describe('useAuthToken', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUser.getIdToken.mockResolvedValue('test-token-123');
});
it('returns token after user resolves', async () => {
const { result } = renderHook(() => useAuthToken());
await waitFor(() => expect(result.current.token).toBe('test-token-123'));
expect(result.current.loading).toBe(false);
});
it('returns null when getIdToken fails', async () => {
mockUser.getIdToken.mockRejectedValue(new Error('auth error'));
const { result } = renderHook(() => useAuthToken());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.token).toBeNull();
});
it('returns null when no user', async () => {
vi.mocked(useAuth).mockReturnValue({ user: null, loading: false, isAdmin: false } as any);
const { result } = renderHook(() => useAuthToken());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.token).toBeNull();
});
});
describe('authenticatedFetcher', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('sends Authorization header and returns JSON', async () => {
const mockResponse = { data: 'test' };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockResponse),
});
const result = await authenticatedFetcher(['/api/test', 'my-token']);
expect(global.fetch).toHaveBeenCalledWith('/api/test', expect.objectContaining({
headers: {
Authorization: 'Bearer my-token',
'Content-Type': 'application/json',
},
}));
expect(result).toEqual(mockResponse);
});
it('throws on non-ok response', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.resolve({ error: 'server error' }),
});
await expect(authenticatedFetcher(['/api/test', 'bad-token'])).rejects.toThrow();
});
it('retries with fresh token on 401 when forceRefresh is registered', async () => {
// First, render useAuthToken to register the module-level forceRefresh callback
mockUser.getIdToken
.mockResolvedValueOnce('stale-token')
.mockResolvedValueOnce('fresh-token');
vi.mocked(useAuth).mockReturnValue({ user: mockUser, loading: false, isAdmin: true } as any);
const { result } = renderHook(() => useAuthToken());
await waitFor(() => expect(result.current.token).toBe('stale-token'));
// Now test authenticatedFetcher with a 401 → retry flow
const mockData = { data: 'success' };
global.fetch = vi.fn()
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({ error: 'unauthorized' }) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(mockData) });
const fetchResult = await authenticatedFetcher(['/api/test', 'stale-token']);
expect(fetchResult).toEqual(mockData);
expect(global.fetch).toHaveBeenCalledTimes(2);
// Second call should use the fresh token
expect(global.fetch).toHaveBeenLastCalledWith('/api/test', expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer fresh-token' }),
}));
});
it('throws 401 when forceRefresh returns null', async () => {
// Register forceRefresh with a user that fails to refresh
vi.mocked(useAuth).mockReturnValue({ user: null, loading: false, isAdmin: false } as any);
renderHook(() => useAuthToken());
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ error: 'unauthorized' }),
});
await expect(authenticatedFetcher(['/api/test', 'stale-token'])).rejects.toThrow();
});
});
describe('useAuthFetch', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUser.getIdToken.mockResolvedValue('fetch-token');
vi.mocked(useAuth).mockReturnValue({ user: mockUser, loading: false, isAdmin: true } as any);
global.fetch = vi.fn().mockResolvedValue({ ok: true });
});
it('adds Authorization header to requests', async () => {
const { result } = renderHook(() => useAuthFetch());
await waitFor(() => expect(result.current.token).toBe('fetch-token'));
await result.current.fetchWithAuth('/api/test', { method: 'POST', body: JSON.stringify({ a: 1 }) });
expect(global.fetch).toHaveBeenCalledWith('/api/test', expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer fetch-token',
'Content-Type': 'application/json',
}),
}));
});
it('preserves caller headers', async () => {
const { result } = renderHook(() => useAuthFetch());
await waitFor(() => expect(result.current.token).toBe('fetch-token'));
await result.current.fetchWithAuth('/api/test', {
headers: { 'X-Custom': 'value' } as any,
});
expect(global.fetch).toHaveBeenCalledWith('/api/test', expect.objectContaining({
headers: expect.objectContaining({
'X-Custom': 'value',
Authorization: 'Bearer fetch-token',
}),
}));
});
it('retries with fresh token on 401', async () => {
mockUser.getIdToken
.mockResolvedValueOnce('stale-token')
.mockResolvedValueOnce('fresh-token');
vi.mocked(useAuth).mockReturnValue({ user: mockUser, loading: false, isAdmin: true } as any);
global.fetch = vi.fn()
.mockResolvedValueOnce({ ok: false, status: 401 })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ success: true }) });
const { result } = renderHook(() => useAuthFetch());
await waitFor(() => expect(result.current.token).toBe('stale-token'));
const response = await result.current.fetchWithAuth('/api/test');
expect(response.ok).toBe(true);
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('skips Content-Type for FormData', async () => {
const { result } = renderHook(() => useAuthFetch());
await waitFor(() => expect(result.current.token).toBe('fetch-token'));
const formData = new FormData();
formData.append('file', 'test');
await result.current.fetchWithAuth('/api/upload', { method: 'POST', body: formData });
const call = vi.mocked(global.fetch).mock.calls[0];
const headers = call[1]?.headers as Record<string, string>;
expect(headers['Content-Type']).toBeUndefined();
expect(headers['Authorization']).toBe('Bearer fetch-token');
});
it('does not retry more than once on 401', async () => {
mockUser.getIdToken
.mockResolvedValueOnce('token-1')
.mockResolvedValueOnce('token-2');
vi.mocked(useAuth).mockReturnValue({ user: mockUser, loading: false, isAdmin: true } as any);
// Both original and retry return 401
global.fetch = vi.fn()
.mockResolvedValueOnce({ ok: false, status: 401 })
.mockResolvedValueOnce({ ok: false, status: 401 });
const { result } = renderHook(() => useAuthFetch());
await waitFor(() => expect(result.current.token).toBe('token-1'));
const response = await result.current.fetchWithAuth('/api/test');
expect(response.status).toBe(401);
// Should only retry once (2 total calls), not loop
expect(global.fetch).toHaveBeenCalledTimes(2);
});
});
describe('authenticatedFetcher error objects', () => {
it('throws error with status and info from non-ok response', async () => {
const errorBody = { message: 'Forbidden', code: 'ACCESS_DENIED' };
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 403,
json: () => Promise.resolve(errorBody),
});
try {
await authenticatedFetcher(['/api/test', 'token']);
expect.fail('should have thrown');
} catch (error: any) {
expect(error.status).toBe(403);
expect(error.info).toEqual(errorBody);
}
});
it('handles invalid JSON in error response gracefully', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: () => Promise.reject(new Error('invalid json')),
});
try {
await authenticatedFetcher(['/api/test', 'token']);
expect.fail('should have thrown');
} catch (error: any) {
expect(error.status).toBe(500);
expect(error.info).toEqual({ message: 'Could not parse error JSON.' });
}
});
});
describe('useAuthToken ref-counting', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUser.getIdToken.mockResolvedValue('ref-token');
vi.mocked(useAuth).mockReturnValue({ user: mockUser, loading: false, isAdmin: true } as any);
});
it('forceRefresh callback survives when one of multiple hooks unmounts', async () => {
// Mount two hooks
const hook1 = renderHook(() => useAuthToken());
const hook2 = renderHook(() => useAuthToken());
await waitFor(() => expect(hook1.result.current.token).toBe('ref-token'));
await waitFor(() => expect(hook2.result.current.token).toBe('ref-token'));
// Unmount one — callback should still work
hook1.unmount();
// Set up a 401 scenario to test the callback is still registered
mockUser.getIdToken.mockResolvedValueOnce('fresh-after-unmount');
global.fetch = vi.fn()
.mockResolvedValueOnce({ ok: false, status: 401, json: () => Promise.resolve({}) })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ ok: true }) });
const result = await authenticatedFetcher(['/api/test', 'ref-token']);
expect(result).toEqual({ ok: true });
expect(global.fetch).toHaveBeenCalledTimes(2);
});
it('forceRefresh callback cleared when all hooks unmount', async () => {
const hook1 = renderHook(() => useAuthToken());
const hook2 = renderHook(() => useAuthToken());
await waitFor(() => expect(hook1.result.current.token).toBe('ref-token'));
await waitFor(() => expect(hook2.result.current.token).toBe('ref-token'));
// Unmount both
hook1.unmount();
hook2.unmount();
// 401 should not retry (callback is null) — should throw
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: () => Promise.resolve({ error: 'unauthorized' }),
});
await expect(authenticatedFetcher(['/api/test', 'ref-token'])).rejects.toThrow();
// Should only have made 1 call (no retry)
expect(global.fetch).toHaveBeenCalledTimes(1);
});
});