@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.
- package/dist/auth.cjs +147 -25
- package/dist/auth.cjs.map +1 -1
- package/dist/auth.d.cts +107 -2
- package/dist/auth.d.ts +107 -2
- package/dist/auth.mjs +147 -25
- 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__/jwt.test.ts +119 -0
- package/src/auth/__tests__/onUnauthorized.test.ts +206 -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 +111 -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 +172 -14
- package/src/auth/hooks/useAutoAuth.ts +5 -17
- package/src/auth/hooks/useTokenRefresh.ts +3 -21
- package/src/auth/utils/guard.ts +79 -0
- package/src/auth/utils/index.ts +9 -0
- package/src/auth/utils/jwt.ts +66 -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,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JWT expiry-helper tests (auth/utils/jwt.ts).
|
|
3
|
+
*
|
|
4
|
+
* These functions decide whether the client should trust a persisted token or
|
|
5
|
+
* treat the session as dead. They must FAIL CLOSED: anything undecodable counts
|
|
6
|
+
* as expired, so garbage in localStorage sends the user to the login form
|
|
7
|
+
* rather than looping on 401s. (This is the automated version of the user's
|
|
8
|
+
* manual "clear localStorage and the form appeared".)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, expect, it } from 'vitest';
|
|
12
|
+
|
|
13
|
+
import { getTokenExpiry, isTokenExpired, isTokenExpiringSoon } from '../utils/jwt';
|
|
14
|
+
|
|
15
|
+
const NOW = 1_800_000_000_000; // fixed "now" in ms (injected, not Date.now())
|
|
16
|
+
|
|
17
|
+
/** Base64url without padding — mirrors real JWT segment encoding. */
|
|
18
|
+
function b64url(obj: unknown): string {
|
|
19
|
+
const json = JSON.stringify(obj);
|
|
20
|
+
const b64 = Buffer.from(json, 'utf8').toString('base64');
|
|
21
|
+
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Build a syntactically valid 3-segment JWT with the given payload. */
|
|
25
|
+
function jwt(payload: Record<string, unknown>): string {
|
|
26
|
+
const header = b64url({ alg: 'HS256', typ: 'JWT' });
|
|
27
|
+
const body = b64url(payload);
|
|
28
|
+
return `${header}.${body}.signature`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** exp claim is in SECONDS in a real JWT. */
|
|
32
|
+
const expSeconds = (ms: number) => Math.floor(ms / 1000);
|
|
33
|
+
|
|
34
|
+
describe('getTokenExpiry', () => {
|
|
35
|
+
it('extracts exp (converted to ms) from a well-formed token', () => {
|
|
36
|
+
const token = jwt({ exp: expSeconds(NOW) });
|
|
37
|
+
expect(getTokenExpiry(token)).toBe(expSeconds(NOW) * 1000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('returns null for null / undefined / empty', () => {
|
|
41
|
+
expect(getTokenExpiry(null)).toBeNull();
|
|
42
|
+
expect(getTokenExpiry(undefined)).toBeNull();
|
|
43
|
+
expect(getTokenExpiry('')).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('returns null for a non-JWT string (wrong segment count)', () => {
|
|
47
|
+
expect(getTokenExpiry('not-a-jwt')).toBeNull();
|
|
48
|
+
expect(getTokenExpiry('only.two')).toBeNull();
|
|
49
|
+
expect(getTokenExpiry('a.b.c.d')).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns null when the payload is not valid base64/JSON', () => {
|
|
53
|
+
expect(getTokenExpiry('aaa.!!!!.bbb')).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('returns null when exp is missing or non-numeric', () => {
|
|
57
|
+
expect(getTokenExpiry(jwt({ sub: 'user' }))).toBeNull();
|
|
58
|
+
expect(getTokenExpiry(jwt({ exp: 'soon' }))).toBeNull();
|
|
59
|
+
expect(getTokenExpiry(jwt({ exp: null }))).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('handles a non-object payload (e.g. a JSON number) as no-exp', () => {
|
|
63
|
+
const header = b64url({ alg: 'HS256' });
|
|
64
|
+
const body = b64url(42 as unknown as Record<string, unknown>);
|
|
65
|
+
expect(getTokenExpiry(`${header}.${body}.sig`)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('isTokenExpired (fail-closed)', () => {
|
|
70
|
+
it('false for a token that expires in the future', () => {
|
|
71
|
+
const token = jwt({ exp: expSeconds(NOW + 60_000) });
|
|
72
|
+
expect(isTokenExpired(token, 0, NOW)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('true for a token already past exp', () => {
|
|
76
|
+
const token = jwt({ exp: expSeconds(NOW - 1) });
|
|
77
|
+
expect(isTokenExpired(token, 0, NOW)).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('true exactly at exp (boundary is inclusive → expired)', () => {
|
|
81
|
+
const token = jwt({ exp: expSeconds(NOW) });
|
|
82
|
+
expect(isTokenExpired(token, 0, NOW)).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('skew treats a soon-to-expire token as already expired', () => {
|
|
86
|
+
const token = jwt({ exp: expSeconds(NOW + 30_000) }); // expires in 30s
|
|
87
|
+
expect(isTokenExpired(token, 60_000, NOW)).toBe(true); // 60s skew
|
|
88
|
+
expect(isTokenExpired(token, 10_000, NOW)).toBe(false); // 10s skew
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('true (fail-closed) for null / garbage / no-exp tokens', () => {
|
|
92
|
+
expect(isTokenExpired(null, 0, NOW)).toBe(true);
|
|
93
|
+
expect(isTokenExpired(undefined, 0, NOW)).toBe(true);
|
|
94
|
+
expect(isTokenExpired('garbage', 0, NOW)).toBe(true);
|
|
95
|
+
expect(isTokenExpired(jwt({ sub: 'x' }), 0, NOW)).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('isTokenExpiringSoon', () => {
|
|
100
|
+
it('true when expiry is within the threshold', () => {
|
|
101
|
+
const token = jwt({ exp: expSeconds(NOW + 5 * 60_000) }); // 5 min
|
|
102
|
+
expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('false when expiry is beyond the threshold', () => {
|
|
106
|
+
const token = jwt({ exp: expSeconds(NOW + 20 * 60_000) }); // 20 min
|
|
107
|
+
expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('false (not "soon") for undecodable tokens — the expired path owns those', () => {
|
|
111
|
+
expect(isTokenExpiringSoon(null, 10 * 60_000, NOW)).toBe(false);
|
|
112
|
+
expect(isTokenExpiringSoon('garbage', 10 * 60_000, NOW)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('true for an already-expired but decodable token', () => {
|
|
116
|
+
const token = jwt({ exp: expSeconds(NOW - 1000) });
|
|
117
|
+
expect(isTokenExpiringSoon(token, 10 * 60_000, NOW)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|