@startsimpli/auth 0.4.7 → 0.4.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/auth",
3
- "version": "0.4.7",
3
+ "version": "0.4.9",
4
4
  "description": "Shared authentication package for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -29,17 +29,18 @@
29
29
  "clean": "rm -rf dist"
30
30
  },
31
31
  "peerDependencies": {
32
- "react": "^18.0.0 || ^19.0.0",
33
- "next": "^14.0.0 || ^15.0.0 || ^16.0.0"
32
+ "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
33
+ "react": "^18.0.0 || ^19.0.0"
34
34
  },
35
35
  "devDependencies": {
36
- "@types/node": "^20.17.14",
37
- "@types/react": "^18.3.18",
38
- "@vitest/ui": "^3.0.0",
39
- "happy-dom": "^15.11.7",
36
+ "@types/node": "^20.19.39",
37
+ "@types/react": "^19.2.14",
38
+ "@vitest/ui": "^4.1.5",
39
+ "jsdom": "^29.0.2",
40
+ "@types/jsdom": "^21.1.7",
40
41
  "tsup": "^8.5.1",
41
- "typescript": "^5.7.3",
42
- "vitest": "^3.0.0"
42
+ "typescript": "^6.0.3",
43
+ "vitest": "^4.1.5"
43
44
  },
44
45
  "keywords": [
45
46
  "authentication",
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Contract tests for AuthClient register + OAuth support (bead gkat).
3
+ *
4
+ * These tests define the target API that unblocks raise-simpli's provider
5
+ * swap (bead q6vf). They are written to fail until the implementation lands.
6
+ *
7
+ * Scope of this file:
8
+ * - AuthClient.register({ email, password, passwordConfirm, name? })
9
+ * - AuthClient.signInWithGoogle(redirectTo?)
10
+ * - AuthClient.completeGoogleCallback(code, state)
11
+ *
12
+ * A sibling test file (auth-context-oauth-register.test.tsx) covers the same
13
+ * features through the AuthProvider/useAuth surface.
14
+ */
15
+
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
17
+ import { AuthClient } from '../client/auth-client'
18
+
19
+ function makeToken(exp: number): string {
20
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
21
+ const body = btoa(JSON.stringify({ tokenType: 'access', exp, iat: exp - 3600, jti: 'test', userId: '123' }))
22
+ return `${header}.${body}.signature`
23
+ }
24
+
25
+ const VALID_TOKEN = makeToken(Math.floor(Date.now() / 1000) + 3600)
26
+
27
+ function makeConfig() {
28
+ return { apiBaseUrl: 'http://localhost:8001' }
29
+ }
30
+
31
+ function userPayload(overrides: Record<string, unknown> = {}) {
32
+ return {
33
+ id: 'user-1',
34
+ email: 'new@example.com',
35
+ first_name: 'New',
36
+ last_name: 'User',
37
+ is_email_verified: false,
38
+ created_at: '2026-04-20T00:00:00Z',
39
+ updated_at: '2026-04-20T00:00:00Z',
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ describe('AuthClient.register (contract)', () => {
45
+ beforeEach(() => {
46
+ vi.restoreAllMocks()
47
+ })
48
+
49
+ it('POSTs { email, password, password_confirm, name } to /api/v1/auth/register/ and returns a Session', async () => {
50
+ const client = new AuthClient(makeConfig())
51
+ const mockFetch = vi.fn()
52
+ .mockResolvedValueOnce({
53
+ ok: true,
54
+ status: 200,
55
+ json: async () => ({
56
+ access: VALID_TOKEN,
57
+ user: userPayload({ email: 'new@example.com', first_name: 'New', last_name: 'User' }),
58
+ }),
59
+ })
60
+ vi.stubGlobal('fetch', mockFetch)
61
+
62
+ const session = await client.register({
63
+ email: 'new@example.com',
64
+ password: 'SecurePass1!',
65
+ passwordConfirm: 'SecurePass1!',
66
+ name: 'New User',
67
+ })
68
+
69
+ // Request shape
70
+ const call = mockFetch.mock.calls[0]
71
+ expect(call[0]).toContain('/api/v1/auth/register/')
72
+ expect(call[1].method).toBe('POST')
73
+ const body = JSON.parse(call[1].body as string)
74
+ expect(body.email).toBe('new@example.com')
75
+ expect(body.password).toBe('SecurePass1!')
76
+ // Backend expects snake_case for confirmation field
77
+ expect(body.password_confirm).toBe('SecurePass1!')
78
+ expect(body.name).toBe('New User')
79
+
80
+ // Return shape
81
+ expect(session.accessToken).toBe(VALID_TOKEN)
82
+ expect(session.user.email).toBe('new@example.com')
83
+ expect(session.user.firstName).toBe('New')
84
+ expect(session.user.lastName).toBe('User')
85
+ expect(session.expiresAt).toBeGreaterThan(Date.now())
86
+ })
87
+
88
+ it('throws a readable error when backend rejects (e.g. email already exists)', async () => {
89
+ const client = new AuthClient(makeConfig())
90
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
91
+ ok: false,
92
+ status: 400,
93
+ json: async () => ({
94
+ error: 'VALIDATION_ERROR',
95
+ detail: { email: ['user with this email already exists.'] },
96
+ }),
97
+ }))
98
+
99
+ await expect(
100
+ client.register({ email: 'taken@example.com', password: 'x', passwordConfirm: 'x' })
101
+ ).rejects.toThrow(/already exists/i)
102
+ })
103
+
104
+ it('fetches /me/ when the register response has no user payload', async () => {
105
+ const client = new AuthClient(makeConfig())
106
+ vi.stubGlobal('fetch', vi.fn()
107
+ // register response: access only, no user
108
+ .mockResolvedValueOnce({
109
+ ok: true,
110
+ status: 200,
111
+ json: async () => ({ access: VALID_TOKEN }),
112
+ })
113
+ // fallback /me/
114
+ .mockResolvedValueOnce({
115
+ ok: true,
116
+ status: 200,
117
+ json: async () => ({ user: userPayload({ email: 'fetched@example.com' }) }),
118
+ }))
119
+
120
+ const session = await client.register({
121
+ email: 'fetched@example.com',
122
+ password: 'SecurePass1!',
123
+ passwordConfirm: 'SecurePass1!',
124
+ })
125
+
126
+ expect(session.user.email).toBe('fetched@example.com')
127
+ })
128
+
129
+ it('starts the refresh timer on successful register (same as login)', async () => {
130
+ const client = new AuthClient(makeConfig())
131
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
132
+ ok: true,
133
+ status: 200,
134
+ json: async () => ({ access: VALID_TOKEN, user: userPayload() }),
135
+ }))
136
+
137
+ await client.register({ email: 'new@example.com', password: 'x', passwordConfirm: 'x' })
138
+
139
+ // The refresh timer is a private member — asserting via internal state
140
+ // is brittle, but the behavior we care about is observable: getSession
141
+ // should return a non-null session after register.
142
+ const session = client.getSession()
143
+ expect(session).not.toBeNull()
144
+ })
145
+
146
+ it('mirrors the access token into module-level storage (functions.ts getAccessToken)', async () => {
147
+ const client = new AuthClient(makeConfig())
148
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
149
+ ok: true,
150
+ status: 200,
151
+ json: async () => ({ access: VALID_TOKEN, user: userPayload() }),
152
+ }))
153
+
154
+ const { getAccessToken, setAccessToken } = await import('../client/functions')
155
+ setAccessToken(null) // start clean
156
+
157
+ await client.register({ email: 'new@example.com', password: 'x', passwordConfirm: 'x' })
158
+
159
+ // @startsimpli/api's FetchWrapper reads via this module-level getter.
160
+ // Without the mirror, useAuth().session would have a token but API calls
161
+ // would go out unauthenticated.
162
+ expect(getAccessToken()).toBe(VALID_TOKEN)
163
+ })
164
+ })
165
+
166
+ describe('AuthClient.signInWithGoogle (contract)', () => {
167
+ beforeEach(() => {
168
+ vi.restoreAllMocks()
169
+ })
170
+
171
+ it('GETs /api/v1/auth/oauth/google/initiate/ (optionally with redirect_to) and returns the auth URL', async () => {
172
+ const client = new AuthClient(makeConfig())
173
+ const mockFetch = vi.fn().mockResolvedValueOnce({
174
+ ok: true,
175
+ status: 200,
176
+ json: async () => ({ auth_url: 'https://accounts.google.com/o/oauth2/v2/auth?...' }),
177
+ })
178
+ vi.stubGlobal('fetch', mockFetch)
179
+
180
+ const url = await client.signInWithGoogle('/dashboard')
181
+
182
+ const call = mockFetch.mock.calls[0]
183
+ expect(call[0]).toContain('/api/v1/auth/oauth/google/initiate/')
184
+ // Method may be GET or POST depending on implementation — just verify it
185
+ // round-trips the redirect target if the implementation supports it.
186
+ expect(url).toBe('https://accounts.google.com/o/oauth2/v2/auth?...')
187
+ })
188
+
189
+ it('throws when the backend fails to produce an auth URL', async () => {
190
+ const client = new AuthClient(makeConfig())
191
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
192
+ ok: false,
193
+ status: 500,
194
+ json: async () => ({ detail: 'OAuth provider unavailable' }),
195
+ }))
196
+
197
+ await expect(
198
+ client.signInWithGoogle()
199
+ ).rejects.toThrow(/OAuth|unavailable/i)
200
+ })
201
+ })
202
+
203
+ describe('AuthClient.completeGoogleCallback (contract)', () => {
204
+ beforeEach(() => {
205
+ vi.restoreAllMocks()
206
+ })
207
+
208
+ it('exchanges code+state at /api/v1/auth/oauth/google/callback/ and returns a Session', async () => {
209
+ const client = new AuthClient(makeConfig())
210
+ const mockFetch = vi.fn().mockResolvedValueOnce({
211
+ ok: true,
212
+ status: 200,
213
+ json: async () => ({
214
+ access: VALID_TOKEN,
215
+ user: userPayload({ email: 'oauth@example.com', first_name: 'OAuth', last_name: 'User' }),
216
+ }),
217
+ })
218
+ vi.stubGlobal('fetch', mockFetch)
219
+
220
+ const session = await client.completeGoogleCallback('test-code', 'test-state')
221
+
222
+ const call = mockFetch.mock.calls[0]
223
+ const url = typeof call[0] === 'string' ? call[0] : call[0].url
224
+ expect(url).toContain('/api/v1/auth/oauth/google/callback/')
225
+ expect(url).toContain('code=test-code')
226
+ expect(url).toContain('state=test-state')
227
+
228
+ expect(session.accessToken).toBe(VALID_TOKEN)
229
+ expect(session.user.email).toBe('oauth@example.com')
230
+ expect(session.expiresAt).toBeGreaterThan(Date.now())
231
+ })
232
+
233
+ it('throws a readable error when Google rejects the exchange (invalid/expired state)', async () => {
234
+ const client = new AuthClient(makeConfig())
235
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
236
+ ok: false,
237
+ status: 400,
238
+ json: async () => ({ detail: 'Invalid or expired state parameter' }),
239
+ }))
240
+
241
+ await expect(
242
+ client.completeGoogleCallback('bad-code', 'bad-state')
243
+ ).rejects.toThrow(/state|invalid|expired/i)
244
+ })
245
+
246
+ it('populates session from /me/ when callback response has no user payload', async () => {
247
+ const client = new AuthClient(makeConfig())
248
+ vi.stubGlobal('fetch', vi.fn()
249
+ .mockResolvedValueOnce({
250
+ ok: true,
251
+ status: 200,
252
+ json: async () => ({ access: VALID_TOKEN }),
253
+ })
254
+ .mockResolvedValueOnce({
255
+ ok: true,
256
+ status: 200,
257
+ json: async () => ({ user: userPayload({ email: 'oauth-me@example.com' }) }),
258
+ }))
259
+
260
+ const session = await client.completeGoogleCallback('ok-code', 'ok-state')
261
+
262
+ expect(session.user.email).toBe('oauth-me@example.com')
263
+ })
264
+ })
@@ -39,21 +39,6 @@ function makeJwt(payload: object): string {
39
39
  const VALID_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 3600, userId: '1' });
40
40
  const REFRESHED_TOKEN = makeJwt({ exp: Math.floor(Date.now() / 1000) + 7200, userId: '1' });
41
41
 
42
- // Stub the CSRF token helper so refreshAccessToken() doesn't fail
43
- vi.mock('../utils/cookies', () => ({
44
- getCsrfToken: () => 'test-csrf',
45
- setCsrfToken: vi.fn(),
46
- }));
47
-
48
- // Stub fetchCsrfToken (called inside refreshAccessToken)
49
- vi.mock('../client/functions', async (importOriginal) => {
50
- const original = await importOriginal<typeof import('../client/functions')>();
51
- return {
52
- ...original,
53
- fetchCsrfToken: vi.fn().mockResolvedValue(undefined),
54
- };
55
- });
56
-
57
42
  describe('authFetch — concurrent 401 refresh atomicity (uhxu)', () => {
58
43
  beforeEach(() => {
59
44
  vi.clearAllMocks();
@@ -72,6 +72,7 @@ const {
72
72
  setAccessToken,
73
73
  getAccessToken,
74
74
  setOnSessionExpired,
75
+ notifySessionExpired,
75
76
  setRememberMe,
76
77
  } = await import('../client/functions');
77
78
 
@@ -212,6 +213,236 @@ describe('authFetch session expiration on unrecoverable 401', () => {
212
213
  });
213
214
  });
214
215
 
216
+ describe('verifyEmail error extraction (nested DRF validation shape)', () => {
217
+ beforeEach(() => {
218
+ vi.clearAllMocks();
219
+ mockSessionStorage.clear();
220
+ mockLocalStorage.clear();
221
+ });
222
+
223
+ it('extracts nested detail.token[0] string from validation error', async () => {
224
+ mockFetch.mockImplementation(() =>
225
+ Promise.resolve({
226
+ ok: false,
227
+ status: 400,
228
+ json: async () => ({
229
+ error: 'VALIDATION_ERROR',
230
+ detail: { token: ['Invalid verification link. Please check the link or request a new one.'] },
231
+ }),
232
+ })
233
+ );
234
+
235
+ let caught: Error | null = null;
236
+ try {
237
+ const { verifyEmail } = await import('../client/functions');
238
+ await verifyEmail('bad-token');
239
+ } catch (err) {
240
+ caught = err as Error;
241
+ }
242
+
243
+ expect(caught).not.toBeNull();
244
+ expect(caught!.message).toBe('Invalid verification link. Please check the link or request a new one.');
245
+ expect(caught!.message).not.toBe('[object Object]');
246
+ });
247
+
248
+ it('falls back to fallback text when body has no extractable string', async () => {
249
+ mockFetch.mockImplementation(() =>
250
+ Promise.resolve({
251
+ ok: false,
252
+ status: 500,
253
+ json: async () => ({}),
254
+ })
255
+ );
256
+
257
+ let caught: Error | null = null;
258
+ try {
259
+ const { verifyEmail } = await import('../client/functions');
260
+ await verifyEmail('bad-token');
261
+ } catch (err) {
262
+ caught = err as Error;
263
+ }
264
+
265
+ expect(caught!.message).toBe('Failed to verify email');
266
+ });
267
+
268
+ it('handles string detail (standard DRF shape)', async () => {
269
+ mockFetch.mockImplementation(() =>
270
+ Promise.resolve({
271
+ ok: false,
272
+ status: 400,
273
+ json: async () => ({ detail: 'Token has expired.' }),
274
+ })
275
+ );
276
+
277
+ let caught: Error | null = null;
278
+ try {
279
+ const { verifyEmail } = await import('../client/functions');
280
+ await verifyEmail('expired');
281
+ } catch (err) {
282
+ caught = err as Error;
283
+ }
284
+
285
+ expect(caught!.message).toBe('Token has expired.');
286
+ });
287
+ });
288
+
289
+ describe('extractApiError prefers human-readable error string over code fields', () => {
290
+ it('returns "error" message (not "code") for { error, code } shape', async () => {
291
+ // Re-import so we pick up the current module for each test run
292
+ const { extractApiError } = await import('../client/functions')
293
+ const msg = extractApiError(
294
+ {
295
+ error: 'No active account found with the given credentials',
296
+ code: 'unauthorized',
297
+ statusCode: 401,
298
+ },
299
+ 'Login failed'
300
+ )
301
+ expect(msg).toBe('No active account found with the given credentials')
302
+ })
303
+
304
+ it('prefers detail.string over error', async () => {
305
+ const { extractApiError } = await import('../client/functions')
306
+ const msg = extractApiError({ detail: 'Account is locked', error: 'locked' }, 'x')
307
+ expect(msg).toBe('Account is locked')
308
+ })
309
+
310
+ it('returns nested detail.field[0] when detail is an object', async () => {
311
+ const { extractApiError } = await import('../client/functions')
312
+ const msg = extractApiError({ detail: { token: ['Invalid token'] } }, 'x')
313
+ expect(msg).toBe('Invalid token')
314
+ })
315
+
316
+ it('ignores code/statusCode/timestamp fields when looking for a message', async () => {
317
+ const { extractApiError } = await import('../client/functions')
318
+ const msg = extractApiError(
319
+ { code: 'unauthorized', statusCode: 401, timestamp: '2026-01-01' },
320
+ 'fallback'
321
+ )
322
+ expect(msg).toBe('fallback')
323
+ })
324
+
325
+ it('falls back when no message fields exist', async () => {
326
+ const { extractApiError } = await import('../client/functions')
327
+ expect(extractApiError({}, 'Login failed')).toBe('Login failed')
328
+ })
329
+ })
330
+
331
+ describe('refreshAccessToken — transient vs permanent failures', () => {
332
+ beforeEach(() => {
333
+ vi.clearAllMocks()
334
+ mockSessionStorage.clear()
335
+ mockLocalStorage.clear()
336
+ setAccessToken(VALID_TOKEN)
337
+ })
338
+
339
+ it('returns null and CLEARS the access token on 401 (session dead)', async () => {
340
+ const { refreshAccessToken } = await import('../client/functions')
341
+ mockFetch.mockImplementation(() =>
342
+ Promise.resolve({
343
+ ok: false,
344
+ status: 401,
345
+ json: async () => ({}),
346
+ })
347
+ )
348
+ const result = await refreshAccessToken()
349
+ expect(result).toBeNull()
350
+ expect(getAccessToken()).toBeNull()
351
+ })
352
+
353
+ it('returns null and CLEARS the access token on 403 (session rejected)', async () => {
354
+ const { refreshAccessToken } = await import('../client/functions')
355
+ mockFetch.mockImplementation(() =>
356
+ Promise.resolve({
357
+ ok: false,
358
+ status: 403,
359
+ json: async () => ({}),
360
+ })
361
+ )
362
+ const result = await refreshAccessToken()
363
+ expect(result).toBeNull()
364
+ expect(getAccessToken()).toBeNull()
365
+ })
366
+
367
+ it('THROWS TransientRefreshError on 500 (backend down) and KEEPS the access token', async () => {
368
+ const { refreshAccessToken, TransientRefreshError } = await import('../client/functions')
369
+ mockFetch.mockImplementation(() =>
370
+ Promise.resolve({
371
+ ok: false,
372
+ status: 500,
373
+ statusText: 'Internal Server Error',
374
+ json: async () => ({}),
375
+ })
376
+ )
377
+
378
+ await expect(refreshAccessToken()).rejects.toBeInstanceOf(TransientRefreshError)
379
+ expect(getAccessToken()).toBe(VALID_TOKEN)
380
+ })
381
+
382
+ it('THROWS TransientRefreshError on 503 (service unavailable) and KEEPS the token', async () => {
383
+ const { refreshAccessToken, TransientRefreshError } = await import('../client/functions')
384
+ mockFetch.mockImplementation(() =>
385
+ Promise.resolve({
386
+ ok: false,
387
+ status: 503,
388
+ statusText: 'Service Unavailable',
389
+ json: async () => ({}),
390
+ })
391
+ )
392
+
393
+ await expect(refreshAccessToken()).rejects.toBeInstanceOf(TransientRefreshError)
394
+ expect(getAccessToken()).toBe(VALID_TOKEN)
395
+ })
396
+
397
+ it('THROWS TransientRefreshError on fetch network failure and KEEPS the token', async () => {
398
+ const { refreshAccessToken, TransientRefreshError } = await import('../client/functions')
399
+ mockFetch.mockImplementation(() => Promise.reject(new TypeError('Failed to fetch')))
400
+
401
+ await expect(refreshAccessToken()).rejects.toBeInstanceOf(TransientRefreshError)
402
+ expect(getAccessToken()).toBe(VALID_TOKEN)
403
+ })
404
+ })
405
+
406
+ describe('notifySessionExpired cooldown', () => {
407
+ beforeEach(() => {
408
+ setOnSessionExpired(null);
409
+ });
410
+
411
+ afterEach(() => {
412
+ setOnSessionExpired(null);
413
+ });
414
+
415
+ it('fires the registered callback at most once per cooldown window', () => {
416
+ const onExpired = vi.fn();
417
+ setOnSessionExpired(onExpired);
418
+
419
+ notifySessionExpired();
420
+ notifySessionExpired();
421
+ notifySessionExpired();
422
+ notifySessionExpired();
423
+ notifySessionExpired();
424
+
425
+ expect(onExpired).toHaveBeenCalledTimes(1);
426
+ });
427
+
428
+ it('no-ops when no callback is registered', () => {
429
+ expect(() => notifySessionExpired()).not.toThrow();
430
+ });
431
+
432
+ it('resets the cooldown when a new callback is registered', () => {
433
+ const first = vi.fn();
434
+ setOnSessionExpired(first);
435
+ notifySessionExpired();
436
+ expect(first).toHaveBeenCalledTimes(1);
437
+
438
+ // re-register: cooldown resets, so next notify should fire the new callback
439
+ const second = vi.fn();
440
+ setOnSessionExpired(second);
441
+ notifySessionExpired();
442
+ expect(second).toHaveBeenCalledTimes(1);
443
+ });
444
+ });
445
+
215
446
  describe('signOut cookie cleanup', () => {
216
447
  beforeEach(() => {
217
448
  vi.clearAllMocks();
@@ -258,20 +489,41 @@ describe('refreshAccessToken clears session on failure', () => {
258
489
  });
259
490
 
260
491
  it('clears token when refresh endpoint returns non-ok', async () => {
261
- mockFetch.mockImplementation((url: string) => {
262
- if (typeof url === 'string' && url.includes('/csrf/')) {
263
- return Promise.resolve({ ok: true });
264
- }
265
- return Promise.resolve({
492
+ mockFetch.mockImplementation(() =>
493
+ Promise.resolve({
266
494
  ok: false,
267
495
  status: 401,
268
496
  json: async () => ({ detail: 'Token expired' }),
269
- });
270
- });
497
+ })
498
+ );
271
499
 
272
500
  const result = await refreshAccessToken();
273
501
 
274
502
  expect(result).toBeNull();
275
503
  expect(getAccessToken()).toBeNull();
276
504
  });
505
+
506
+ it('does not fetch the CSRF endpoint or send X-CSRFToken', async () => {
507
+ mockFetch.mockImplementation(() =>
508
+ Promise.resolve({
509
+ ok: true,
510
+ status: 200,
511
+ json: async () => ({ access: VALID_TOKEN }),
512
+ })
513
+ );
514
+
515
+ await refreshAccessToken();
516
+
517
+ const csrfCall = mockFetch.mock.calls.find(
518
+ (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/csrf/')
519
+ );
520
+ expect(csrfCall).toBeUndefined();
521
+
522
+ const refreshCall = mockFetch.mock.calls.find(
523
+ (c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).includes('/auth/token/refresh/')
524
+ );
525
+ expect(refreshCall).toBeDefined();
526
+ const headers = refreshCall![1]?.headers;
527
+ expect(headers['X-CSRFToken']).toBeUndefined();
528
+ });
277
529
  });
@@ -0,0 +1,38 @@
1
+ // Node 25 installs globalThis.localStorage / sessionStorage = {} (stub with
2
+ // no Storage methods) before jsdom/happy-dom load, and the DOM env can't
3
+ // override an already-defined global. Replace both with working in-memory
4
+ // Storage impls so tests can use bare `localStorage` / `sessionStorage`.
5
+ class MemStorage implements Storage {
6
+ private store: Record<string, string> = {};
7
+ get length(): number {
8
+ return Object.keys(this.store).length;
9
+ }
10
+ clear(): void {
11
+ this.store = {};
12
+ }
13
+ getItem(key: string): string | null {
14
+ return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
15
+ }
16
+ setItem(key: string, value: string): void {
17
+ this.store[key] = String(value);
18
+ }
19
+ removeItem(key: string): void {
20
+ delete this.store[key];
21
+ }
22
+ key(i: number): string | null {
23
+ return Object.keys(this.store)[i] ?? null;
24
+ }
25
+ }
26
+
27
+ Object.defineProperty(globalThis, 'localStorage', {
28
+ configurable: true,
29
+ writable: true,
30
+ value: new MemStorage(),
31
+ });
32
+ Object.defineProperty(globalThis, 'sessionStorage', {
33
+ configurable: true,
34
+ writable: true,
35
+ value: new MemStorage(),
36
+ });
37
+
38
+ export {};