7f2cc11320ef

make Codex PDF recovery hermetic with a 250-entry multi-type vault (v5.2.59)

time
2026-09-15 00:45 UTC · 1 month ago
machine
koh2
agent
gpt-5
session
hermetic codex recovery
parent
cc664d67df06
4 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1tests/codex-pdf-restore.spec.ts+193 −49
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.58",4+  "version": "5.2.59",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.2.58",5+  "version": "5.2.59",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.58",3+  "version": "5.2.59",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

tests/codex-pdf-restore.spec.ts+193 −49
@@ -1,9 +1,9 @@11 /**22  * codex-pdf-restore.spec.ts — end-to-end Codex PDF import test.33  *4- * Runs the real importCodexPDF pipeline against the user's actual Codex PDF5- * inside a live Chrome+extension context. This is the only reliable way to6- * exercise the full chain (pdf.js → canvas render → cell crop → Aztec decode4+ * Builds a Codex PDF from a seeded encrypted vault, then runs the real5+ * importCodexPDF pipeline inside a fresh Chrome+extension context. This is the6+ * only reliable way to exercise the full chain (pdf.js → canvas render → cell crop → Aztec decode77  * → ChunkBuffer → reconstructAndVerify) because every step needs a real DOM.88  *99  * Run: npx playwright test tests/codex-pdf-restore.spec.ts@@ -22,31 +22,149 @@2222  * triggering handlePDFSelected() inside the extension.2323  */2424 25-import { chromium, test, expect } from "@playwright/test";25+import { test, expect } from "@playwright/test";2626 import type { BrowserContext, Page } from "@playwright/test";27-import { fileURLToPath } from "url";28-import { dirname, join } from "path";29-import os from "os";30-import fs from "fs/promises";31-import { existsSync } from "fs";27+import { readFile, readdir } from "node:fs/promises";28+import { join } from "node:path";29+import {30+  EXTENSION_PATH,31+  makeContext,32+  setupVault,33+  VAULT_PASSWORD,34+} from "./helpers/vault";3235 33-const __filename = fileURLToPath(import.meta.url);34-const __dirname = dirname(__filename);35-const EXTENSION_PATH = join(__dirname, "..", "dist-chrome");36+const SEEDED_ENTRY_COUNT = 250;37+const SEEDED_ENTRY_TITLE = "Seeded Codex recovery entry";3638 37-const CODEX_PDF_PATH = "/Users/june/Downloads/peach-codex-2026-08-20.pdf";38-const CURRENT_GEN_PDF_PATH = join(__dirname, "fixtures", "peach-codex-current-gen.pdf");39+async function findCodexGeneratorBundle(): Promise<string> {40+  const assetsPath = join(EXTENSION_PATH, "assets");41+  const viewBundle = (await readdir(assetsPath)).find((name) =>42+    name.startsWith("CodexGenerateView-"),43+  );44+  if (!viewBundle) throw new Error("built Codex generation view was not found");45+  const source = await readFile(join(assetsPath, viewBundle), "utf8");46+  const generatorBundle = source.match(/\.\/((?:generate)-[^"'`]+\.js)/)?.[1];47+  if (!generatorBundle) throw new Error("built Codex generator bundle was not found");48+  return generatorBundle;49+}3950 40-async function makeContext(): Promise<BrowserContext> {41-  const userDataDir = join(os.tmpdir(), `peach-codex-test-${Date.now()}`);42-  await fs.mkdir(userDataDir, { recursive: true });43-  return chromium.launchPersistentContext(userDataDir, {44-    headless: false,45-    args: [46-      `--load-extension=${EXTENSION_PATH}`,47-      `--disable-extensions-except=${EXTENSION_PATH}`,48-    ],49-  });51+async function createSeededCodexFile(): Promise<{52+  name: string;53+  mimeType: string;54+  buffer: Buffer;55+}> {56+  const seedContext = await makeContext("peach-codex-seed");57+  try {58+    const { popup } = await setupVault(seedContext);59+    const seeded = await popup.evaluate(async ({ count, sentinelTitle }) => {60+      const entries = Array.from({ length: count }, (_, index) => {61+        const title = index === 0 ? sentinelTitle : `Seeded Codex entry ${index + 1}`;62+        switch (index % 4) {63+          case 1:64+            return {65+              type: "note",66+              title,67+              notes: `Recovery note ${index + 1}: ${(index * 2654435761).toString(36)}`,68+            };69+          case 2:70+            return {71+              type: "card",72+              title,73+              cardholderName: `Seeded Holder ${index + 1}`,74+              cardNumber: `41111111${String(index).padStart(8, "0")}`,75+              expMonth: String((index % 12) + 1).padStart(2, "0"),76+              expYear: String(2030 + (index % 8)),77+              cvc: String(100 + (index % 900)),78+              network: "visa",79+              last4: String(index).padStart(4, "0").slice(-4),80+            };81+          case 3:82+            return {83+              type: "passkey",84+              title,85+              passkey: {86+                rpId: `passkey-${index + 1}.codex-recovery.test`,87+                rpName: `Seeded Passkey ${index + 1}`,88+                credentialId: `credential-${index + 1}`,89+                publicKeyCose: `cose-${index + 1}`,90+                userHandle: `handle-${index + 1}`,91+                userName: `passkey-user-${index + 1}`,92+                userDisplayName: `Passkey User ${index + 1}`,93+                algorithm: -7,94+                signCount: index,95+                aaguid: "AAAAAAAAAAAAAAAAAAAAAA",96+                backupEligible: true,97+                backupState: false,98+                createdAt: 1_700_000_000_000 + index,99+              },100+            };101+          default:102+            return {103+              type: "login",104+              title,105+              url: `https://account-${index + 1}.codex-recovery.test`,106+              username: `seeded-user-${index + 1}`,107+              password: `seeded-password-${index + 1}-${(index * 2654435761).toString(36)}`,108+            };109+        }110+      });111+      return chrome.runtime.sendMessage({112+        type: "BATCH_ADD_ENTRIES",113+        payload: { entries },114+      });115+    }, { count: SEEDED_ENTRY_COUNT, sentinelTitle: SEEDED_ENTRY_TITLE });116+    if (!seeded.ok || seeded.data.length !== SEEDED_ENTRY_COUNT) {117+      throw new Error(`seeded vault contains ${seeded.data?.length ?? 0} entries`);118+    }119+    const snapshot = await popup.evaluate(async () => {120+      const stored = await chrome.storage.local.get([121+        "peach_vault_a",122+        "peach_vault_b",123+        "peach_last_modified_at",124+      ]);125+      return {126+        slotA: JSON.stringify(stored.peach_vault_a),127+        slotB: JSON.stringify(stored.peach_vault_b),128+        lastModified: String(stored.peach_last_modified_at),129+      };130+    });131+    const generatorBundle = await findCodexGeneratorBundle();132+    const pdfBytes = await popup.evaluate(133+      async ({ bundle, vault, count }) => {134+        const generatorModule = (await import(135+          chrome.runtime.getURL(`assets/${bundle}`)136+        )) as Record<string, unknown>;137+        const generatorApi = Object.values(generatorModule).find(138+          (value): value is {139+            generateCodex: (deps: object) => Promise<{ pdfBytes: Uint8Array }>;140+          } =>141+            typeof value === "object" &&142+            value !== null &&143+            "generateCodex" in value &&144+            typeof value.generateCodex === "function",145+        );146+        if (!generatorApi) throw new Error("Codex generator export was not found");147+        const result = await generatorApi.generateCodex({148+          getSlotA: async () => vault.slotA,149+          getSlotB: async () => vault.slotB,150+          getLastModified: async () => vault.lastModified,151+          getAllEntries: async () =>152+            Array.from({ length: count }, () => ({ deletedAt: null })),153+          getVerifyCode: async () => "PX-SEED-CDEX",154+          isVaultUnlocked: () => true,155+        });156+        return Array.from(result.pdfBytes);157+      },158+      { bundle: generatorBundle, vault: snapshot, count: SEEDED_ENTRY_COUNT },159+    );160+    return {161+      name: "seeded-vault-codex.pdf",162+      mimeType: "application/pdf",163+      buffer: Buffer.from(pdfBytes),164+    };165+  } finally {166+    await seedContext.close();167+  }50168 }51169 52170 async function getExtId(ctx: BrowserContext): Promise<string> {@@ -90,7 +208,15 @@90208 91209 test.describe("Codex PDF restore", () => {92210   let ctx: BrowserContext;211+  let seededCodexFile: Awaited<ReturnType<typeof createSeededCodexFile>>;93212 213+  test.describe.configure({ timeout: 180_000 });214+215+  test.beforeAll(async () => {216+    test.setTimeout(180_000);217+    seededCodexFile = await createSeededCodexFile();218+  });219+94220   test.beforeEach(async () => {95221     ctx = await makeContext();96222   });@@ -99,17 +225,11 @@99225     await ctx.close();100226   });101227 102-  test("imports all chunks from the real Codex PDF", async () => {103-    // The full import of a large Codex (21+ pages) including scale-detection104-    // renders can take up to 3 minutes. Set an explicit test timeout.105-    test.setTimeout(180_000);106-228+  test("restores a seeded vault from its generated Codex PDF", async () => {107229     const extId = await getExtId(ctx);108230     const page = await openCodexRestorePage(ctx, extId);109231 110-    // CDP-based setInputFiles bypasses the OS dialog and dispatches the111-    // change event directly, firing handlePDFSelected inside the extension.112-    await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH);232+    await page.locator('input[type="file"]').setInputFiles(seededCodexFile);113233 114234     // After handlePDFSelected runs importCodexPDF:115235     //   success  → phase="ready"  → body contains "master password" + "X / Y codes"@@ -135,20 +255,38 @@135255     expect(bodyText).not.toContain("no Codex codes found");136256     expect(bodyText).not.toContain("failed to read");137257 138-    const passphraseVisible = await page.locator("#codex-passphrase").isVisible().catch(() => false);139-    if (passphraseVisible) {140-      console.log("✓ Buffer is ready — passphrase prompt visible.");141-    }142-    expect(bodyText).toMatch(/master password|codes/);258+    await page.locator("#codex-passphrase").fill(VAULT_PASSWORD);259+    await page.getByRole("button", { name: "restore vault" }).click();260+    await expect(page.getByText("✓ vault restored")).toBeVisible({ timeout: 30_000 });261+262+    const restored = await page.evaluate(async () => {263+      const response = await chrome.runtime.sendMessage({ type: "GET_ENTRIES" });264+      const entries = response.data as { title: string; type?: string }[];265+      return {266+        titles: entries.map((entry) => entry.title),267+        typeCounts: Object.fromEntries(268+          ["login", "note", "card", "passkey"].map((type) => [269+            type,270+            entries.filter((entry) => entry.type === type).length,271+          ]),272+        ),273+      };274+    });275+    expect(restored.titles).toHaveLength(SEEDED_ENTRY_COUNT);276+    expect(restored.titles).toContain(SEEDED_ENTRY_TITLE);277+    expect(restored.typeCounts).toEqual({278+      login: 63,279+      note: 63,280+      card: 62,281+      passkey: 62,282+    });143283   });144284 145285   test("decodes at least one Aztec envelope from page 1", async () => {146-    test.setTimeout(120_000);147-148286     const extId = await getExtId(ctx);149287     const page = await openCodexRestorePage(ctx, extId);150288 151-    await page.locator('input[type="file"]').setInputFiles(CODEX_PDF_PATH);289+    await page.locator('input[type="file"]').setInputFiles(seededCodexFile);152290 153291     await page.waitForFunction(154292       () => {@@ -170,16 +308,10 @@170308     expect(bodyText).not.toContain("failed to read");171309   });172310 173-  test("imports the current-generation Codex PDF when the fixture is present", async () => {174-    test.setTimeout(180_000);175-    if (!existsSync(CURRENT_GEN_PDF_PATH)) {176-      test.skip();177-      return;178-    }179-311+  test("imports the generated current-generation Codex PDF without missing stripes", async () => {180312     const extId = await getExtId(ctx);181313     const page = await openCodexRestorePage(ctx, extId, "codex=restore&codexMetrics=1");182-    await page.locator('input[type="file"]').setInputFiles(CURRENT_GEN_PDF_PATH);314+    await page.locator('input[type="file"]').setInputFiles(seededCodexFile);183315 184316     await page.waitForFunction(185317       () => {@@ -193,8 +325,20 @@193325       { timeout: 150_000, polling: 1_000 },194326     );195327 196-    const metrics = await page.evaluate(() => (window as any).__codexImportMetrics ?? null);328+    const metrics = await page.evaluate(329+      () =>330+        (331+          window as Window & {332+            __codexImportMetrics?: {333+              decodeRate: number;334+              shortStripeCount: number;335+              perStripeDecoded: number[];336+            };337+          }338+        ).__codexImportMetrics ?? null,339+    );197340     expect(metrics).not.toBeNull();341+    if (!metrics) throw new Error("Codex import metrics were not recorded");198342     expect(metrics.decodeRate).toBeGreaterThanOrEqual(0.98);199343     expect(metrics.shortStripeCount).toBe(0);200344     expect(metrics.perStripeDecoded.length).toBeGreaterThan(0);

view file