codex-handlers.test.ts

7.7 KB · raw

1/**2 * Tests for src/background/codex-handlers.ts.3 *4 * These exercise the gate logic and error paths without standing up a full5 * vault — license and vault modules are mocked. The one happy-path that6 * needs a real vault is exercised via the messages.test.ts harness; here we7 * focus on the boundary behaviour the handler owns.8 */910import { beforeEach, describe, expect, it, vi } from "vitest";1112type StorageShape = Record<string, unknown>;1314describe("codex handlers", () => {15  let storage: StorageShape;1617  beforeEach(() => {18    vi.resetModules();19    vi.doUnmock("./vault");20    vi.doUnmock("./license");21    storage = {};2223    const chromeMock = {24      storage: {25        local: {26          get: vi.fn(async (key?: string | string[]) => {27            if (!key) return storage;28            if (typeof key === "string") return { [key]: storage[key] };29            return key.reduce<Record<string, unknown>>((acc, k) => {30              acc[k] = storage[k];31              return acc;32            }, {});33          }),34          set: vi.fn(async (next: Record<string, unknown>) => {35            Object.assign(storage, next);36          }),37          remove: vi.fn(async (keys: string | string[]) => {38            const list = Array.isArray(keys) ? keys : [keys];39            for (const k of list) delete storage[k];40          }),41        },42      },43    } as unknown as typeof chrome;44    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;45  });4647  it("handleGetCodexVaultData rejects free users with pro_required", async () => {48    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));49    vi.doMock("./vault", () => ({50      isUnlocked: () => true,51      getEntries: () => [],52      getRecoveryWords: async () => null,53    }));54    const { handleGetCodexVaultData } = await import("./codex-handlers");55    const result = await handleGetCodexVaultData();56    expect(result).toEqual({ ok: false, error: "pro_required" });57  });5859  it("handleGetCodexVaultData returns VAULT_LOCKED for locked vault", async () => {60    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => true) }));61    vi.doMock("./vault", () => ({62      isUnlocked: () => false,63      getEntries: () => {64        throw new Error("vault locked");65      },66      getRecoveryWords: async () => {67        throw new Error("vault locked");68      },69    }));70    const { handleGetCodexVaultData } = await import("./codex-handlers");71    const result = await handleGetCodexVaultData();72    expect(result.ok).toBe(false);73    expect(result.error).toMatch(/^VAULT_LOCKED/);74  });7576  it("handleEstimateCodexPages returns >=1 page for a real slotA blob", async () => {77    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => true) }));78    vi.doMock("./vault", () => ({})); // unused on this path79    // A representative slotA-shaped record; field contents don't matter for80    // the size estimate — only the JSON-serialised byte count does.81    storage.peach_vault_a = {82      version: 1,83      salt: "00".repeat(16),84      nonce: "00".repeat(12),85      // ~2 KiB of hex so the deflated stream lands in a predictable range.86      ciphertext: "ab".repeat(1024),87      tag: "00".repeat(16),88    };89    storage.peach_vault_b = {90      version: 1,91      salt: "11".repeat(16),92      nonce: "11".repeat(12),93      ciphertext: "cd".repeat(1024),94      tag: "11".repeat(16),95    };96    const { handleEstimateCodexPages } = await import("./codex-handlers");97    const result = await handleEstimateCodexPages();98    expect(result.ok).toBe(true);99    expect(typeof result.pages).toBe("number");100    expect(result.pages!).toBeGreaterThanOrEqual(1);101  });102103  it("handleRestoreFromCodex rejects an invalid blob with INVALID_VAULT_BLOB", async () => {104    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));105    const unlockFromCodex = vi.fn();106    vi.doMock("./vault", () => ({107      unlockFromCodex,108      isUnlocked: () => false,109      getEntries: () => [],110    }));111112    const { handleRestoreFromCodex } = await import("./codex-handlers");113    const result = await handleRestoreFromCodex({114      vaultBlob: "this-is-not-valid-base64url-json!!!",115      timestamp: 0,116      verifyCode: "PX-AAAA-BBBB",117      vaultFingerprint: "0".repeat(16),118      entryCount: 0,119      masterPassphrase: "ignored",120    });121    expect(result).toEqual({ ok: false, error: "INVALID_VAULT_BLOB" });122    // Restoration must short-circuit before reaching the vault module on a123    // malformed blob — we never want to touch storage on garbage input.124    expect(unlockFromCodex).not.toHaveBeenCalled();125  });126127  // PCH-01-017: validate attacker-supplied metadata before it reaches storage.128  it("restore_from_codex_rejects_malformed_verify_code", async () => {129    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));130    const unlockFromCodex = vi.fn();131    vi.doMock("./vault", () => ({ unlockFromCodex, isUnlocked: () => false, getEntries: () => [] }));132133    const { handleRestoreFromCodex } = await import("./codex-handlers");134    const result = await handleRestoreFromCodex({135      vaultBlob: "anything",136      timestamp: 1_700_000_000_000,137      verifyCode: "not-a-code",138      vaultFingerprint: "0".repeat(16),139      entryCount: 0,140      masterPassphrase: "ignored",141    });142    expect(result).toEqual({ ok: false, error: "INVALID_VERIFY_CODE" });143    expect(unlockFromCodex).not.toHaveBeenCalled();144  });145146  it("restore_from_codex_rejects_nan_timestamp_and_oversized_blob", async () => {147    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));148    const unlockFromCodex = vi.fn();149    vi.doMock("./vault", () => ({ unlockFromCodex, isUnlocked: () => false, getEntries: () => [] }));150151    const { handleRestoreFromCodex } = await import("./codex-handlers");152    const base = {153      verifyCode: "PX-AAAA-BBBB",154      vaultFingerprint: "0".repeat(16),155      entryCount: 0,156      masterPassphrase: "ignored",157    };158159    const nanResult = await handleRestoreFromCodex({ ...base, vaultBlob: "ok", timestamp: NaN });160    expect(nanResult).toEqual({ ok: false, error: "INVALID_VAULT_BLOB" });161162    const bigResult = await handleRestoreFromCodex({163      ...base,164      vaultBlob: "A".repeat(10 * 1024 * 1024 + 1),165      timestamp: 1_700_000_000_000,166    });167    expect(bigResult).toEqual({ ok: false, error: "INVALID_VAULT_BLOB" });168    expect(unlockFromCodex).not.toHaveBeenCalled();169  });170171  it("handleRestoreFromCodex passes through stringified slot payloads without double-encoding", async () => {172    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));173    const unlockFromCodex = vi.fn(async () => ({ ok: true as const, entryCount: 1 }));174    vi.doMock("./vault", () => ({175      unlockFromCodex,176      isUnlocked: () => false,177      getEntries: () => [],178    }));179180    const payload = {181      vaultBlob: Buffer.from(182        JSON.stringify({183          a: "{\"version\":1,\"salt\":\"deadbeef\",\"nonce\":\"cafebabe\",\"ciphertext\":\"00\",\"tag\":\"11\"}",184          b: null,185        }),186        "utf8",187      ).toString("base64url"),188      timestamp: 1,189      verifyCode: "PX-AAAA-BBBB",190      vaultFingerprint: "0123456789ABCDEF0123456789ABCDEF",191      entryCount: 1,192      masterPassphrase: "correct horse battery staple",193    };194195    const { handleRestoreFromCodex } = await import("./codex-handlers");196    const result = await handleRestoreFromCodex(payload);197198    expect(result).toEqual({199      ok: true,200      data: { entryCount: 1, verifyCode: "PX-AAAA-BBBB" },201    });202    expect(unlockFromCodex).toHaveBeenCalledWith({203      slotA:204        "{\"version\":1,\"salt\":\"deadbeef\",\"nonce\":\"cafebabe\",\"ciphertext\":\"00\",\"tag\":\"11\"}",205      slotB: null,206      timestamp: 1,207      masterPassphrase: "correct horse battery staple",208    });209  });210});