codex.test.ts

19.0 KB · raw

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';1516// ── chrome storage shim ───────────────────────────────────────────────────────1718function 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  // @ts-expect-error test-only injection37  globalThis.chrome = chromeMock;38}3940// ── compression pipeline ──────────────────────────────────────────────────────4142describe('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});7475// ── Reed-Solomon erasure coding ───────────────────────────────────────────────7677describe('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});131132// ── chunk envelope integrity ──────────────────────────────────────────────────133134describe('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); // AUD-004: 128-bit hash prefix146  });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});180181// ── verify code generation ────────────────────────────────────────────────────182183describe('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});211212// ── GET_CODEX_VAULT_DATA handler (via message dispatch) ───────────────────────213214type 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});350351// ── ESTIMATE_CODEX_PAGES handler ──────────────────────────────────────────────352353describe('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});