@jmruthers/pace-core 0.5.182 → 0.5.183
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 +1 -1
- package/src/hooks/public/usePublicEventLogo.test.ts +147 -0
- package/src/styles/index.test.ts +21 -0
- package/src/types/__tests__/organisation.roles.test.ts +55 -0
- package/src/utils/audit/audit.test.ts +65 -0
- package/src/utils/device/deviceFingerprint.test.ts +171 -0
- package/src/utils/validation/__tests__/validationUtils.test.ts +72 -0
package/package.json
CHANGED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { renderHook, waitFor, act } from '@testing-library/react';
|
|
2
|
+
import { describe, it, expect, vi, beforeEach, afterEach, afterAll } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
usePublicEventLogo,
|
|
5
|
+
clearPublicLogoCache,
|
|
6
|
+
getPublicLogoCacheStats
|
|
7
|
+
} from './usePublicEventLogo';
|
|
8
|
+
import { createMockSupabaseClient } from '../../__tests__/helpers/supabaseMock';
|
|
9
|
+
import type { SupabaseClient } from '@supabase/supabase-js';
|
|
10
|
+
import type { Database } from '../../types/database';
|
|
11
|
+
|
|
12
|
+
vi.mock('../../utils/core/logger', () => {
|
|
13
|
+
const logger = {
|
|
14
|
+
debug: vi.fn(),
|
|
15
|
+
info: vi.fn(),
|
|
16
|
+
warn: vi.fn(),
|
|
17
|
+
error: vi.fn()
|
|
18
|
+
};
|
|
19
|
+
return {
|
|
20
|
+
createLogger: vi.fn(() => logger)
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const VALID_ORG_ID = '123e4567-e89b-12d3-a456-426614174000';
|
|
25
|
+
const originalFetch = globalThis.fetch;
|
|
26
|
+
|
|
27
|
+
describe('usePublicEventLogo', () => {
|
|
28
|
+
let mockSupabase: SupabaseClient<Database>;
|
|
29
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
mockSupabase = createMockSupabaseClient() as unknown as SupabaseClient<Database>;
|
|
33
|
+
fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
|
34
|
+
(globalThis as any).fetch = fetchMock;
|
|
35
|
+
clearPublicLogoCache();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
clearPublicLogoCache();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
(globalThis as any).fetch = originalFetch;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('fetches and caches the logo when RPC succeeds', async () => {
|
|
48
|
+
const logoUrl = 'https://cdn.example.com/logo.png';
|
|
49
|
+
(mockSupabase.rpc as any).mockResolvedValue({ data: [{ logo_url: logoUrl }], error: null });
|
|
50
|
+
|
|
51
|
+
const { result } = renderHook(() =>
|
|
52
|
+
usePublicEventLogo('event-123', 'Big Event', VALID_ORG_ID, {
|
|
53
|
+
supabase: mockSupabase
|
|
54
|
+
})
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
await waitFor(() => expect(result.current.logoUrl).toBe(logoUrl));
|
|
58
|
+
expect(result.current.fallbackText).toBe('BE');
|
|
59
|
+
expect(fetchMock).toHaveBeenCalledWith(logoUrl, { method: 'HEAD' });
|
|
60
|
+
|
|
61
|
+
const stats = getPublicLogoCacheStats();
|
|
62
|
+
expect(stats.size).toBe(1);
|
|
63
|
+
expect(stats.keys[0]).toContain('event-123');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('refetch clears cache and requests fresh data', async () => {
|
|
67
|
+
const firstUrl = 'https://cdn.example.com/logo.png';
|
|
68
|
+
const secondUrl = 'https://cdn.example.com/logo-2.png';
|
|
69
|
+
(mockSupabase.rpc as any)
|
|
70
|
+
.mockResolvedValueOnce({ data: [{ logo_url: firstUrl }], error: null })
|
|
71
|
+
.mockResolvedValueOnce({ data: [{ logo_url: secondUrl }], error: null });
|
|
72
|
+
|
|
73
|
+
const { result } = renderHook(() =>
|
|
74
|
+
usePublicEventLogo('event-123', 'Big Event', VALID_ORG_ID, {
|
|
75
|
+
supabase: mockSupabase
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
await waitFor(() => expect(result.current.logoUrl).toBe(firstUrl));
|
|
80
|
+
|
|
81
|
+
await act(async () => {
|
|
82
|
+
await result.current.refetch();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await waitFor(() => expect(result.current.logoUrl).toBe(secondUrl));
|
|
86
|
+
expect((mockSupabase.rpc as any)).toHaveBeenCalledTimes(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('exposes error state when RPC fails', async () => {
|
|
90
|
+
(mockSupabase.rpc as any).mockResolvedValue({ data: null, error: { message: 'RPC failed' } });
|
|
91
|
+
|
|
92
|
+
const { result } = renderHook(() =>
|
|
93
|
+
usePublicEventLogo('event-123', 'Big Event', VALID_ORG_ID, {
|
|
94
|
+
supabase: mockSupabase
|
|
95
|
+
})
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
99
|
+
expect(result.current.logoUrl).toBeNull();
|
|
100
|
+
expect(result.current.error).toEqual(new Error('RPC failed'));
|
|
101
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('skips image validation when disabled', async () => {
|
|
105
|
+
const logoUrl = 'https://cdn.example.com/logo.png';
|
|
106
|
+
(mockSupabase.rpc as any).mockResolvedValue({ data: [{ logo_url: logoUrl }], error: null });
|
|
107
|
+
|
|
108
|
+
const { result } = renderHook(() =>
|
|
109
|
+
usePublicEventLogo('event-123', 'Big Event', VALID_ORG_ID, {
|
|
110
|
+
supabase: mockSupabase,
|
|
111
|
+
validateImage: false
|
|
112
|
+
})
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
await waitFor(() => expect(result.current.logoUrl).toBe(logoUrl));
|
|
116
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('uses custom fallback text generator', () => {
|
|
120
|
+
const { result } = renderHook(() =>
|
|
121
|
+
usePublicEventLogo(undefined, 'Public Event', VALID_ORG_ID, {
|
|
122
|
+
supabase: mockSupabase,
|
|
123
|
+
generateFallbackText: () => 'CSTM'
|
|
124
|
+
})
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(result.current.fallbackText).toBe('CSTM');
|
|
128
|
+
expect(result.current.logoUrl).toBeNull();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('clears cached logo entries when clearPublicLogoCache is called', async () => {
|
|
132
|
+
const logoUrl = 'https://cdn.example.com/logo.png';
|
|
133
|
+
(mockSupabase.rpc as any).mockResolvedValue({ data: [{ logo_url: logoUrl }], error: null });
|
|
134
|
+
|
|
135
|
+
const { result } = renderHook(() =>
|
|
136
|
+
usePublicEventLogo('event-123', 'Big Event', VALID_ORG_ID, {
|
|
137
|
+
supabase: mockSupabase
|
|
138
|
+
})
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
await waitFor(() => expect(result.current.logoUrl).toBe(logoUrl));
|
|
142
|
+
expect(getPublicLogoCacheStats().size).toBe(1);
|
|
143
|
+
|
|
144
|
+
clearPublicLogoCache();
|
|
145
|
+
expect(getPublicLogoCacheStats().size).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getAllStylePaths, getStylePath, styleConfig } from './index';
|
|
2
|
+
|
|
3
|
+
describe('styles exports', () => {
|
|
4
|
+
it('exposes metadata for the core stylesheet', () => {
|
|
5
|
+
expect(styleConfig.core).toEqual({
|
|
6
|
+
path: './core.css',
|
|
7
|
+
description: expect.stringContaining('Complete CSS foundation')
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('returns the correct path for a requested style', () => {
|
|
12
|
+
expect(getStylePath('core')).toBe('./core.css');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('lists all available style paths exactly once', () => {
|
|
16
|
+
const allPaths = getAllStylePaths();
|
|
17
|
+
|
|
18
|
+
expect(allPaths).toEqual(['./core.css']);
|
|
19
|
+
expect(new Set(allPaths).size).toBe(allPaths.length);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ORGANISATION_ROLE_PERMISSIONS, type OrganisationPermission } from '../organisation';
|
|
3
|
+
|
|
4
|
+
const EXPECTED_ROLE_ORDER = ['supporter', 'member', 'leader', 'org_admin'] as const;
|
|
5
|
+
const ALLOWED_PERMISSIONS: OrganisationPermission[] = [
|
|
6
|
+
'view_basic',
|
|
7
|
+
'view_details',
|
|
8
|
+
'moderate_content',
|
|
9
|
+
'manage_events',
|
|
10
|
+
'manage_members',
|
|
11
|
+
'manage_settings',
|
|
12
|
+
'*'
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
describe('[types] Organisation role permissions', () => {
|
|
16
|
+
it('exposes all supported roles in a predictable order', () => {
|
|
17
|
+
expect(Object.keys(ORGANISATION_ROLE_PERMISSIONS)).toEqual(Array.from(EXPECTED_ROLE_ORDER));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('only uses recognised permission tokens', () => {
|
|
21
|
+
Object.values(ORGANISATION_ROLE_PERMISSIONS).forEach(permissionList => {
|
|
22
|
+
permissionList.forEach(permission => {
|
|
23
|
+
expect(ALLOWED_PERMISSIONS).toContain(permission);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('enforces progressive access (each role is a superset of the previous one)', () => {
|
|
29
|
+
const supporter = new Set(ORGANISATION_ROLE_PERMISSIONS.supporter);
|
|
30
|
+
const member = new Set(ORGANISATION_ROLE_PERMISSIONS.member);
|
|
31
|
+
const leader = new Set(ORGANISATION_ROLE_PERMISSIONS.leader);
|
|
32
|
+
const admin = new Set(ORGANISATION_ROLE_PERMISSIONS.org_admin);
|
|
33
|
+
|
|
34
|
+
supporter.forEach(permission => {
|
|
35
|
+
expect(member.has(permission)).toBe(true);
|
|
36
|
+
expect(leader.has(permission)).toBe(true);
|
|
37
|
+
expect(admin.has(permission)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
member.forEach(permission => {
|
|
41
|
+
expect(leader.has(permission)).toBe(true);
|
|
42
|
+
expect(admin.has(permission)).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
leader.forEach(permission => {
|
|
46
|
+
expect(admin.has(permission)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('retains privileged capabilities for the organisation admin role', () => {
|
|
51
|
+
expect(ORGANISATION_ROLE_PERMISSIONS.org_admin).toEqual(
|
|
52
|
+
expect.arrayContaining(['manage_members', 'manage_settings'])
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
auditLogger,
|
|
4
|
+
logAuthEvent,
|
|
5
|
+
logPermissionEvent,
|
|
6
|
+
logSecurityEvent,
|
|
7
|
+
logAuditEvent
|
|
8
|
+
} from './audit';
|
|
9
|
+
|
|
10
|
+
describe('audit logger utility', () => {
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
auditLogger.clearEvents();
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('attaches timestamps to every raw log entry', () => {
|
|
17
|
+
vi.useFakeTimers();
|
|
18
|
+
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
|
19
|
+
|
|
20
|
+
auditLogger.log({ type: 'auth', action: 'login', severity: 'high' });
|
|
21
|
+
|
|
22
|
+
const [event] = auditLogger.getEvents();
|
|
23
|
+
expect(event).toMatchObject({ type: 'auth', action: 'login', severity: 'high' });
|
|
24
|
+
expect(event.timestamp).toBe(Date.now());
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('filters security events without mutating the original queue', async () => {
|
|
28
|
+
await auditLogger.logAuthEvent({ event: 'login', userId: 'user-1' });
|
|
29
|
+
await auditLogger.logSecurityEvent({ event: 'policy_violation', data: { ip: '127.0.0.1' } });
|
|
30
|
+
await auditLogger.logPermissionEvent({ event: 'role_grant', userId: 'user-2' });
|
|
31
|
+
|
|
32
|
+
const securityEvents = await auditLogger.getSecurityEvents();
|
|
33
|
+
expect(securityEvents).toHaveLength(1);
|
|
34
|
+
expect(securityEvents[0]).toMatchObject({ type: 'security', event: 'policy_violation' });
|
|
35
|
+
|
|
36
|
+
const allEvents = auditLogger.getEvents();
|
|
37
|
+
expect(allEvents).toHaveLength(3);
|
|
38
|
+
expect(allEvents).not.toBe(securityEvents); // defensive copy for getEvents
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('records structured data through the async wrappers', async () => {
|
|
42
|
+
await auditLogger.logPermissionEvent({ event: 'role_update', userId: 'user-123', data: { scope: 'org' } });
|
|
43
|
+
|
|
44
|
+
const [permissionEvent] = auditLogger.getEvents();
|
|
45
|
+
expect(permissionEvent).toMatchObject({
|
|
46
|
+
type: 'permission',
|
|
47
|
+
event: 'role_update',
|
|
48
|
+
userId: 'user-123',
|
|
49
|
+
data: { scope: 'org' }
|
|
50
|
+
});
|
|
51
|
+
expect(typeof permissionEvent.timestamp).toBe('number');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('routes the public helper functions through the shared logger instance', () => {
|
|
55
|
+
logAuthEvent('login_attempt', 'alpha', { method: 'password' });
|
|
56
|
+
logPermissionEvent('grant_role', 'beta', { role: 'leader' });
|
|
57
|
+
logSecurityEvent('rate_limit', 'system', { ip: '127.0.0.1' });
|
|
58
|
+
logAuditEvent('generic_event', 'gamma');
|
|
59
|
+
|
|
60
|
+
const events = auditLogger.getEvents();
|
|
61
|
+
expect(events).toHaveLength(4);
|
|
62
|
+
expect(events.map(event => event.type)).toEqual(['auth', 'permission', 'security', 'auth']);
|
|
63
|
+
expect(events[3]).toMatchObject({ action: 'generic_event', user: 'gamma' });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { generateDeviceFingerprint, validateDeviceFingerprint, type DeviceFingerprint } from './deviceFingerprint';
|
|
3
|
+
import { secureStorage } from '../security/secureStorage';
|
|
4
|
+
|
|
5
|
+
vi.mock('../security/secureStorage', () => ({
|
|
6
|
+
secureStorage: {
|
|
7
|
+
setItem: vi.fn()
|
|
8
|
+
}
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
describe('device fingerprinting utilities', () => {
|
|
12
|
+
const setItemMock = secureStorage.setItem as ReturnType<typeof vi.fn>;
|
|
13
|
+
const originalCreateElement = document.createElement.bind(document);
|
|
14
|
+
let createElementSpy: ReturnType<typeof vi.spyOn>;
|
|
15
|
+
let intlSpy: ReturnType<typeof vi.spyOn>;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
setItemMock.mockReset();
|
|
19
|
+
vi.useFakeTimers();
|
|
20
|
+
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
|
21
|
+
|
|
22
|
+
Object.defineProperty(window.navigator, 'userAgent', {
|
|
23
|
+
value: 'TestAgent/1.0',
|
|
24
|
+
configurable: true
|
|
25
|
+
});
|
|
26
|
+
Object.defineProperty(window.navigator, 'language', {
|
|
27
|
+
value: 'en-GB',
|
|
28
|
+
configurable: true
|
|
29
|
+
});
|
|
30
|
+
Object.defineProperty(window.navigator, 'platform', {
|
|
31
|
+
value: 'MacIntel',
|
|
32
|
+
configurable: true
|
|
33
|
+
});
|
|
34
|
+
Object.defineProperty(window, 'screen', {
|
|
35
|
+
value: { width: 1920, height: 1080, colorDepth: 24 },
|
|
36
|
+
configurable: true
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
intlSpy = vi.spyOn(Intl, 'DateTimeFormat').mockImplementation(() => ({
|
|
40
|
+
resolvedOptions: () => ({ timeZone: 'UTC' })
|
|
41
|
+
}) as Intl.DateTimeFormat);
|
|
42
|
+
|
|
43
|
+
createElementSpy = vi.spyOn(document, 'createElement').mockImplementation(tagName => {
|
|
44
|
+
if (tagName === 'canvas') {
|
|
45
|
+
const ctx2d = {
|
|
46
|
+
textBaseline: '',
|
|
47
|
+
font: '',
|
|
48
|
+
fillStyle: '',
|
|
49
|
+
fillRect: vi.fn(),
|
|
50
|
+
fillText: vi.fn()
|
|
51
|
+
} as unknown as CanvasRenderingContext2D;
|
|
52
|
+
|
|
53
|
+
const debugInfo = {
|
|
54
|
+
UNMASKED_VENDOR_WEBGL: 'vendorConst',
|
|
55
|
+
UNMASKED_RENDERER_WEBGL: 'rendererConst'
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const gl = {
|
|
59
|
+
getExtension: vi.fn(() => debugInfo),
|
|
60
|
+
getParameter: vi.fn((param: string) =>
|
|
61
|
+
param === debugInfo.UNMASKED_VENDOR_WEBGL ? 'ExampleVendor' : 'ExampleRenderer'
|
|
62
|
+
)
|
|
63
|
+
} as unknown as WebGLRenderingContext;
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
getContext: (type: string) => {
|
|
67
|
+
if (type === '2d') return ctx2d;
|
|
68
|
+
if (type === 'webgl' || type === 'experimental-webgl') return gl;
|
|
69
|
+
return null;
|
|
70
|
+
},
|
|
71
|
+
toDataURL: () => 'data:image/png;base64,fingerprint'
|
|
72
|
+
} as unknown as HTMLCanvasElement;
|
|
73
|
+
}
|
|
74
|
+
return originalCreateElement(tagName);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
vi.useRealTimers();
|
|
80
|
+
createElementSpy.mockRestore();
|
|
81
|
+
intlSpy.mockRestore();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('captures deterministic device details and persists the fingerprint payload', () => {
|
|
85
|
+
const fingerprint = generateDeviceFingerprint();
|
|
86
|
+
|
|
87
|
+
expect(fingerprint.components).toMatchObject({
|
|
88
|
+
language: 'en-GB',
|
|
89
|
+
platform: 'MacIntel',
|
|
90
|
+
screen: '1920x1080x24',
|
|
91
|
+
timezone: 'UTC'
|
|
92
|
+
});
|
|
93
|
+
// userAgent is a hashed value, length depends on the hash
|
|
94
|
+
expect(fingerprint.components.userAgent).toBeTruthy();
|
|
95
|
+
expect(typeof fingerprint.components.userAgent).toBe('string');
|
|
96
|
+
expect(fingerprint.entropy).toBeGreaterThan(0);
|
|
97
|
+
|
|
98
|
+
expect(setItemMock).toHaveBeenCalledTimes(1);
|
|
99
|
+
const [storageKey, storedValue] = setItemMock.mock.calls[0];
|
|
100
|
+
expect(storageKey).toBe('device_fingerprint');
|
|
101
|
+
expect(JSON.parse(storedValue)).toMatchObject({ hash: fingerprint.hash, components: fingerprint.components });
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('falls back gracefully when fingerprinting fails', () => {
|
|
105
|
+
// Mock all potential failure points to ensure fallback is triggered
|
|
106
|
+
createElementSpy.mockImplementation(() => {
|
|
107
|
+
throw new Error('canvas unavailable');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Also mock navigator properties to throw to ensure complete failure
|
|
111
|
+
const originalUserAgent = Object.getOwnPropertyDescriptor(window.navigator, 'userAgent');
|
|
112
|
+
Object.defineProperty(window.navigator, 'userAgent', {
|
|
113
|
+
get: () => { throw new Error('userAgent unavailable'); },
|
|
114
|
+
configurable: true
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const fallback = generateDeviceFingerprint();
|
|
118
|
+
|
|
119
|
+
expect(fallback.hash).toMatch(/^fallback_/);
|
|
120
|
+
expect(fallback.entropy).toBe(1);
|
|
121
|
+
expect(setItemMock).not.toHaveBeenCalled();
|
|
122
|
+
|
|
123
|
+
// Restore original userAgent
|
|
124
|
+
if (originalUserAgent) {
|
|
125
|
+
Object.defineProperty(window.navigator, 'userAgent', originalUserAgent);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('validates stored fingerprints with age and confidence thresholds', () => {
|
|
130
|
+
const baseComponents = {
|
|
131
|
+
userAgent: 'ua',
|
|
132
|
+
language: 'lang',
|
|
133
|
+
platform: 'platform',
|
|
134
|
+
screen: 'screen',
|
|
135
|
+
timezone: 'tz'
|
|
136
|
+
} as DeviceFingerprint['components'];
|
|
137
|
+
|
|
138
|
+
const stored: DeviceFingerprint = {
|
|
139
|
+
hash: 'stored',
|
|
140
|
+
timestamp: Date.now(),
|
|
141
|
+
components: baseComponents,
|
|
142
|
+
entropy: 3
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const current: DeviceFingerprint = {
|
|
146
|
+
hash: 'current',
|
|
147
|
+
timestamp: Date.now(),
|
|
148
|
+
components: { ...baseComponents, screen: 'screen', timezone: 'tz', platform: 'platform-modified' },
|
|
149
|
+
entropy: 3
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const freshResult = validateDeviceFingerprint(stored, current);
|
|
153
|
+
expect(freshResult.isValid).toBe(true);
|
|
154
|
+
expect(freshResult.confidence).toBe(80);
|
|
155
|
+
expect(freshResult.reasons).toEqual(['Component mismatch: platform']);
|
|
156
|
+
|
|
157
|
+
const staleResult = validateDeviceFingerprint(
|
|
158
|
+
{ ...stored, timestamp: Date.now() - 31 * 24 * 60 * 60 * 1000 },
|
|
159
|
+
current
|
|
160
|
+
);
|
|
161
|
+
expect(staleResult.isValid).toBe(false);
|
|
162
|
+
expect(staleResult.reasons).toContain('Fingerprint too old');
|
|
163
|
+
|
|
164
|
+
const divergent = validateDeviceFingerprint(stored, {
|
|
165
|
+
...current,
|
|
166
|
+
components: { ...baseComponents, language: 'other', platform: 'alt', screen: 'another' }
|
|
167
|
+
});
|
|
168
|
+
expect(divergent.isValid).toBe(false);
|
|
169
|
+
expect(divergent.reasons).toContain('Device signature changed significantly');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { validateUserInput, generateCSPHeader, RateLimiter } from '../sanitization';
|
|
4
|
+
|
|
5
|
+
// validateUserInput is re-exported from validationUtils but implemented via sanitizeFormData
|
|
6
|
+
import { validateUserInput as validateAndSanitize } from '../validationUtils';
|
|
7
|
+
|
|
8
|
+
describe('validation utilities', () => {
|
|
9
|
+
const profileSchema = z.object({
|
|
10
|
+
name: z.string().min(1, 'Name is required'),
|
|
11
|
+
bio: z.string().min(1)
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('sanitizes inputs according to field-specific rules before validation', () => {
|
|
15
|
+
const payload = {
|
|
16
|
+
name: ' <script>alert(1)</script> ',
|
|
17
|
+
bio: 'Hello <em>World</em> <strong>safe</strong>'
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const result = validateAndSanitize(profileSchema, payload, {
|
|
21
|
+
name: { allowHtml: false, maxLength: 50 },
|
|
22
|
+
bio: { allowHtml: true, allowedTags: ['strong'] }
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
expect(result.success).toBe(true);
|
|
26
|
+
expect(result.data).toEqual({
|
|
27
|
+
name: '<script>alert(1)</script>',
|
|
28
|
+
bio: 'Hello World <strong>safe</strong>'
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns descriptive errors when validation fails even after sanitization', () => {
|
|
33
|
+
const result = validateAndSanitize(profileSchema, { name: ' ', bio: '' });
|
|
34
|
+
|
|
35
|
+
expect(result.success).toBe(false);
|
|
36
|
+
// Zod's default error message format
|
|
37
|
+
expect(result.error).toMatch(/name|required|character/i);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('generates CSP headers with custom overrides while retaining defaults', () => {
|
|
41
|
+
const header = generateCSPHeader({
|
|
42
|
+
img: "img-src 'self' https://cdn.example.com",
|
|
43
|
+
connect: "connect-src 'self' https://api.example.com"
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(header).toContain("default-src 'self'");
|
|
47
|
+
expect(header).toContain("img-src 'self' https://cdn.example.com");
|
|
48
|
+
expect(header).toContain("connect-src 'self' https://api.example.com");
|
|
49
|
+
expect(header).toContain("frame-src 'none'");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('enforces rate limits and resets windows correctly', () => {
|
|
53
|
+
vi.useFakeTimers();
|
|
54
|
+
vi.setSystemTime(0);
|
|
55
|
+
|
|
56
|
+
const limiter = new RateLimiter(2, 1000);
|
|
57
|
+
expect(limiter.isAllowed('user-1')).toBe(true);
|
|
58
|
+
expect(limiter.isAllowed('user-1')).toBe(true);
|
|
59
|
+
expect(limiter.isAllowed('user-1')).toBe(false);
|
|
60
|
+
expect(limiter.getRemainingAttempts('user-1')).toBe(0);
|
|
61
|
+
|
|
62
|
+
// Advance time past the reset window (need to go past the resetTime)
|
|
63
|
+
vi.advanceTimersByTime(1001);
|
|
64
|
+
expect(limiter.isAllowed('user-1')).toBe(true);
|
|
65
|
+
expect(limiter.getRemainingAttempts('user-1')).toBe(1);
|
|
66
|
+
|
|
67
|
+
limiter.reset('user-1');
|
|
68
|
+
expect(limiter.getRemainingAttempts('user-1')).toBe(2);
|
|
69
|
+
|
|
70
|
+
vi.useRealTimers();
|
|
71
|
+
});
|
|
72
|
+
});
|