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 onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },239 sendMessage: vi.fn(async () => ({ ok: true })),240 },241 tabs: {242 query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {243 if (typeof cb === 'function') { cb([{ id: 1 }]); return; }244 return Promise.resolve([{ id: 1 }]);245 }),246 sendMessage: vi.fn(async () => ({ ok: true })),247 },248 storage: {249 local: {250 get: vi.fn(async (key?: string | string[]) => {251 if (!key) return storage;252 if (typeof key === 'string') return { [key]: storage[key] };253 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});254 }),255 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),256 remove: vi.fn(async (keys: string | string[]) => {257 const list = Array.isArray(keys) ? keys : [keys];258 for (const key of list) delete storage[key];259 }),260 },261 session: {262 get: vi.fn(async (key?: string | string[]) => {263 if (!key) return sessionStorage;264 if (typeof key === 'string') return { [key]: sessionStorage[key] };265 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});266 }),267 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),268 remove: vi.fn(async (keys: string | string[]) => {269 const list = Array.isArray(keys) ? keys : [keys];270 for (const key of list) delete sessionStorage[key];271 }),272 clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),273 },274 },275 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },276 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },277 scripting: { executeScript: vi.fn(async () => []) },278 } as unknown as typeof chrome;279280 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;281282 const messages = await import('../../background/messages');283 messages.setupMessageHandler();284 const vault = await import('../../background/vault');285 await vault.ensureSettingsLoaded();286 });287288 async function dispatch(message: Message): Promise<MessageResponse> {289 return new Promise<MessageResponse>((resolve) => {290 const keepOpen = listener(message, { id: 'peach-extension' }, resolve);291 expect(keepOpen).toBe(true);292 });293 }294295 async function mockPro(): Promise<void> {296 const license = await import('../../background/license');297 vi.spyOn(license, 'isProUser').mockResolvedValue(true);298 }299300 async function mockNoPro(): Promise<void> {301 const license = await import('../../background/license');302 vi.spyOn(license, 'isProUser').mockResolvedValue(false);303 }304305 it('is Pro-gated', async () => {306 await mockNoPro();307 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });308 expect(result.ok).toBe(false);309 expect((result as { error: string }).error).toMatch(/pro_required/);310 });311312 it('fails when vault is locked', async () => {313 await mockPro();314 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });315 expect(result.ok).toBe(false);316 expect((result as { error: string }).error).toMatch(/VAULT_LOCKED|vault_locked/);317 });318319 it('succeeds with Pro and unlocked vault', async () => {320 await mockPro();321 const vault = await import('../../background/vault');322 await vault.createVault('test-pass-12345');323 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });324 expect(result.ok).toBe(true);325 expect((result as { data: Record<string, unknown> }).data).toBeTruthy();326 });327328 it('snapshot includes entryCount', async () => {329 await mockPro();330 const vault = await import('../../background/vault');331 await vault.createVault('test-pass-12345');332 await dispatch({333 type: 'ADD_ENTRY',334 payload: { title: 'GitHub', username: 'alice', password: 'pw1', url: '' },335 });336 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });337 const data = (result as { data: { entryCount: number } }).data;338 expect(data.entryCount).toBe(1);339 });340341 it('snapshot includes verifyCode in PX-XXXX-XXXX format', async () => {342 await mockPro();343 const vault = await import('../../background/vault');344 await vault.createVault('test-pass-12345');345 const result = await dispatch({ type: 'GET_CODEX_VAULT_DATA' });346 const data = (result as { data: { verifyCode: string } }).data;347 expect(data.verifyCode).toMatch(/^PX-[A-Z0-9]{4}-[A-Z0-9]{4}$/);348 });349});350351352353describe('ESTIMATE_CODEX_PAGES handler', () => {354 let storage: StorageShape;355 let sessionStorage: StorageShape;356 let listener: MessageListener;357358 beforeEach(async () => {359 vi.resetModules();360 vi.useRealTimers();361 storage = { peach_settings: { breachMonitoring: false } };362 sessionStorage = {};363364 vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));365 vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);366367 const chromeMock = {368 runtime: {369 id: 'peach-extension',370 onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },371 sendMessage: vi.fn(async () => ({ ok: true })),372 },373 tabs: {374 query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {375 if (typeof cb === 'function') { cb([{ id: 1 }]); return; }376 return Promise.resolve([{ id: 1 }]);377 }),378 sendMessage: vi.fn(async () => ({ ok: true })),379 },380 storage: {381 local: {382 get: vi.fn(async (key?: string | string[]) => {383 if (!key) return storage;384 if (typeof key === 'string') return { [key]: storage[key] };385 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});386 }),387 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),388 remove: vi.fn(async (keys: string | string[]) => {389 const list = Array.isArray(keys) ? keys : [keys];390 for (const key of list) delete storage[key];391 }),392 },393 session: {394 get: vi.fn(async (key?: string | string[]) => {395 if (!key) return sessionStorage;396 if (typeof key === 'string') return { [key]: sessionStorage[key] };397 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});398 }),399 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),400 remove: vi.fn(async (keys: string | string[]) => {401 const list = Array.isArray(keys) ? keys : [keys];402 for (const key of list) delete sessionStorage[key];403 }),404 clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),405 },406 },407 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },408 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },409 scripting: { executeScript: vi.fn(async () => []) },410 } as unknown as typeof chrome;411412 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;413414 const messages = await import('../../background/messages');415 messages.setupMessageHandler();416 const vault = await import('../../background/vault');417 await vault.ensureSettingsLoaded();418 });419420 async function dispatch(message: Message): Promise<MessageResponse> {421 return new Promise<MessageResponse>((resolve) => {422 const keepOpen = listener(message, { id: 'peach-extension' }, resolve);423 expect(keepOpen).toBe(true);424 });425 }426427 async function mockPro(): Promise<void> {428 const license = await import('../../background/license');429 vi.spyOn(license, 'isProUser').mockResolvedValue(true);430 }431432 async function mockNoPro(): Promise<void> {433 const license = await import('../../background/license');434 vi.spyOn(license, 'isProUser').mockResolvedValue(false);435 }436437 it('is Pro-gated', async () => {438 await mockNoPro();439 const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' });440 expect(result.ok).toBe(false);441 expect((result as { error: string }).error).toMatch(/pro_required/);442 });443444 it('returns at least 1 page with an unlocked vault', async () => {445 await mockPro();446 const vault = await import('../../background/vault');447 await vault.createVault('test-pass-12345');448 const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' });449 expect(result.ok).toBe(true);450 expect((result as unknown as { pages: number }).pages).toBeGreaterThanOrEqual(1);451 });452453 it('returns a page count (numeric) for any vault', async () => {454 await mockPro();455 const vault = await import('../../background/vault');456 await vault.createVault('test-pass-12345');457 const result = await dispatch({ type: 'ESTIMATE_CODEX_PAGES' });458 expect(typeof (result as unknown as { pages: number }).pages).toBe('number');459 });460});