messages.test.ts

71.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;2021describe("background messages", () => {22  let storage: StorageShape;23  let sessionStorage: StorageShape;24  let listener: MessageListener;25  let fetchMock: ReturnType<typeof vi.fn>;2627  beforeEach(async () => {28    vi.resetModules();29    vi.useRealTimers();30    storage = {31      peach_settings: { breachMonitoring: false },32    };33    sessionStorage = {};3435    fetchMock = vi.fn(async () => ({36      ok: true,37      status: 200,38      text: async () => "",39    }));40    vi.stubGlobal("fetch", fetchMock);41    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);4243    const tabs = [{ id: 1 }];44    const chromeMock = {45      runtime: {46        id: "peach-extension",47        getURL: (p: string) => `chrome-extension://peach-extension/${p}`,48        onMessage: {49          addListener: vi.fn((fn: MessageListener) => {50            listener = fn;51          }),52        },53        sendMessage: vi.fn(async () => ({ ok: true })),54      },55      tabs: {56        query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {57          if (typeof callback === "function") {58            callback(tabs);59            return;60          }61          return Promise.resolve(tabs);62        }),63        sendMessage: vi.fn(async () => ({ ok: true })),64      },65      storage: {66        local: {67          get: vi.fn(async (key?: string | string[]) => {68            if (!key) return storage;69            if (typeof key === "string") {70              return { [key]: storage[key] };71            }7273            return key.reduce<Record<string, unknown>>((result, entry) => {74              result[entry] = storage[entry];75              return result;76            }, {});77          }),78          set: vi.fn(async (next: Record<string, unknown>) => {79            Object.assign(storage, next);80          }),81          remove: vi.fn(async (keys: string | string[]) => {82            const list = Array.isArray(keys) ? keys : [keys];83            for (const key of list) {84              delete storage[key];85            }86          }),87          clear: vi.fn(async () => {88            for (const key of Object.keys(storage)) delete storage[key];89          }),90        },91        session: {92          get: vi.fn(async (key?: string | string[]) => {93            if (!key) return sessionStorage;94            if (typeof key === "string") {95              return { [key]: sessionStorage[key] };96            }97            return key.reduce<Record<string, unknown>>((result, entry) => {98              result[entry] = sessionStorage[entry];99              return result;100            }, {});101          }),102          set: vi.fn(async (next: Record<string, unknown>) => {103            Object.assign(sessionStorage, 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 sessionStorage[key];109            }110          }),111          clear: vi.fn(async () => {112            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];113          }),114        },115        sync: {116          get: vi.fn(async (_key?: string | string[]) => ({})),117          set: vi.fn(async (_values: Record<string, unknown>) => undefined),118          remove: vi.fn(async (_keys: string | string[]) => undefined),119          clear: vi.fn(async () => undefined),120        },121      },122      alarms: {123        clear: vi.fn(),124        create: vi.fn(),125        onAlarm: { addListener: vi.fn() },126      },127      idle: {128        setDetectionInterval: vi.fn(),129        onStateChanged: { addListener: vi.fn() },130      },131      scripting: {132        executeScript: vi.fn(async () => []),133      },134    } as unknown as typeof chrome;135136    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;137138    const messages = await import("./messages");139    messages.setupMessageHandler();140    const vault = await import("./vault");141    await vault.ensureSettingsLoaded();142  });143144  async function dispatch(145    message: Message,146    sender: chrome.runtime.MessageSender = {147      id: "peach-extension",148      url: "chrome-extension://peach-extension/popup.html",149    }150  ): Promise<MessageResponse> {151    return await new Promise<MessageResponse>((resolve) => {152      const keepChannelOpen = listener(message, sender, (response) => resolve(response));153      expect(keepChannelOpen).toBe(true);154    });155  }156157  it("PB-26-303: page GET_SETTINGS returns only theme, accent, and its own summon record", async () => {158    storage.peach_settings = {159      theme: "dark",160      accentColor: "peach",161      autoLockMinutes: 5,162      pinEnabled: true,163      manualSummonDomains: {164        "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },165        "other.example": { firstSummonedAt: 4, summonCount: 5, lastSummonedAt: 6 },166      },167    };168    const response = await dispatch(169      { type: "GET_SETTINGS" },170      {171        id: "peach-extension",172        url: "https://login.example.com/form",173        tab: { id: 1 },174        frameId: 0,175      } as chrome.runtime.MessageSender,176    );177178    expect(response).toEqual({179      ok: true,180      data: {181        theme: "dark",182        accentColor: "peach",183        manualSummonDomains: {184          "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },185        },186      },187    });188    await expect(dispatch({ type: "GET_SETTINGS" })).resolves.toMatchObject({189      ok: true,190      data: {191        autoLockMinutes: 5,192        pinEnabled: true,193        manualSummonDomains: {194          "example.com": expect.anything(),195          "other.example": expect.anything(),196        },197      },198    });199  });200201  it("RECORD_MANUAL_SUMMON binds and bounds the sender domain", async () => {202    const sender = {203      id: "peach-extension",204      url: "https://login.example.com/form",205      origin: "https://login.example.com",206      tab: { id: 1 },207      frameId: 0,208    } as chrome.runtime.MessageSender;209210    await expect(dispatch({211      type: "RECORD_MANUAL_SUMMON",212      payload: { domain: "evil.example" },213    }, sender)).resolves.toEqual({ ok: false, error: "invalid_domain" });214    await expect(dispatch({215      type: "RECORD_MANUAL_SUMMON",216      payload: { domain: "example.com" },217    }, sender)).resolves.toEqual({ ok: true });218    await expect(dispatch({219      type: "RECORD_MANUAL_SUMMON",220      payload: { domain: "a".repeat(254) },221    }, { ...sender, origin: "https://a.com", url: "https://a.com/form" })).resolves.toEqual({222      ok: false,223      error: "invalid_domain",224    });225  });226227  it("GET_ENTRIES when unlocked returns the entry array", async () => {228    const vault = await import("./vault");229    await vault.createVault("correct-pass-12345");230    await vault.addEntry({231      title: "GitHub",232      username: "june",233      password: "secret123",234      url: "https://github.com",235    });236237    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({238      ok: true,239      data: [240        expect.objectContaining({241          title: "GitHub",242          username: "june",243        }),244      ],245    });246  });247248  it("GET_ENTRIES when locked returns a vault locked error", async () => {249    const vault = await import("./vault");250    await vault.createVault("correct-pass-12345");251    vault.lock();252253    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({254      ok: false,255      error: "vault_locked",256    });257  });258259  it("ADD_ENTRY with a valid payload returns the created entry", async () => {260    const vault = await import("./vault");261    await vault.createVault("correct-pass-12345");262263    const response = await dispatch({264      type: "ADD_ENTRY",265      payload: {266        title: "GitHub",267        username: "june",268        password: "secret123",269        url: "https://github.com",270      },271    });272273    expect(response).toMatchObject({274      ok: true,275      data: expect.objectContaining({276        id: expect.any(String),277        title: "GitHub",278      }),279    });280    expect(vault.getEntries()).toHaveLength(1);281  });282283  it("ADD_ENTRY when locked returns a vault locked error", async () => {284    const vault = await import("./vault");285    await vault.createVault("correct-pass-12345");286    vault.lock();287288    await expect(289      dispatch({290        type: "ADD_ENTRY",291        payload: {292          title: "GitHub",293          username: "june",294          password: "secret123",295          url: "https://github.com",296        },297      })298    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });299  });300301  it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {302    const vault = await import("./vault");303    await vault.createVault("correct-pass-12345");304305    await expect(306      dispatch({307        type: "ADD_ENTRY",308        payload: {309          title: "GitHub",310          username: "june",311          password: "",312          url: "https://github.com",313        },314      })315    ).resolves.toMatchObject({ ok: true });316  });317318  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {319    const vault = await import("./vault");320    await vault.createVault("correct-pass-12345");321    const entry = await vault.addEntry({322      title: "GitHub",323      username: "june",324      password: "secret123",325      url: "https://github.com",326    });327328    await expect(329      dispatch({330        type: "UPDATE_ENTRY",331        payload: { id: entry.id, patch: { password: "new-secret" } },332      })333    ).resolves.toMatchObject({ ok: true });334335    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");336  });337338  it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {339    const vault = await import("./vault");340    await vault.createVault("correct-pass-12345");341    const entry = await vault.addEntry({342      title: "GitHub",343      username: "june",344      password: "secret123",345      url: "https://github.com",346    });347348    await expect(349      dispatch({350        type: "UPDATE_ENTRY",351        payload: { id: entry.id, patch: { password: "" } },352      })353    ).resolves.toMatchObject({ ok: true });354355    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();356  });357358  it("DELETE_ENTRY soft-deletes the target entry", async () => {359    const vault = await import("./vault");360    await vault.createVault("correct-pass-12345");361    const entry = await vault.addEntry({362      title: "GitHub",363      username: "june",364      password: "secret123",365      url: "https://github.com",366    });367368    await expect(369      dispatch({370        type: "DELETE_ENTRY",371        payload: { id: entry.id },372      })373    ).resolves.toMatchObject({ ok: true });374375    expect(vault.getEntries()).toEqual([]);376    expect(vault.getDeletedEntries()).toHaveLength(1);377  });378379  it("DELETE_ENTRY of a missing id returns an error", async () => {380    const vault = await import("./vault");381    await vault.createVault("correct-pass-12345");382383    await expect(384      dispatch({385        type: "DELETE_ENTRY",386        payload: { id: "missing" },387      })388    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });389  });390391  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {392    const vault = await import("./vault");393    await vault.createVault("correct-pass-12345");394    const entry = await vault.addEntry({395      title: "GitHub",396      username: "june",397      password: "secret123",398      url: "https://github.com",399    });400401    const response = await dispatch(402      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },403      { id: "peach-extension", url: "https://github.com/login" },404    );405406    expect(response).toMatchObject({407      ok: true,408      data: expect.objectContaining({409        id: entry.id,410        username: "june",411        password: "secret123",412      }),413    });414    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();415  });416417  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {418    const vault = await import("./vault");419    await vault.createVault("correct-pass-12345");420    vault.lock();421422    await expect(423      dispatch({424        type: "GET_ENTRY_FOR_FILL",425        payload: { id: "missing" },426      })427    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });428  });429430  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {431    const vault = await import("./vault");432    await vault.createVault("correct-pass-12345");433    const generated = await generatePasskeyKeypair(-7);434    const entry = await vault.addEntry({435      type: "login",436      title: "WebAuthn.io",437      passkey: {438        rpId: "webauthn.io",439        rpName: "WebAuthn.io",440        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),441        publicKeyCose: base64urlEncode(generated.publicKeyCose),442        privateKeyJwk: generated.privateKeyJwk,443        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),444        userName: "june",445        userDisplayName: "June",446        algorithm: -7,447        signCount: 0,448        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",449        backupEligible: true,450        backupState: false,451        createdAt: Date.now(),452      },453    });454455    await expect(456      dispatch({457        type: "GET_ENTRY_FOR_FILL",458        payload: { id: entry.id },459      })460    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });461  });462463  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {464    const vault = await import("./vault");465    await vault.createVault("correct-pass-12345");466    await vault.addEntry({467      title: "GitHub",468      username: "june",469      password: "secret123",470      url: "https://github.com",471      totp: "JBSWY3DPEHPK3PXP",472    });473474    const response = await dispatch({475      type: "AUTOFILL_QUERY",476      payload: { domain: "github.com" },477    });478479    expect(response).toMatchObject({480      ok: true,481      data: [482        {483          id: expect.any(String),484          title: "GitHub",485          username: "june",486          url: "https://github.com",487          hasTotp: true,488        },489      ],490    });491    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();492    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();493  });494495  it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {496    const vault = await import("./vault");497    await vault.createVault("correct-pass-12345");498    await vault.addEntry({499      title: "GitHub",500      username: "june",501      password: "secret123",502      url: "https://github.com",503    });504    await vault.addEntry({505      type: "note",506      title: "Secure note",507      notes: "hello",508    });509510    const response = await dispatch({511      type: "AUTOFILL_QUERY",512      payload: { domain: "github.com", includeAll: true },513    });514515    expect(response).toMatchObject({516      ok: true,517      data: [518        {519          id: expect.any(String),520          title: "GitHub",521          username: "june",522          url: "https://github.com",523          hasPasskey: false,524        },525      ],526    });527    const data = response.data as Array<Record<string, unknown>>;528    expect(data).toHaveLength(1);529    // No secrets cross the boundary.530    expect(data[0]).not.toHaveProperty("password");531    expect(data[0]).not.toHaveProperty("totp");532  });533534  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {535    const vault = await import("./vault");536    await vault.createVault("correct-pass-12345");537    await vault.addEntry({538      title: "GitHub",539      username: "june",540      password: "secret123",541      url: "https://github.com",542      totp: "JBSWY3DPEHPK3PXP",543    });544    await vault.addEntry({545      title: "No 2FA",546      username: "person",547      password: "secret123",548      url: "https://github.com",549    });550551    const response = await dispatch({552      type: "AUTOFILL_QUERY",553      payload: { domain: "github.com", kind: "totp" },554    });555556    expect(response).toMatchObject({557      ok: true,558      data: [559        {560          id: expect.any(String),561          title: "GitHub",562          username: "june",563          url: "https://github.com",564          hasTotp: true,565        },566      ],567    });568    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();569    expect(response.data).toHaveLength(1);570  });571572  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {573    const vault = await import("./vault");574    await vault.createVault("correct-pass-12345");575    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });576577    const response = await dispatch({578      type: "RESTORE_ENTRIES",579      payload: {580        mode: "replace",581        entries: [582          {583            id: "restored-1",584            type: "login",585            title: "B",586            username: "june",587            password: "two",588            url: "https://b.test",589            createdAt: 1,590            modifiedAt: 1,591          },592        ],593      },594    });595596    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });597    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);598  });599600  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {601    const vault = await import("./vault");602    await vault.createVault("correct-pass-12345");603604    await expect(605      dispatch({606        type: "RESTORE_ENTRIES",607        payload: { mode: "replace", entries: [] },608      })609    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });610  });611612  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {613    const vault = await import("./vault");614    await vault.createVault("correct-pass-12345");615616    await expect(617      dispatch({618        type: "CHANGE_PASSWORD",619        payload: {620          currentPassword: "correct-pass-12345",621          newPassword: "new-pass-67890",622        },623      })624    ).resolves.toMatchObject({ ok: true });625626    vault.lock();627    expect(await vault.unlock("new-pass-67890")).toBe(true);628629    await expect(630      dispatch({631        type: "CHANGE_PASSWORD",632        payload: {633          currentPassword: "wrong-pass",634          newPassword: "another-pass",635        },636      })637    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });638  });639640  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {641    const vault = await import("./vault");642    await vault.createVault("correct-pass-12345");643644    (645      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>646    ).mockClear();647    (648      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>649    ).mockClear();650651    await expect(652      dispatch({653        type: "UPDATE_SETTINGS",654        payload: { autoLockMinutes: 30 },655      })656    ).resolves.toMatchObject({ ok: true });657658    expect(vault.getSettings().autoLockMinutes).toBe(30);659    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);660    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {661      delayInMinutes: 30,662    });663  });664665  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {666    vi.useFakeTimers();667    const vault = await import("./vault");668    await vault.createVault("correct-pass-12345");669    const entry = await vault.addEntry({670      title: "GitHub",671      username: "june",672      password: "secret123",673      url: "https://github.com",674    });675    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);676677    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });678    await vi.runAllTimersAsync();679    const response = await responsePromise;680681    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });682    expect(vault.getEntryById(entry.id)?.breach).toBe(7);683    breachSpy.mockRestore();684  });685686  it("rejects passkey approval from a different tab than the ceremony", async () => {687    sessionStorage.passkey_pending_301 = {688      requestId: 301,689      type: "get",690      rpId: "example.com",691      rpName: "Example",692      userName: "[email protected]",693      tabId: 7,694      origin: "https://example.com",695    };696697    await expect(698      dispatch(699        { type: "PASSKEY_AUTH_RESPONSE", payload: { requestId: 301, approved: true } },700        { id: "peach-extension", tab: { id: 8 }, url: "https://example.com/login" } as chrome.runtime.MessageSender,701      ),702    ).resolves.toMatchObject({ ok: false, error: "invalid_passkey_approval_sender" });703  });704705  it("messages from foreign or URL-less senders are rejected", async () => {706    const vault = await import("./vault");707    await vault.createVault("correct-pass-12345");708709    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({710      ok: false,711      error: "invalid_sender",712    });713714    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({715      ok: false,716      error: "invalid_sender",717    });718  });719720  it("LOCK and UNLOCK messages update vault state correctly", async () => {721    const vault = await import("./vault");722723    await expect(724      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })725    ).resolves.toMatchObject({ ok: true });726    expect(vault.isUnlocked()).toBe(true);727728    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });729    expect(vault.isUnlocked()).toBe(false);730731    await expect(732      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })733    ).resolves.toMatchObject({ ok: false });734    expect(vault.isUnlocked()).toBe(false);735736    await expect(737      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })738    ).resolves.toMatchObject({ ok: true });739    expect(vault.isUnlocked()).toBe(true);740  });741742  it("does not acknowledge LOCK until session cleanup finishes", async () => {743    await expect(744      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })745    ).resolves.toMatchObject({ ok: true });746747    const removalStarted = deferred();748    const releaseRemoval = deferred();749    const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;750    remove.mockImplementation(async (keys: string | string[]) => {751      removalStarted.resolve();752      await releaseRemoval.promise;753      for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];754    });755756    let responded = false;757    const response = dispatch({ type: "LOCK" }).then((result) => {758      responded = true;759      return result;760    });761    await removalStarted.promise;762763    try {764      await Promise.resolve();765      expect(responded).toBe(false);766    } finally {767      releaseRemoval.resolve();768    }769770    await expect(response).resolves.toMatchObject({ ok: true });771  });772773  it("remembers the last successful unlock method", async () => {774    const vault = await import("./vault");775    const password = "correct-pass-12345";776    const popupSender = {777      id: "peach-extension",778      url: "chrome-extension://peach-extension/src/popup/index.html",779    } as chrome.runtime.MessageSender;780781    await vault.createVault(password);782    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);783    vault.lock();784785    await expect(786      dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),787    ).resolves.toMatchObject({ ok: true });788    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);789790    vault.lock();791    await expect(792      dispatch({ type: "UNLOCK", payload: { password } }, popupSender),793    ).resolves.toMatchObject({ ok: true });794    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);795  });796797  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {798    const response = await dispatch({799      type: "FIRE_CANARY_PING",800      payload: {801        webhookUrl: "https://example.com/hook",802        entryTitle: "GitHub",803        trigger: "password_copied",804      },805    });806807    expect(response).toMatchObject({ ok: true });808    expect(fetchMock).toHaveBeenCalledWith(809      "https://example.com/hook",810      expect.objectContaining({811        method: "POST",812        headers: { "Content-Type": "application/json" },813        body: expect.any(String),814      })815    );816    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;817    expect(body).toMatchObject({818      entry: "GitHub",819      trigger: "password_copied",820      source: "peach-extension",821    });822  });823824  it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {825    const payload = {826      webhookUrl: "https://example.com/hook",827      entryTitle: "GitHub",828      trigger: "autofilled" as const,829    };830    const first = await dispatch({ type: "FIRE_CANARY_PING", payload });831    expect(first).toMatchObject({ ok: true });832    expect(fetchMock).toHaveBeenCalledTimes(1);833    expect(Object.keys(storage).some((key) => key.includes("GitHub"))).toBe(false);834    expect(Object.keys(storage).some((key) => key.startsWith("canary-last-fetch:"))).toBe(true);835836    const second = await dispatch({ type: "FIRE_CANARY_PING", payload });837    expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });838    expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again839  });840841  it("LV-3: removes legacy plaintext rate-limit keys", async () => {842    const legacyKey = "canary-last-fetch:https://example.com/hook:GitHub";843    storage[legacyKey] = Date.now();844845    await dispatch({846      type: "FIRE_CANARY_PING",847      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },848    });849850    expect(storage[legacyKey]).toBeUndefined();851  });852853  it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {854    await dispatch({855      type: "FIRE_CANARY_PING",856      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },857    });858    await dispatch({859      type: "FIRE_CANARY_PING",860      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },861    });862    expect(fetchMock).toHaveBeenCalledTimes(2);863  });864865  it("LV-3: test pings are exempt from the debounce", async () => {866    const payload = {867      webhookUrl: "https://example.com/hook",868      entryTitle: "GitHub",869      trigger: "test" as const,870    };871    await dispatch({ type: "FIRE_CANARY_PING", payload });872    await dispatch({ type: "FIRE_CANARY_PING", payload });873    expect(fetchMock).toHaveBeenCalledTimes(2);874  });875876  it("rejects test pings from content scripts before contacting the webhook", async () => {877    const response = await dispatch(878      {879        type: "FIRE_CANARY_PING",880        payload: {881          webhookUrl: "https://example.com/hook",882          entryTitle: "GitHub",883          trigger: "test",884        },885      },886      {887        id: "peach-extension",888        url: "https://example.com/login",889        tab: { id: 7 },890      } as chrome.runtime.MessageSender,891    );892893    expect(response).toMatchObject({ ok: false, error: "invalid_sender" });894    expect(fetchMock).not.toHaveBeenCalled();895  });896897  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {898    await expect(899      dispatch({900        type: "FIRE_CANARY_PING",901        payload: {902          webhookUrl: "http://example.com/hook",903          entryTitle: "GitHub",904          trigger: "password_copied",905        },906      })907    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });908909    expect(fetchMock).not.toHaveBeenCalled();910  });911912  it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {913    for (const webhookUrl of [914      "https://[::ffff:192.168.0.1]/hook",915      "https://[::ffff:c0a8:1]/hook",916      "https://[::ffff:10.0.0.1]/hook",917      "https://[::ffff:127.0.0.1]/hook",918      "https://[::ffff:169.254.1.1]/hook",919      "https://[::ffff:172.16.0.1]/hook",920    ]) {921      await expect(922        dispatch({923          type: "FIRE_CANARY_PING",924          payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },925        })926      ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });927    }928929    expect(fetchMock).not.toHaveBeenCalled();930  });931932  it("GET_IDENTITIES when locked returns vault locked error", async () => {933    const vault = await import("./vault");934    await vault.createVault("correct-pass-12345");935    vault.lock();936    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({937      ok: false,938      error: "vault_locked",939    });940  });941942  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {943    const vault = await import("./vault");944    await vault.createVault("correct-pass-12345");945    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });946    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });947    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });948949    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };950    expect(resp.ok).toBe(true);951    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;952    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({953      type: "email",954      useCount: 2,955    });956    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({957      type: "username",958      useCount: 1,959    });960    // emails appear before usernames961    const emailIdx = data.findIndex((d) => d.value === "[email protected]");962    const userIdx = data.findIndex((d) => d.value === "juni_patel");963    expect(emailIdx).toBeLessThan(userIdx);964  });965966  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {967    const vault = await import("./vault");968    await vault.createVault("correct-pass-12345");969    for (let i = 0; i < 3; i++) {970      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });971    }972973    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };974    const data = resp.data as Array<{ value: string; useCount: number }>;975    const hits = data.filter((d) => d.value === "[email protected]");976    expect(hits).toHaveLength(1);977    expect(hits[0]!.useCount).toBe(3);978  });979980  it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {981    const vault = await import("./vault");982    await vault.createVault("correct-pass-12345");983    await vault.addEntry({984      title: "Private identity",985      username: "[email protected]",986      password: "pw",987      url: "https://private.example",988    });989990    const senders = [991      {992        id: "peach-extension",993        url: "https://private.example/signup",994        tab: { id: 1 } as chrome.tabs.Tab,995      },996      { id: "peach-extension", url: "https://private.example/signup" },997    ] satisfies chrome.runtime.MessageSender[];998    for (const sender of senders) {999      await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({1000        ok: false,1001        error: "invalid_sender",1002      });1003    }1004  });10051006  it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {1007    const vault = await import("./vault");1008    await vault.createVault("correct-pass-12345");1009    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;10101011    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer1012    alarmsCreate.mockClear();1013    await dispatch({ type: "GET_ENTRIES" });1014    expect(alarmsCreate).toHaveBeenCalled();10151016    // Passive queries are in the exclusion list and must not re-arm1017    alarmsCreate.mockClear();1018    await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });1019    expect(alarmsCreate).not.toHaveBeenCalled();1020  });10211022  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {1023    const vault = await import("./vault");1024    await vault.createVault("correct-pass-12345");1025    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });10261027    // Simulate an activated Pro license + already-celebrated marker.1028    storage.peach_license = "PEACH-PRO-TEST-KEY";1029    storage.peach_pro_celebrated = true;10301031    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1032    expect(res).toMatchObject({ ok: true });10331034    // Vault data is gone…1035    expect(storage.peach_vault_a).toBeUndefined();1036    // …but the paid license and celebration flag survive the wipe.1037    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");1038    expect(storage.peach_pro_celebrated).toBe(true);1039  });10401041  it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {1042    const messages = await import("./messages");1043    const vault = await import("./vault");10441045    let resolveGate: () => void = () => {};1046    messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));10471048    let settled = false;1049    const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });10501051    // While restore is in flight the handler must NOT have answered yet —1052    // this is the window where the old code returned "no vault" → onboarding.1053    await new Promise((r) => setTimeout(r, 20));1054    expect(settled).toBe(false);10551056    // Restore completes (the vault becomes available), gate opens.1057    await vault.createVault("correct-pass-12345");1058    resolveGate();10591060    await expect(pending).resolves.toMatchObject({ ok: true, data: true });1061  });10621063  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {1064    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });1065    expect(res.ok).toBe(false);1066    // Nothing was written — no vault exists.1067    const has = await dispatch({ type: "HAS_VAULT" });1068    expect((has as { data?: boolean }).data).toBe(false);1069  });10701071  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {1072    const vault = await import("./vault");1073    await vault.createVault("correct-pass-12345");1074    const e = await vault.addEntry({1075      title: "Mail",1076      username: "june",1077      password: "pw",1078      url: "https://mail.test",1079      totp: "JBSWY3DPEHPK3PXP",1080    });10811082    const res = await dispatch(1083      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1084      { id: "peach-extension", url: "https://mail.test/login" },1085    );1086    expect(res.ok).toBe(true);1087    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };1088    expect(data.totp).toBeUndefined();1089    expect(data.totpCode).toMatch(/^\d{6}$/);1090    expect(data.totpRemaining).toBeGreaterThan(0);1091    expect(data.totpRemaining).toBeLessThanOrEqual(30);1092  });10931094  it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {1095    const vault = await import("./vault");1096    await vault.createVault("correct-pass-12345");1097    const e = await vault.addEntry({1098      title: "Bank",1099      username: "june",1100      password: "old-pw",1101      url: "https://bank.test",1102    });1103    await vault.updateEntry(e.id, { password: "new-pw" });11041105    const fill = await dispatch(1106      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1107      { id: "peach-extension", url: "https://bank.test/login" },1108    );1109    expect(fill.ok).toBe(true);1110    // Passive autofill never receives prior passwords (blast-radius containment).1111    expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();11121113    const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1114    expect(edit.ok).toBe(true);1115    const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];1116    expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);1117  });11181119  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {1120    const vault = await import("./vault");1121    await vault.createVault("correct-pass-12345");1122    const e = await vault.addEntry({1123      title: "Mail",1124      username: "june",1125      password: "pw",1126      url: "https://mail.test",1127      totp: "JBSWY3DPEHPK3PXP",1128    });11291130    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1131    expect(res.ok).toBe(true);1132    const data = res.data as { totp?: string };1133    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1134  });11351136  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1137    const vault = await import("./vault");1138    await vault.createVault("correct-pass-12345");11391140    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1141    expect(res).toMatchObject({ ok: true });11421143    expect(storage.peach_vault_a).toBeUndefined();1144    expect(storage.peach_license).toBeUndefined();1145    expect(storage.peach_pro_celebrated).toBeUndefined();1146  });11471148  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---11491150  function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1151    return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1152  }11531154  async function addPasskeyOnlyEntry(title: string, url: string) {1155    const vault = await import("./vault");1156    const generated = await generatePasskeyKeypair(-7);1157    return vault.addEntry({1158      type: "login",1159      title,1160      url,1161      passkey: {1162        rpId: new URL(url).hostname,1163        rpName: title,1164        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1165        publicKeyCose: base64urlEncode(generated.publicKeyCose),1166        privateKeyJwk: generated.privateKeyJwk,1167        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1168        userName: "june",1169        userDisplayName: "June",1170        algorithm: -7,1171        signCount: 0,1172        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1173        backupEligible: true,1174        backupState: false,1175        createdAt: Date.now(),1176      },1177    });1178  }11791180  it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1181    const vault = await import("./vault");1182    await vault.createVault("correct-pass-12345");1183    await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1184    await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });11851186    const result = await dispatch(1187      {1188        type: "AUTOFILL_QUERY",1189        payload: {1190          domain: "github.com",1191          includeAll: true,1192          userGesture: true,1193          explicitSummon: true,1194        },1195      },1196      tabSender("https://evil.example.com/phish"),1197    );11981199    expect(result).toMatchObject({ ok: true });1200    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1201  });12021203  it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1204    const vault = await import("./vault");1205    await vault.createVault("correct-pass-12345");1206    await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1207    await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });12081209    const result = await dispatch(1210      { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1211      tabSender("https://evil.example.com/verify"),1212    );12131214    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1215  });12161217  it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1218    const vault = await import("./vault");1219    const messages = await import("./messages");1220    await vault.createVault("correct-pass-12345");1221    const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1222    const sender = tabSender("https://evil.example.com/phish");1223    const token = messages.createSummonCapability(1, "https://evil.example.com");12241225    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1226    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });12271228    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1229    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1230    const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1231    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1232  });12331234  it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1235    const vault = await import("./vault");1236    const messages = await import("./messages");1237    await vault.createVault("correct-pass-12345");1238    const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1239    const sender = tabSender("https://evil.example.com/login");1240    const token = messages.createSummonCapability(1, "https://evil.example.com");12411242    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1243    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1244    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1245    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });12461247    const offDomainReplay = await dispatch(1248      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1249      tabSender("https://github.com/login"),1250    );1251    expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1252  });12531254  it("PB-26-201: summon capability cannot fill a different entry", async () => {1255    const vault = await import("./vault");1256    const messages = await import("./messages");1257    await vault.createVault("correct-pass-12345");1258    const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1259    const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1260    const sender = tabSender("https://evil.example.com/phish");1261    const token = messages.createSummonCapability(1, "https://evil.example.com");1262    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1263    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1264    expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1265    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1266    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1267    expect(first.id).not.toBe(second.id);1268  });12691270  it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1271    const vault = await import("./vault");1272    const messages = await import("./messages");1273    await vault.createVault("correct-pass-12345");1274    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1275    const token = messages.createSummonCapability(1, "https://evil.example.com");1276    const result = await dispatch(1277      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1278      tabSender("https://evil.example.com/phish"),1279    );1280    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1281  });12821283  it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1284    const vault = await import("./vault");1285    const messages = await import("./messages");1286    await vault.createVault("correct-pass-12345");1287    const entry = await vault.addEntry({1288      title: "Evil",1289      password: "known",1290      url: "https://evil.example.com",1291    });1292    const sender = tabSender("https://evil.example.com/login");1293    const token = messages.createSummonCapability(1, "https://evil.example.com");12941295    const result = await dispatch(1296      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1297      sender,1298    );12991300    expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1301  });13021303  it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1304    vi.useFakeTimers();1305    vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1306    const vault = await import("./vault");1307    const messages = await import("./messages");1308    await vault.createVault("correct-pass-12345");1309    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1310    const sender = tabSender("https://evil.example.com/phish");1311    const token = messages.createSummonCapability(1, "https://evil.example.com");1312    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1313    vi.advanceTimersByTime(30_001);13141315    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1316    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1317  });13181319  it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1320    const vault = await import("./vault");1321    const messages = await import("./messages");1322    await vault.createVault("correct-pass-12345");1323    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1324    const token = messages.createSummonCapability(1, "https://evil.example.com");1325    await dispatch(1326      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1327      tabSender("https://evil.example.com/phish"),1328    );13291330    const result = await dispatch(1331      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1332      tabSender("https://evil.example.com/phish", 2),1333    );1334    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1335  });13361337  it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1338    const vault = await import("./vault");1339    const messages = await import("./messages");1340    await vault.createVault("correct-pass-12345");1341    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1342    const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1343    await dispatch(1344      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1345      tabSender("https://evil.example.com/phish", 1, 3),1346    );13471348    const result = await dispatch(1349      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1350      tabSender("https://evil.example.com/phish", 1, 4),1351    );1352    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1353  });13541355  it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1356    const vault = await import("./vault");1357    const messages = await import("./messages");1358    await vault.createVault("correct-pass-12345");1359    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1360    const token = messages.createSummonCapability(1, "https://evil.example.com");1361    await dispatch(1362      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1363      tabSender("https://evil.example.com/phish"),1364    );13651366    const result = await dispatch(1367      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1368      tabSender("https://other.example.com/phish"),1369    );1370    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1371  });13721373  it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1374    const vault = await import("./vault");1375    const messages = await import("./messages");1376    await vault.createVault("correct-pass-12345");1377    const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1378    const sender = tabSender("https://evil.example.com/phish");1379    const token = messages.createSummonCapability(1, "https://evil.example.com");1380    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);13811382    const missing = await dispatch(1383      { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1384      sender,1385    );1386    const existingReplay = await dispatch(1387      { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1388      sender,1389    );1390    expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1391    expect(existingReplay).toEqual(missing);1392  });13931394  it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1395    const vault = await import("./vault");1396    const messages = await import("./messages");1397    await vault.createVault("correct-pass-12345");1398    await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1399    const sender = tabSender("https://evil.example.com/phish");1400    const token = messages.createSummonCapability(1, "https://evil.example.com");1401    const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1402    const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);14031404    expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1405    expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1406  });14071408  it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1409    const vault = await import("./vault");1410    const messages = await import("./messages");1411    await vault.createVault("correct-pass-12345");1412    const card = await vault.addEntry({1413      type: "card",1414      title: "Personal Visa",1415      cardholderName: "June Peach",1416      cardNumber: "4111111111111111",1417      cvc: "123",1418      expMonth: "12",1419      expYear: "2030",1420    });1421    const sender = tabSender("https://shop.example.com/checkout");1422    const passive = await dispatch(1423      { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1424      sender,1425    );1426    expect(passive).toMatchObject({ ok: true, data: [] });14271428    const token = messages.createSummonCapability(1, "https://shop.example.com");1429    const inventory = await dispatch(1430      { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1431      sender,1432    );1433    expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1434    expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");14351436    const fill = await dispatch(1437      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1438      sender,1439    );1440    expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1441    const replay = await dispatch(1442      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1443      sender,1444    );1445    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1446  });14471448  it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1449    const result = await dispatch(1450      { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1451      tabSender("https://example.com/login"),1452    );14531454    expect(result).toMatchObject({ ok: true });1455    expect(sessionStorage.peach_open_entry).toBe("entry-123");1456    expect(sessionStorage.peach_inline_unlock).toBeUndefined();1457  });14581459  it("MV-1: tab request with a matching domain returns the entry", async () => {1460    const vault = await import("./vault");1461    await vault.createVault("correct-pass-12345");1462    const entry = await vault.addEntry({1463      title: "GitHub",1464      username: "june",1465      password: "secret123",1466      url: "https://github.com",1467    });14681469    const res = await dispatch(1470      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1471      tabSender("https://github.com/login"),1472    );1473    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1474  });14751476  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1477    const vault = await import("./vault");1478    await vault.createVault("correct-pass-12345");1479    const entry = await vault.addEntry({1480      title: "GitHub",1481      username: "june",1482      password: "secret123",1483      url: "https://github.com",1484    });14851486    const res = await dispatch(1487      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1488      tabSender("https://evil.example.com/phish"),1489    );1490    // Refused with the same error as a missing id — no cross-domain oracle.1491    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1492  });14931494  it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1495    const vault = await import("./vault");1496    await vault.createVault("correct-pass-12345");1497    const entry = await vault.addEntry({1498      title: "GitHub",1499      username: "june",1500      password: "secret123",1501      url: "https://github.com",1502    });15031504    const res = await dispatch(1505      {1506        type: "GET_ENTRY_FOR_FILL",1507        payload: { id: entry.id, explicitSummon: true },1508      },1509      tabSender("https://evil.example.com/phish"),1510    );1511    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1512  });15131514  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1515    const vault = await import("./vault");1516    await vault.createVault("correct-pass-12345");1517    const entry = await vault.addEntry({1518      title: "GitHub",1519      username: "june",1520      password: "secret123",1521      url: "https://github.com",1522    });15231524    const res = await dispatch(1525      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1526      tabSender("not a url"),1527    );1528    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1529  });15301531  // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---15321533  async function seedTwoDomainVault() {1534    const vault = await import("./vault");1535    await vault.createVault("correct-pass-12345");1536    await vault.addEntry({1537      title: "GitHub",1538      username: "june",1539      password: "gh",1540      url: "https://github.com",1541    });1542    await vault.addEntry({1543      title: "Example",1544      username: "june",1545      password: "ex",1546      url: "https://example.com",1547    });1548  }15491550  it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1551    await seedTwoDomainVault();15521553    const res = await dispatch({1554      type: "AUTOFILL_QUERY",1555      payload: { domain: "github.com", includeAll: true },1556    });1557    expect(res.ok).toBe(true);1558    const data = res.data as Array<{ title: string }>;1559    // includeAll was stripped — only the github.com match comes back.1560    expect(data).toHaveLength(1);1561    expect(data[0].title).toBe("GitHub");1562  });15631564  it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1565    await seedTwoDomainVault();15661567    const res = await dispatch({1568      type: "AUTOFILL_QUERY",1569      payload: { domain: "github.com", includeAll: true, userGesture: true },1570    });1571    expect(res.ok).toBe(true);1572    const data = res.data as Array<{ title: string }>;1573    expect(data.map((d) => d.title)).toEqual(["GitHub"]);1574  });15751576  it("PB-26-104: decoy pairing messages hide devices and prior sync outcome", async () => {1577    const vault = await import("./vault");1578    const { listPairedDevices, upsertPairedDevice } = await import("./paired-devices");1579    const { setSyncRunner, triggerSyncIfReachable } = await import("./sync-trigger");1580    await vault.createVault("correct-pass-12345");1581    await vault.setupDuress("decoy-pass-67890");1582    await upsertPairedDevice({1583      deviceId: "android-real",1584      deviceName: "Real Pixel",1585      deviceType: "android",1586      longTermPublicKeyHex: "a".repeat(64),1587      trustSource: "direct",1588      pairedAt: 1_700_000_000_000,1589    });1590    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));1591    await triggerSyncIfReachable({ reason: "manual" });15921593    await vault.lock();1594    expect(await vault.unlock("decoy-pass-67890")).toBe(true);1595    await expect(dispatch({ type: "LIST_PAIRED_DEVICES" })).resolves.toEqual({1596      ok: true,1597      data: { devices: [] },1598    });1599    await expect(dispatch({ type: "GET_SYNC_STATE" })).resolves.toEqual({1600      ok: true,1601      data: { status: "idle" },1602    });1603    expect((await listPairedDevices()).map((device) => device.deviceName)).toEqual(["Real Pixel"]);1604  });16051606  it("PB-26-104: real unlocked pairing messages retain the device list", async () => {1607    const vault = await import("./vault");1608    const { upsertPairedDevice } = await import("./paired-devices");1609    await vault.createVault("correct-pass-12345");1610    await upsertPairedDevice({1611      deviceId: "android-real",1612      deviceName: "Real Pixel",1613      deviceType: "android",1614      longTermPublicKeyHex: "a".repeat(64),1615      trustSource: "direct",1616      pairedAt: 1_700_000_000_000,1617    });16181619    await expect(dispatch({ type: "LIST_PAIRED_DEVICES" })).resolves.toMatchObject({1620      ok: true,1621      data: { devices: [{ deviceId: "android-real", deviceName: "Real Pixel" }] },1622    });1623  });16241625  async function seedPendingVouched(deviceId: string) {1626    await chrome.storage.local.set({1627      peach_pending_vouched_devices_v1: [{1628        deviceId,1629        deviceName: "New Pixel",1630        deviceType: "android",1631        longTermPublicKeyHex: "e".repeat(64),1632        signingPublicKeyHex: "a".repeat(64),1633        wsPort: 51234,1634        pairedAt: 1_700_000_000_020,1635        receivedAt: 1_700_000_000_050,1636        vouchedByDeviceId: "extension-issuer",1637      }],1638    });1639  }16401641  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1642    const vault = await import("./vault");1643    await vault.createVault("correct-pass-12345");1644    await seedPendingVouched("android-new");1645    const { findPairedDeviceById } = await import("./paired-devices");1646    const { listPendingVouchedDevices } = await import(1647      "./pending-vouched-devices"1648    );16491650    const res = await dispatch({1651      type: "CONFIRM_VOUCHED_DEVICE",1652      payload: { deviceId: "android-new" },1653    });1654    expect(res).toMatchObject({ ok: true });16551656    expect(await findPairedDeviceById("android-new")).toMatchObject({1657      deviceName: "New Pixel",1658      trustSource: "vouched",1659      longTermPublicKeyHex: "e".repeat(64),1660    });1661    expect(await listPendingVouchedDevices()).toHaveLength(0);1662  });16631664  it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1665    const vault = await import("./vault");1666    await vault.createVault("correct-pass-12345");1667    await seedPendingVouched("android-relabelled");1668    const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1669      await import("./paired-devices");1670    const { listPendingVouchedDevices } = await import(1671      "./pending-vouched-devices"1672    );16731674    // The queue entry predates the revocation, and it carries a fresh id, so1675    // sweeping the queue by id at revocation time would not have caught it.1676    await upsertPairedDevice({1677      deviceId: "android-old",1678      deviceName: "New Pixel",1679      deviceType: "android",1680      longTermPublicKeyHex: "e".repeat(64),1681      signingPublicKeyHex: "a".repeat(64),1682      pairedAt: 1_700_000_000_000,1683      trustSource: "vouched",1684    });1685    await revokeDeviceById("android-old", "self");16861687    const res = await dispatch({1688      type: "CONFIRM_VOUCHED_DEVICE",1689      payload: { deviceId: "android-relabelled" },1690    });1691    expect(res).toMatchObject({ ok: false, error: "device_revoked" });1692    expect(await findPairedDeviceById("android-relabelled")).toBeNull();1693    expect(await listPendingVouchedDevices()).toHaveLength(0);1694  });16951696  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1697    const vault = await import("./vault");1698    await vault.createVault("correct-pass-12345");1699    await seedPendingVouched("android-new");1700    const { findPairedDeviceById } = await import("./paired-devices");1701    const { listPendingVouchedDevices } = await import(1702      "./pending-vouched-devices"1703    );17041705    const res = await dispatch({1706      type: "REJECT_VOUCHED_DEVICE",1707      payload: { deviceId: "android-new" },1708    });1709    expect(res).toMatchObject({ ok: true });17101711    expect(await findPairedDeviceById("android-new")).toBeNull();1712    expect(await listPendingVouchedDevices()).toHaveLength(0);1713  });17141715  // --- unpair: UNPAIR_DEVICE revokes the device and drops it from the registry ---17161717  it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1718    const vault = await import("./vault");1719    await vault.createVault("correct-pass-12345");1720    const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1721      await import("./paired-devices");17221723    await upsertPairedDevice({1724      deviceId: "phone-1",1725      deviceName: "June's Phone",1726      deviceType: "android",1727      longTermPublicKeyHex: "a".repeat(64),1728      signingPublicKeyHex: "b".repeat(64),1729      pairedAt: 1_700_000_000_000,1730      trustSource: "direct",1731    });17321733    const res = await dispatch({1734      type: "UNPAIR_DEVICE",1735      payload: { deviceId: "phone-1" },1736    });1737    expect(res).toMatchObject({ ok: true });17381739    // The row is gone locally — re-listing must not surface it.1740    expect(await findPairedDeviceById("phone-1")).toBeNull();1741    // The revocation is recorded so a future manifest from a peer cannot1742    // resurrect this device under either the same id or the same key (MV-7 + S1).1743    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1744    const records = await listDeviceRevocations();1745    expect(records).toHaveLength(1);1746    expect(records[0]).toMatchObject({1747      deviceId: "phone-1",1748      longTermPublicKeyHex: "a".repeat(64),1749      revokedByDeviceId: expect.any(String),1750    });1751  });17521753  it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1754    const vault = await import("./vault");1755    await vault.createVault("correct-pass-12345");1756    const { listDeviceRevocations } = await import("./paired-devices");17571758    const res = await dispatch({1759      type: "UNPAIR_DEVICE",1760      payload: { deviceId: "never-paired" },1761    });1762    expect(res).toMatchObject({ ok: true });1763    // A revocation with the all-zero key sentinel is still written — the1764    // contract is that the user-intent is recorded even when the registry had1765    // no row. This is what blocks a peer from later vouched-adopting the same1766    // fresh id back in.1767    const records = await listDeviceRevocations();1768    expect(records).toHaveLength(1);1769    expect(records[0]).toMatchObject({1770      deviceId: "never-paired",1771      longTermPublicKeyHex: "0".repeat(64),1772    });1773  });17741775  it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1776    const res = await dispatch({1777      type: "UNPAIR_DEVICE",1778      payload: { deviceId: "" },1779    });1780    expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1781  });17821783  // The old "fresh direct re-pair clears the old revocation" test dispatched1784  // COMPLETE_PAIRING, a handler deleted on 2026-08-04 (no legitimate senders;1785  // adoption runs in-background via relay-adoption-bg.ts). The live1786  // revocation-overlap repair is pinned in paired-devices.test.ts1787  // (repairPairedRevocationOverlaps, runs at service-worker boot).1788  it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1789    const vault = await import("./vault");1790    await vault.createVault("correct-pass-12345");1791    const contentScriptSender = {1792      id: "peach-extension",1793      url: "https://evil.example/page",1794    };1795    const extensionPageSender = {1796      id: "peach-extension",1797      url: "chrome-extension://peach-extension/popup.html",1798    };1799    const completion = { requestId: 1, rpId: "evil.example" };18001801    // Content-script senders must never reach the gated passkey-store handler.1802    await expect(1803      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1804    ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });18051806    // Extension-page senders still reach it — refused by the pending-ceremony1807    // binding, not by the sender gate.1808    await expect(1809      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1810    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1811  });18121813  it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1814    const vault = await import("./vault");1815    await vault.createVault("correct-pass-12345");1816    const extensionPageSender = {1817      id: "peach-extension",1818      url: "chrome-extension://peach-extension/popup.html",1819    };1820    const completion = {1821      requestId: 123,1822      rpId: "example.com",1823      rpName: "Example",1824      userName: "june",1825      userHandle: "BBBB",1826      credentialId: "cred-1",1827      publicKeyCose: "c3BraQ",1828      algorithm: -7 as const,1829    };18301831    // No pending ceremony at all -> refuse.1832    await expect(1833      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1834    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });18351836    // Seed a background-minted pending ceremony.1837    sessionStorage.passkey_pending_firefox = {1838      requestId: 123,1839      type: "create",1840      rpId: "example.com",1841      rpName: "Example",1842      userName: "june",1843      browser: "firefox",1844      challenge: "AAAA",1845      userHandle: "BBBB",1846    };18471848    // Mismatched userHandle (attacker-forged completion) -> refuse.1849    await expect(1850      dispatch(1851        { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1852        extensionPageSender1853      )1854    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });18551856    // Matching completion -> stored.1857    await expect(1858      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1859    ).resolves.toMatchObject({ ok: true });1860    const entries = await dispatch({ type: "GET_ENTRIES" });1861    expect(entries).toMatchObject({ ok: true });1862    const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1863    expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1864  });18651866  it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1867    await expect(1868      dispatch(1869        {1870          type: "INITIATE_FIREFOX_PASSKEY_CREATE",1871          payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1872        },1873        { id: "peach-extension", url: "https://evil.example/page" }1874      )1875    ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1876  });18771878  it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1879    // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts;1880    // here we just pin the storage-level invariant the UNPAIR_DEVICE call1881    // establishes, since the rest of the testbed's revoke coverage rides on1882    // this state.1883    const vault = await import("./vault");1884    await vault.createVault("correct-pass-12345");1885    const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1886      "./paired-devices"1887    );18881889    await upsertPairedDevice({1890      deviceId: "phone-1",1891      deviceName: "June's Phone",1892      deviceType: "android",1893      longTermPublicKeyHex: "a".repeat(64),1894      signingPublicKeyHex: "b".repeat(64),1895      pairedAt: 1_700_000_000_000,1896      trustSource: "direct",1897    });18981899    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1900    await revokeDeviceById("phone-1", "self");1901    // The key check is what blocks a re-vouch under a fresh id.1902    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1903    expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1904  });19051906  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---19071908  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1909    const vault = await import("./vault");1910    await vault.createVault("correct-pass-12345");19111912    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1913    expect(res).toMatchObject({ ok: false, error: "password_required" });1914  });19151916  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1917    const vault = await import("./vault");1918    await vault.createVault("correct-pass-12345");19191920    const res = await dispatch({1921      type: "GET_RECOVERY_WORDS",1922      payload: { password: "not-the-password" },1923    });1924    expect(res).toMatchObject({ ok: false, error: "wrong_password" });1925    expect(res.data).toBeUndefined();1926  });19271928  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1929    const vault = await import("./vault");1930    await vault.createVault("correct-pass-12345");19311932    const res = await dispatch({1933      type: "GET_RECOVERY_WORDS",1934      payload: { password: "correct-pass-12345" },1935    });1936    expect(res.ok).toBe(true);1937    expect((res.data as string[]).length).toBe(12);1938  });19391940  it("PB-26-311: cleartext export requires a fresh master-password proof", async () => {1941    const vault = await import("./vault");1942    await vault.createVault("correct-pass-12345");19431944    await expect(1945      dispatch({ type: "EXPORT_ENTRIES" as never, payload: {} }),1946    ).resolves.toMatchObject({ ok: false, error: "password_required" });1947    await expect(1948      dispatch({1949        type: "EXPORT_ENTRIES" as never,1950        payload: { password: "not-the-password" },1951      }),1952    ).resolves.toMatchObject({ ok: false, error: "wrong_password" });1953    await expect(1954      dispatch({1955        type: "EXPORT_ENTRIES" as never,1956        payload: { password: "correct-pass-12345" },1957      }),1958    ).resolves.toMatchObject({ ok: true, data: [] });1959  });19601961  it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1962    const vault = await import("./vault");1963    const password = "correct-pass-12345";1964    const popupSender = {1965      id: "peach-extension",1966      url: "chrome-extension://peach-extension/src/popup/index.html",1967    } as chrome.runtime.MessageSender;19681969    await vault.createVault(password);1970    await vault.setupPinUnlock(password, "1234");1971    await vault.lock();19721973    await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1974      ok: true,1975      data: { configured: true, available: true, attemptsRemaining: 3 },1976    });1977  });1978});