1import { describe, it, expect, beforeEach } from 'vitest';2import { vi } from 'vitest';3import { deflate, inflate } from 'pako';45import {6 buildChunkEnvelope,7 verifyChunkIntegrity,8 getOrCreateCodexVerifyCode,9 clearVerifyCode,10} from './helpers';11import { encodeWithErasure, decodeWithErasure } from './erasure';12import { CodexError } from '../../shared/codex-types';1314import type { Message, MessageResponse } from '../../shared/types';15161718function installChromeStorageMock(storage: Record<string, unknown>): void {19 const chromeMock = {20 storage: {21 local: {22 get: vi.fn(async (key?: string | string[]) => {23 if (!key) return { ...storage };24 if (typeof key === 'string') return { [key]: storage[key] };25 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});26 }),27 set: vi.fn(async (entries: Record<string, unknown>) => { Object.assign(storage, entries); }),28 remove: vi.fn(async (k: string | string[]) => {29 const keys = Array.isArray(k) ? k : [k];30 for (const key of keys) delete storage[key];31 }),32 },33 },34 runtime: {},35 };36 37 globalThis.chrome = chromeMock;38}39404142describe('compression pipeline', () => {43 it('deflates and inflates correctly', () => {44 const original = 'peach codex test payload ' + 'x'.repeat(1000);45 const bytes = new TextEncoder().encode(original);46 const compressed = deflate(bytes, { level: 9 });47 const decompressed = inflate(compressed);48 const restored = new TextDecoder().decode(decompressed);49 expect(restored).toBe(original);50 });5152 it('compressed data is smaller than original for repetitive content', () => {53 const repetitive = 'password123 '.repeat(500);54 const bytes = new TextEncoder().encode(repetitive);55 const compressed = deflate(bytes, { level: 9 });56 expect(compressed.length).toBeLessThan(bytes.length);57 });5859 it('compression round-trip handles empty payload', () => {60 const bytes = new TextEncoder().encode('{}');61 const compressed = deflate(bytes);62 const decompressed = inflate(compressed);63 expect(new TextDecoder().decode(decompressed)).toBe('{}');64 });6566 it('compression round-trip handles unicode', () => {67 const unicode = '日本語テスト 🔑 vault data';68 const bytes = new TextEncoder().encode(unicode);69 const compressed = deflate(bytes);70 const decompressed = inflate(compressed);71 expect(new TextDecoder().decode(decompressed)).toBe(unicode);72 });73});74757677describe('Reed-Solomon erasure coding', () => {78 it('encodes without error', async () => {79 const data = crypto.getRandomValues(new Uint8Array(10_000));80 const { dataChunks, parityChunks, parityShards } = await encodeWithErasure(data);81 expect(dataChunks.length).toBeGreaterThan(0);82 expect(parityChunks.length).toBe(parityShards);83 });8485 it('reconstructs from all chunks', async () => {86 const original = crypto.getRandomValues(new Uint8Array(10_000));87 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);88 const allChunks = new Map<number, Uint8Array>();89 dataChunks.forEach((c, i) => allChunks.set(i, c));90 parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c));91 const reconstructed = await decodeWithErasure(allChunks, dataShards, totalChunks);92 expect(reconstructed.slice(0, original.length)).toEqual(original);93 });9495 it('reconstructs after losing exactly 1 chunk', async () => {96 const original = crypto.getRandomValues(new Uint8Array(20_000));97 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);98 const allChunks = new Map<number, Uint8Array>();99 dataChunks.forEach((c, i) => allChunks.set(i, c));100 parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c));101 allChunks.delete(0);102 const reconstructed = await decodeWithErasure(allChunks, dataShards, totalChunks);103 expect(reconstructed.slice(0, original.length)).toEqual(original);104 });105106 it('reconstructs after losing exactly 2 chunks', async () => {107 const original = crypto.getRandomValues(new Uint8Array(20_000));108 const { dataChunks, parityChunks, dataShards, totalChunks } = await encodeWithErasure(original);109 const allChunks = new Map<number, Uint8Array>();110 dataChunks.forEach((c, i) => allChunks.set(i, c));111 parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c));112 const indices = Array.from(allChunks.keys());113 allChunks.delete(indices[0]!);114 allChunks.delete(indices[Math.floor(indices.length / 2)]!);115 const reconstructed = await decodeWithErasure(allChunks, dataShards, totalChunks);116 expect(reconstructed.slice(0, original.length)).toEqual(original);117 });118119 it('throws CodexError when too many chunks missing', async () => {120 const original = crypto.getRandomValues(new Uint8Array(10_000));121 const { dataChunks, parityChunks, dataShards, parityShards, totalChunks } = await encodeWithErasure(original);122 const allChunks = new Map<number, Uint8Array>();123 dataChunks.forEach((c, i) => allChunks.set(i, c));124 parityChunks.forEach((c, i) => allChunks.set(dataShards + i, c));125 for (let i = 0; i <= parityShards; i++) {126 allChunks.delete(i);127 }128 await expect(decodeWithErasure(allChunks, dataShards, totalChunks)).rejects.toThrow(CodexError);129 });130});131132133134describe('chunk envelope integrity', () => {135 it('builds chunk envelope with correct fields', async () => {136 const bytes = new Uint8Array([1, 2, 3, 4, 5]);137 const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234');138 expect(envelope.v).toBe(3);139 expect(envelope.i).toBe(0);140 expect(envelope.t).toBe(10);141 expect(envelope.p).toBe(false);142 expect(envelope.c).toBe('PX-TEST-1234');143 expect(envelope.d).toBeTruthy();144 expect(envelope.h).toBeTruthy();145 expect(envelope.h.length).toBe(32); 146 });147148 it('integrity check passes for valid envelope', async () => {149 const bytes = new Uint8Array([1, 2, 3, 4, 5]);150 const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234');151 expect(await verifyChunkIntegrity(envelope)).toBe(true);152 });153154 it('integrity check fails for tampered data', async () => {155 const bytes = new Uint8Array([1, 2, 3, 4, 5]);156 const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234');157 const tampered = { ...envelope, d: envelope.d.slice(0, -2) + "00" };158 expect(await verifyChunkIntegrity(tampered)).toBe(false);159 });160161 it('integrity check fails for tampered hash', async () => {162 const bytes = new Uint8Array([1, 2, 3, 4, 5]);163 const envelope = await buildChunkEnvelope(bytes, 0, 10, false, 'PX-TEST-1234');164 const tampered = { ...envelope, h: 'XXXXXXXX' };165 expect(await verifyChunkIntegrity(tampered)).toBe(false);166 });167168 it('parity chunks are flagged correctly', async () => {169 const bytes = new Uint8Array([1, 2, 3]);170 const parity = await buildChunkEnvelope(bytes, 5, 7, true, 'PX-PARITY-00');171 expect(parity.p).toBe(true);172 });173174 it('data chunks are flagged correctly', async () => {175 const bytes = new Uint8Array([1, 2, 3]);176 const data = await buildChunkEnvelope(bytes, 2, 7, false, 'PX-DATA-001');177 expect(data.p).toBe(false);178 });179});180181182183describe('verify code generation', () => {184 let storage: Record<string, unknown>;185186 beforeEach(() => {187 storage = {};188 installChromeStorageMock(storage);189 });190191 it('generates PX-XXXX-XXXX format', async () => {192 const code = await getOrCreateCodexVerifyCode();193 expect(code).toMatch(/^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/);194 });195196 it('returns same code on subsequent calls', async () => {197 const c1 = await getOrCreateCodexVerifyCode();198 const c2 = await getOrCreateCodexVerifyCode();199 expect(c1).toBe(c2);200 });201202 it('uses unambiguous character set (no O, 0, I, 1)', async () => {203 for (let i = 0; i < 20; i++) {204 await clearVerifyCode();205 const code = await getOrCreateCodexVerifyCode();206 const inner = code.replace('PX-', '').replace('-', '');207 expect(inner).not.toMatch(/[O01I]/);208 }209 });210});211212213214type StorageShape = Record<string, unknown>;215type MessageListener = (216 msg: unknown,217 sender: chrome.runtime.MessageSender,218 sendResponse: (response: MessageResponse) => void219) => boolean;220221describe('GET_CODEX_VAULT_DATA handler', () => {222 let storage: StorageShape;223 let sessionStorage: StorageShape;224 let listener: MessageListener;225226 beforeEach(async () => {227 vi.resetModules();228 vi.useRealTimers();229 storage = { peach_settings: { breachMonitoring: false } };230 sessionStorage = {};231232 vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));233 vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);234235 const chromeMock = {236 runtime: {237 id: 'peach-extension',238 getURL: (path = '') => `chrome-extension://peach-extension/${path.replace(/^\//, '')}`,239 onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },240 sendMessage: vi.fn(async () => ({ ok: true })),241 },242 tabs: {243 query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {244 if (typeof cb === 'function') { cb([{ id: 1 }]); return; }245 return Promise.resolve([{ id: 1 }]);246 }),247 sendMessage: vi.fn(async () => ({ ok: true })),248 },249 storage: {250 local: {251 get: vi.fn(async (key?: string | string[]) => {252 if (!key) return storage;253 if (typeof key === 'string') return { [key]: storage[key] };254 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});255 }),256 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),257 remove: vi.fn(async (keys: string | string[]) => {258 const list = Array.isArray(keys) ? keys : [keys];259 for (const key of list) delete storage[key];260 }),261 },262 session: {263 get: vi.fn(async (key?: string | string[]) => {264 if (!key) return sessionStorage;265 if (typeof key === 'string') return { [key]: sessionStorage[key] };266 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});267 }),268 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),269 remove: vi.fn(async (keys: string | string[]) => {270 const list = Array.isArray(keys) ? keys : [keys];271 for (const key of list) delete sessionStorage[key];272 }),273 clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),274 },275 },276 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },277 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },278 scripting: { executeScript: vi.fn(async () => []) },279 } as unknown as typeof chrome;280281 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;282283 const messages = await import('../../background/messages');284 messages.setupMessageHandler();285 const vault = await import('../../background/vault');286 await vault.ensureSettingsLoaded();287 });288289 async function dispatch(message: Message): Promise<MessageResponse> {290 return new Promise<MessageResponse>((resolve) => {291 const keepOpen = listener(292 message,293 { id: 'peach-extension', url: 'chrome-extension://peach-extension/popup.html' },294 resolve295 );296 expect(keepOpen).toBe(true);297 });298 }299300 async function mockPro(): Promise<void> {301 const license = await import('../../background/license');302 vi.spyOn(license, 'isProUser').mockResolvedValue(true);303 }304305 async function mockNoPro(): Promise<void> {306 const license = await import('../../background/license');307 vi.spyOn(license, 'isProUser').mockResolvedValue(false);308 }309310 it('is Pro-gated', async () => {311 await mockNoPro();312 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });313 expect(result.ok).toBe(false);314 expect((result as { error: string }).error).toMatch(/pro_required/);315 });316317 it('fails when vault is locked', async () => {318 await mockPro();319 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });320 expect(result.ok).toBe(false);321 expect((result as { error: string }).error).toMatch(/VAULT_LOCKED|vault_locked/);322 });323324 it('succeeds with Pro and unlocked vault', async () => {325 await mockPro();326 const vault = await import('../../background/vault');327 await vault.createVault('test-pass-12345');328 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });329 expect(result.ok).toBe(true);330 expect((result as { data: Record<string, unknown> }).data).toBeTruthy();331 });332333 it('snapshot includes entryCount', async () => {334 await mockPro();335 const vault = await import('../../background/vault');336 await vault.createVault('test-pass-12345');337 await dispatch({338 type: 'ADD_ENTRY',339 payload: { title: 'GitHub', username: 'alice', password: 'pw1', url: '' },340 });341 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });342 const data = (result as { data: { entryCount: number } }).data;343 expect(data.entryCount).toBe(1);344 });345346 it('snapshot includes verifyCode in PX-XXXX-XXXX format', async () => {347 await mockPro();348 const vault = await import('../../background/vault');349 await vault.createVault('test-pass-12345');350 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });351 const data = (result as { data: { verifyCode: string } }).data;352 expect(data.verifyCode).toMatch(/^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/);353 });354355 356 357 it('withholds recoveryWords without a password proof', async () => {358 await mockPro();359 const vault = await import('../../background/vault');360 await vault.createVault('test-pass-12345');361 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });362 expect(result.ok).toBe(true);363 const data = (result as { data: { recoveryWords: unknown } }).data;364 expect(data.recoveryWords).toBeNull();365 });366367 it('rejects a wrong password proof', async () => {368 await mockPro();369 const vault = await import('../../background/vault');370 await vault.createVault('test-pass-12345');371 const result = await dispatch({372 type: 'GET_CODEX_VAULT_DATA',373 payload: { password: 'not-the-password' },374 });375 expect(result.ok).toBe(false);376 expect((result as { error: string }).error).toBe('wrong_password');377 });378379 it('returns 12 recovery words with a correct password proof', async () => {380 await mockPro();381 const vault = await import('../../background/vault');382 await vault.createVault('test-pass-12345');383 const result = await dispatch({384 type: 'GET_CODEX_VAULT_DATA',385 payload: { password: 'test-pass-12345' },386 });387 expect(result.ok).toBe(true);388 const data = (result as { data: { recoveryWords: string[] } }).data;389 expect(data.recoveryWords).toHaveLength(12);390 });391});392393394395describe('ESTIMATE_CODEX_PAGES handler', () => {396 let storage: StorageShape;397 let sessionStorage: StorageShape;398 let listener: MessageListener;399400 beforeEach(async () => {401 vi.resetModules();402 vi.useRealTimers();403 storage = { peach_settings: { breachMonitoring: false } };404 sessionStorage = {};405406 vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));407 vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);408409 const chromeMock = {410 runtime: {411 id: 'peach-extension',412 getURL: (path = '') => `chrome-extension://peach-extension/${path.replace(/^\//, '')}`,413 onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },414 sendMessage: vi.fn(async () => ({ ok: true })),415 },416 tabs: {417 query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {418 if (typeof cb === 'function') { cb([{ id: 1 }]); return; }419 return Promise.resolve([{ id: 1 }]);420 }),421 sendMessage: vi.fn(async () => ({ ok: true })),422 },423 storage: {424 local: {425 get: vi.fn(async (key?: string | string[]) => {426 if (!key) return storage;427 if (typeof key === 'string') return { [key]: storage[key] };428 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});429 }),430 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),431 remove: vi.fn(async (keys: string | string[]) => {432 const list = Array.isArray(keys) ? keys : [keys];433 for (const key of list) delete storage[key];434 }),435 },436 session: {437 get: vi.fn(async (key?: string | string[]) => {438 if (!key) return sessionStorage;439 if (typeof key === 'string') return { [key]: sessionStorage[key] };440 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});441 }),442 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),443 remove: vi.fn(async (keys: string | string[]) => {444 const list = Array.isArray(keys) ? keys : [keys];445 for (const key of list) delete sessionStorage[key];446 }),447 clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),448 },449 },450 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },451 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },452 scripting: { executeScript: vi.fn(async () => []) },453 } as unknown as typeof chrome;454455 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;456457 const messages = await import('../../background/messages');458 messages.setupMessageHandler();459 const vault = await import('../../background/vault');460 await vault.ensureSettingsLoaded();461 });462463 async function dispatch(message: Message): Promise<MessageResponse> {464 return new Promise<MessageResponse>((resolve) => {465 const keepOpen = listener(466 message,467 { id: 'peach-extension', url: 'chrome-extension://peach-extension/popup.html' },468 resolve469 );470 expect(keepOpen).toBe(true);471 });472 }473474 async function mockPro(): Promise<void> {475 const license = await import('../../background/license');476 vi.spyOn(license, 'isProUser').mockResolvedValue(true);477 }478479 async function mockNoPro(): Promise<void> {480 const license = await import('../../background/license');481 vi.spyOn(license, 'isProUser').mockResolvedValue(false);482 }483484 it('is Pro-gated', async () => {485 await mockNoPro();486 const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' });487 expect(result.ok).toBe(false);488 expect((result as { error: string }).error).toMatch(/pro_required/);489 });490491 it('returns at least 1 page with an unlocked vault', async () => {492 await mockPro();493 const vault = await import('../../background/vault');494 await vault.createVault('test-pass-12345');495 const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' });496 expect(result.ok).toBe(true);497 expect((result as unknown as { pages: number }).pages).toBeGreaterThanOrEqual(1);498 });499500 it('returns a page count (numeric) for any vault', async () => {501 await mockPro();502 const vault = await import('../../background/vault');503 await vault.createVault('test-pass-12345');504 const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' });505 expect(typeof (result as unknown as { pages: number }).pages).toBe('number');506 });507});