import { describe, it, expect, beforeEach } from 'vitest'; import { vi } from 'vitest'; import { deflate, inflate } from 'pako'; import { buildChunkEnvelope, verifyChunkIntegrity, getOrCreateCodexVerifyCode, clearVerifyCode, } from './helpers'; import { encodeWithErasure, decodeWithErasure } from './erasure'; import { CodexError } from '../../shared/codex-types'; import type { Message, MessageResponse } from '../../shared/types'; // ── chrome storage shim ─────────────────────────────────────────────────────── function installChromeStorageMock(storage: Record): void { const chromeMock = { storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return { ...storage }; if (typeof key === 'string') return { [key]: storage[key] }; return key.reduce>((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (entries: Record) => { Object.assign(storage, entries); }), remove: vi.fn(async (k: string | string[]) => { const keys = Array.isArray(k) ? k : [k]; for (const key of keys) delete storage[key]; }), }, }, runtime: {}, }; // @ts-expect-error test-only injection globalThis.chrome = chromeMock; } // ── compression pipeline ────────────────────────────────────────────────────── describe('compression pipeline', () => { it('deflates and inflates correctly', () => { const original = 'peach codex test payload ' + 'x'.repeat(1000); const bytes = new TextEncoder().encode(original); const compressed = deflate(bytes, { level: 9 }); const decompressed = inflate(compressed); const restored = new TextDecoder().decode(decompressed); expect(restored).toBe(original); }); it('compressed data is smaller than original for repetitive content', () => { const repetitive = 'password123 '.repeat(500); const bytes = new TextEncoder().encode(repetitive); const compressed = deflate(bytes, { level: 9 }); expect(compressed.length).toBeLessThan(bytes.length); }); it('compression round-trip handles empty payload', () => { const bytes = new TextEncoder().encode('{}'); const compressed = deflate(bytes); const decompressed = inflate(compressed); expect(new TextDecoder().decode(decompressed)).toBe('{}'); }); it('compression round-trip handles unicode', () => { const unicode = '日本語テスト 🔑 vault data'; const bytes = new TextEncoder().encode(unicode); const compressed = deflate(bytes); const decompressed = inflate(compressed); expect(new TextDecoder().decode(decompressed)).toBe(unicode); }); }); // ── Reed-Solomon erasure coding ─────────────────────────────────────────────── describe('Reed-Solomon erasure coding', () => { it('encodes without error', async () => { const data = crypto.getRandomValues(new Uint8Array(10_000)); const { dataChunks, parityChunks, parityShards } = await encodeWithErasure(data); expect(dataChunks.length).toBeGreaterThan(0); expect(parityChunks.length).toBe(parityShards); }); it('reconstructs from all chunks', async () => { const original = crypto.getRandomValues(new Uint8Array(10_000)); const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original); const allChunks = new Map(); dataChunks.forEach((c, i) => allChunks.set(i, c)); parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c)); const reconstructed = await decodeWithErasure(allChunks, dataShards, totalChunks); expect(reconstructed.slice(0, original.length)).toEqual(original); }); it('reconstructs after losing exactly 1 chunk', async () => { const original = crypto.getRandomValues(new Uint8Array(20_000)); const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original); const allChunks = new Map(); dataChunks.forEach((c, i) => allChunks.set(i, c)); parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c)); allChunks.delete(0); const reconstructed = await decodeWithErasure(allChunks, dataShards, totalChunks); expect(reconstructed.slice(0, original.length)).toEqual(original); }); it('reconstructs after losing exactly 2 chunks', async () => { const original = crypto.getRandomValues(new Uint8Array(20_000)); const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original); const allChunks = new Map(); dataChunks.forEach((c, i) => allChunks.set(i, c)); parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c)); const indices = Array.from(allChunks.keys()); allChunks.delete(indices[0]!); allChunks.delete(indices[Math.floor(indices.length / 2)]!); const reconstructed = await decodeWithErasure(allChunks, dataShards, totalChunks); expect(reconstructed.slice(0, original.length)).toEqual(original); }); it('throws CodexError when too many chunks missing', async () => { const original = crypto.getRandomValues(new Uint8Array(10_000)); const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } = await encodeWithErasure(original); const allChunks = new Map(); dataChunks.forEach((c, i) => allChunks.set(i, c)); parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c)); for (let i = 0; i <= parityShards; i++) { allChunks.delete(i); } await expect(decodeWithErasure(allChunks, dataShards, totalChunks)).rejects.toThrow(CodexError); }); }); // ── chunk envelope integrity ────────────────────────────────────────────────── describe('chunk envelope integrity', () => { it('builds chunk envelope with correct fields', async () => { const bytes = new Uint8Array([1, 2, 3, 4, 5]); const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234'); expect(envelope.v).toBe(3); expect(envelope.i).toBe(0); expect(envelope.t).toBe(10); expect(envelope.p).toBe(false); expect(envelope.c).toBe('PX-TEST-1234'); expect(envelope.d).toBeTruthy(); expect(envelope.h).toBeTruthy(); expect(envelope.h.length).toBe(32); // AUD-004: 128-bit hash prefix }); it('integrity check passes for valid envelope', async () => { const bytes = new Uint8Array([1, 2, 3, 4, 5]); const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234'); expect(await verifyChunkIntegrity(envelope)).toBe(true); }); it('integrity check fails for tampered data', async () => { const bytes = new Uint8Array([1, 2, 3, 4, 5]); const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234'); const tampered = { ...envelope, d: envelope.d.slice(0, -2) + "00" }; expect(await verifyChunkIntegrity(tampered)).toBe(false); }); it('integrity check fails for tampered hash', async () => { const bytes = new Uint8Array([1, 2, 3, 4, 5]); const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234'); const tampered = { ...envelope, h: 'XXXXXXXX' }; expect(await verifyChunkIntegrity(tampered)).toBe(false); }); it('parity chunks are flagged correctly', async () => { const bytes = new Uint8Array([1, 2, 3]); const parity = await buildChunkEnvelope(bytes, 5, 7, true, 'PX-PARITY-00'); expect(parity.p).toBe(true); }); it('data chunks are flagged correctly', async () => { const bytes = new Uint8Array([1, 2, 3]); const data = await buildChunkEnvelope(bytes, 2, 7, false, 'PX-DATA-001'); expect(data.p).toBe(false); }); }); // ── verify code generation ──────────────────────────────────────────────────── describe('verify code generation', () => { let storage: Record; beforeEach(() => { storage = {}; installChromeStorageMock(storage); }); it('generates PX-XXXX-XXXX format', async () => { const code = await getOrCreateCodexVerifyCode(); expect(code).toMatch(/^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/); }); it('returns same code on subsequent calls', async () => { const c1 = await getOrCreateCodexVerifyCode(); const c2 = await getOrCreateCodexVerifyCode(); expect(c1).toBe(c2); }); it('uses unambiguous character set (no O, 0, I, 1)', async () => { for (let i = 0; i < 20; i++) { await clearVerifyCode(); const code = await getOrCreateCodexVerifyCode(); const inner = code.replace('PX-', '').replace('-', ''); expect(inner).not.toMatch(/[O01I]/); } }); }); // ── GET_CODEX_VAULT_DATA handler (via message dispatch) ─────────────────────── type StorageShape = Record; type MessageListener = ( msg: unknown, sender: chrome.runtime.MessageSender, sendResponse: (response: MessageResponse) => void ) => boolean; describe('GET_CODEX_VAULT_DATA handler', () => { let storage: StorageShape; let sessionStorage: StorageShape; let listener: MessageListener; beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); storage = { peach_settings: { breachMonitoring: false } }; sessionStorage = {}; vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' }))); vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false); const chromeMock = { runtime: { id: 'peach-extension', onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) }, sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn((_, cb?: (tabs: unknown[]) => void) => { if (typeof cb === 'function') { cb([{ id: 1 }]); return; } return Promise.resolve([{ id: 1 }]); }), sendMessage: vi.fn(async () => ({ ok: true })), }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === 'string') return { [key]: storage[key] }; return key.reduce>((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete storage[key]; }), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return sessionStorage; if (typeof key === 'string') return { [key]: sessionStorage[key] }; return key.reduce>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete sessionStorage[key]; }), clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, scripting: { executeScript: vi.fn(async () => []) }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; const messages = await import('../../background/messages'); messages.setupMessageHandler(); const vault = await import('../../background/vault'); await vault.ensureSettingsLoaded(); }); async function dispatch(message: Message): Promise { return new Promise((resolve) => { const keepOpen = listener(message, { id: 'peach-extension' }, resolve); expect(keepOpen).toBe(true); }); } async function mockPro(): Promise { const license = await import('../../background/license'); vi.spyOn(license, 'isProUser').mockResolvedValue(true); } async function mockNoPro(): Promise { const license = await import('../../background/license'); vi.spyOn(license, 'isProUser').mockResolvedValue(false); } it('is Pro-gated', async () => { await mockNoPro(); const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' }); expect(result.ok).toBe(false); expect((result as { error: string }).error).toMatch(/pro_required/); }); it('fails when vault is locked', async () => { await mockPro(); const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' }); expect(result.ok).toBe(false); expect((result as { error: string }).error).toMatch(/VAULT_LOCKED|vault_locked/); }); it('succeeds with Pro and unlocked vault', async () => { await mockPro(); const vault = await import('../../background/vault'); await vault.createVault('test-pass-12345'); const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' }); expect(result.ok).toBe(true); expect((result as { data: Record }).data).toBeTruthy(); }); it('snapshot includes entryCount', async () => { await mockPro(); const vault = await import('../../background/vault'); await vault.createVault('test-pass-12345'); await dispatch({ type: 'ADD_ENTRY', payload: { title: 'GitHub', username: 'alice', password: 'pw1', url: '' }, }); const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' }); const data = (result as { data: { entryCount: number } }).data; expect(data.entryCount).toBe(1); }); it('snapshot includes verifyCode in PX-XXXX-XXXX format', async () => { await mockPro(); const vault = await import('../../background/vault'); await vault.createVault('test-pass-12345'); const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' }); const data = (result as { data: { verifyCode: string } }).data; expect(data.verifyCode).toMatch(/^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/); }); }); // ── ESTIMATE_CODEX_PAGES handler ────────────────────────────────────────────── describe('ESTIMATE_CODEX_PAGES handler', () => { let storage: StorageShape; let sessionStorage: StorageShape; let listener: MessageListener; beforeEach(async () => { vi.resetModules(); vi.useRealTimers(); storage = { peach_settings: { breachMonitoring: false } }; sessionStorage = {}; vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' }))); vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false); const chromeMock = { runtime: { id: 'peach-extension', onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) }, sendMessage: vi.fn(async () => ({ ok: true })), }, tabs: { query: vi.fn((_, cb?: (tabs: unknown[]) => void) => { if (typeof cb === 'function') { cb([{ id: 1 }]); return; } return Promise.resolve([{ id: 1 }]); }), sendMessage: vi.fn(async () => ({ ok: true })), }, storage: { local: { get: vi.fn(async (key?: string | string[]) => { if (!key) return storage; if (typeof key === 'string') return { [key]: storage[key] }; return key.reduce>((acc, k) => { acc[k] = storage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(storage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete storage[key]; }), }, session: { get: vi.fn(async (key?: string | string[]) => { if (!key) return sessionStorage; if (typeof key === 'string') return { [key]: sessionStorage[key] }; return key.reduce>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(sessionStorage, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const key of list) delete sessionStorage[key]; }), clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }), }, }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, scripting: { executeScript: vi.fn(async () => []) }, } as unknown as typeof chrome; (globalThis as { chrome?: typeof chrome }).chrome = chromeMock; const messages = await import('../../background/messages'); messages.setupMessageHandler(); const vault = await import('../../background/vault'); await vault.ensureSettingsLoaded(); }); async function dispatch(message: Message): Promise { return new Promise((resolve) => { const keepOpen = listener(message, { id: 'peach-extension' }, resolve); expect(keepOpen).toBe(true); }); } async function mockPro(): Promise { const license = await import('../../background/license'); vi.spyOn(license, 'isProUser').mockResolvedValue(true); } async function mockNoPro(): Promise { const license = await import('../../background/license'); vi.spyOn(license, 'isProUser').mockResolvedValue(false); } it('is Pro-gated', async () => { await mockNoPro(); const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' }); expect(result.ok).toBe(false); expect((result as { error: string }).error).toMatch(/pro_required/); }); it('returns at least 1 page with an unlocked vault', async () => { await mockPro(); const vault = await import('../../background/vault'); await vault.createVault('test-pass-12345'); const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' }); expect(result.ok).toBe(true); expect((result as unknown as { pages: number }).pages).toBeGreaterThanOrEqual(1); }); it('returns a page count (numeric) for any vault', async () => { await mockPro(); const vault = await import('../../background/vault'); await vault.createVault('test-pass-12345'); const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' }); expect(typeof (result as unknown as { pages: number }).pages).toBe('number'); }); });