codex-handlers.test.ts

8.1 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    }));111    vi.doMock("../shared/native", () => ({112      isNativePlatform: () => false,113      nativePlugin: () => null,114    }));115116    const { handleRestoreFromCodex } = await import("./codex-handlers");117    const result = await handleRestoreFromCodex({118      vaultBlob: "this-is-not-valid-base64url-json!!!",119      timestamp: 0,120      verifyCode: "PX-AAAA-BBBB",121      vaultFingerprint: "0".repeat(16),122      entryCount: 0,123      masterPassphrase: "ignored",124    });125    expect(result).toEqual({ ok: false, error: "INVALID_VAULT_BLOB" });126    // Restoration must short-circuit before reaching the vault module on a127    // malformed blob — we never want to touch storage on garbage input.128    expect(unlockFromCodex).not.toHaveBeenCalled();129  });130131  // PCH-01-017: validate attacker-supplied metadata before it reaches storage.132  it("restore_from_codex_rejects_malformed_verify_code", async () => {133    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));134    const unlockFromCodex = vi.fn();135    vi.doMock("./vault", () => ({ unlockFromCodex, isUnlocked: () => false, getEntries: () => [] }));136    vi.doMock("../shared/native", () => ({ isNativePlatform: () => false, nativePlugin: () => null }));137138    const { handleRestoreFromCodex } = await import("./codex-handlers");139    const result = await handleRestoreFromCodex({140      vaultBlob: "anything",141      timestamp: 1_700_000_000_000,142      verifyCode: "not-a-code",143      vaultFingerprint: "0".repeat(16),144      entryCount: 0,145      masterPassphrase: "ignored",146    });147    expect(result).toEqual({ ok: false, error: "INVALID_VERIFY_CODE" });148    expect(unlockFromCodex).not.toHaveBeenCalled();149  });150151  it("restore_from_codex_rejects_nan_timestamp_and_oversized_blob", async () => {152    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));153    const unlockFromCodex = vi.fn();154    vi.doMock("./vault", () => ({ unlockFromCodex, isUnlocked: () => false, getEntries: () => [] }));155    vi.doMock("../shared/native", () => ({ isNativePlatform: () => false, nativePlugin: () => null }));156157    const { handleRestoreFromCodex } = await import("./codex-handlers");158    const base = {159      verifyCode: "PX-AAAA-BBBB",160      vaultFingerprint: "0".repeat(16),161      entryCount: 0,162      masterPassphrase: "ignored",163    };164165    const nanResult = await handleRestoreFromCodex({ ...base, vaultBlob: "ok", timestamp: NaN });166    expect(nanResult).toEqual({ ok: false, error: "INVALID_VAULT_BLOB" });167168    const bigResult = await handleRestoreFromCodex({169      ...base,170      vaultBlob: "A".repeat(10 * 1024 * 1024 + 1),171      timestamp: 1_700_000_000_000,172    });173    expect(bigResult).toEqual({ ok: false, error: "INVALID_VAULT_BLOB" });174    expect(unlockFromCodex).not.toHaveBeenCalled();175  });176177  it("handleRestoreFromCodex passes through stringified slot payloads without double-encoding", async () => {178    vi.doMock("./license", () => ({ isProUser: vi.fn(async () => false) }));179    const unlockFromCodex = vi.fn(async () => ({ ok: true as const, entryCount: 1 }));180    vi.doMock("./vault", () => ({181      unlockFromCodex,182      isUnlocked: () => false,183      getEntries: () => [],184    }));185    vi.doMock("../shared/native", () => ({186      isNativePlatform: () => false,187      nativePlugin: () => null,188    }));189190    const payload = {191      vaultBlob: Buffer.from(192        JSON.stringify({193          a: "{\"version\":1,\"salt\":\"deadbeef\",\"nonce\":\"cafebabe\",\"ciphertext\":\"00\",\"tag\":\"11\"}",194          b: null,195        }),196        "utf8",197      ).toString("base64url"),198      timestamp: 1,199      verifyCode: "PX-AAAA-BBBB",200      vaultFingerprint: "0123456789ABCDEF0123456789ABCDEF",201      entryCount: 1,202      masterPassphrase: "correct horse battery staple",203    };204205    const { handleRestoreFromCodex } = await import("./codex-handlers");206    const result = await handleRestoreFromCodex(payload);207208    expect(result).toEqual({209      ok: true,210      data: { entryCount: 1, verifyCode: "PX-AAAA-BBBB" },211    });212    expect(unlockFromCodex).toHaveBeenCalledWith({213      slotA:214        "{\"version\":1,\"salt\":\"deadbeef\",\"nonce\":\"cafebabe\",\"ciphertext\":\"00\",\"tag\":\"11\"}",215      slotB: null,216      timestamp: 1,217      masterPassphrase: "correct horse battery staple",218    });219  });220});