import assert from 'node:assert/strict'; import { access } from 'node:fs/promises'; import { createHash, webcrypto } from 'node:crypto'; import { test } from 'node:test'; import { loadTypescript } from './support/load-typescript.mjs'; const moduleURL = new URL('../src/analytics/Identity.ts', import.meta.url); assert.equal(await access(moduleURL).then(() => true, () => false), true, 'private browser identity module must exist'); const { getVisitorId, makeDogId, VISITOR_ID_STORAGE_KEY } = await loadTypescript(moduleURL); const first = '01234567-89ab-4cde-8f01-23456789abcd'; const second = '12345678-9abc-4def-9012-3456789abcde'; const digest = webcrypto.subtle.digest.bind(webcrypto.subtle); const hash = (visitor, name) => makeDogId(visitor, name, digest); test('visitor UUID persists across collector reloads without creating another identifier', () => { const values = new Map(); const storage = { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value) }; assert.equal(getVisitorId({ getStorage: () => storage, randomUUID: () => first }), first); assert.equal(values.get(VISITOR_ID_STORAGE_KEY), first); assert.equal(VISITOR_ID_STORAGE_KEY, 'behind-glass.visitor-id.v1'); assert.equal(getVisitorId({ getStorage: () => storage, randomUUID: () => { throw Error('must reuse stored ID'); } }), first); }); test('malformed stored UUID is replaced by a fresh valid random UUID', () => { for (const bad of ['', 'not-a-uuid', first + 'x', '00000000-0000-0000-0000-000000000000', first.replace('-4cde-', '-1cde-'), first.replace('-4cde-', '-7cde-')]) { let value = bad; assert.equal(getVisitorId({ getStorage: () => ({ getItem: () => value, setItem: (_key, next) => { value = next; } }), randomUUID: () => first }), first); assert.equal(value, first); } }); test('blocked storage getter, read, write, and absent storage use one UUID per collector', () => { for (const getStorage of [ () => undefined, () => { throw Error('blocked getter'); }, () => ({ getItem() { throw Error('blocked read'); }, setItem() { throw Error('blocked write'); } }), () => ({ getItem: () => null, setItem() { throw Error('full'); } }), ]) { let created = 0; const options = { getStorage, randomUUID: () => (++created === 1 ? first : second) }; assert.equal(getVisitorId(options), first); assert.equal(getVisitorId(options), first); assert.equal(created, 1); assert.equal(getVisitorId({ getStorage, randomUUID: () => second }), second); } }); test('unavailable, throwing, or invalid random UUID creation leaves telemetry inert', () => { for (const randomUUID of [undefined, () => { throw Error('unavailable'); }, () => 'invented-fingerprint', () => first.replace('-4cde-', '-1cde-'), () => first.replace('-4cde-', '-7cde-')]) { const options = { getStorage: () => undefined, randomUUID }; assert.equal(getVisitorId(options), undefined); assert.equal(getVisitorId(options), undefined); } }); test('stored uppercase UUID is replaced and noncanonical generated UUID stays inert', () => { let stored = first.toUpperCase(); const storage = { getItem: () => stored, setItem: (_key, value) => { stored = value; } }; assert.equal(getVisitorId({ getStorage: () => storage, randomUUID: () => second }), second); assert.equal(stored, second); assert.equal(getVisitorId({ getStorage: () => undefined, randomUUID: () => first.toUpperCase() }), undefined); }); test('empty and whitespace names use the explicit default name hash', async () => { assert.equal(await hash(first, ''), await hash(first, '멍멍이')); assert.equal(await hash(first, ' \n\t '), await hash(first, '멍멍이')); }); test('hash uses NFC, trim and 20 code points without friendly display suffixes', async () => { assert.equal(await hash(first, ' 호롱 '), await hash(first, '호롱')); assert.equal(await hash(first, '🐕'.repeat(21)), await hash(first, '🐕'.repeat(20))); assert.notEqual(await hash(first, '호롱'), await hash(first, '호롱이')); }); test('WebCrypto hashes the exact versioned UTF-8 JSON tuple to lowercase SHA-256', async () => { const expected = createHash('sha256').update(JSON.stringify([1, first, '호롱']), 'utf8').digest('hex'); assert.equal(await hash(first, '호롱'), expected); assert.match(expected, /^[0-9a-f]{64}$/); assert.notEqual(await hash('a|b', 'c'), await hash('a', 'b|c'), 'tuple framing cannot collide through a delimiter'); }); test('missing, throwing, rejected or malformed digest disables only the affected hash', async () => { for (const unavailable of [undefined, () => { throw Error('unavailable'); }, async () => { throw Error('failed'); }, async () => new ArrayBuffer(0)]) { assert.equal(await makeDogId(first, '호롱', unavailable), ''); } assert.match(await hash(first, '호롱'), /^[0-9a-f]{64}$/); });