2026-04-01 13:54:43 +02:00
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
|
|
|
|
|
|
const TEST_KEY_B64 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
|
|
|
|
|
|
|
|
|
|
async function realImportKey(b64) {
|
|
|
|
|
const raw = Uint8Array.from(globalThis.atob(b64), c => c.codePointAt(0))
|
|
|
|
|
return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt'])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function realEncrypt(data, key) {
|
|
|
|
|
const json = new TextEncoder().encode(JSON.stringify(data))
|
|
|
|
|
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
|
|
|
|
const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, json)
|
|
|
|
|
const buf = new Uint8Array(encrypted)
|
|
|
|
|
const combined = new Uint8Array(12 + buf.length)
|
|
|
|
|
combined.set(iv)
|
|
|
|
|
combined.set(buf, 12)
|
|
|
|
|
return globalThis.btoa(String.fromCodePoint(...combined))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The module destructures { fetch } from globalThis at import time,
|
|
|
|
|
// so we must set up globalThis.fetch BEFORE each dynamic import.
|
|
|
|
|
async function loadModule() {
|
|
|
|
|
vi.resetModules()
|
|
|
|
|
const mod = await import('../../assets/modules/analytics.js')
|
|
|
|
|
return mod
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('analytics.js', () => {
|
|
|
|
|
let fetchMock
|
|
|
|
|
let beaconMock
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
vi.restoreAllMocks()
|
|
|
|
|
sessionStorage.clear()
|
|
|
|
|
document.body.innerHTML = ''
|
|
|
|
|
document.body.removeAttribute('data-k')
|
|
|
|
|
document.body.removeAttribute('data-e')
|
|
|
|
|
document.body.removeAttribute('data-uid')
|
|
|
|
|
|
|
|
|
|
fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 })
|
|
|
|
|
globalThis.fetch = fetchMock
|
|
|
|
|
|
|
|
|
|
beaconMock = vi.fn().mockReturnValue(true)
|
|
|
|
|
navigator.sendBeacon = beaconMock
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns early when data-k is missing', async () => {
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
|
|
|
expect(beaconMock).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns early when data-e is missing', async () => {
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns early when importKey fails', async () => {
|
|
|
|
|
document.body.dataset.k = '!!invalid!!'
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('creates visitor and tracks page view', async () => {
|
|
|
|
|
const key = await realImportKey(TEST_KEY_B64)
|
|
|
|
|
const visitorResp = await realEncrypt({ uid: 'v1', h: 'hash1' }, key)
|
|
|
|
|
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
let callCount = 0
|
|
|
|
|
fetchMock.mockImplementation(async () => {
|
|
|
|
|
callCount++
|
|
|
|
|
if (callCount === 1) {
|
|
|
|
|
return { ok: true, status: 200, json: async () => ({ d: visitorResp }) }
|
|
|
|
|
}
|
|
|
|
|
return { ok: true, status: 204 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Disable sendBeacon so trackPageView uses fetch
|
|
|
|
|
navigator.sendBeacon = undefined
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
|
|
|
expect(sessionStorage.getItem('_u')).toBe('v1')
|
|
|
|
|
expect(sessionStorage.getItem('_h')).toBe('hash1')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reuses existing visitor from sessionStorage', async () => {
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
sessionStorage.setItem('_u', 'existing')
|
|
|
|
|
sessionStorage.setItem('_h', 'existinghash')
|
|
|
|
|
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
// Only trackPageView via beacon, no fetch for visitor creation
|
|
|
|
|
expect(beaconMock).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('returns early when visitor creation returns null', async () => {
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
// fetch returns 204 (no body) -> send() returns null -> no visitor
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
expect(sessionStorage.getItem('_u')).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('uses sendBeacon when available and not expecting response', async () => {
|
|
|
|
|
const key = await realImportKey(TEST_KEY_B64)
|
|
|
|
|
const visitorResp = await realEncrypt({ uid: 'v2', h: 'hash2' }, key)
|
|
|
|
|
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
fetchMock.mockResolvedValue({
|
|
|
|
|
ok: true, status: 200, json: async () => ({ d: visitorResp }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
// First call uses fetch (getOrCreateVisitor expects response), second uses sendBeacon (trackPageView)
|
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(beaconMock).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles fetch 403 by clearing session and retrying', async () => {
|
|
|
|
|
const key = await realImportKey(TEST_KEY_B64)
|
|
|
|
|
const visitorResp = await realEncrypt({ uid: 'v3', h: 'hash3' }, key)
|
|
|
|
|
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
navigator.sendBeacon = undefined
|
|
|
|
|
|
|
|
|
|
let callCount = 0
|
|
|
|
|
fetchMock.mockImplementation(async () => {
|
|
|
|
|
callCount++
|
|
|
|
|
if (callCount === 1) return { ok: true, status: 200, json: async () => ({ d: visitorResp }) }
|
|
|
|
|
if (callCount === 2) return { ok: false, status: 403 }
|
|
|
|
|
if (callCount === 3) return { ok: true, status: 200, json: async () => ({ d: visitorResp }) }
|
|
|
|
|
return { ok: true, status: 204 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
expect(callCount).toBe(4)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles fetch network error gracefully', async () => {
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
fetchMock.mockRejectedValue(new Error('Network error'))
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
expect(sessionStorage.getItem('_u')).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles response without d field', async () => {
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
fetchMock.mockResolvedValue({
|
|
|
|
|
ok: true, status: 200, json: async () => ({ other: 'data' }),
|
|
|
|
|
})
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
expect(sessionStorage.getItem('_u')).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles response with invalid encrypted data', async () => {
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
fetchMock.mockResolvedValue({
|
|
|
|
|
ok: true, status: 200, json: async () => ({ d: 'invalidbase64!!' }),
|
|
|
|
|
})
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
expect(sessionStorage.getItem('_u')).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('handles decrypt failure with valid base64 but wrong key data', async () => {
|
|
|
|
|
// Encrypt with a different key so decryption with TEST_KEY fails
|
|
|
|
|
const otherKeyRaw = new Uint8Array(32)
|
|
|
|
|
otherKeyRaw[0] = 1
|
|
|
|
|
const otherKey = await globalThis.crypto.subtle.importKey('raw', otherKeyRaw, 'AES-GCM', false, ['encrypt'])
|
|
|
|
|
const json = new TextEncoder().encode(JSON.stringify({ uid: 'x', h: 'y' }))
|
|
|
|
|
const iv = globalThis.crypto.getRandomValues(new Uint8Array(12))
|
|
|
|
|
const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, otherKey, json)
|
|
|
|
|
const buf = new Uint8Array(encrypted)
|
|
|
|
|
const combined = new Uint8Array(12 + buf.length)
|
|
|
|
|
combined.set(iv)
|
|
|
|
|
combined.set(buf, 12)
|
|
|
|
|
const wrongEncrypted = globalThis.btoa(String.fromCodePoint(...combined))
|
|
|
|
|
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
fetchMock.mockResolvedValue({
|
|
|
|
|
ok: true, status: 200, json: async () => ({ d: wrongEncrypted }),
|
|
|
|
|
})
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
expect(sessionStorage.getItem('_u')).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('calls setAuth when data-uid is present', async () => {
|
|
|
|
|
const key = await realImportKey(TEST_KEY_B64)
|
|
|
|
|
const visitorResp = await realEncrypt({ uid: 'v4', h: 'hash4' }, key)
|
|
|
|
|
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
document.body.dataset.uid = '42'
|
|
|
|
|
|
|
|
|
|
fetchMock.mockResolvedValue({
|
|
|
|
|
ok: true, status: 200, json: async () => ({ d: visitorResp }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
// fetch for visitor creation, beacon for pageview + setAuth
|
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
|
|
|
expect(beaconMock).toHaveBeenCalledTimes(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('setAuth does nothing without session', async () => {
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
const { initAnalytics, setAuth } = await loadModule()
|
|
|
|
|
|
|
|
|
|
// Init to set encKey but visitor creation fails (no response body)
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
fetchMock.mockClear()
|
|
|
|
|
beaconMock.mockClear()
|
|
|
|
|
|
|
|
|
|
await setAuth(42)
|
|
|
|
|
|
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
|
|
|
expect(beaconMock).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('setAuth does nothing without encKey', async () => {
|
|
|
|
|
sessionStorage.setItem('_u', 'uid')
|
|
|
|
|
sessionStorage.setItem('_h', 'hash')
|
|
|
|
|
|
|
|
|
|
// Don't call initAnalytics -> encKey stays null
|
|
|
|
|
const { setAuth } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await setAuth(42)
|
|
|
|
|
|
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled()
|
|
|
|
|
expect(beaconMock).not.toHaveBeenCalled()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-01 20:17:21 +02:00
|
|
|
it('covers navigator.language being undefined', async () => {
|
|
|
|
|
const key = await realImportKey(TEST_KEY_B64)
|
|
|
|
|
const visitorResp = await realEncrypt({ uid: 'vl', h: 'hashl' }, key)
|
|
|
|
|
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
const origLang = navigator.language
|
|
|
|
|
Object.defineProperty(navigator, 'language', { value: '', configurable: true })
|
|
|
|
|
|
|
|
|
|
fetchMock.mockResolvedValue({
|
|
|
|
|
ok: true, status: 200, json: async () => ({ d: visitorResp }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
Object.defineProperty(navigator, 'language', { value: origLang, configurable: true })
|
|
|
|
|
|
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('retry getOrCreateVisitor fails on second attempt after 403', async () => {
|
|
|
|
|
const key = await realImportKey(TEST_KEY_B64)
|
|
|
|
|
const visitorResp = await realEncrypt({ uid: 'v6', h: 'hash6' }, key)
|
|
|
|
|
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
navigator.sendBeacon = undefined
|
|
|
|
|
|
|
|
|
|
let callCount = 0
|
|
|
|
|
fetchMock.mockImplementation(async () => {
|
|
|
|
|
callCount++
|
|
|
|
|
// 1st: visitor creation succeeds
|
|
|
|
|
if (callCount === 1) return { ok: true, status: 200, json: async () => ({ d: visitorResp }) }
|
|
|
|
|
// 2nd: trackPageView gets 403 -> clears session
|
|
|
|
|
if (callCount === 2) return { ok: false, status: 403 }
|
|
|
|
|
// 3rd: second getOrCreateVisitor also fails (204 = no body)
|
|
|
|
|
return { ok: true, status: 204 }
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { initAnalytics } = await loadModule()
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
// After 403, retry creates visitor (call 3) but gets 204 -> null visitor -> early return
|
|
|
|
|
expect(callCount).toBe(3)
|
|
|
|
|
expect(sessionStorage.getItem('_u')).toBeNull()
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-01 13:54:43 +02:00
|
|
|
it('setAuth sends when session and key exist', async () => {
|
|
|
|
|
const key = await realImportKey(TEST_KEY_B64)
|
|
|
|
|
const visitorResp = await realEncrypt({ uid: 'v5', h: 'hash5' }, key)
|
|
|
|
|
|
|
|
|
|
document.body.dataset.k = TEST_KEY_B64
|
|
|
|
|
document.body.dataset.e = '/t'
|
|
|
|
|
|
|
|
|
|
fetchMock.mockResolvedValue({
|
|
|
|
|
ok: true, status: 200, json: async () => ({ d: visitorResp }),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const { initAnalytics, setAuth } = await loadModule()
|
|
|
|
|
|
|
|
|
|
await initAnalytics()
|
|
|
|
|
|
|
|
|
|
beaconMock.mockClear()
|
|
|
|
|
await setAuth(99)
|
|
|
|
|
|
|
|
|
|
expect(beaconMock).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
})
|