@djangocfg/api 2.1.449 → 2.1.452

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Unrecoverable-401 contract test.
3
+ *
4
+ * Regression guard for the "stuck on Authenticating…" bug: when the refresh
5
+ * token is dead (expired, or blacklisted after rotation), the refresh handler
6
+ * fails and the session is UNRECOVERABLE. The client must then:
7
+ * - report failure from the proactive path (`refreshNow()` → null), and
8
+ * - fire `auth.onUnauthorized(cb)` from the reactive (interceptor) path,
9
+ * so the app can clear tokens and show the login form. If neither fires, stale
10
+ * tokens sit in storage and the guard hangs forever.
11
+ *
12
+ * The store reads `window.localStorage` and captures `isBrowser` at import
13
+ * time, so we install a minimal localStorage on globalThis BEFORE importing it.
14
+ */
15
+
16
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
17
+
18
+ function installBrowserStorage() {
19
+ const map = new Map<string, string>();
20
+ const storage = {
21
+ getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
22
+ setItem: (k: string, v: string) => void map.set(k, v),
23
+ removeItem: (k: string) => void map.delete(k),
24
+ clear: () => map.clear(),
25
+ };
26
+ vi.stubGlobal('window', { localStorage: storage, location: { protocol: 'http:' } });
27
+ vi.stubGlobal('localStorage', storage);
28
+ return map;
29
+ }
30
+
31
+ describe('unrecoverable 401 handling', () => {
32
+ beforeEach(() => {
33
+ vi.unstubAllGlobals();
34
+ vi.resetModules();
35
+ });
36
+
37
+ it('refreshNow() returns null when the refresh handler fails (blacklisted token)', async () => {
38
+ installBrowserStorage();
39
+ const { auth } = await import('../../_api/generated/helpers/auth');
40
+
41
+ auth.setToken('access-stale');
42
+ auth.setRefreshToken('R-blacklisted');
43
+
44
+ // Model a server that rejects the (blacklisted) refresh token.
45
+ auth.setRefreshHandler(async () => {
46
+ const err = new Error('Token is blacklisted') as Error & { code: string };
47
+ err.code = 'token_not_valid';
48
+ throw err;
49
+ });
50
+
51
+ const store = auth as unknown as { refreshNow?: () => Promise<string | null> };
52
+ if (typeof store.refreshNow !== 'function') return; // pre-regen: skip gracefully
53
+
54
+ const result = await store.refreshNow();
55
+ expect(result).toBeNull(); // proactive path reports the dead session
56
+ });
57
+
58
+ it('refreshNow() returns null when there is no refresh token at all', async () => {
59
+ installBrowserStorage();
60
+ const { auth } = await import('../../_api/generated/helpers/auth');
61
+
62
+ auth.setToken('access-orphan');
63
+ // no refresh token set
64
+ auth.setRefreshHandler(async () => ({ access: 'never', refresh: 'never' }));
65
+
66
+ const store = auth as unknown as { refreshNow?: () => Promise<string | null> };
67
+ if (typeof store.refreshNow !== 'function') return;
68
+
69
+ expect(await store.refreshNow()).toBeNull();
70
+ });
71
+
72
+ it('onUnauthorized callback can be registered and cleared (wiring the login-form escape hatch)', async () => {
73
+ installBrowserStorage();
74
+ const { auth } = await import('../../_api/generated/helpers/auth');
75
+
76
+ // The AuthContext relies on this being a no-throw registration surface.
77
+ const cb = vi.fn();
78
+ expect(() => auth.onUnauthorized(cb)).not.toThrow();
79
+ expect(() => auth.onUnauthorized(null)).not.toThrow();
80
+ });
81
+ });
82
+
83
+ /**
84
+ * Reactive path: the response interceptor installed by installAuthOnClient must
85
+ * call onUnauthorized(response) when a 401 cannot be recovered, and must NOT
86
+ * call it when the refresh handler rescues the request. We install the auth
87
+ * machinery onto a FAKE client that just captures the response interceptor, then
88
+ * invoke it with a synthetic 401 — exercising the real recovery branch logic.
89
+ */
90
+ describe('response interceptor → onUnauthorized (reactive 401 path)', () => {
91
+ beforeEach(() => {
92
+ vi.unstubAllGlobals();
93
+ vi.resetModules();
94
+ });
95
+
96
+ async function installOnFakeClient() {
97
+ installBrowserStorage();
98
+ const { auth, installAuthOnClient } = await import('../../_api/generated/helpers/auth');
99
+
100
+ let responseInterceptor:
101
+ | ((res: Response, req: Request) => Promise<Response> | Response)
102
+ | null = null;
103
+
104
+ const fakeClient = {
105
+ setConfig: () => {},
106
+ interceptors: {
107
+ request: { use: () => {} },
108
+ response: {
109
+ use: (fn: (res: Response, req: Request) => Promise<Response> | Response) => {
110
+ responseInterceptor = fn;
111
+ },
112
+ },
113
+ error: { use: () => {} },
114
+ },
115
+ };
116
+
117
+ installAuthOnClient(fakeClient as never);
118
+ return { auth, invoke: () => responseInterceptor! };
119
+ }
120
+
121
+ function res401(): Response {
122
+ return new Response(JSON.stringify({ detail: 'Token is blacklisted', code: 'token_not_valid' }), {
123
+ status: 401,
124
+ headers: { 'content-type': 'application/json' },
125
+ });
126
+ }
127
+ function req(url = 'http://localhost:8000/cfg/accounts/profile/'): Request {
128
+ return new Request(url, { method: 'GET' });
129
+ }
130
+
131
+ it('fires onUnauthorized when there is NO refresh handler / token', async () => {
132
+ const { auth, invoke } = await installOnFakeClient();
133
+ const onUnauth = vi.fn();
134
+ auth.onUnauthorized(onUnauth);
135
+ // No refresh token, no handler → tryRefresh returns null → onUnauthorized fires.
136
+
137
+ const out = await invoke()(res401(), req());
138
+ expect(onUnauth).toHaveBeenCalledTimes(1);
139
+ expect(out.status).toBe(401); // original 401 surfaced
140
+ });
141
+
142
+ it('fires onUnauthorized when the refresh handler FAILS (blacklisted token)', async () => {
143
+ const { auth, invoke } = await installOnFakeClient();
144
+ auth.setToken('access-stale');
145
+ auth.setRefreshToken('R-dead');
146
+ auth.setRefreshHandler(async () => {
147
+ throw new Error('Token is blacklisted');
148
+ });
149
+ const onUnauth = vi.fn();
150
+ auth.onUnauthorized(onUnauth);
151
+
152
+ const out = await invoke()(res401(), req());
153
+ expect(onUnauth).toHaveBeenCalledTimes(1);
154
+ expect(out.status).toBe(401);
155
+ });
156
+
157
+ it('does NOT fire onUnauthorized on a non-401 response', async () => {
158
+ const { auth, invoke } = await installOnFakeClient();
159
+ const onUnauth = vi.fn();
160
+ auth.onUnauthorized(onUnauth);
161
+
162
+ const ok = new Response('{}', { status: 200 });
163
+ const out = await invoke()(ok, req());
164
+ expect(onUnauth).not.toHaveBeenCalled();
165
+ expect(out.status).toBe(200);
166
+ });
167
+
168
+ it('does NOT fire onUnauthorized when refresh SUCCEEDS and retry is attempted', async () => {
169
+ const { auth, invoke } = await installOnFakeClient();
170
+ auth.setToken('access-stale');
171
+ auth.setRefreshToken('R-good');
172
+ auth.setRefreshHandler(async () => ({ access: 'access-fresh', refresh: 'R-rotated' }));
173
+ const onUnauth = vi.fn();
174
+ auth.onUnauthorized(onUnauth);
175
+
176
+ // The interceptor retries via global fetch after a successful refresh.
177
+ // Stub fetch to return 200 for the retry so onUnauthorized is not reached.
178
+ const fetchMock = vi.fn(async () => new Response('{}', { status: 200 }));
179
+ vi.stubGlobal('fetch', fetchMock);
180
+
181
+ const out = await invoke()(res401(), req());
182
+ expect(fetchMock).toHaveBeenCalledTimes(1); // retry happened
183
+ expect(onUnauth).not.toHaveBeenCalled(); // recovered → no logout
184
+ expect(out.status).toBe(200);
185
+ expect(auth.getToken()).toBe('access-fresh'); // rotated tokens persisted
186
+ expect(auth.getRefreshToken()).toBe('R-rotated');
187
+ });
188
+
189
+ it('fires onUnauthorized when the retry STILL returns 401', async () => {
190
+ const { auth, invoke } = await installOnFakeClient();
191
+ auth.setToken('access-stale');
192
+ auth.setRefreshToken('R-good');
193
+ auth.setRefreshHandler(async () => ({ access: 'access-fresh', refresh: 'R-rotated' }));
194
+ const onUnauth = vi.fn();
195
+ auth.onUnauthorized(onUnauth);
196
+
197
+ // Refresh succeeds but the retried request is still rejected (401).
198
+ const fetchMock = vi.fn(async () => new Response('{}', { status: 401 }));
199
+ vi.stubGlobal('fetch', fetchMock);
200
+
201
+ const out = await invoke()(res401(), req());
202
+ expect(fetchMock).toHaveBeenCalledTimes(1);
203
+ expect(onUnauth).toHaveBeenCalledTimes(1); // dead even after refresh+retry
204
+ expect(out.status).toBe(401);
205
+ });
206
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Auth path normalization tests (auth/utils/path.ts).
3
+ *
4
+ * These gate whether the magic-link / OTP-in-URL auto-auth fires on the current
5
+ * page. A locale prefix (`/ru/auth`) or trailing slash must NOT stop `/auth`
6
+ * from matching — otherwise a localized magic link silently does nothing. And a
7
+ * 2-letter path segment that is actually a route (not a locale) must not be
8
+ * eaten (edge case documented below).
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { isAllowedAuthPath, normalizePath } from '../utils/path';
14
+
15
+ describe('normalizePath', () => {
16
+ it.each([
17
+ ['/auth', '/auth'],
18
+ ['/auth/', '/auth'],
19
+ ['/ru/auth', '/auth'],
20
+ ['/en/auth/', '/auth'],
21
+ ['/ru/auth/callback', '/auth/callback'],
22
+ ['/', '/'],
23
+ ['', ''],
24
+ ['/dashboard', '/dashboard'],
25
+ ['/ru/', '/'],
26
+ ])('normalizes %j → %j', (input, expected) => {
27
+ expect(normalizePath(input)).toBe(expected);
28
+ });
29
+
30
+ it('returns empty string for null / undefined', () => {
31
+ expect(normalizePath(null)).toBe('');
32
+ expect(normalizePath(undefined)).toBe('');
33
+ });
34
+
35
+ it('strips only a leading 2-letter segment, not longer ones', () => {
36
+ // '/abc/auth' — 'abc' is 3 letters, not a locale, so it stays.
37
+ expect(normalizePath('/abc/auth')).toBe('/abc/auth');
38
+ });
39
+ });
40
+
41
+ describe('isAllowedAuthPath', () => {
42
+ it('matches the exact allowed path', () => {
43
+ expect(isAllowedAuthPath('/auth', ['/auth'])).toBe(true);
44
+ });
45
+ it('matches with a locale prefix', () => {
46
+ expect(isAllowedAuthPath('/ru/auth', ['/auth'])).toBe(true);
47
+ });
48
+ it('matches with a trailing slash', () => {
49
+ expect(isAllowedAuthPath('/auth/', ['/auth'])).toBe(true);
50
+ });
51
+ it('matches a nested path under an allowed root', () => {
52
+ expect(isAllowedAuthPath('/ru/auth/callback', ['/auth'])).toBe(true);
53
+ });
54
+ it('does NOT match an unrelated page', () => {
55
+ expect(isAllowedAuthPath('/dashboard/device', ['/auth'])).toBe(false);
56
+ });
57
+ it('does NOT match a path that merely starts with the same letters', () => {
58
+ // '/authentication' must not match allow-list entry '/auth'.
59
+ expect(isAllowedAuthPath('/authentication', ['/auth'])).toBe(false);
60
+ });
61
+ it('honors multiple allowed paths', () => {
62
+ expect(isAllowedAuthPath('/login', ['/auth', '/login'])).toBe(true);
63
+ });
64
+ it('returns false for null pathname', () => {
65
+ expect(isAllowedAuthPath(null, ['/auth'])).toBe(false);
66
+ });
67
+ });
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Profile cache tests (auth/hooks/useProfileCache.ts).
3
+ *
4
+ * The profile cache is a localStorage entry that lets the app render "logged in"
5
+ * UI without an API round-trip. It is DIRECTLY implicated in the stuck-auth bug:
6
+ * a cached profile left behind by a now-dead session made AuthContext skip the
7
+ * API and treat the user as authenticated. So the cache must expire on TTL,
8
+ * invalidate on version bump, and fail CLOSED on corruption (return null + clear)
9
+ * rather than surfacing garbage.
10
+ *
11
+ * The module reads `window.localStorage` / `Date.now()` at call time; we install
12
+ * a fake storage on globalThis before import and drive time via vi.setSystemTime.
13
+ */
14
+
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+
17
+ function installBrowserStorage() {
18
+ const map = new Map<string, string>();
19
+ const storage = {
20
+ getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
21
+ setItem: (k: string, v: string) => void map.set(k, v),
22
+ removeItem: (k: string) => void map.delete(k),
23
+ clear: () => map.clear(),
24
+ key: (i: number) => Array.from(map.keys())[i] ?? null,
25
+ get length() { return map.size; },
26
+ };
27
+ // useBase64 checks `isDev` via env and uses window for btoa/atob; provide both.
28
+ vi.stubGlobal('window', { localStorage: storage });
29
+ vi.stubGlobal('localStorage', storage);
30
+ return map;
31
+ }
32
+
33
+ const CACHE_KEY = 'user_profile_cache';
34
+ const NOW = 1_800_000_000_000;
35
+
36
+ // Minimal User-shaped object; the cache is generic over its shape.
37
+ const sampleUser = { id: 7, email: 'a@b.com', is_staff: false } as never;
38
+
39
+ describe('profile cache', () => {
40
+ beforeEach(() => {
41
+ vi.unstubAllGlobals();
42
+ vi.resetModules();
43
+ vi.useFakeTimers();
44
+ vi.setSystemTime(NOW);
45
+ });
46
+ afterEach(() => {
47
+ vi.useRealTimers();
48
+ });
49
+
50
+ it('round-trips a profile set→get within TTL', async () => {
51
+ installBrowserStorage();
52
+ const { setCachedProfile, getCachedProfile } = await import('../hooks/useProfileCache');
53
+ setCachedProfile(sampleUser);
54
+ expect(getCachedProfile()).toEqual(sampleUser);
55
+ });
56
+
57
+ it('returns null when nothing is cached', async () => {
58
+ installBrowserStorage();
59
+ const { getCachedProfile } = await import('../hooks/useProfileCache');
60
+ expect(getCachedProfile()).toBeNull();
61
+ });
62
+
63
+ it('expires after TTL and clears the entry', async () => {
64
+ const map = installBrowserStorage();
65
+ const { setCachedProfile, getCachedProfile } = await import('../hooks/useProfileCache');
66
+ setCachedProfile(sampleUser, { ttl: 1000 });
67
+ expect(getCachedProfile()).toEqual(sampleUser);
68
+
69
+ vi.setSystemTime(NOW + 1001); // just past TTL
70
+ expect(getCachedProfile()).toBeNull();
71
+ expect(map.has(CACHE_KEY)).toBe(false); // expired cache is purged
72
+ });
73
+
74
+ it('keeps the profile right up to the TTL boundary', async () => {
75
+ installBrowserStorage();
76
+ const { setCachedProfile, getCachedProfile } = await import('../hooks/useProfileCache');
77
+ setCachedProfile(sampleUser, { ttl: 1000 });
78
+ vi.setSystemTime(NOW + 1000); // exactly TTL — age === ttl, not yet "> ttl"
79
+ expect(getCachedProfile()).toEqual(sampleUser);
80
+ });
81
+
82
+ it('invalidates on a cache-version mismatch (migration safety)', async () => {
83
+ const map = installBrowserStorage();
84
+ const { getCachedProfile } = await import('../hooks/useProfileCache');
85
+ const { encodeBase64 } = await import('../hooks/useBase64');
86
+ // Hand-write an entry from a FUTURE/old version.
87
+ const stale = { version: 999, data: sampleUser, timestamp: NOW, ttl: 10_000 };
88
+ map.set(CACHE_KEY, encodeBase64(JSON.stringify(stale)));
89
+ expect(getCachedProfile()).toBeNull();
90
+ expect(map.has(CACHE_KEY)).toBe(false);
91
+ });
92
+
93
+ it('fails closed on corrupt cache data (returns null + clears)', async () => {
94
+ const map = installBrowserStorage();
95
+ const { getCachedProfile } = await import('../hooks/useProfileCache');
96
+ map.set(CACHE_KEY, 'this-is-not-valid-encoded-json');
97
+ expect(getCachedProfile()).toBeNull();
98
+ expect(map.has(CACHE_KEY)).toBe(false);
99
+ });
100
+
101
+ it('clearProfileCache removes the entry', async () => {
102
+ const map = installBrowserStorage();
103
+ const { setCachedProfile, clearProfileCache } = await import('../hooks/useProfileCache');
104
+ setCachedProfile(sampleUser);
105
+ expect(map.has(CACHE_KEY)).toBe(true);
106
+ clearProfileCache();
107
+ expect(map.has(CACHE_KEY)).toBe(false);
108
+ });
109
+
110
+ it('hasValidCache reflects presence + freshness', async () => {
111
+ installBrowserStorage();
112
+ const { setCachedProfile, hasValidCache } = await import('../hooks/useProfileCache');
113
+ expect(hasValidCache()).toBe(false);
114
+ setCachedProfile(sampleUser, { ttl: 500 });
115
+ expect(hasValidCache()).toBe(true);
116
+ vi.setSystemTime(NOW + 501);
117
+ expect(hasValidCache()).toBe(false);
118
+ });
119
+
120
+ it('getCacheMetadata reports age / ttl / expiresIn', async () => {
121
+ installBrowserStorage();
122
+ const { setCachedProfile, getCacheMetadata } = await import('../hooks/useProfileCache');
123
+ expect(getCacheMetadata()).toEqual({ exists: false });
124
+
125
+ setCachedProfile(sampleUser, { ttl: 10_000 });
126
+ vi.setSystemTime(NOW + 3_000);
127
+ const meta = getCacheMetadata();
128
+ expect(meta).toMatchObject({ exists: true, age: 3_000, ttl: 10_000, expiresIn: 7_000 });
129
+ });
130
+
131
+ it('getCacheMetadata clamps expiresIn to 0 once expired', async () => {
132
+ installBrowserStorage();
133
+ const { setCachedProfile, getCacheMetadata } = await import('../hooks/useProfileCache');
134
+ setCachedProfile(sampleUser, { ttl: 1_000 });
135
+ vi.setSystemTime(NOW + 5_000);
136
+ expect(getCacheMetadata()?.expiresIn).toBe(0);
137
+ });
138
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Bootstrap dead-session detection (isSessionDeadOnBootstrap in AuthContext).
3
+ *
4
+ * This is the decision that, at app start, either shows the login form or lets
5
+ * the app try to use / refresh the persisted tokens. It reproduces the exact
6
+ * scenario the user hit: stale tokens in localStorage that can never recover,
7
+ * where the fix used to be "clear localStorage by hand". The session is DEAD
8
+ * only when the access token is unusable AND the refresh token can't save it.
9
+ */
10
+
11
+ import { describe, expect, it } from 'vitest';
12
+
13
+ import { isSessionDeadOnBootstrap } from '../context/AuthContext';
14
+
15
+ const NOW = 1_800_000_000_000;
16
+ const expSeconds = (ms: number) => Math.floor(ms / 1000);
17
+
18
+ function b64url(obj: unknown): string {
19
+ return Buffer.from(JSON.stringify(obj), 'utf8')
20
+ .toString('base64')
21
+ .replace(/\+/g, '-')
22
+ .replace(/\//g, '_')
23
+ .replace(/=+$/, '');
24
+ }
25
+ function jwt(payload: Record<string, unknown>): string {
26
+ return `${b64url({ alg: 'HS256' })}.${b64url(payload)}.sig`;
27
+ }
28
+ const live = () => jwt({ exp: expSeconds(NOW + 3_600_000) }); // +1h
29
+ const dead = () => jwt({ exp: expSeconds(NOW - 3_600_000) }); // -1h
30
+
31
+ describe('isSessionDeadOnBootstrap', () => {
32
+ it('ALIVE: live access + live refresh', () => {
33
+ expect(isSessionDeadOnBootstrap(live(), live(), NOW)).toBe(false);
34
+ });
35
+
36
+ it('ALIVE: live access even if refresh is dead (access still works)', () => {
37
+ expect(isSessionDeadOnBootstrap(live(), dead(), NOW)).toBe(false);
38
+ });
39
+
40
+ it('ALIVE: live access even with no refresh token', () => {
41
+ expect(isSessionDeadOnBootstrap(live(), null, NOW)).toBe(false);
42
+ });
43
+
44
+ it('ALIVE: dead access but LIVE refresh (refresh can rescue it)', () => {
45
+ expect(isSessionDeadOnBootstrap(dead(), live(), NOW)).toBe(false);
46
+ });
47
+
48
+ it('DEAD: dead access + dead refresh (the classic stuck state)', () => {
49
+ expect(isSessionDeadOnBootstrap(dead(), dead(), NOW)).toBe(true);
50
+ });
51
+
52
+ it('DEAD: dead access + no refresh token', () => {
53
+ expect(isSessionDeadOnBootstrap(dead(), null, NOW)).toBe(true);
54
+ });
55
+
56
+ it('DEAD: both tokens null (empty storage that still triggered init)', () => {
57
+ expect(isSessionDeadOnBootstrap(null, null, NOW)).toBe(true);
58
+ });
59
+
60
+ it('DEAD: garbage access + garbage refresh (corrupt storage — user cleared this by hand)', () => {
61
+ expect(isSessionDeadOnBootstrap('garbage', 'also-garbage', NOW)).toBe(true);
62
+ });
63
+
64
+ it('DEAD: garbage access + no refresh', () => {
65
+ expect(isSessionDeadOnBootstrap('xxxxx', null, NOW)).toBe(true);
66
+ });
67
+
68
+ it('ALIVE: garbage access but a LIVE refresh token still lets us recover', () => {
69
+ expect(isSessionDeadOnBootstrap('garbage', live(), NOW)).toBe(false);
70
+ });
71
+
72
+ it('DEAD: access expired exactly at now + refresh expired (inclusive boundary)', () => {
73
+ const atNow = jwt({ exp: expSeconds(NOW) });
74
+ expect(isSessionDeadOnBootstrap(atNow, atNow, NOW)).toBe(true);
75
+ });
76
+ });
77
+
78
+ /**
79
+ * `isAuthenticated` is now derived as `!isSessionDeadOnBootstrap(...)` (via the
80
+ * private isSessionAlive wrapper in AuthContext). These assertions read the same
81
+ * predicate from the guard's point of view — proving the fix is self-sufficient:
82
+ * a stale session reads as unauthenticated from LOCAL exp alone, with no server
83
+ * 401 required (the exact case a CSP-blocked / hung request used to swallow).
84
+ */
85
+ describe('isAuthenticated gate (derived from session liveness, network-free)', () => {
86
+ const alive = (a: string | null, r: string | null) => !isSessionDeadOnBootstrap(a, r, NOW);
87
+
88
+ it('reports authenticated while the access token is still valid', () => {
89
+ expect(alive(live(), null)).toBe(true);
90
+ });
91
+
92
+ it('stays authenticated when access is expired but refresh can still renew', () => {
93
+ // Between access expiry and the silent refresh — NOT bounced to /auth.
94
+ expect(alive(dead(), live())).toBe(true);
95
+ });
96
+
97
+ it('reports UNAUTHENTICATED the instant both tokens are expired — no 401 needed', () => {
98
+ // This is the reporter's scenario: an expired session where the 401 that
99
+ // would normally trigger logout is swallowed by CSP / a hung request.
100
+ expect(alive(dead(), dead())).toBe(false);
101
+ });
102
+
103
+ it('reports UNAUTHENTICATED for a stale access with no refresh token', () => {
104
+ expect(alive(dead(), null)).toBe(false);
105
+ });
106
+
107
+ it('reports UNAUTHENTICATED for garbage tokens (would have read "present" before)', () => {
108
+ // The OLD isAuthenticated() returned true for any non-null token string.
109
+ expect(alive('stale-non-jwt', 'also-garbage')).toBe(false);
110
+ });
111
+ });
@@ -7,38 +7,48 @@
7
7
  * branch relies on.
8
8
  */
9
9
 
10
+ // @vitest-environment jsdom
10
11
  import { act, renderHook } from '@testing-library/react';
12
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
11
13
 
12
14
  import { useAuthForm } from '../hooks/useAuthForm';
13
15
 
14
- // Mock dependencies
15
- jest.mock('../context', () => ({
16
+ // useAuthForm pulls in useAutoAuth → useCfgRouter → next/navigation, which
17
+ // throws outside an app-router provider. Stub the navigation surface it uses.
18
+ vi.mock('next/navigation', () => ({
19
+ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn(), back: vi.fn() }),
20
+ usePathname: () => '/auth',
21
+ useSearchParams: () => new URLSearchParams(),
22
+ }));
23
+
24
+ // Mock dependencies (migrated from Jest → Vitest)
25
+ vi.mock('../context', () => ({
16
26
  useAuth: () => ({
17
- requestOTP: jest.fn().mockResolvedValue({ success: true }),
18
- verifyOTP: jest.fn(),
19
- getSavedEmail: jest.fn().mockReturnValue(null),
20
- saveEmail: jest.fn(),
21
- clearSavedEmail: jest.fn(),
22
- getSavedPhone: jest.fn().mockReturnValue(null),
23
- savePhone: jest.fn(),
24
- clearSavedPhone: jest.fn(),
27
+ requestOTP: vi.fn().mockResolvedValue({ success: true }),
28
+ verifyOTP: vi.fn(),
29
+ getSavedEmail: vi.fn().mockReturnValue(null),
30
+ saveEmail: vi.fn(),
31
+ clearSavedEmail: vi.fn(),
32
+ getSavedPhone: vi.fn().mockReturnValue(null),
33
+ savePhone: vi.fn(),
34
+ clearSavedPhone: vi.fn(),
25
35
  }),
26
36
  }));
27
37
 
28
- jest.mock('../hooks/useTwoFactor', () => ({
38
+ vi.mock('../hooks/useTwoFactor', () => ({
29
39
  useTwoFactor: () => ({
30
40
  isLoading: false,
31
41
  warning: null,
32
- verifyTOTP: jest.fn(),
33
- verifyBackupCode: jest.fn(),
42
+ verifyTOTP: vi.fn(),
43
+ verifyBackupCode: vi.fn(),
34
44
  }),
35
45
  }));
36
46
 
37
- jest.mock('../utils/logger', () => ({
47
+ vi.mock('../utils/logger', () => ({
38
48
  authLogger: {
39
- info: jest.fn(),
40
- warn: jest.fn(),
41
- error: jest.fn(),
49
+ info: vi.fn(),
50
+ warn: vi.fn(),
51
+ error: vi.fn(),
42
52
  },
43
53
  }));
44
54
 
@@ -49,7 +59,7 @@ describe('useAuthForm - 2FA', () => {
49
59
  };
50
60
 
51
61
  beforeEach(() => {
52
- jest.clearAllMocks();
62
+ vi.clearAllMocks();
53
63
  });
54
64
 
55
65
  describe('step transitions', () => {