From ee166753d847064e22142b147b2fd58977d777ae Mon Sep 17 00:00:00 2001 From: root Date: Fri, 20 Mar 2026 11:52:57 +0000 Subject: [PATCH 1/4] feat: add device fingerprinting to referral anti-abuse system - Add deviceFingerprint utility that generates a SHA-256 hash from stable browser properties (canvas, WebGL, screen, timezone, hardware) - Send fingerprint as x-device-fingerprint header on referral creation - Header is optional/backward-compatible: omitted if fingerprint is empty - Add comprehensive tests for both the utility and the hook Relates to decentraland/social-service-ea#400 --- src/hooks/useTrackReferral.spec.ts | 140 +++++++++++++++++++++ src/hooks/useTrackReferral.ts | 15 ++- src/shared/utils/deviceFingerprint.spec.ts | 49 ++++++++ src/shared/utils/deviceFingerprint.ts | 99 +++++++++++++++ 4 files changed, 300 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useTrackReferral.spec.ts create mode 100644 src/shared/utils/deviceFingerprint.spec.ts create mode 100644 src/shared/utils/deviceFingerprint.ts diff --git a/src/hooks/useTrackReferral.spec.ts b/src/hooks/useTrackReferral.spec.ts new file mode 100644 index 00000000..95eb38b0 --- /dev/null +++ b/src/hooks/useTrackReferral.spec.ts @@ -0,0 +1,140 @@ +import { renderHook, act } from '@testing-library/react' +import fetch from 'decentraland-crypto-fetch' +import { useCurrentConnectionData } from '../shared/connection' +import { generateDeviceFingerprint } from '../shared/utils/deviceFingerprint' +import { handleErrorWithContext } from '../shared/utils/errorHandler' +import { useTrackReferral } from './useTrackReferral' + +jest.mock('decentraland-crypto-fetch', () => jest.fn()) + +jest.mock('../modules/config', () => ({ + config: { get: jest.fn().mockReturnValue('https://mock-referral-server.com') } +})) + +jest.mock('../shared/connection', () => ({ + useCurrentConnectionData: jest.fn() +})) + +jest.mock('../shared/utils/deviceFingerprint', () => ({ + generateDeviceFingerprint: jest.fn() +})) + +jest.mock('../shared/utils/errorHandler', () => ({ + handleErrorWithContext: jest.fn() +})) + +const mockFetch = fetch as jest.MockedFunction +const mockUseCurrentConnectionData = useCurrentConnectionData as jest.MockedFunction +const mockGenerateDeviceFingerprint = generateDeviceFingerprint as jest.MockedFunction + +describe('useTrackReferral', () => { + const mockIdentity = { + ephemeralIdentity: { privateKey: 'pk', publicKey: 'pub', address: '0xuser' }, + expiration: new Date(Date.now() + 3600000), + authChain: [] + } + + beforeEach(() => { + jest.clearAllMocks() + mockUseCurrentConnectionData.mockReturnValue({ + identity: mockIdentity, + account: '0xuser' + } as ReturnType) + mockFetch.mockResolvedValue({} as Response) + mockGenerateDeviceFingerprint.mockResolvedValue('abc123fingerprint') + }) + + it('should send the device fingerprint header on POST', async () => { + const { result } = renderHook(() => useTrackReferral()) + + await act(async () => { + await result.current.track('0xreferrer', 'POST') + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://mock-referral-server.com/referral-progress', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'x-device-fingerprint': 'abc123fingerprint' + }), + body: JSON.stringify({ referrer: '0xreferrer' }), + identity: mockIdentity + }) + ) + }) + + it('should send the device fingerprint header on PATCH', async () => { + const { result } = renderHook(() => useTrackReferral()) + + await act(async () => { + await result.current.track('0xreferrer', 'PATCH') + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://mock-referral-server.com/referral-progress', + expect.objectContaining({ + method: 'PATCH', + headers: expect.objectContaining({ + 'x-device-fingerprint': 'abc123fingerprint' + }), + identity: mockIdentity + }) + ) + }) + + it('should omit device fingerprint header when fingerprint is empty', async () => { + mockGenerateDeviceFingerprint.mockResolvedValue('') + + const { result } = renderHook(() => useTrackReferral()) + + await act(async () => { + await result.current.track('0xreferrer', 'POST') + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://mock-referral-server.com/referral-progress', + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'x-device-fingerprint': expect.anything() + }) + }) + ) + }) + + it('should throw when identity is not available', async () => { + mockUseCurrentConnectionData.mockReturnValue({ + identity: null, + account: null + } as unknown as ReturnType) + + const { result } = renderHook(() => useTrackReferral()) + + await expect(result.current.track('0xreferrer')).rejects.toThrow('No identity available for tracking referral') + }) + + it('should handle fetch errors and re-throw', async () => { + const error = new Error('Network error') + mockFetch.mockRejectedValue(error) + + const { result } = renderHook(() => useTrackReferral()) + + await expect(result.current.track('0xreferrer')).rejects.toThrow('Network error') + expect(handleErrorWithContext).toHaveBeenCalledWith(error, 'Failed to track referral progress', expect.any(Object)) + }) + + it('should report isReady as true when identity exists', () => { + const { result } = renderHook(() => useTrackReferral()) + expect(result.current.isReady).toBe(true) + }) + + it('should report isReady as false when identity is null', () => { + mockUseCurrentConnectionData.mockReturnValue({ + identity: null, + account: null + } as unknown as ReturnType) + + const { result } = renderHook(() => useTrackReferral()) + expect(result.current.isReady).toBe(false) + }) +}) diff --git a/src/hooks/useTrackReferral.ts b/src/hooks/useTrackReferral.ts index 6a12f99a..58bea965 100644 --- a/src/hooks/useTrackReferral.ts +++ b/src/hooks/useTrackReferral.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react' import fetch from 'decentraland-crypto-fetch' import { config } from '../modules/config' import { useCurrentConnectionData } from '../shared/connection' +import { generateDeviceFingerprint } from '../shared/utils/deviceFingerprint' import { handleErrorWithContext } from '../shared/utils/errorHandler' const REFERRAL_SERVER_URL = config.get('REFERRAL_SERVER_URL') @@ -18,11 +19,19 @@ export const useTrackReferral = () => { try { const body = method === 'POST' ? JSON.stringify({ referrer }) : undefined + const deviceFingerprint = await generateDeviceFingerprint() + + const headers: Record = { + contentType: 'application/json' + } + + if (deviceFingerprint) { + headers['x-device-fingerprint'] = deviceFingerprint + } + await fetch(`${REFERRAL_SERVER_URL}/referral-progress`, { method, - headers: { - contentType: 'application/json' - }, + headers, ...(body && { body }), identity }) diff --git a/src/shared/utils/deviceFingerprint.spec.ts b/src/shared/utils/deviceFingerprint.spec.ts new file mode 100644 index 00000000..8025802f --- /dev/null +++ b/src/shared/utils/deviceFingerprint.spec.ts @@ -0,0 +1,49 @@ +import { generateDeviceFingerprint } from './deviceFingerprint' + +describe('generateDeviceFingerprint', () => { + const mockDigest = jest.fn() + + beforeEach(() => { + jest.clearAllMocks() + + // Mock crypto.subtle.digest to return a predictable hash + Object.defineProperty(global, 'crypto', { + value: { + subtle: { + digest: mockDigest + } + }, + writable: true, + configurable: true + }) + + mockDigest.mockResolvedValue(new Uint8Array([0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90]).buffer) + }) + + it('should return a hex string when all components are available', async () => { + const fingerprint = await generateDeviceFingerprint() + expect(fingerprint).toBe('abcdef1234567890') + expect(mockDigest).toHaveBeenCalledWith('SHA-256', expect.any(Uint8Array)) + }) + + it('should return a non-empty string even when canvas and WebGL are unavailable', async () => { + // In jsdom, canvas context returns null and WebGL is not available, + // but screen info and browser properties still provide data + const fingerprint = await generateDeviceFingerprint() + expect(typeof fingerprint).toBe('string') + expect(fingerprint.length).toBeGreaterThan(0) + }) + + it('should return empty string if hashing fails completely', async () => { + mockDigest.mockRejectedValue(new Error('crypto not available')) + + const fingerprint = await generateDeviceFingerprint() + expect(fingerprint).toBe('') + }) + + it('should produce consistent results for the same environment', async () => { + const fp1 = await generateDeviceFingerprint() + const fp2 = await generateDeviceFingerprint() + expect(fp1).toBe(fp2) + }) +}) diff --git a/src/shared/utils/deviceFingerprint.ts b/src/shared/utils/deviceFingerprint.ts new file mode 100644 index 00000000..4aeb7fb3 --- /dev/null +++ b/src/shared/utils/deviceFingerprint.ts @@ -0,0 +1,99 @@ +/** + * Generates a lightweight device fingerprint based on browser characteristics. + * This is used as an anti-abuse signal for the referral system — it helps detect + * users rotating VPNs/proxies while using the same physical device. + * + * The fingerprint is a hex-encoded hash of stable browser properties: + * - Canvas rendering output + * - WebGL renderer info + * - Screen dimensions + * - Timezone + * - Language and platform + * - Hardware concurrency and device memory + * + * This does NOT use cookies, local storage, or any persistent state. + * It is computed fresh on each call and sent as a request header. + */ + +async function hashString(input: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(input) + const hashBuffer = await crypto.subtle.digest('SHA-256', data) + const hashArray = Array.from(new Uint8Array(hashBuffer)) + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') +} + +function getCanvasFingerprint(): string { + try { + const canvas = document.createElement('canvas') + canvas.width = 200 + canvas.height = 50 + const ctx = canvas.getContext('2d') + if (!ctx) return '' + + ctx.textBaseline = 'top' + ctx.font = '14px Arial' + ctx.fillStyle = '#f60' + ctx.fillRect(125, 1, 62, 20) + ctx.fillStyle = '#069' + ctx.fillText('Dcl fingerprint', 2, 15) + ctx.fillStyle = 'rgba(102, 204, 0, 0.7)' + ctx.fillText('Dcl fingerprint', 4, 17) + + return canvas.toDataURL() + } catch { + return '' + } +} + +function getWebGLInfo(): string { + try { + const canvas = document.createElement('canvas') + const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl') + if (!gl || !(gl instanceof WebGLRenderingContext)) return '' + + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info') + if (!debugInfo) return '' + + const vendor = gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) as string + const renderer = gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) as string + return `${vendor}~${renderer}` + } catch { + return '' + } +} + +function getScreenInfo(): string { + try { + return `${screen.width}x${screen.height}x${screen.colorDepth}x${window.devicePixelRatio}` + } catch { + return '' + } +} + +function getBrowserProperties(): string { + const props = [ + navigator.language, + navigator.platform, + navigator.hardwareConcurrency?.toString() ?? '', + (navigator as Navigator & { deviceMemory?: number }).deviceMemory?.toString() ?? '', + Intl.DateTimeFormat().resolvedOptions().timeZone, + new Date().getTimezoneOffset().toString() + ] + return props.join('|') +} + +/** + * Generates a device fingerprint string (SHA-256 hex hash). + * Returns an empty string if fingerprinting fails entirely. + */ +export async function generateDeviceFingerprint(): Promise { + try { + const components = [getCanvasFingerprint(), getWebGLInfo(), getScreenInfo(), getBrowserProperties()] + + const raw = components.join('||') + return await hashString(raw) + } catch { + return '' + } +} From bff8a09b80d08daced112f949c7ef28e45444741 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 20 Mar 2026 11:57:01 +0000 Subject: [PATCH 2/4] fix: update deviceFingerprint test assertion for jsdom compatibility --- src/shared/utils/deviceFingerprint.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/shared/utils/deviceFingerprint.spec.ts b/src/shared/utils/deviceFingerprint.spec.ts index 8025802f..e40f7eba 100644 --- a/src/shared/utils/deviceFingerprint.spec.ts +++ b/src/shared/utils/deviceFingerprint.spec.ts @@ -23,7 +23,8 @@ describe('generateDeviceFingerprint', () => { it('should return a hex string when all components are available', async () => { const fingerprint = await generateDeviceFingerprint() expect(fingerprint).toBe('abcdef1234567890') - expect(mockDigest).toHaveBeenCalledWith('SHA-256', expect.any(Uint8Array)) + expect(mockDigest).toHaveBeenCalledTimes(1) + expect(mockDigest.mock.calls[0][0]).toBe('SHA-256') }) it('should return a non-empty string even when canvas and WebGL are unavailable', async () => { From aadd763f6e5abf455a00ec1f3d397f5e32af6a87 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 20 Mar 2026 12:23:23 +0000 Subject: [PATCH 3/4] fix: add missing ConnectionContextValue properties to test mock --- src/hooks/useTrackReferral.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/hooks/useTrackReferral.spec.ts b/src/hooks/useTrackReferral.spec.ts index 95eb38b0..e46c9f87 100644 --- a/src/hooks/useTrackReferral.spec.ts +++ b/src/hooks/useTrackReferral.spec.ts @@ -38,7 +38,12 @@ describe('useTrackReferral', () => { jest.clearAllMocks() mockUseCurrentConnectionData.mockReturnValue({ identity: mockIdentity, - account: '0xuser' + account: '0xuser', + isLoading: false, + provider: undefined, + providerType: undefined, + chainId: undefined, + getIdentitySignature: jest.fn() } as ReturnType) mockFetch.mockResolvedValue({} as Response) mockGenerateDeviceFingerprint.mockResolvedValue('abc123fingerprint') From d057660c657eed2854c89024f92c4a59344ab50d Mon Sep 17 00:00:00 2001 From: root Date: Fri, 20 Mar 2026 13:09:55 +0000 Subject: [PATCH 4/4] fix: rewrite tests to follow DCL testing standards - Restructure tests using describe/when/and context-building pattern - Use lowercase describe sentences with 'when' for top-level contexts - Use 'should' prefix for it blocks describing expectations - Build context in nested describes with beforeEach setup - Move jest.clearAllMocks to afterEach instead of beforeEach - Properly separate identity-available vs identity-unavailable scenarios --- src/hooks/useTrackReferral.spec.ts | 190 ++++++++++++--------- src/shared/utils/deviceFingerprint.spec.ts | 57 ++++--- 2 files changed, 141 insertions(+), 106 deletions(-) diff --git a/src/hooks/useTrackReferral.spec.ts b/src/hooks/useTrackReferral.spec.ts index e46c9f87..0b92f6ea 100644 --- a/src/hooks/useTrackReferral.spec.ts +++ b/src/hooks/useTrackReferral.spec.ts @@ -27,119 +27,143 @@ const mockFetch = fetch as jest.MockedFunction const mockUseCurrentConnectionData = useCurrentConnectionData as jest.MockedFunction const mockGenerateDeviceFingerprint = generateDeviceFingerprint as jest.MockedFunction -describe('useTrackReferral', () => { +describe('when using the useTrackReferral hook', () => { const mockIdentity = { ephemeralIdentity: { privateKey: 'pk', publicKey: 'pub', address: '0xuser' }, expiration: new Date(Date.now() + 3600000), authChain: [] } - beforeEach(() => { + afterEach(() => { jest.clearAllMocks() - mockUseCurrentConnectionData.mockReturnValue({ - identity: mockIdentity, - account: '0xuser', - isLoading: false, - provider: undefined, - providerType: undefined, - chainId: undefined, - getIdentitySignature: jest.fn() - } as ReturnType) - mockFetch.mockResolvedValue({} as Response) - mockGenerateDeviceFingerprint.mockResolvedValue('abc123fingerprint') }) - it('should send the device fingerprint header on POST', async () => { - const { result } = renderHook(() => useTrackReferral()) - - await act(async () => { - await result.current.track('0xreferrer', 'POST') + describe('when the identity is available', () => { + beforeEach(() => { + mockUseCurrentConnectionData.mockReturnValue({ + identity: mockIdentity, + account: '0xuser', + isLoading: false, + provider: undefined, + providerType: undefined, + chainId: undefined, + getIdentitySignature: jest.fn() + } as ReturnType) + mockFetch.mockResolvedValue({} as Response) + mockGenerateDeviceFingerprint.mockResolvedValue('abc123fingerprint') }) - expect(mockFetch).toHaveBeenCalledWith( - 'https://mock-referral-server.com/referral-progress', - expect.objectContaining({ - method: 'POST', - headers: expect.objectContaining({ - 'x-device-fingerprint': 'abc123fingerprint' - }), - body: JSON.stringify({ referrer: '0xreferrer' }), - identity: mockIdentity - }) - ) - }) + it('should report isReady as true', () => { + const { result } = renderHook(() => useTrackReferral()) + expect(result.current.isReady).toBe(true) + }) - it('should send the device fingerprint header on PATCH', async () => { - const { result } = renderHook(() => useTrackReferral()) + describe('when tracking a referral with POST', () => { + it('should send the device fingerprint header', async () => { + const { result } = renderHook(() => useTrackReferral()) - await act(async () => { - await result.current.track('0xreferrer', 'PATCH') - }) + await act(async () => { + await result.current.track('0xreferrer', 'POST') + }) - expect(mockFetch).toHaveBeenCalledWith( - 'https://mock-referral-server.com/referral-progress', - expect.objectContaining({ - method: 'PATCH', - headers: expect.objectContaining({ - 'x-device-fingerprint': 'abc123fingerprint' - }), - identity: mockIdentity + expect(mockFetch).toHaveBeenCalledWith( + 'https://mock-referral-server.com/referral-progress', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'x-device-fingerprint': 'abc123fingerprint' + }), + body: JSON.stringify({ referrer: '0xreferrer' }), + identity: mockIdentity + }) + ) }) - ) - }) + }) - it('should omit device fingerprint header when fingerprint is empty', async () => { - mockGenerateDeviceFingerprint.mockResolvedValue('') + describe('when tracking a referral with PATCH', () => { + it('should send the device fingerprint header', async () => { + const { result } = renderHook(() => useTrackReferral()) - const { result } = renderHook(() => useTrackReferral()) + await act(async () => { + await result.current.track('0xreferrer', 'PATCH') + }) - await act(async () => { - await result.current.track('0xreferrer', 'POST') + expect(mockFetch).toHaveBeenCalledWith( + 'https://mock-referral-server.com/referral-progress', + expect.objectContaining({ + method: 'PATCH', + headers: expect.objectContaining({ + 'x-device-fingerprint': 'abc123fingerprint' + }), + identity: mockIdentity + }) + ) + }) }) - expect(mockFetch).toHaveBeenCalledWith( - 'https://mock-referral-server.com/referral-progress', - expect.objectContaining({ - headers: expect.not.objectContaining({ - 'x-device-fingerprint': expect.anything() - }) + describe('when the device fingerprint is empty', () => { + beforeEach(() => { + mockGenerateDeviceFingerprint.mockResolvedValue('') }) - ) - }) - it('should throw when identity is not available', async () => { - mockUseCurrentConnectionData.mockReturnValue({ - identity: null, - account: null - } as unknown as ReturnType) + it('should omit the device fingerprint header', async () => { + const { result } = renderHook(() => useTrackReferral()) - const { result } = renderHook(() => useTrackReferral()) + await act(async () => { + await result.current.track('0xreferrer', 'POST') + }) - await expect(result.current.track('0xreferrer')).rejects.toThrow('No identity available for tracking referral') - }) + expect(mockFetch).toHaveBeenCalledWith( + 'https://mock-referral-server.com/referral-progress', + expect.objectContaining({ + headers: expect.not.objectContaining({ + 'x-device-fingerprint': expect.anything() + }) + }) + ) + }) + }) - it('should handle fetch errors and re-throw', async () => { - const error = new Error('Network error') - mockFetch.mockRejectedValue(error) + describe('when the fetch request fails', () => { + let error: Error - const { result } = renderHook(() => useTrackReferral()) + beforeEach(() => { + error = new Error('Network error') + mockFetch.mockRejectedValue(error) + }) - await expect(result.current.track('0xreferrer')).rejects.toThrow('Network error') - expect(handleErrorWithContext).toHaveBeenCalledWith(error, 'Failed to track referral progress', expect.any(Object)) - }) + it('should handle the error with context and re-throw', async () => { + const { result } = renderHook(() => useTrackReferral()) - it('should report isReady as true when identity exists', () => { - const { result } = renderHook(() => useTrackReferral()) - expect(result.current.isReady).toBe(true) + await expect(result.current.track('0xreferrer')).rejects.toThrow('Network error') + expect(handleErrorWithContext).toHaveBeenCalledWith(error, 'Failed to track referral progress', expect.any(Object)) + }) + }) }) - it('should report isReady as false when identity is null', () => { - mockUseCurrentConnectionData.mockReturnValue({ - identity: null, - account: null - } as unknown as ReturnType) + describe('when the identity is not available', () => { + beforeEach(() => { + mockUseCurrentConnectionData.mockReturnValue({ + identity: null, + account: null, + isLoading: false, + provider: undefined, + providerType: undefined, + chainId: undefined, + getIdentitySignature: jest.fn() + } as unknown as ReturnType) + }) + + it('should report isReady as false', () => { + const { result } = renderHook(() => useTrackReferral()) + expect(result.current.isReady).toBe(false) + }) - const { result } = renderHook(() => useTrackReferral()) - expect(result.current.isReady).toBe(false) + describe('when attempting to track a referral', () => { + it('should throw an error indicating no identity is available', async () => { + const { result } = renderHook(() => useTrackReferral()) + await expect(result.current.track('0xreferrer')).rejects.toThrow('No identity available for tracking referral') + }) + }) }) }) diff --git a/src/shared/utils/deviceFingerprint.spec.ts b/src/shared/utils/deviceFingerprint.spec.ts index e40f7eba..1e9f1620 100644 --- a/src/shared/utils/deviceFingerprint.spec.ts +++ b/src/shared/utils/deviceFingerprint.spec.ts @@ -1,12 +1,11 @@ import { generateDeviceFingerprint } from './deviceFingerprint' -describe('generateDeviceFingerprint', () => { - const mockDigest = jest.fn() +describe('when generating a device fingerprint', () => { + let mockDigest: jest.Mock beforeEach(() => { - jest.clearAllMocks() + mockDigest = jest.fn() - // Mock crypto.subtle.digest to return a predictable hash Object.defineProperty(global, 'crypto', { value: { subtle: { @@ -20,31 +19,43 @@ describe('generateDeviceFingerprint', () => { mockDigest.mockResolvedValue(new Uint8Array([0xab, 0xcd, 0xef, 0x12, 0x34, 0x56, 0x78, 0x90]).buffer) }) - it('should return a hex string when all components are available', async () => { - const fingerprint = await generateDeviceFingerprint() - expect(fingerprint).toBe('abcdef1234567890') - expect(mockDigest).toHaveBeenCalledTimes(1) - expect(mockDigest.mock.calls[0][0]).toBe('SHA-256') + afterEach(() => { + jest.clearAllMocks() }) - it('should return a non-empty string even when canvas and WebGL are unavailable', async () => { - // In jsdom, canvas context returns null and WebGL is not available, - // but screen info and browser properties still provide data - const fingerprint = await generateDeviceFingerprint() - expect(typeof fingerprint).toBe('string') - expect(fingerprint.length).toBeGreaterThan(0) + describe('when all browser components are available', () => { + it('should return a hex string hashed with SHA-256', async () => { + const fingerprint = await generateDeviceFingerprint() + expect(fingerprint).toBe('abcdef1234567890') + expect(mockDigest).toHaveBeenCalledTimes(1) + expect(mockDigest.mock.calls[0][0]).toBe('SHA-256') + }) }) - it('should return empty string if hashing fails completely', async () => { - mockDigest.mockRejectedValue(new Error('crypto not available')) + describe('when canvas and WebGL are unavailable', () => { + it('should return a non-empty string using remaining browser properties', async () => { + const fingerprint = await generateDeviceFingerprint() + expect(typeof fingerprint).toBe('string') + expect(fingerprint.length).toBeGreaterThan(0) + }) + }) - const fingerprint = await generateDeviceFingerprint() - expect(fingerprint).toBe('') + describe('when the hashing fails completely', () => { + beforeEach(() => { + mockDigest.mockRejectedValue(new Error('crypto not available')) + }) + + it('should return an empty string', async () => { + const fingerprint = await generateDeviceFingerprint() + expect(fingerprint).toBe('') + }) }) - it('should produce consistent results for the same environment', async () => { - const fp1 = await generateDeviceFingerprint() - const fp2 = await generateDeviceFingerprint() - expect(fp1).toBe(fp2) + describe('when called multiple times in the same environment', () => { + it('should produce consistent results', async () => { + const fp1 = await generateDeviceFingerprint() + const fp2 = await generateDeviceFingerprint() + expect(fp1).toBe(fp2) + }) }) })