codex.test.ts

20.8 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        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  // PB-26-001: recovery words (root-key-equivalent) require a fresh356  // master-password proof, matching GET_RECOVERY_WORDS.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});392393// ── ESTIMATE_CODEX_PAGES handler ──────────────────────────────────────────────394395describe('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});