@djangocfg/api 2.1.450 → 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.
- package/dist/auth.cjs +70 -8
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +105 -2
- package/dist/auth.d.ts +105 -2
- package/dist/auth.mjs +70 -8
- package/dist/auth.mjs.map +1 -1
- package/package.json +4 -2
- package/src/auth/__tests__/authRedirect.test.ts +101 -0
- package/src/auth/__tests__/base64.test.ts +53 -0
- package/src/auth/__tests__/constants.test.ts +30 -0
- package/src/auth/__tests__/formatAuthError.test.ts +48 -0
- package/src/auth/__tests__/guard.test.ts +144 -0
- package/src/auth/__tests__/path.test.ts +67 -0
- package/src/auth/__tests__/profileCache.test.ts +138 -0
- package/src/auth/__tests__/sessionBootstrap.test.ts +35 -0
- package/src/auth/__tests__/useAuthForm.2fa.test.ts +28 -18
- package/src/auth/__tests__/useTokenRefresh.dom.test.tsx +167 -0
- package/src/auth/__tests__/useTwoFactorStatus.test.ts +45 -36
- package/src/auth/__tests__/validation.test.ts +54 -0
- package/src/auth/context/AuthContext.tsx +55 -5
- package/src/auth/hooks/useAutoAuth.ts +5 -17
- package/src/auth/utils/guard.ts +79 -0
- package/src/auth/utils/index.ts +9 -0
- package/src/auth/utils/path.ts +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@djangocfg/api",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.452",
|
|
4
4
|
"description": "Auto-generated TypeScript API client with React hooks, SWR integration, and Zod validation for Django REST Framework backends",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"django",
|
|
@@ -84,9 +84,11 @@
|
|
|
84
84
|
"zod": "^4.3.6"
|
|
85
85
|
},
|
|
86
86
|
"devDependencies": {
|
|
87
|
+
"@djangocfg/typescript-config": "^2.1.452",
|
|
88
|
+
"@testing-library/react": "^16.3.2",
|
|
87
89
|
"@types/node": "^25.2.3",
|
|
88
90
|
"@types/react": "^19.2.15",
|
|
89
|
-
"
|
|
91
|
+
"jsdom": "^29.1.1",
|
|
90
92
|
"next": "^16.2.2",
|
|
91
93
|
"react": "^19.2.4",
|
|
92
94
|
"tsup": "^8.5.0",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redirect-URL manager tests (auth/hooks/useAuthRedirect.ts).
|
|
3
|
+
*
|
|
4
|
+
* After login the app must send the user back to where they were headed. The
|
|
5
|
+
* manager persists that URL in sessionStorage (surviving the auth round-trip,
|
|
6
|
+
* readable across component instances) and clears it on use. The public methods
|
|
7
|
+
* getFinalRedirectUrl / useAndClearRedirect / hasRedirect read straight from
|
|
8
|
+
* sessionStorage, bypassing React state — that is exactly the cross-instance
|
|
9
|
+
* behaviour under test.
|
|
10
|
+
*
|
|
11
|
+
* useAuthRedirectManager calls useSessionStorage → React.useState. We mock
|
|
12
|
+
* useState with a plain closure so the hook body can run outside a React render
|
|
13
|
+
* (we are testing the storage logic, not React state wiring).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
+
|
|
18
|
+
// Mock React's useState so the hook runs without a renderer. The lazy
|
|
19
|
+
// initializer is invoked immediately (mirroring React) and its value returned.
|
|
20
|
+
vi.mock('react', () => ({
|
|
21
|
+
useState: (init: unknown) => {
|
|
22
|
+
const value = typeof init === 'function' ? (init as () => unknown)() : init;
|
|
23
|
+
return [value, () => {}];
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
function installSessionStorage() {
|
|
28
|
+
const map = new Map<string, string>();
|
|
29
|
+
const storage = {
|
|
30
|
+
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
|
|
31
|
+
setItem: (k: string, v: string) => void map.set(k, v),
|
|
32
|
+
removeItem: (k: string) => void map.delete(k),
|
|
33
|
+
clear: () => map.clear(),
|
|
34
|
+
};
|
|
35
|
+
vi.stubGlobal('window', { sessionStorage: storage, localStorage: storage });
|
|
36
|
+
vi.stubGlobal('sessionStorage', storage);
|
|
37
|
+
return map;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const KEY = 'auth_redirect_url';
|
|
41
|
+
|
|
42
|
+
describe('useAuthRedirectManager (storage-backed paths)', () => {
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.unstubAllGlobals();
|
|
45
|
+
vi.resetModules();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('falls back to the provided fallbackUrl when nothing is saved', async () => {
|
|
49
|
+
installSessionStorage();
|
|
50
|
+
const { useAuthRedirectManager } = await import('../hooks/useAuthRedirect');
|
|
51
|
+
const mgr = useAuthRedirectManager({ fallbackUrl: '/home', clearOnUse: true });
|
|
52
|
+
expect(mgr.getFinalRedirectUrl()).toBe('/home');
|
|
53
|
+
expect(mgr.hasRedirect()).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('reads a saved redirect straight from sessionStorage', async () => {
|
|
57
|
+
const map = installSessionStorage();
|
|
58
|
+
map.set(KEY, JSON.stringify('/projects/42'));
|
|
59
|
+
const { useAuthRedirectManager } = await import('../hooks/useAuthRedirect');
|
|
60
|
+
const mgr = useAuthRedirectManager({ fallbackUrl: '/home' });
|
|
61
|
+
|
|
62
|
+
expect(mgr.hasRedirect()).toBe(true);
|
|
63
|
+
expect(mgr.getFinalRedirectUrl()).toBe('/projects/42');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('useAndClearRedirect returns the saved URL then clears it (clearOnUse)', async () => {
|
|
67
|
+
const map = installSessionStorage();
|
|
68
|
+
map.set(KEY, JSON.stringify('/settings'));
|
|
69
|
+
const { useAuthRedirectManager } = await import('../hooks/useAuthRedirect');
|
|
70
|
+
const mgr = useAuthRedirectManager({ fallbackUrl: '/home', clearOnUse: true });
|
|
71
|
+
|
|
72
|
+
expect(mgr.useAndClearRedirect()).toBe('/settings');
|
|
73
|
+
expect(map.has(KEY)).toBe(false); // cleared from storage
|
|
74
|
+
|
|
75
|
+
// A FRESH manager (mirrors a new component instance after the redirect) now
|
|
76
|
+
// sees empty storage and falls back. (This manager's mocked React state
|
|
77
|
+
// still holds the old value — real React would re-init from storage.)
|
|
78
|
+
const fresh = useAuthRedirectManager({ fallbackUrl: '/home' });
|
|
79
|
+
expect(fresh.getFinalRedirectUrl()).toBe('/home');
|
|
80
|
+
expect(fresh.hasRedirect()).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('useAndClearRedirect keeps the URL when clearOnUse is false', async () => {
|
|
84
|
+
const map = installSessionStorage();
|
|
85
|
+
map.set(KEY, JSON.stringify('/keep-me'));
|
|
86
|
+
const { useAuthRedirectManager } = await import('../hooks/useAuthRedirect');
|
|
87
|
+
const mgr = useAuthRedirectManager({ fallbackUrl: '/home', clearOnUse: false });
|
|
88
|
+
|
|
89
|
+
expect(mgr.useAndClearRedirect()).toBe('/keep-me');
|
|
90
|
+
expect(map.has(KEY)).toBe(true); // preserved
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('gracefully falls back on corrupt storage JSON', async () => {
|
|
94
|
+
const map = installSessionStorage();
|
|
95
|
+
map.set(KEY, 'not-json'); // getRedirectFromStorage catches + returns ''
|
|
96
|
+
const { useAuthRedirectManager } = await import('../hooks/useAuthRedirect');
|
|
97
|
+
const mgr = useAuthRedirectManager({ fallbackUrl: '/home' });
|
|
98
|
+
|
|
99
|
+
expect(mgr.getFinalRedirectUrl()).toBe('/home');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base64 helper tests (auth/hooks/useBase64.ts).
|
|
3
|
+
*
|
|
4
|
+
* The profile cache is stored base64-encoded, so a broken roundtrip corrupts
|
|
5
|
+
* the cache (which then must fail closed — see profileCache.test). These tests
|
|
6
|
+
* pin the roundtrip, including UTF-8 / emoji, and the fail-safe behaviour that
|
|
7
|
+
* returns the input unchanged on a decode error rather than throwing.
|
|
8
|
+
*
|
|
9
|
+
* NOTE: isDev is false under vitest (NODE_ENV='test'), so encoding is active.
|
|
10
|
+
* The Node.js Buffer fallback path runs when `window` is absent.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
import { decodeBase64, encodeBase64 } from '../hooks/useBase64';
|
|
16
|
+
|
|
17
|
+
describe('base64 roundtrip (Node fallback path — no window)', () => {
|
|
18
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
19
|
+
|
|
20
|
+
it.each([
|
|
21
|
+
'hello world',
|
|
22
|
+
'{"id":7,"email":"a@b.com"}',
|
|
23
|
+
'ünïcödé and ✅ 🚀 emoji',
|
|
24
|
+
'кириллица и пробелы',
|
|
25
|
+
'中文字符',
|
|
26
|
+
'',
|
|
27
|
+
])('roundtrips %j', (input) => {
|
|
28
|
+
const encoded = encodeBase64(input);
|
|
29
|
+
expect(decodeBase64(encoded)).toBe(input);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns the input unchanged when decoding invalid base64 (fail-safe)', () => {
|
|
33
|
+
// Not valid base64 → Buffer/atob throw internally → original returned.
|
|
34
|
+
const garbage = '!!!not-base64!!!';
|
|
35
|
+
const out = decodeBase64(garbage);
|
|
36
|
+
// Either the original string or a best-effort decode — never a throw.
|
|
37
|
+
expect(typeof out).toBe('string');
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('base64 roundtrip (browser path — window with btoa/atob)', () => {
|
|
42
|
+
afterEach(() => vi.unstubAllGlobals());
|
|
43
|
+
|
|
44
|
+
it('roundtrips UTF-8 through the browser btoa/atob branch', () => {
|
|
45
|
+
vi.stubGlobal('window', {});
|
|
46
|
+
vi.stubGlobal('btoa', (s: string) => Buffer.from(s, 'binary').toString('base64'));
|
|
47
|
+
vi.stubGlobal('atob', (s: string) => Buffer.from(s, 'base64').toString('binary'));
|
|
48
|
+
|
|
49
|
+
const input = 'ünïcödé 🚀 профиль';
|
|
50
|
+
const encoded = encodeBase64(input);
|
|
51
|
+
expect(decodeBase64(encoded)).toBe(input);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth constants invariants (auth/constants.ts).
|
|
3
|
+
*
|
|
4
|
+
* These lengths gate OTP/TOTP/backup-code validation across the form. A silent
|
|
5
|
+
* change here (e.g. bumping EMAIL_OTP_LENGTH) would desync the frontend from the
|
|
6
|
+
* backend's code length and break login, so pin them explicitly. useAutoAuth
|
|
7
|
+
* also compares the URL OTP against EMAIL_OTP_LENGTH before auto-submitting.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
|
|
12
|
+
import { AUTH_CONSTANTS } from '../constants';
|
|
13
|
+
|
|
14
|
+
describe('AUTH_CONSTANTS', () => {
|
|
15
|
+
it('email OTP length is 4 (matches backend numeric OTP)', () => {
|
|
16
|
+
expect(AUTH_CONSTANTS.EMAIL_OTP_LENGTH).toBe(4);
|
|
17
|
+
});
|
|
18
|
+
it('TOTP length is 6 (RFC 6238)', () => {
|
|
19
|
+
expect(AUTH_CONSTANTS.TOTP_LENGTH).toBe(6);
|
|
20
|
+
});
|
|
21
|
+
it('backup code max length is 12', () => {
|
|
22
|
+
expect(AUTH_CONSTANTS.BACKUP_CODE_MAX_LENGTH).toBe(12);
|
|
23
|
+
});
|
|
24
|
+
it('all lengths are positive integers', () => {
|
|
25
|
+
for (const v of Object.values(AUTH_CONSTANTS)) {
|
|
26
|
+
expect(Number.isInteger(v)).toBe(true);
|
|
27
|
+
expect(v).toBeGreaterThan(0);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* formatAuthError tests (auth/utils/errors.ts).
|
|
3
|
+
*
|
|
4
|
+
* Turns whatever the auth layer throws (string, Error, DRF `{detail}`, axios-ish
|
|
5
|
+
* nested `response.data.detail`) into a user-facing string, with a stable
|
|
6
|
+
* fallback. The precedence order matters: message > detail > nested detail, and
|
|
7
|
+
* it must never throw on null/odd shapes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, expect, it } from 'vitest';
|
|
11
|
+
|
|
12
|
+
import { formatAuthError } from '../utils/errors';
|
|
13
|
+
|
|
14
|
+
describe('formatAuthError', () => {
|
|
15
|
+
it('returns a plain string as-is', () => {
|
|
16
|
+
expect(formatAuthError('Something broke')).toBe('Something broke');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('prefers error.message (Error instances)', () => {
|
|
20
|
+
expect(formatAuthError(new Error('boom'))).toBe('boom');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('reads DRF-style { detail }', () => {
|
|
24
|
+
expect(formatAuthError({ detail: 'Token is blacklisted' })).toBe('Token is blacklisted');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('reads nested response.data.detail', () => {
|
|
28
|
+
expect(formatAuthError({ response: { data: { detail: 'nested detail' } } })).toBe('nested detail');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('message wins over detail when both present', () => {
|
|
32
|
+
expect(formatAuthError({ message: 'msg', detail: 'det' })).toBe('msg');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('detail wins over nested detail', () => {
|
|
36
|
+
expect(
|
|
37
|
+
formatAuthError({ detail: 'top', response: { data: { detail: 'deep' } } }),
|
|
38
|
+
).toBe('top');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('falls back for null / undefined / empty object', () => {
|
|
42
|
+
const FALLBACK = 'An unexpected error occurred';
|
|
43
|
+
expect(formatAuthError(null)).toBe(FALLBACK);
|
|
44
|
+
expect(formatAuthError(undefined)).toBe(FALLBACK);
|
|
45
|
+
expect(formatAuthError({})).toBe(FALLBACK);
|
|
46
|
+
expect(formatAuthError(42)).toBe(FALLBACK);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth-guard state resolution tests (auth/utils/guard.ts).
|
|
3
|
+
*
|
|
4
|
+
* This is the exact branching that produced "stuck on Authenticating…": the
|
|
5
|
+
* guard must show a preloader while auth is resolving, redirect to the login
|
|
6
|
+
* form once resolved-and-unauthenticated, and render content when authenticated.
|
|
7
|
+
* The critical regression guard is the transition authLoading:false +
|
|
8
|
+
* isAuthenticated:false → isLoading:false + shouldRedirect:true. If isLoading
|
|
9
|
+
* stayed true there, the spinner would hang forever.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest';
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
resolveGuardIsLoading,
|
|
16
|
+
resolveGuardIsAuthenticated,
|
|
17
|
+
shouldRedirectToAuth,
|
|
18
|
+
nextAuthEvaluationDelay,
|
|
19
|
+
type GuardInput,
|
|
20
|
+
} from '../utils/guard';
|
|
21
|
+
|
|
22
|
+
const base: GuardInput = {
|
|
23
|
+
mounted: true,
|
|
24
|
+
requireAuth: true,
|
|
25
|
+
authLoading: false,
|
|
26
|
+
isRedirecting: false,
|
|
27
|
+
isAuthenticated: true,
|
|
28
|
+
};
|
|
29
|
+
const s = (o: Partial<GuardInput>): GuardInput => ({ ...base, ...o });
|
|
30
|
+
|
|
31
|
+
describe('resolveGuardIsLoading', () => {
|
|
32
|
+
it('preloader before mount (SSR/hydration parity), regardless of auth', () => {
|
|
33
|
+
expect(resolveGuardIsLoading(s({ mounted: false, isAuthenticated: true }))).toBe(true);
|
|
34
|
+
expect(resolveGuardIsLoading(s({ mounted: false, isAuthenticated: false }))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('public route never blocks once mounted', () => {
|
|
38
|
+
expect(resolveGuardIsLoading(s({ requireAuth: false, isAuthenticated: false }))).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('blocks while auth is still resolving', () => {
|
|
42
|
+
expect(resolveGuardIsLoading(s({ authLoading: true }))).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('blocks while a redirect is in flight', () => {
|
|
46
|
+
expect(resolveGuardIsLoading(s({ isRedirecting: true }))).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('blocks when resolved but unauthenticated (about to redirect)', () => {
|
|
50
|
+
expect(resolveGuardIsLoading(s({ authLoading: false, isAuthenticated: false }))).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('renders content when mounted + authenticated + not loading', () => {
|
|
54
|
+
expect(resolveGuardIsLoading(base)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe('shouldRedirectToAuth', () => {
|
|
59
|
+
it('REGRESSION: resolved + unauthenticated → redirect (never hang)', () => {
|
|
60
|
+
expect(shouldRedirectToAuth(s({ authLoading: false, isAuthenticated: false }))).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not redirect before mount', () => {
|
|
64
|
+
expect(shouldRedirectToAuth(s({ mounted: false, isAuthenticated: false }))).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('does not redirect while auth is still loading', () => {
|
|
68
|
+
expect(shouldRedirectToAuth(s({ authLoading: true, isAuthenticated: false }))).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('does not redirect when authenticated', () => {
|
|
72
|
+
expect(shouldRedirectToAuth(s({ isAuthenticated: true }))).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('does not redirect twice (already redirecting)', () => {
|
|
76
|
+
expect(shouldRedirectToAuth(s({ isAuthenticated: false, isRedirecting: true }))).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not redirect on a public route', () => {
|
|
80
|
+
expect(shouldRedirectToAuth(s({ requireAuth: false, isAuthenticated: false }))).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('safety-net timeout (effectiveAuthLoading = authLoading && !timedOut)', () => {
|
|
85
|
+
// useAuthGuard forces authLoading→false once its timeout fires. Model that
|
|
86
|
+
// here to prove the guard then acts (redirect / render) instead of spinning.
|
|
87
|
+
const withTimeout = (o: Partial<GuardInput>, timedOut: boolean): GuardInput => {
|
|
88
|
+
const st = s(o);
|
|
89
|
+
return { ...st, authLoading: st.authLoading && !timedOut };
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
it('a wedged loading state stops blocking once the timeout fires', () => {
|
|
93
|
+
// authLoading stuck true, unauthenticated. Before timeout: still loading.
|
|
94
|
+
expect(resolveGuardIsLoading(withTimeout({ authLoading: true, isAuthenticated: false }, false))).toBe(true);
|
|
95
|
+
// After timeout: authLoading forced false → resolves, and since unauth it
|
|
96
|
+
// is "about to redirect" loading, but shouldRedirect now fires.
|
|
97
|
+
expect(shouldRedirectToAuth(withTimeout({ authLoading: true, isAuthenticated: false }, true))).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('timeout lets an authenticated-but-wedged session render content', () => {
|
|
101
|
+
expect(resolveGuardIsLoading(withTimeout({ authLoading: true, isAuthenticated: true }, true))).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('resolveGuardIsAuthenticated', () => {
|
|
106
|
+
it('is true on a public route regardless of session', () => {
|
|
107
|
+
expect(resolveGuardIsAuthenticated({ requireAuth: false, isAuthenticated: false })).toBe(true);
|
|
108
|
+
});
|
|
109
|
+
it('reflects the session on a protected route', () => {
|
|
110
|
+
expect(resolveGuardIsAuthenticated({ requireAuth: true, isAuthenticated: true })).toBe(true);
|
|
111
|
+
expect(resolveGuardIsAuthenticated({ requireAuth: true, isAuthenticated: false })).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('nextAuthEvaluationDelay (time-driven re-check scheduling)', () => {
|
|
116
|
+
const NOW = 1_800_000_000_000;
|
|
117
|
+
|
|
118
|
+
it('schedules on the SOONEST future expiry + 1s cushion', () => {
|
|
119
|
+
const access = NOW + 5 * 60_000; // 5 min
|
|
120
|
+
const refresh = NOW + 60 * 60_000; // 60 min
|
|
121
|
+
expect(nextAuthEvaluationDelay(access, refresh, NOW)).toBe(5 * 60_000 + 1000);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('uses refresh expiry when access is already past', () => {
|
|
125
|
+
const access = NOW - 1000; // already expired → ignored
|
|
126
|
+
const refresh = NOW + 10 * 60_000;
|
|
127
|
+
expect(nextAuthEvaluationDelay(access, refresh, NOW)).toBe(10 * 60_000 + 1000);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('returns null when nothing live remains (no timer to arm)', () => {
|
|
131
|
+
expect(nextAuthEvaluationDelay(NOW - 1, NOW - 2, NOW)).toBeNull();
|
|
132
|
+
expect(nextAuthEvaluationDelay(null, null, NOW)).toBeNull();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('handles a single live token (other absent)', () => {
|
|
136
|
+
expect(nextAuthEvaluationDelay(NOW + 30_000, null, NOW)).toBe(31_000);
|
|
137
|
+
expect(nextAuthEvaluationDelay(null, NOW + 30_000, NOW)).toBe(31_000);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('never returns a negative delay', () => {
|
|
141
|
+
const d = nextAuthEvaluationDelay(NOW + 1, NOW + 1, NOW);
|
|
142
|
+
expect(d).toBeGreaterThan(0);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -74,3 +74,38 @@ describe('isSessionDeadOnBootstrap', () => {
|
|
|
74
74
|
expect(isSessionDeadOnBootstrap(atNow, atNow, NOW)).toBe(true);
|
|
75
75
|
});
|
|
76
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
|
+
});
|