messages.test.ts

78.1 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";4import type { Message, MessageResponse } from "../shared/types";56type StorageShape = Record<string, unknown>;78function deferred(): { promise: Promise<void>; resolve: () => void } {9  let resolve!: () => void;10  const promise = new Promise<void>((done) => {11    resolve = done;12  });13  return { promise, resolve };14}15type MessageListener = (16  msg: unknown,17  sender: chrome.runtime.MessageSender,18  sendResponse: (response: MessageResponse) => void19) => boolean;2021/**22 * Chrome re-encodes everything it stores: `undefined` is dropped and object23 * keys come back in its own order, so a value read back is deep-equal to what24 * was set but never the same object nor the same JSON text. Storing the value25 * by reference would let an order-sensitive write-verify pass here and fail on26 * a real device — exactly how the secure-reset archive check shipped broken.27 */28function asChromeStores(value: unknown): unknown {29  if (Array.isArray(value)) return value.map(asChromeStores);30  if (value && typeof value === "object") {31    const source = value as StorageShape;32    const clone: StorageShape = {};33    for (const key of Object.keys(source).sort()) clone[key] = asChromeStores(source[key]);34    return clone;35  }36  return value;37}3839function storeWrite(target: StorageShape, next: StorageShape): void {40  for (const [key, value] of Object.entries(next)) {41    if (value === undefined) continue;42    target[key] = asChromeStores(value);43  }44}4546describe("background messages", () => {47  let storage: StorageShape;48  let sessionStorage: StorageShape;49  let listener: MessageListener;50  let fetchMock: ReturnType<typeof vi.fn>;5152  beforeEach(async () => {53    vi.resetModules();54    vi.useRealTimers();55    storage = {56      peach_settings: { breachMonitoring: false },57    };58    sessionStorage = {};59    fetchMock = vi.fn(async () => ({60      ok: true,61      status: 200,62      text: async () => "",63    }));64    vi.stubGlobal("fetch", fetchMock);65    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);6667    const tabs = [{ id: 1 }];68    const chromeMock = {69      runtime: {70        id: "peach-extension",71        getURL: (p: string) => `chrome-extension://peach-extension/${p}`,72        onMessage: {73          addListener: vi.fn((fn: MessageListener) => {74            listener = fn;75          }),76        },77        sendMessage: vi.fn(async () => ({ ok: true })),78      },79      tabs: {80        query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {81          if (typeof callback === "function") {82            callback(tabs);83            return;84          }85          return Promise.resolve(tabs);86        }),87        sendMessage: vi.fn(async () => ({ ok: true })),88      },89      storage: {90        local: {91          get: vi.fn(async (key?: string | string[]) => {92            if (!key) return storage;93            if (typeof key === "string") {94              return { [key]: storage[key] };95            }9697            return key.reduce<Record<string, unknown>>((result, entry) => {98              result[entry] = storage[entry];99              return result;100            }, {});101          }),102          set: vi.fn(async (next: Record<string, unknown>) => {103            storeWrite(storage, next);104          }),105          remove: vi.fn(async (keys: string | string[]) => {106            const list = Array.isArray(keys) ? keys : [keys];107            for (const key of list) {108              delete storage[key];109            }110          }),111          clear: vi.fn(async () => {112            for (const key of Object.keys(storage)) delete storage[key];113          }),114        },115        session: {116          get: vi.fn(async (key?: string | string[]) => {117            if (!key) return sessionStorage;118            if (typeof key === "string") {119              return { [key]: sessionStorage[key] };120            }121            return key.reduce<Record<string, unknown>>((result, entry) => {122              result[entry] = sessionStorage[entry];123              return result;124            }, {});125          }),126          set: vi.fn(async (next: Record<string, unknown>) => {127            storeWrite(sessionStorage, next);128          }),129          remove: vi.fn(async (keys: string | string[]) => {130            const list = Array.isArray(keys) ? keys : [keys];131            for (const key of list) {132              delete sessionStorage[key];133            }134          }),135          clear: vi.fn(async () => {136            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];137          }),138        },139        sync: {140          get: vi.fn(async (_key?: string | string[]) => ({})),141          set: vi.fn(async (_values: Record<string, unknown>) => undefined),142          remove: vi.fn(async (_keys: string | string[]) => undefined),143          clear: vi.fn(async () => undefined),144        },145      },146      alarms: {147        clear: vi.fn(),148        create: vi.fn(),149        onAlarm: { addListener: vi.fn() },150      },151      idle: {152        setDetectionInterval: vi.fn(),153        onStateChanged: { addListener: vi.fn() },154      },155      scripting: {156        executeScript: vi.fn(async () => []),157      },158    } as unknown as typeof chrome;159160    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;161162    const messages = await import("./messages");163    messages.setupMessageHandler();164    const vault = await import("./vault");165    await vault.ensureSettingsLoaded();166  });167168  async function dispatch(169    message: Message,170    sender: chrome.runtime.MessageSender = {171      id: "peach-extension",172      url: "chrome-extension://peach-extension/popup.html",173    }174  ): Promise<MessageResponse> {175    return await new Promise<MessageResponse>((resolve) => {176      const keepChannelOpen = listener(message, sender, (response) => resolve(response));177      expect(keepChannelOpen).toBe(true);178    });179  }180181  it("PB-26-303: page GET_SETTINGS returns only theme, accent, and its own summon record", async () => {182    storage.peach_settings = {183      theme: "dark",184      accentColor: "peach",185      autoLockMinutes: 5,186      pinEnabled: true,187      manualSummonDomains: {188        "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },189        "other.example": { firstSummonedAt: 4, summonCount: 5, lastSummonedAt: 6 },190      },191    };192    const response = await dispatch(193      { type: "GET_SETTINGS" },194      {195        id: "peach-extension",196        url: "https://login.example.com/form",197        tab: { id: 1 },198        frameId: 0,199      } as chrome.runtime.MessageSender,200    );201202    expect(response).toEqual({203      ok: true,204      data: {205        theme: "dark",206        accentColor: "peach",207        manualSummonDomains: {208          "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },209        },210      },211    });212    await expect(dispatch({ type: "GET_SETTINGS" })).resolves.toMatchObject({213      ok: true,214      data: {215        autoLockMinutes: 5,216        pinEnabled: true,217        manualSummonDomains: {218          "example.com": expect.anything(),219          "other.example": expect.anything(),220        },221      },222    });223  });224225  it("RECORD_MANUAL_SUMMON binds and bounds the sender domain", async () => {226    const sender = {227      id: "peach-extension",228      url: "https://login.example.com/form",229      origin: "https://login.example.com",230      tab: { id: 1 },231      frameId: 0,232    } as chrome.runtime.MessageSender;233234    await expect(dispatch({235      type: "RECORD_MANUAL_SUMMON",236      payload: { domain: "evil.example" },237    }, sender)).resolves.toEqual({ ok: false, error: "invalid_domain" });238    await expect(dispatch({239      type: "RECORD_MANUAL_SUMMON",240      payload: { domain: "example.com" },241    }, sender)).resolves.toEqual({ ok: true });242    await expect(dispatch({243      type: "RECORD_MANUAL_SUMMON",244      payload: { domain: "a".repeat(254) },245    }, { ...sender, origin: "https://a.com", url: "https://a.com/form" })).resolves.toEqual({246      ok: false,247      error: "invalid_domain",248    });249  });250251  it("GET_ENTRIES when unlocked returns the entry array", async () => {252    const vault = await import("./vault");253    await vault.createVault("correct-pass-12345");254    await vault.addEntry({255      title: "GitHub",256      username: "june",257      password: "secret123",258      url: "https://github.com",259    });260261    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({262      ok: true,263      data: [264        expect.objectContaining({265          title: "GitHub",266          username: "june",267        }),268      ],269    });270  });271272  it("GET_ENTRIES when locked returns a vault locked error", async () => {273    const vault = await import("./vault");274    await vault.createVault("correct-pass-12345");275    vault.lock();276277    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({278      ok: false,279      error: "vault_locked",280    });281  });282283  it("ADD_ENTRY with a valid payload returns the created entry", async () => {284    const vault = await import("./vault");285    await vault.createVault("correct-pass-12345");286287    const response = await dispatch({288      type: "ADD_ENTRY",289      payload: {290        title: "GitHub",291        username: "june",292        password: "secret123",293        url: "https://github.com",294      },295    });296297    expect(response).toMatchObject({298      ok: true,299      data: expect.objectContaining({300        id: expect.any(String),301        title: "GitHub",302      }),303    });304    expect(vault.getEntries()).toHaveLength(1);305  });306307  it("ADD_ENTRY when locked returns a vault locked error", async () => {308    const vault = await import("./vault");309    await vault.createVault("correct-pass-12345");310    vault.lock();311312    await expect(313      dispatch({314        type: "ADD_ENTRY",315        payload: {316          title: "GitHub",317          username: "june",318          password: "secret123",319          url: "https://github.com",320        },321      })322    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });323  });324325  it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {326    const vault = await import("./vault");327    await vault.createVault("correct-pass-12345");328329    await expect(330      dispatch({331        type: "ADD_ENTRY",332        payload: {333          title: "GitHub",334          username: "june",335          password: "",336          url: "https://github.com",337        },338      })339    ).resolves.toMatchObject({ ok: true });340  });341342  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {343    const vault = await import("./vault");344    await vault.createVault("correct-pass-12345");345    const entry = await vault.addEntry({346      title: "GitHub",347      username: "june",348      password: "secret123",349      url: "https://github.com",350    });351352    await expect(353      dispatch({354        type: "UPDATE_ENTRY",355        payload: { id: entry.id, patch: { password: "new-secret" } },356      })357    ).resolves.toMatchObject({ ok: true });358359    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");360  });361362  it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {363    const vault = await import("./vault");364    await vault.createVault("correct-pass-12345");365    const entry = await vault.addEntry({366      title: "GitHub",367      username: "june",368      password: "secret123",369      url: "https://github.com",370    });371372    await expect(373      dispatch({374        type: "UPDATE_ENTRY",375        payload: { id: entry.id, patch: { password: "" } },376      })377    ).resolves.toMatchObject({ ok: true });378379    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();380  });381382  it("DELETE_ENTRY soft-deletes the target entry", async () => {383    const vault = await import("./vault");384    await vault.createVault("correct-pass-12345");385    const entry = await vault.addEntry({386      title: "GitHub",387      username: "june",388      password: "secret123",389      url: "https://github.com",390    });391392    await expect(393      dispatch({394        type: "DELETE_ENTRY",395        payload: { id: entry.id },396      })397    ).resolves.toMatchObject({ ok: true });398399    expect(vault.getEntries()).toEqual([]);400    expect(vault.getDeletedEntries()).toHaveLength(1);401  });402403  it("DELETE_ENTRY of a missing id returns an error", async () => {404    const vault = await import("./vault");405    await vault.createVault("correct-pass-12345");406407    await expect(408      dispatch({409        type: "DELETE_ENTRY",410        payload: { id: "missing" },411      })412    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });413  });414415  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {416    const vault = await import("./vault");417    await vault.createVault("correct-pass-12345");418    const entry = await vault.addEntry({419      title: "GitHub",420      username: "june",421      password: "secret123",422      url: "https://github.com",423    });424425    const response = await dispatch(426      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },427      { id: "peach-extension", url: "https://github.com/login" },428    );429430    expect(response).toMatchObject({431      ok: true,432      data: expect.objectContaining({433        id: entry.id,434        username: "june",435        password: "secret123",436      }),437    });438    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();439  });440441  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {442    const vault = await import("./vault");443    await vault.createVault("correct-pass-12345");444    vault.lock();445446    await expect(447      dispatch({448        type: "GET_ENTRY_FOR_FILL",449        payload: { id: "missing" },450      })451    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });452  });453454  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {455    const vault = await import("./vault");456    await vault.createVault("correct-pass-12345");457    const generated = await generatePasskeyKeypair(-7);458    const entry = await vault.addEntry({459      type: "login",460      title: "WebAuthn.io",461      passkey: {462        rpId: "webauthn.io",463        rpName: "WebAuthn.io",464        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),465        publicKeyCose: base64urlEncode(generated.publicKeyCose),466        privateKeyJwk: generated.privateKeyJwk,467        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),468        userName: "june",469        userDisplayName: "June",470        algorithm: -7,471        signCount: 0,472        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",473        backupEligible: true,474        backupState: false,475        createdAt: Date.now(),476      },477    });478479    await expect(480      dispatch({481        type: "GET_ENTRY_FOR_FILL",482        payload: { id: entry.id },483      })484    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });485  });486487  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {488    const vault = await import("./vault");489    await vault.createVault("correct-pass-12345");490    await vault.addEntry({491      title: "GitHub",492      username: "june",493      password: "secret123",494      url: "https://github.com",495      totp: "JBSWY3DPEHPK3PXP",496    });497498    const response = await dispatch({499      type: "AUTOFILL_QUERY",500      payload: { domain: "github.com" },501    });502503    expect(response).toMatchObject({504      ok: true,505      data: [506        {507          id: expect.any(String),508          title: "GitHub",509          username: "june",510          url: "https://github.com",511          hasTotp: true,512        },513      ],514    });515    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();516    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();517  });518519  it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {520    const vault = await import("./vault");521    await vault.createVault("correct-pass-12345");522    await vault.addEntry({523      title: "GitHub",524      username: "june",525      password: "secret123",526      url: "https://github.com",527    });528    await vault.addEntry({529      type: "note",530      title: "Secure note",531      notes: "hello",532    });533534    const response = await dispatch({535      type: "AUTOFILL_QUERY",536      payload: { domain: "github.com", includeAll: true },537    });538539    expect(response).toMatchObject({540      ok: true,541      data: [542        {543          id: expect.any(String),544          title: "GitHub",545          username: "june",546          url: "https://github.com",547          hasPasskey: false,548        },549      ],550    });551    const data = response.data as Array<Record<string, unknown>>;552    expect(data).toHaveLength(1);553    // No secrets cross the boundary.554    expect(data[0]).not.toHaveProperty("password");555    expect(data[0]).not.toHaveProperty("totp");556  });557558  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {559    const vault = await import("./vault");560    await vault.createVault("correct-pass-12345");561    await vault.addEntry({562      title: "GitHub",563      username: "june",564      password: "secret123",565      url: "https://github.com",566      totp: "JBSWY3DPEHPK3PXP",567    });568    await vault.addEntry({569      title: "No 2FA",570      username: "person",571      password: "secret123",572      url: "https://github.com",573    });574575    const response = await dispatch({576      type: "AUTOFILL_QUERY",577      payload: { domain: "github.com", kind: "totp" },578    });579580    expect(response).toMatchObject({581      ok: true,582      data: [583        {584          id: expect.any(String),585          title: "GitHub",586          username: "june",587          url: "https://github.com",588          hasTotp: true,589        },590      ],591    });592    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();593    expect(response.data).toHaveLength(1);594  });595596  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {597    const vault = await import("./vault");598    await vault.createVault("correct-pass-12345");599    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });600601    const response = await dispatch({602      type: "RESTORE_ENTRIES",603      payload: {604        mode: "replace",605        entries: [606          {607            id: "restored-1",608            type: "login",609            title: "B",610            username: "june",611            password: "two",612            url: "https://b.test",613            createdAt: 1,614            modifiedAt: 1,615          },616        ],617      },618    });619620    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });621    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);622  });623624  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {625    const vault = await import("./vault");626    await vault.createVault("correct-pass-12345");627628    await expect(629      dispatch({630        type: "RESTORE_ENTRIES",631        payload: { mode: "replace", entries: [] },632      })633    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });634  });635636  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {637    const vault = await import("./vault");638    await vault.createVault("correct-pass-12345");639640    await expect(641      dispatch({642        type: "CHANGE_PASSWORD",643        payload: {644          currentPassword: "correct-pass-12345",645          newPassword: "new-pass-67890",646        },647      })648    ).resolves.toMatchObject({ ok: true });649650    vault.lock();651    expect(await vault.unlock("new-pass-67890")).toBe(true);652653    await expect(654      dispatch({655        type: "CHANGE_PASSWORD",656        payload: {657          currentPassword: "wrong-pass",658          newPassword: "another-pass",659        },660      })661    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });662  });663664  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {665    const vault = await import("./vault");666    await vault.createVault("correct-pass-12345");667668    (669      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>670    ).mockClear();671    (672      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>673    ).mockClear();674675    await expect(676      dispatch({677        type: "UPDATE_SETTINGS",678        payload: { autoLockMinutes: 30 },679      })680    ).resolves.toMatchObject({ ok: true });681682    expect(vault.getSettings().autoLockMinutes).toBe(30);683    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);684    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {685      delayInMinutes: 30,686    });687  });688689  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {690    vi.useFakeTimers();691    const vault = await import("./vault");692    await vault.createVault("correct-pass-12345");693    const entry = await vault.addEntry({694      title: "GitHub",695      username: "june",696      password: "secret123",697      url: "https://github.com",698    });699    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);700701    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });702    await vi.runAllTimersAsync();703    const response = await responsePromise;704705    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });706    expect(vault.getEntryById(entry.id)?.breach).toBe(7);707    breachSpy.mockRestore();708  });709710  it("rejects passkey approval from a different tab than the ceremony", async () => {711    sessionStorage.passkey_pending_301 = {712      requestId: 301,713      type: "get",714      rpId: "example.com",715      rpName: "Example",716      userName: "[email protected]",717      tabId: 7,718      origin: "https://example.com",719    };720721    await expect(722      dispatch(723        { type: "PASSKEY_AUTH_RESPONSE", payload: { requestId: 301, approved: true } },724        { id: "peach-extension", tab: { id: 8 }, url: "https://example.com/login" } as chrome.runtime.MessageSender,725      ),726    ).resolves.toMatchObject({ ok: false, error: "invalid_passkey_approval_sender" });727  });728729  it("messages from foreign or URL-less senders are rejected", async () => {730    const vault = await import("./vault");731    await vault.createVault("correct-pass-12345");732733    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({734      ok: false,735      error: "invalid_sender",736    });737738    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({739      ok: false,740      error: "invalid_sender",741    });742  });743744  it("LOCK and UNLOCK messages update vault state correctly", async () => {745    const vault = await import("./vault");746747    await expect(748      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })749    ).resolves.toMatchObject({ ok: true });750    expect(vault.isUnlocked()).toBe(true);751752    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });753    expect(vault.isUnlocked()).toBe(false);754755    await expect(756      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })757    ).resolves.toMatchObject({ ok: false });758    expect(vault.isUnlocked()).toBe(false);759760    await expect(761      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })762    ).resolves.toMatchObject({ ok: true });763    expect(vault.isUnlocked()).toBe(true);764  });765766  it("does not acknowledge LOCK until session cleanup finishes", async () => {767    await expect(768      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })769    ).resolves.toMatchObject({ ok: true });770771    const removalStarted = deferred();772    const releaseRemoval = deferred();773    const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;774    remove.mockImplementation(async (keys: string | string[]) => {775      removalStarted.resolve();776      await releaseRemoval.promise;777      for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];778    });779780    let responded = false;781    const response = dispatch({ type: "LOCK" }).then((result) => {782      responded = true;783      return result;784    });785    await removalStarted.promise;786787    try {788      await Promise.resolve();789      expect(responded).toBe(false);790    } finally {791      releaseRemoval.resolve();792    }793794    await expect(response).resolves.toMatchObject({ ok: true });795  });796797  it("remembers the last successful unlock method", async () => {798    const vault = await import("./vault");799    const password = "correct-pass-12345";800    const popupSender = {801      id: "peach-extension",802      url: "chrome-extension://peach-extension/src/popup/index.html",803    } as chrome.runtime.MessageSender;804805    await vault.createVault(password);806    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);807    vault.lock();808809    await expect(810      dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),811    ).resolves.toMatchObject({ ok: true });812    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);813814    vault.lock();815    await expect(816      dispatch({ type: "UNLOCK", payload: { password } }, popupSender),817    ).resolves.toMatchObject({ ok: true });818    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);819  });820821  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {822    const response = await dispatch({823      type: "FIRE_CANARY_PING",824      payload: {825        webhookUrl: "https://example.com/hook",826        entryTitle: "GitHub",827        trigger: "password_copied",828      },829    });830831    expect(response).toMatchObject({ ok: true });832    expect(fetchMock).toHaveBeenCalledWith(833      "https://example.com/hook",834      expect.objectContaining({835        method: "POST",836        headers: { "Content-Type": "application/json" },837        body: expect.any(String),838      })839    );840    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;841    expect(body).toMatchObject({842      entry: "GitHub",843      trigger: "password_copied",844      source: "peach-extension",845    });846  });847848  it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {849    const payload = {850      webhookUrl: "https://example.com/hook",851      entryTitle: "GitHub",852      trigger: "autofilled" as const,853    };854    const first = await dispatch({ type: "FIRE_CANARY_PING", payload });855    expect(first).toMatchObject({ ok: true });856    expect(fetchMock).toHaveBeenCalledTimes(1);857    expect(Object.keys(storage).some((key) => key.includes("GitHub"))).toBe(false);858    expect(Object.keys(storage).some((key) => key.startsWith("canary-last-fetch:"))).toBe(true);859860    const second = await dispatch({ type: "FIRE_CANARY_PING", payload });861    expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });862    expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again863  });864865  it("LV-3: removes legacy plaintext rate-limit keys", async () => {866    const legacyKey = "canary-last-fetch:https://example.com/hook:GitHub";867    storage[legacyKey] = Date.now();868869    await dispatch({870      type: "FIRE_CANARY_PING",871      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },872    });873874    expect(storage[legacyKey]).toBeUndefined();875  });876877  it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {878    await dispatch({879      type: "FIRE_CANARY_PING",880      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },881    });882    await dispatch({883      type: "FIRE_CANARY_PING",884      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },885    });886    expect(fetchMock).toHaveBeenCalledTimes(2);887  });888889  it("LV-3: test pings are exempt from the debounce", async () => {890    const payload = {891      webhookUrl: "https://example.com/hook",892      entryTitle: "GitHub",893      trigger: "test" as const,894    };895    await dispatch({ type: "FIRE_CANARY_PING", payload });896    await dispatch({ type: "FIRE_CANARY_PING", payload });897    expect(fetchMock).toHaveBeenCalledTimes(2);898  });899900  it("rejects test pings from content scripts before contacting the webhook", async () => {901    const response = await dispatch(902      {903        type: "FIRE_CANARY_PING",904        payload: {905          webhookUrl: "https://example.com/hook",906          entryTitle: "GitHub",907          trigger: "test",908        },909      },910      {911        id: "peach-extension",912        url: "https://example.com/login",913        tab: { id: 7 },914      } as chrome.runtime.MessageSender,915    );916917    expect(response).toMatchObject({ ok: false, error: "invalid_sender" });918    expect(fetchMock).not.toHaveBeenCalled();919  });920921  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {922    await expect(923      dispatch({924        type: "FIRE_CANARY_PING",925        payload: {926          webhookUrl: "http://example.com/hook",927          entryTitle: "GitHub",928          trigger: "password_copied",929        },930      })931    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });932933    expect(fetchMock).not.toHaveBeenCalled();934  });935936  it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {937    for (const webhookUrl of [938      "https://[::ffff:192.168.0.1]/hook",939      "https://[::ffff:c0a8:1]/hook",940      "https://[::ffff:10.0.0.1]/hook",941      "https://[::ffff:127.0.0.1]/hook",942      "https://[::ffff:169.254.1.1]/hook",943      "https://[::ffff:172.16.0.1]/hook",944    ]) {945      await expect(946        dispatch({947          type: "FIRE_CANARY_PING",948          payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },949        })950      ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });951    }952953    expect(fetchMock).not.toHaveBeenCalled();954  });955956  it("GET_IDENTITIES when locked returns vault locked error", async () => {957    const vault = await import("./vault");958    await vault.createVault("correct-pass-12345");959    vault.lock();960    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({961      ok: false,962      error: "vault_locked",963    });964  });965966  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {967    const vault = await import("./vault");968    await vault.createVault("correct-pass-12345");969    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });970    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });971    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });972973    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };974    expect(resp.ok).toBe(true);975    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;976    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({977      type: "email",978      useCount: 2,979    });980    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({981      type: "username",982      useCount: 1,983    });984    // emails appear before usernames985    const emailIdx = data.findIndex((d) => d.value === "[email protected]");986    const userIdx = data.findIndex((d) => d.value === "juni_patel");987    expect(emailIdx).toBeLessThan(userIdx);988  });989990  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {991    const vault = await import("./vault");992    await vault.createVault("correct-pass-12345");993    for (let i = 0; i < 3; i++) {994      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });995    }996997    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };998    const data = resp.data as Array<{ value: string; useCount: number }>;999    const hits = data.filter((d) => d.value === "[email protected]");1000    expect(hits).toHaveLength(1);1001    expect(hits[0]!.useCount).toBe(3);1002  });10031004  it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {1005    const vault = await import("./vault");1006    await vault.createVault("correct-pass-12345");1007    await vault.addEntry({1008      title: "Private identity",1009      username: "[email protected]",1010      password: "pw",1011      url: "https://private.example",1012    });10131014    const senders = [1015      {1016        id: "peach-extension",1017        url: "https://private.example/signup",1018        tab: { id: 1 } as chrome.tabs.Tab,1019      },1020      { id: "peach-extension", url: "https://private.example/signup" },1021    ] satisfies chrome.runtime.MessageSender[];1022    for (const sender of senders) {1023      await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({1024        ok: false,1025        error: "invalid_sender",1026      });1027    }1028  });10291030  it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {1031    const vault = await import("./vault");1032    await vault.createVault("correct-pass-12345");1033    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;10341035    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer1036    alarmsCreate.mockClear();1037    await dispatch({ type: "GET_ENTRIES" });1038    expect(alarmsCreate).toHaveBeenCalled();10391040    // Passive queries are in the exclusion list and must not re-arm1041    alarmsCreate.mockClear();1042    await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });1043    expect(alarmsCreate).not.toHaveBeenCalled();1044  });10451046  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {1047    const vault = await import("./vault");1048    await vault.createVault("correct-pass-12345");1049    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });10501051    // Simulate an activated Pro license + already-celebrated marker.1052    storage.peach_license = "PEACH-PRO-TEST-KEY";1053    storage.peach_pro_celebrated = true;10541055    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1056    expect(res).toMatchObject({ ok: true });10571058    // Vault data is gone…1059    expect(storage.peach_vault_a).toBeUndefined();1060    // …but the paid license and celebration flag survive the wipe.1061    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");1062    expect(storage.peach_pro_celebrated).toBe(true);1063  });10641065  it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {1066    const messages = await import("./messages");1067    const vault = await import("./vault");10681069    let resolveGate: () => void = () => {};1070    messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));10711072    let settled = false;1073    const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });10741075    // While restore is in flight the handler must NOT have answered yet —1076    // this is the window where the old code returned "no vault" → onboarding.1077    await new Promise((r) => setTimeout(r, 20));1078    expect(settled).toBe(false);10791080    // Restore completes (the vault becomes available), gate opens.1081    await vault.createVault("correct-pass-12345");1082    resolveGate();10831084    await expect(pending).resolves.toMatchObject({ ok: true, data: true });1085  });10861087  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {1088    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });1089    expect(res.ok).toBe(false);1090    // Nothing was written — no vault exists.1091    const has = await dispatch({ type: "HAS_VAULT" });1092    expect((has as { data?: boolean }).data).toBe(false);1093  });10941095  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {1096    const vault = await import("./vault");1097    await vault.createVault("correct-pass-12345");1098    const e = await vault.addEntry({1099      title: "Mail",1100      username: "june",1101      password: "pw",1102      url: "https://mail.test",1103      totp: "JBSWY3DPEHPK3PXP",1104    });11051106    const res = await dispatch(1107      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1108      { id: "peach-extension", url: "https://mail.test/login" },1109    );1110    expect(res.ok).toBe(true);1111    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };1112    expect(data.totp).toBeUndefined();1113    expect(data.totpCode).toMatch(/^\d{6}$/);1114    expect(data.totpRemaining).toBeGreaterThan(0);1115    expect(data.totpRemaining).toBeLessThanOrEqual(30);1116  });11171118  it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {1119    const vault = await import("./vault");1120    await vault.createVault("correct-pass-12345");1121    const e = await vault.addEntry({1122      title: "Bank",1123      username: "june",1124      password: "old-pw",1125      url: "https://bank.test",1126    });1127    await vault.updateEntry(e.id, { password: "new-pw" });11281129    const fill = await dispatch(1130      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1131      { id: "peach-extension", url: "https://bank.test/login" },1132    );1133    expect(fill.ok).toBe(true);1134    // Passive autofill never receives prior passwords (blast-radius containment).1135    expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();11361137    const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1138    expect(edit.ok).toBe(true);1139    const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];1140    expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);1141  });11421143  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {1144    const vault = await import("./vault");1145    await vault.createVault("correct-pass-12345");1146    const e = await vault.addEntry({1147      title: "Mail",1148      username: "june",1149      password: "pw",1150      url: "https://mail.test",1151      totp: "JBSWY3DPEHPK3PXP",1152    });11531154    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1155    expect(res.ok).toBe(true);1156    const data = res.data as { totp?: string };1157    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1158  });11591160  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1161    const vault = await import("./vault");1162    await vault.createVault("correct-pass-12345");11631164    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1165    expect(res).toMatchObject({ ok: true });11661167    expect(storage.peach_vault_a).toBeUndefined();1168    expect(storage.peach_license).toBeUndefined();1169    expect(storage.peach_pro_celebrated).toBeUndefined();1170  });11711172  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---11731174  function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1175    return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1176  }11771178  async function addPasskeyOnlyEntry(title: string, url: string) {1179    const vault = await import("./vault");1180    const generated = await generatePasskeyKeypair(-7);1181    return vault.addEntry({1182      type: "login",1183      title,1184      url,1185      passkey: {1186        rpId: new URL(url).hostname,1187        rpName: title,1188        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1189        publicKeyCose: base64urlEncode(generated.publicKeyCose),1190        privateKeyJwk: generated.privateKeyJwk,1191        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1192        userName: "june",1193        userDisplayName: "June",1194        algorithm: -7,1195        signCount: 0,1196        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1197        backupEligible: true,1198        backupState: false,1199        createdAt: Date.now(),1200      },1201    });1202  }12031204  it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1205    const vault = await import("./vault");1206    await vault.createVault("correct-pass-12345");1207    await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1208    await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });12091210    const result = await dispatch(1211      {1212        type: "AUTOFILL_QUERY",1213        payload: {1214          domain: "github.com",1215          includeAll: true,1216          userGesture: true,1217          explicitSummon: true,1218        },1219      },1220      tabSender("https://evil.example.com/phish"),1221    );12221223    expect(result).toMatchObject({ ok: true });1224    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1225  });12261227  it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1228    const vault = await import("./vault");1229    await vault.createVault("correct-pass-12345");1230    await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1231    await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });12321233    const result = await dispatch(1234      { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1235      tabSender("https://evil.example.com/verify"),1236    );12371238    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1239  });12401241  it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1242    const vault = await import("./vault");1243    const messages = await import("./messages");1244    await vault.createVault("correct-pass-12345");1245    const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1246    const sender = tabSender("https://evil.example.com/phish");1247    const token = messages.createSummonCapability(1, "https://evil.example.com");12481249    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1250    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });12511252    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1253    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1254    const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1255    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1256  });12571258  it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1259    const vault = await import("./vault");1260    const messages = await import("./messages");1261    await vault.createVault("correct-pass-12345");1262    const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1263    const sender = tabSender("https://evil.example.com/login");1264    const token = messages.createSummonCapability(1, "https://evil.example.com");12651266    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1267    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1268    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1269    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });12701271    const offDomainReplay = await dispatch(1272      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1273      tabSender("https://github.com/login"),1274    );1275    expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1276  });12771278  it("PB-26-201: summon capability cannot fill a different entry", async () => {1279    const vault = await import("./vault");1280    const messages = await import("./messages");1281    await vault.createVault("correct-pass-12345");1282    const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1283    const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1284    const sender = tabSender("https://evil.example.com/phish");1285    const token = messages.createSummonCapability(1, "https://evil.example.com");1286    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1287    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1288    expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1289    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1290    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1291    expect(first.id).not.toBe(second.id);1292  });12931294  it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1295    const vault = await import("./vault");1296    const messages = await import("./messages");1297    await vault.createVault("correct-pass-12345");1298    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1299    const token = messages.createSummonCapability(1, "https://evil.example.com");1300    const result = await dispatch(1301      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1302      tabSender("https://evil.example.com/phish"),1303    );1304    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1305  });13061307  it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1308    const vault = await import("./vault");1309    const messages = await import("./messages");1310    await vault.createVault("correct-pass-12345");1311    const entry = await vault.addEntry({1312      title: "Evil",1313      password: "known",1314      url: "https://evil.example.com",1315    });1316    const sender = tabSender("https://evil.example.com/login");1317    const token = messages.createSummonCapability(1, "https://evil.example.com");13181319    const result = await dispatch(1320      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1321      sender,1322    );13231324    expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1325  });13261327  it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1328    vi.useFakeTimers();1329    vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1330    const vault = await import("./vault");1331    const messages = await import("./messages");1332    await vault.createVault("correct-pass-12345");1333    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1334    const sender = tabSender("https://evil.example.com/phish");1335    const token = messages.createSummonCapability(1, "https://evil.example.com");1336    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1337    vi.advanceTimersByTime(30_001);13381339    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1340    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1341  });13421343  it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1344    const vault = await import("./vault");1345    const messages = await import("./messages");1346    await vault.createVault("correct-pass-12345");1347    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1348    const token = messages.createSummonCapability(1, "https://evil.example.com");1349    await dispatch(1350      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1351      tabSender("https://evil.example.com/phish"),1352    );13531354    const result = await dispatch(1355      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1356      tabSender("https://evil.example.com/phish", 2),1357    );1358    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1359  });13601361  it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1362    const vault = await import("./vault");1363    const messages = await import("./messages");1364    await vault.createVault("correct-pass-12345");1365    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1366    const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1367    await dispatch(1368      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1369      tabSender("https://evil.example.com/phish", 1, 3),1370    );13711372    const result = await dispatch(1373      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1374      tabSender("https://evil.example.com/phish", 1, 4),1375    );1376    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1377  });13781379  it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1380    const vault = await import("./vault");1381    const messages = await import("./messages");1382    await vault.createVault("correct-pass-12345");1383    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1384    const token = messages.createSummonCapability(1, "https://evil.example.com");1385    await dispatch(1386      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1387      tabSender("https://evil.example.com/phish"),1388    );13891390    const result = await dispatch(1391      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1392      tabSender("https://other.example.com/phish"),1393    );1394    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1395  });13961397  it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1398    const vault = await import("./vault");1399    const messages = await import("./messages");1400    await vault.createVault("correct-pass-12345");1401    const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1402    const sender = tabSender("https://evil.example.com/phish");1403    const token = messages.createSummonCapability(1, "https://evil.example.com");1404    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);14051406    const missing = await dispatch(1407      { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1408      sender,1409    );1410    const existingReplay = await dispatch(1411      { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1412      sender,1413    );1414    expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1415    expect(existingReplay).toEqual(missing);1416  });14171418  it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1419    const vault = await import("./vault");1420    const messages = await import("./messages");1421    await vault.createVault("correct-pass-12345");1422    await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1423    const sender = tabSender("https://evil.example.com/phish");1424    const token = messages.createSummonCapability(1, "https://evil.example.com");1425    const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1426    const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);14271428    expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1429    expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1430  });14311432  it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1433    const vault = await import("./vault");1434    const messages = await import("./messages");1435    await vault.createVault("correct-pass-12345");1436    const card = await vault.addEntry({1437      type: "card",1438      title: "Personal Visa",1439      cardholderName: "June Peach",1440      cardNumber: "4111111111111111",1441      cvc: "123",1442      expMonth: "12",1443      expYear: "2030",1444    });1445    const sender = tabSender("https://shop.example.com/checkout");1446    const passive = await dispatch(1447      { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1448      sender,1449    );1450    expect(passive).toMatchObject({ ok: true, data: [] });14511452    const token = messages.createSummonCapability(1, "https://shop.example.com");1453    const inventory = await dispatch(1454      { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1455      sender,1456    );1457    expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1458    expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");14591460    const fill = await dispatch(1461      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1462      sender,1463    );1464    expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1465    const replay = await dispatch(1466      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1467      sender,1468    );1469    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1470  });14711472  it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1473    const result = await dispatch(1474      { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1475      tabSender("https://example.com/login"),1476    );14771478    expect(result).toMatchObject({ ok: true });1479    expect(sessionStorage.peach_open_entry).toBe("entry-123");1480    expect(sessionStorage.peach_inline_unlock).toBeUndefined();1481  });14821483  it("MV-1: tab request with a matching domain returns the entry", async () => {1484    const vault = await import("./vault");1485    await vault.createVault("correct-pass-12345");1486    const entry = await vault.addEntry({1487      title: "GitHub",1488      username: "june",1489      password: "secret123",1490      url: "https://github.com",1491    });14921493    const res = await dispatch(1494      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1495      tabSender("https://github.com/login"),1496    );1497    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1498  });14991500  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1501    const vault = await import("./vault");1502    await vault.createVault("correct-pass-12345");1503    const entry = await vault.addEntry({1504      title: "GitHub",1505      username: "june",1506      password: "secret123",1507      url: "https://github.com",1508    });15091510    const res = await dispatch(1511      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1512      tabSender("https://evil.example.com/phish"),1513    );1514    // Refused with the same error as a missing id — no cross-domain oracle.1515    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1516  });15171518  it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1519    const vault = await import("./vault");1520    await vault.createVault("correct-pass-12345");1521    const entry = await vault.addEntry({1522      title: "GitHub",1523      username: "june",1524      password: "secret123",1525      url: "https://github.com",1526    });15271528    const res = await dispatch(1529      {1530        type: "GET_ENTRY_FOR_FILL",1531        payload: { id: entry.id, explicitSummon: true },1532      },1533      tabSender("https://evil.example.com/phish"),1534    );1535    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1536  });15371538  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1539    const vault = await import("./vault");1540    await vault.createVault("correct-pass-12345");1541    const entry = await vault.addEntry({1542      title: "GitHub",1543      username: "june",1544      password: "secret123",1545      url: "https://github.com",1546    });15471548    const res = await dispatch(1549      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1550      tabSender("not a url"),1551    );1552    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1553  });15541555  // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---15561557  async function seedTwoDomainVault() {1558    const vault = await import("./vault");1559    await vault.createVault("correct-pass-12345");1560    await vault.addEntry({1561      title: "GitHub",1562      username: "june",1563      password: "gh",1564      url: "https://github.com",1565    });1566    await vault.addEntry({1567      title: "Example",1568      username: "june",1569      password: "ex",1570      url: "https://example.com",1571    });1572  }15731574  it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1575    await seedTwoDomainVault();15761577    const res = await dispatch({1578      type: "AUTOFILL_QUERY",1579      payload: { domain: "github.com", includeAll: true },1580    });1581    expect(res.ok).toBe(true);1582    const data = res.data as Array<{ title: string }>;1583    // includeAll was stripped — only the github.com match comes back.1584    expect(data).toHaveLength(1);1585    expect(data[0].title).toBe("GitHub");1586  });15871588  it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1589    await seedTwoDomainVault();15901591    const res = await dispatch({1592      type: "AUTOFILL_QUERY",1593      payload: { domain: "github.com", includeAll: true, userGesture: true },1594    });1595    expect(res.ok).toBe(true);1596    const data = res.data as Array<{ title: string }>;1597    expect(data.map((d) => d.title)).toEqual(["GitHub"]);1598  });15991600  it("PB-26-104: decoy pairing messages hide devices and prior sync outcome", async () => {1601    const vault = await import("./vault");1602    const { listPairedDevices, upsertPairedDevice } = await import("./paired-devices");1603    const { setSyncRunner, triggerSyncIfReachable } = await import("./sync-trigger");1604    await vault.createVault("correct-pass-12345");1605    await vault.setupDuress("decoy-pass-67890");1606    await upsertPairedDevice({1607      deviceId: "android-real",1608      deviceName: "Real Pixel",1609      deviceType: "android",1610      longTermPublicKeyHex: "a".repeat(64),1611      trustSource: "direct",1612      pairedAt: 1_700_000_000_000,1613    });1614    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));1615    await triggerSyncIfReachable({ reason: "manual" });16161617    await vault.lock();1618    expect(await vault.unlock("decoy-pass-67890")).toBe(true);1619    await expect(dispatch({ type: "LIST_PAIRED_DEVICES" })).resolves.toEqual({1620      ok: true,1621      data: { devices: [] },1622    });1623    await expect(dispatch({ type: "GET_SYNC_STATE" })).resolves.toEqual({1624      ok: true,1625      data: { status: "idle" },1626    });1627    expect((await listPairedDevices()).map((device) => device.deviceName)).toEqual(["Real Pixel"]);1628  });16291630  it("PB-26-104: real unlocked pairing messages retain the device list", async () => {1631    const vault = await import("./vault");1632    const { upsertPairedDevice } = await import("./paired-devices");1633    await vault.createVault("correct-pass-12345");1634    await upsertPairedDevice({1635      deviceId: "android-real",1636      deviceName: "Real Pixel",1637      deviceType: "android",1638      longTermPublicKeyHex: "a".repeat(64),1639      trustSource: "direct",1640      pairedAt: 1_700_000_000_000,1641    });16421643    await expect(dispatch({ type: "LIST_PAIRED_DEVICES" })).resolves.toMatchObject({1644      ok: true,1645      data: { devices: [{ deviceId: "android-real", deviceName: "Real Pixel" }] },1646    });1647  });16481649  async function seedPendingVouched(deviceId: string) {1650    await chrome.storage.local.set({1651      peach_pending_vouched_devices_v1: [{1652        deviceId,1653        deviceName: "New Pixel",1654        deviceType: "android",1655        longTermPublicKeyHex: "e".repeat(64),1656        signingPublicKeyHex: "a".repeat(64),1657        wsPort: 51234,1658        pairedAt: 1_700_000_000_020,1659        receivedAt: 1_700_000_000_050,1660        vouchedByDeviceId: "extension-issuer",1661      }],1662    });1663  }16641665  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1666    const vault = await import("./vault");1667    await vault.createVault("correct-pass-12345");1668    await seedPendingVouched("android-new");1669    const { findPairedDeviceById } = await import("./paired-devices");1670    const { listPendingVouchedDevices } = await import(1671      "./pending-vouched-devices"1672    );16731674    const res = await dispatch({1675      type: "CONFIRM_VOUCHED_DEVICE",1676      payload: { deviceId: "android-new" },1677    });1678    expect(res).toMatchObject({ ok: true });16791680    expect(await findPairedDeviceById("android-new")).toMatchObject({1681      deviceName: "New Pixel",1682      trustSource: "vouched",1683      longTermPublicKeyHex: "e".repeat(64),1684    });1685    expect(await listPendingVouchedDevices()).toHaveLength(0);1686  });16871688  it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1689    const vault = await import("./vault");1690    await vault.createVault("correct-pass-12345");1691    await seedPendingVouched("android-relabelled");1692    const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1693      await import("./paired-devices");1694    const { listPendingVouchedDevices } = await import(1695      "./pending-vouched-devices"1696    );16971698    // The queue entry predates the revocation, and it carries a fresh id, so1699    // sweeping the queue by id at revocation time would not have caught it.1700    await upsertPairedDevice({1701      deviceId: "android-old",1702      deviceName: "New Pixel",1703      deviceType: "android",1704      longTermPublicKeyHex: "e".repeat(64),1705      signingPublicKeyHex: "a".repeat(64),1706      pairedAt: 1_700_000_000_000,1707      trustSource: "vouched",1708    });1709    await revokeDeviceById("android-old", "self");17101711    const res = await dispatch({1712      type: "CONFIRM_VOUCHED_DEVICE",1713      payload: { deviceId: "android-relabelled" },1714    });1715    expect(res).toMatchObject({ ok: false, error: "device_revoked" });1716    expect(await findPairedDeviceById("android-relabelled")).toBeNull();1717    expect(await listPendingVouchedDevices()).toHaveLength(0);1718  });17191720  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1721    const vault = await import("./vault");1722    await vault.createVault("correct-pass-12345");1723    await seedPendingVouched("android-new");1724    const { findPairedDeviceById } = await import("./paired-devices");1725    const { listPendingVouchedDevices } = await import(1726      "./pending-vouched-devices"1727    );17281729    const res = await dispatch({1730      type: "REJECT_VOUCHED_DEVICE",1731      payload: { deviceId: "android-new" },1732    });1733    expect(res).toMatchObject({ ok: true });17341735    expect(await findPairedDeviceById("android-new")).toBeNull();1736    expect(await listPendingVouchedDevices()).toHaveLength(0);1737  });17381739  // --- unpair: UNPAIR_DEVICE revokes the device and drops it from the registry ---17401741  it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1742    const vault = await import("./vault");1743    await vault.createVault("correct-pass-12345");1744    // Every real install mints its identity with (or before) its vault, so a1745    // fixture that seeds trust state without one is an install §11.5 refuses.1746    const identity = await import("./device-identity");1747    await identity.getOrCreateDeviceId();1748    const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1749      await import("./paired-devices");17501751    await upsertPairedDevice({1752      deviceId: "phone-1",1753      deviceName: "June's Phone",1754      deviceType: "android",1755      longTermPublicKeyHex: "a".repeat(64),1756      signingPublicKeyHex: "b".repeat(64),1757      pairedAt: 1_700_000_000_000,1758      trustSource: "direct",1759    });17601761    const res = await dispatch({1762      type: "UNPAIR_DEVICE",1763      payload: { deviceId: "phone-1" },1764    });1765    expect(res).toMatchObject({ ok: true });17661767    // The row is gone locally — re-listing must not surface it.1768    expect(await findPairedDeviceById("phone-1")).toBeNull();1769    // The revocation is recorded so a future manifest from a peer cannot1770    // resurrect this device under either the same id or the same key (MV-7 + S1).1771    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1772    const records = await listDeviceRevocations();1773    expect(records).toHaveLength(1);1774    expect(records[0]).toMatchObject({1775      deviceId: "phone-1",1776      longTermPublicKeyHex: "a".repeat(64),1777      revokedByDeviceId: expect.any(String),1778    });1779  });1780  it("UNPAIR_DEVICE still evicts a peer when the local identity needs migration", async () => {1781    const vault = await import("./vault");1782    await vault.createVault("correct-pass-12345");1783    const identity = await import("./device-identity");1784    await identity.getOrCreateDeviceId();1785    delete storage.peach_local_identity_v4;1786    delete sessionStorage.peach_local_identity_session;1787    const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } = await import("./paired-devices");1788    await upsertPairedDevice({1789      deviceId: "phone-1",1790      deviceName: "June's Phone",1791      deviceType: "android",1792      longTermPublicKeyHex: "a".repeat(64),1793      signingPublicKeyHex: "b".repeat(64),1794      pairedAt: 1_700_000_000_000,1795      trustSource: "direct",1796    });17971798    const response = await dispatch({ type: "UNPAIR_DEVICE", payload: { deviceId: "phone-1" } });17991800    expect(response).toMatchObject({ ok: true });1801    expect(await findPairedDeviceById("phone-1")).toBeNull();1802    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1803  });18041805  it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1806    const vault = await import("./vault");1807    await vault.createVault("correct-pass-12345");1808    const { listDeviceRevocations } = await import("./paired-devices");18091810    const res = await dispatch({1811      type: "UNPAIR_DEVICE",1812      payload: { deviceId: "never-paired" },1813    });1814    expect(res).toMatchObject({ ok: true });1815    // A revocation with the all-zero key sentinel is still written — the1816    // contract is that the user-intent is recorded even when the registry had1817    // no row. This is what blocks a peer from later vouched-adopting the same1818    // fresh id back in.1819    const records = await listDeviceRevocations();1820    expect(records).toHaveLength(1);1821    expect(records[0]).toMatchObject({1822      deviceId: "never-paired",1823      longTermPublicKeyHex: "0".repeat(64),1824    });1825  });18261827  it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1828    const res = await dispatch({1829      type: "UNPAIR_DEVICE",1830      payload: { deviceId: "" },1831    });1832    expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1833  });1834  it("SECURE_RESET archives legacy trust, rotates identity, and reaches the new Sync anchor", async () => {1835    const vault = await import("./vault");1836    await vault.createVault("correct-pass-12345");1837    const identity = await import("./device-identity");1838    const previousDeviceId = await identity.getOrCreateDeviceId();1839    const { upsertPairedDevice } = await import("./paired-devices");1840    await upsertPairedDevice({1841      deviceId: "phone-1",1842      deviceName: "June's Phone",1843      deviceType: "android",1844      longTermPublicKeyHex: "a".repeat(64),1845      signingPublicKeyHex: "b".repeat(64),1846      pairedAt: 1_700_000_000_000,1847      trustSource: "direct",1848    });18491850    const response = await dispatch({1851      type: "SECURE_RESET",1852      payload: { password: "correct-pass-12345" },1853    });18541855    expect(response).toMatchObject({ ok: true, data: { state: "V2_ANCHOR" } });1856    expect(await identity.getOrCreateDeviceId()).not.toBe(previousDeviceId);1857  });1858  it("SECURE_RESET unlocks a locked real vault before rotating identity", async () => {1859    const vault = await import("./vault");1860    await vault.createVault("correct-pass-12345");1861    const identity = await import("./device-identity");1862    const previousDeviceId = await identity.getOrCreateDeviceId();1863    const { upsertPairedDevice } = await import("./paired-devices");1864    await upsertPairedDevice({1865      deviceId: "phone-1",1866      deviceName: "June's Phone",1867      deviceType: "android",1868      longTermPublicKeyHex: "a".repeat(64),1869      signingPublicKeyHex: "b".repeat(64),1870      pairedAt: 1_700_000_000_000,1871      trustSource: "direct",1872    });1873    await vault.lock();1874    expect(vault.isUnlocked()).toBe(false);18751876    const response = await dispatch({1877      type: "SECURE_RESET",1878      payload: { password: "correct-pass-12345" },1879    });18801881    expect(response).toMatchObject({ ok: true, data: { state: "V2_ANCHOR" } });1882    expect(vault.isUnlocked()).toBe(true);1883    expect(await identity.getOrCreateDeviceId()).not.toBe(previousDeviceId);1884  });1885  it("SECURE_RESET completes after fresh unlock when the locked spool hits identity migration", async () => {1886    const vault = await import("./vault");1887    const syncAdapter = await import("./sync-vault-adapter");1888    vault.setLockedSyncHooks({1889      refresh: syncAdapter.refreshLockedSyncSpool,1890      afterUnlock: syncAdapter.drainAndRefreshLockedSyncSpool,1891    });1892    await vault.createVault("correct-pass-12345");1893    const identity = await import("./device-identity");1894    const previousDeviceId = await identity.getOrCreateDeviceId();1895    const { upsertPairedDevice } = await import("./paired-devices");1896    await upsertPairedDevice({1897      deviceId: "phone-1",1898      deviceName: "June's Phone",1899      deviceType: "android",1900      longTermPublicKeyHex: "a".repeat(64),1901      signingPublicKeyHex: "b".repeat(64),1902      pairedAt: 1_700_000_000_000,1903      trustSource: "direct",1904    });1905    await vault.addEntry({ type: "login", title: "spooled", password: "secret" });1906    await syncAdapter.refreshLockedSyncSpool();1907    await vault.lock();1908    const { createLockedSyncVaultAdapter } = await import("./locked-sync-spool");1909    const lockedAdapter = await createLockedSyncVaultAdapter();1910    expect(lockedAdapter).not.toBeNull();1911    await lockedAdapter!.applyEntries({ remote: "AA==" }, { remote: Date.now() + 1 });1912    delete storage.peach_local_identity_v4;1913    delete sessionStorage.peach_local_identity_session;1914    const sessionSet = chrome.storage.session.set as unknown as ReturnType<typeof vi.fn>;1915    sessionSet.mockImplementation(async (next: Record<string, unknown>) => {1916      if (Object.hasOwn(next, "peach_local_identity_session")) {1917        throw new Error("QUOTA_BYTES");1918      }1919      Object.assign(sessionStorage, next);1920    });19211922    const unlocked = await dispatch({1923      type: "UNLOCK",1924      payload: { password: "correct-pass-12345" },1925    });1926    const pairSecrets = await import("./pair-secrets");1927    const root = Uint8Array.from((vault.getRootKeyHex() ?? "").match(/../g)!, (part) => Number.parseInt(part, 16));1928    await pairSecrets.setPairSecret("phone-1", "c".repeat(64), root);1929    root.fill(0);1930    expect(storage.peach_pair_secrets_v1).toBeDefined();1931    expect(sessionStorage.peach_pair_secrets_session).toBeDefined();1932    expect(unlocked).toMatchObject({ ok: true });19331934    const response = await dispatch({1935      type: "SECURE_RESET",1936      payload: { password: "correct-pass-12345" },1937    });19381939    expect(response).toMatchObject({ ok: true, data: { state: "V2_ANCHOR" } });1940    expect(storage.peach_local_identity_v4).toBeDefined();1941    expect(storage.peach_paired_devices_v3).toBeUndefined();1942    expect(storage.peach_pair_secrets_v1).toBeUndefined();1943    expect(sessionStorage.peach_pair_secrets_session).toBeUndefined();1944    expect(await identity.getOrCreateDeviceId()).not.toBe(previousDeviceId);1945  });1946  // The old "fresh direct re-pair clears the old revocation" test dispatched1947  // COMPLETE_PAIRING, a handler deleted on 2026-08-04 (no legitimate senders;1948  // adoption runs in-background via relay-adoption-bg.ts). The live1949  // revocation-overlap repair is pinned in paired-devices.test.ts1950  // (repairPairedRevocationOverlaps, runs at service-worker boot).1951  it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1952    const vault = await import("./vault");1953    await vault.createVault("correct-pass-12345");1954    const contentScriptSender = {1955      id: "peach-extension",1956      url: "https://evil.example/page",1957    };1958    const extensionPageSender = {1959      id: "peach-extension",1960      url: "chrome-extension://peach-extension/popup.html",1961    };1962    const completion = { requestId: 1, rpId: "evil.example" };19631964    // Content-script senders must never reach the gated passkey-store handler.1965    await expect(1966      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1967    ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });19681969    // Extension-page senders still reach it — refused by the pending-ceremony1970    // binding, not by the sender gate.1971    await expect(1972      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1973    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1974  });19751976  it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1977    const vault = await import("./vault");1978    await vault.createVault("correct-pass-12345");1979    const extensionPageSender = {1980      id: "peach-extension",1981      url: "chrome-extension://peach-extension/popup.html",1982    };1983    const completion = {1984      requestId: 123,1985      rpId: "example.com",1986      rpName: "Example",1987      userName: "june",1988      userHandle: "BBBB",1989      credentialId: "cred-1",1990      publicKeyCose: "c3BraQ",1991      algorithm: -7 as const,1992    };19931994    // No pending ceremony at all -> refuse.1995    await expect(1996      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1997    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });19981999    // Seed a background-minted pending ceremony.2000    sessionStorage.passkey_pending_firefox = {2001      requestId: 123,2002      type: "create",2003      rpId: "example.com",2004      rpName: "Example",2005      userName: "june",2006      browser: "firefox",2007      challenge: "AAAA",2008      userHandle: "BBBB",2009    };20102011    // Mismatched userHandle (attacker-forged completion) -> refuse.2012    await expect(2013      dispatch(2014        { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },2015        extensionPageSender2016      )2017    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });20182019    // Matching completion -> stored.2020    await expect(2021      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)2022    ).resolves.toMatchObject({ ok: true });2023    const entries = await dispatch({ type: "GET_ENTRIES" });2024    expect(entries).toMatchObject({ ok: true });2025    const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;2026    expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);2027  });20282029  it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {2030    await expect(2031      dispatch(2032        {2033          type: "INITIATE_FIREFOX_PASSKEY_CREATE",2034          payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },2035        },2036        { id: "peach-extension", url: "https://evil.example/page" }2037      )2038    ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });2039  });20402041  it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {2042    // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts;2043    // here we just pin the storage-level invariant the UNPAIR_DEVICE call2044    // establishes, since the rest of the testbed's revoke coverage rides on2045    // this state.2046    const vault = await import("./vault");2047    await vault.createVault("correct-pass-12345");2048    const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(2049      "./paired-devices"2050    );20512052    await upsertPairedDevice({2053      deviceId: "phone-1",2054      deviceName: "June's Phone",2055      deviceType: "android",2056      longTermPublicKeyHex: "a".repeat(64),2057      signingPublicKeyHex: "b".repeat(64),2058      pairedAt: 1_700_000_000_000,2059      trustSource: "direct",2060    });20612062    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);2063    await revokeDeviceById("phone-1", "self");2064    // The key check is what blocks a re-vouch under a fresh id.2065    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);2066    expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);2067  });20682069  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---20702071  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {2072    const vault = await import("./vault");2073    await vault.createVault("correct-pass-12345");20742075    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });2076    expect(res).toMatchObject({ ok: false, error: "password_required" });2077  });20782079  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {2080    const vault = await import("./vault");2081    await vault.createVault("correct-pass-12345");20822083    const res = await dispatch({2084      type: "GET_RECOVERY_WORDS",2085      payload: { password: "not-the-password" },2086    });2087    expect(res).toMatchObject({ ok: false, error: "wrong_password" });2088    expect(res.data).toBeUndefined();2089  });20902091  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {2092    const vault = await import("./vault");2093    await vault.createVault("correct-pass-12345");20942095    const res = await dispatch({2096      type: "GET_RECOVERY_WORDS",2097      payload: { password: "correct-pass-12345" },2098    });2099    expect(res.ok).toBe(true);2100    expect((res.data as string[]).length).toBe(12);2101  });21022103  it("PB-26-311: cleartext export requires a fresh master-password proof", async () => {2104    const vault = await import("./vault");2105    await vault.createVault("correct-pass-12345");21062107    await expect(2108      dispatch({ type: "EXPORT_ENTRIES" as never, payload: {} }),2109    ).resolves.toMatchObject({ ok: false, error: "password_required" });2110    await expect(2111      dispatch({2112        type: "EXPORT_ENTRIES" as never,2113        payload: { password: "not-the-password" },2114      }),2115    ).resolves.toMatchObject({ ok: false, error: "wrong_password" });2116    await expect(2117      dispatch({2118        type: "EXPORT_ENTRIES" as never,2119        payload: { password: "correct-pass-12345" },2120      }),2121    ).resolves.toMatchObject({ ok: true, data: [] });2122  });21232124  it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {2125    const vault = await import("./vault");2126    const password = "correct-pass-12345";2127    const popupSender = {2128      id: "peach-extension",2129      url: "chrome-extension://peach-extension/src/popup/index.html",2130    } as chrome.runtime.MessageSender;21312132    await vault.createVault(password);2133    await vault.setupPinUnlock(password, "1234");2134    await vault.lock();21352136    await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({2137      ok: true,2138      data: { configured: true, available: true, attemptsRemaining: 3 },2139    });2140  });2141});