messages.test.ts

65.2 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("GET_ENTRIES when unlocked returns the entry array", async () => {158    const vault = await import("./vault");159    await vault.createVault("correct-pass-12345");160    await vault.addEntry({161      title: "GitHub",162      username: "june",163      password: "secret123",164      url: "https://github.com",165    });166167    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({168      ok: true,169      data: [170        expect.objectContaining({171          title: "GitHub",172          username: "june",173        }),174      ],175    });176  });177178  it("GET_ENTRIES when locked returns a vault locked error", async () => {179    const vault = await import("./vault");180    await vault.createVault("correct-pass-12345");181    vault.lock();182183    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({184      ok: false,185      error: "vault_locked",186    });187  });188189  it("ADD_ENTRY with a valid payload returns the created entry", async () => {190    const vault = await import("./vault");191    await vault.createVault("correct-pass-12345");192193    const response = await dispatch({194      type: "ADD_ENTRY",195      payload: {196        title: "GitHub",197        username: "june",198        password: "secret123",199        url: "https://github.com",200      },201    });202203    expect(response).toMatchObject({204      ok: true,205      data: expect.objectContaining({206        id: expect.any(String),207        title: "GitHub",208      }),209    });210    expect(vault.getEntries()).toHaveLength(1);211  });212213  it("ADD_ENTRY when locked returns a vault locked error", async () => {214    const vault = await import("./vault");215    await vault.createVault("correct-pass-12345");216    vault.lock();217218    await expect(219      dispatch({220        type: "ADD_ENTRY",221        payload: {222          title: "GitHub",223          username: "june",224          password: "secret123",225          url: "https://github.com",226        },227      })228    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });229  });230231  it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {232    const vault = await import("./vault");233    await vault.createVault("correct-pass-12345");234235    await expect(236      dispatch({237        type: "ADD_ENTRY",238        payload: {239          title: "GitHub",240          username: "june",241          password: "",242          url: "https://github.com",243        },244      })245    ).resolves.toMatchObject({ ok: true });246  });247248  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {249    const vault = await import("./vault");250    await vault.createVault("correct-pass-12345");251    const entry = await vault.addEntry({252      title: "GitHub",253      username: "june",254      password: "secret123",255      url: "https://github.com",256    });257258    await expect(259      dispatch({260        type: "UPDATE_ENTRY",261        payload: { id: entry.id, patch: { password: "new-secret" } },262      })263    ).resolves.toMatchObject({ ok: true });264265    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");266  });267268  it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {269    const vault = await import("./vault");270    await vault.createVault("correct-pass-12345");271    const entry = await vault.addEntry({272      title: "GitHub",273      username: "june",274      password: "secret123",275      url: "https://github.com",276    });277278    await expect(279      dispatch({280        type: "UPDATE_ENTRY",281        payload: { id: entry.id, patch: { password: "" } },282      })283    ).resolves.toMatchObject({ ok: true });284285    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();286  });287288  it("DELETE_ENTRY soft-deletes the target entry", async () => {289    const vault = await import("./vault");290    await vault.createVault("correct-pass-12345");291    const entry = await vault.addEntry({292      title: "GitHub",293      username: "june",294      password: "secret123",295      url: "https://github.com",296    });297298    await expect(299      dispatch({300        type: "DELETE_ENTRY",301        payload: { id: entry.id },302      })303    ).resolves.toMatchObject({ ok: true });304305    expect(vault.getEntries()).toEqual([]);306    expect(vault.getDeletedEntries()).toHaveLength(1);307  });308309  it("DELETE_ENTRY of a missing id returns an error", async () => {310    const vault = await import("./vault");311    await vault.createVault("correct-pass-12345");312313    await expect(314      dispatch({315        type: "DELETE_ENTRY",316        payload: { id: "missing" },317      })318    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });319  });320321  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {322    const vault = await import("./vault");323    await vault.createVault("correct-pass-12345");324    const entry = await vault.addEntry({325      title: "GitHub",326      username: "june",327      password: "secret123",328      url: "https://github.com",329    });330331    const response = await dispatch(332      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },333      { id: "peach-extension", url: "https://github.com/login" },334    );335336    expect(response).toMatchObject({337      ok: true,338      data: expect.objectContaining({339        id: entry.id,340        username: "june",341        password: "secret123",342      }),343    });344    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();345  });346347  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {348    const vault = await import("./vault");349    await vault.createVault("correct-pass-12345");350    vault.lock();351352    await expect(353      dispatch({354        type: "GET_ENTRY_FOR_FILL",355        payload: { id: "missing" },356      })357    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });358  });359360  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {361    const vault = await import("./vault");362    await vault.createVault("correct-pass-12345");363    const generated = await generatePasskeyKeypair(-7);364    const entry = await vault.addEntry({365      type: "login",366      title: "WebAuthn.io",367      passkey: {368        rpId: "webauthn.io",369        rpName: "WebAuthn.io",370        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),371        publicKeyCose: base64urlEncode(generated.publicKeyCose),372        privateKeyJwk: generated.privateKeyJwk,373        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),374        userName: "june",375        userDisplayName: "June",376        algorithm: -7,377        signCount: 0,378        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",379        backupEligible: true,380        backupState: false,381        createdAt: Date.now(),382      },383    });384385    await expect(386      dispatch({387        type: "GET_ENTRY_FOR_FILL",388        payload: { id: entry.id },389      })390    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });391  });392393  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {394    const vault = await import("./vault");395    await vault.createVault("correct-pass-12345");396    await vault.addEntry({397      title: "GitHub",398      username: "june",399      password: "secret123",400      url: "https://github.com",401      totp: "JBSWY3DPEHPK3PXP",402    });403404    const response = await dispatch({405      type: "AUTOFILL_QUERY",406      payload: { domain: "github.com" },407    });408409    expect(response).toMatchObject({410      ok: true,411      data: [412        {413          id: expect.any(String),414          title: "GitHub",415          username: "june",416          url: "https://github.com",417          hasTotp: true,418        },419      ],420    });421    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();422    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();423  });424425  it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {426    const vault = await import("./vault");427    await vault.createVault("correct-pass-12345");428    await vault.addEntry({429      title: "GitHub",430      username: "june",431      password: "secret123",432      url: "https://github.com",433    });434    await vault.addEntry({435      type: "note",436      title: "Secure note",437      notes: "hello",438    });439440    const response = await dispatch({441      type: "AUTOFILL_QUERY",442      payload: { domain: "github.com", includeAll: true },443    });444445    expect(response).toMatchObject({446      ok: true,447      data: [448        {449          id: expect.any(String),450          title: "GitHub",451          username: "june",452          url: "https://github.com",453          hasPasskey: false,454        },455      ],456    });457    const data = response.data as Array<Record<string, unknown>>;458    expect(data).toHaveLength(1);459    // No secrets cross the boundary.460    expect(data[0]).not.toHaveProperty("password");461    expect(data[0]).not.toHaveProperty("totp");462  });463464  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {465    const vault = await import("./vault");466    await vault.createVault("correct-pass-12345");467    await vault.addEntry({468      title: "GitHub",469      username: "june",470      password: "secret123",471      url: "https://github.com",472      totp: "JBSWY3DPEHPK3PXP",473    });474    await vault.addEntry({475      title: "No 2FA",476      username: "person",477      password: "secret123",478      url: "https://github.com",479    });480481    const response = await dispatch({482      type: "AUTOFILL_QUERY",483      payload: { domain: "github.com", kind: "totp" },484    });485486    expect(response).toMatchObject({487      ok: true,488      data: [489        {490          id: expect.any(String),491          title: "GitHub",492          username: "june",493          url: "https://github.com",494          hasTotp: true,495        },496      ],497    });498    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();499    expect(response.data).toHaveLength(1);500  });501502  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {503    const vault = await import("./vault");504    await vault.createVault("correct-pass-12345");505    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });506507    const response = await dispatch({508      type: "RESTORE_ENTRIES",509      payload: {510        mode: "replace",511        entries: [512          {513            id: "restored-1",514            type: "login",515            title: "B",516            username: "june",517            password: "two",518            url: "https://b.test",519            createdAt: 1,520            modifiedAt: 1,521          },522        ],523      },524    });525526    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });527    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);528  });529530  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {531    const vault = await import("./vault");532    await vault.createVault("correct-pass-12345");533534    await expect(535      dispatch({536        type: "RESTORE_ENTRIES",537        payload: { mode: "replace", entries: [] },538      })539    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });540  });541542  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {543    const vault = await import("./vault");544    await vault.createVault("correct-pass-12345");545546    await expect(547      dispatch({548        type: "CHANGE_PASSWORD",549        payload: {550          currentPassword: "correct-pass-12345",551          newPassword: "new-pass-67890",552        },553      })554    ).resolves.toMatchObject({ ok: true });555556    vault.lock();557    expect(await vault.unlock("new-pass-67890")).toBe(true);558559    await expect(560      dispatch({561        type: "CHANGE_PASSWORD",562        payload: {563          currentPassword: "wrong-pass",564          newPassword: "another-pass",565        },566      })567    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });568  });569570  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {571    const vault = await import("./vault");572    await vault.createVault("correct-pass-12345");573574    (575      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>576    ).mockClear();577    (578      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>579    ).mockClear();580581    await expect(582      dispatch({583        type: "UPDATE_SETTINGS",584        payload: { autoLockMinutes: 30 },585      })586    ).resolves.toMatchObject({ ok: true });587588    expect(vault.getSettings().autoLockMinutes).toBe(30);589    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);590    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {591      delayInMinutes: 30,592    });593  });594595  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {596    vi.useFakeTimers();597    const vault = await import("./vault");598    await vault.createVault("correct-pass-12345");599    const entry = await vault.addEntry({600      title: "GitHub",601      username: "june",602      password: "secret123",603      url: "https://github.com",604    });605    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);606607    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });608    await vi.runAllTimersAsync();609    const response = await responsePromise;610611    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });612    expect(vault.getEntryById(entry.id)?.breach).toBe(7);613    breachSpy.mockRestore();614  });615616  it("rejects passkey approval from a different tab than the ceremony", async () => {617    sessionStorage.passkey_pending_301 = {618      requestId: 301,619      type: "get",620      rpId: "example.com",621      rpName: "Example",622      userName: "[email protected]",623      tabId: 7,624      origin: "https://example.com",625    };626627    await expect(628      dispatch(629        { type: "PASSKEY_AUTH_RESPONSE", payload: { requestId: 301, approved: true } },630        { id: "peach-extension", tab: { id: 8 }, url: "https://example.com/login" } as chrome.runtime.MessageSender,631      ),632    ).resolves.toMatchObject({ ok: false, error: "invalid_passkey_approval_sender" });633  });634635  it("messages from foreign or URL-less senders are rejected", async () => {636    const vault = await import("./vault");637    await vault.createVault("correct-pass-12345");638639    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({640      ok: false,641      error: "invalid_sender",642    });643644    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({645      ok: false,646      error: "invalid_sender",647    });648  });649650  it("LOCK and UNLOCK messages update vault state correctly", async () => {651    const vault = await import("./vault");652653    await expect(654      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })655    ).resolves.toMatchObject({ ok: true });656    expect(vault.isUnlocked()).toBe(true);657658    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });659    expect(vault.isUnlocked()).toBe(false);660661    await expect(662      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })663    ).resolves.toMatchObject({ ok: false });664    expect(vault.isUnlocked()).toBe(false);665666    await expect(667      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })668    ).resolves.toMatchObject({ ok: true });669    expect(vault.isUnlocked()).toBe(true);670  });671672  it("does not acknowledge LOCK until session cleanup finishes", async () => {673    await expect(674      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })675    ).resolves.toMatchObject({ ok: true });676677    const removalStarted = deferred();678    const releaseRemoval = deferred();679    const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;680    remove.mockImplementation(async (keys: string | string[]) => {681      removalStarted.resolve();682      await releaseRemoval.promise;683      for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];684    });685686    let responded = false;687    const response = dispatch({ type: "LOCK" }).then((result) => {688      responded = true;689      return result;690    });691    await removalStarted.promise;692693    try {694      await Promise.resolve();695      expect(responded).toBe(false);696    } finally {697      releaseRemoval.resolve();698    }699700    await expect(response).resolves.toMatchObject({ ok: true });701  });702703  it("remembers the last successful unlock method", async () => {704    const vault = await import("./vault");705    const password = "correct-pass-12345";706    const popupSender = {707      id: "peach-extension",708      url: "chrome-extension://peach-extension/src/popup/index.html",709    } as chrome.runtime.MessageSender;710711    await vault.createVault(password);712    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);713    vault.lock();714715    await expect(716      dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),717    ).resolves.toMatchObject({ ok: true });718    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);719720    vault.lock();721    await expect(722      dispatch({ type: "UNLOCK", payload: { password } }, popupSender),723    ).resolves.toMatchObject({ ok: true });724    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);725  });726727  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {728    const response = await dispatch({729      type: "FIRE_CANARY_PING",730      payload: {731        webhookUrl: "https://example.com/hook",732        entryTitle: "GitHub",733        trigger: "password_copied",734      },735    });736737    expect(response).toMatchObject({ ok: true });738    expect(fetchMock).toHaveBeenCalledWith(739      "https://example.com/hook",740      expect.objectContaining({741        method: "POST",742        headers: { "Content-Type": "application/json" },743        body: expect.any(String),744      })745    );746    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;747    expect(body).toMatchObject({748      entry: "GitHub",749      trigger: "password_copied",750      source: "peach-extension",751    });752  });753754  it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {755    const payload = {756      webhookUrl: "https://example.com/hook",757      entryTitle: "GitHub",758      trigger: "autofilled" as const,759    };760    const first = await dispatch({ type: "FIRE_CANARY_PING", payload });761    expect(first).toMatchObject({ ok: true });762    expect(fetchMock).toHaveBeenCalledTimes(1);763764    const second = await dispatch({ type: "FIRE_CANARY_PING", payload });765    expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });766    expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again767  });768769  it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {770    await dispatch({771      type: "FIRE_CANARY_PING",772      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },773    });774    await dispatch({775      type: "FIRE_CANARY_PING",776      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },777    });778    expect(fetchMock).toHaveBeenCalledTimes(2);779  });780781  it("LV-3: test pings are exempt from the debounce", async () => {782    const payload = {783      webhookUrl: "https://example.com/hook",784      entryTitle: "GitHub",785      trigger: "test" as const,786    };787    await dispatch({ type: "FIRE_CANARY_PING", payload });788    await dispatch({ type: "FIRE_CANARY_PING", payload });789    expect(fetchMock).toHaveBeenCalledTimes(2);790  });791792  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {793    await expect(794      dispatch({795        type: "FIRE_CANARY_PING",796        payload: {797          webhookUrl: "http://example.com/hook",798          entryTitle: "GitHub",799          trigger: "password_copied",800        },801      })802    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });803804    expect(fetchMock).not.toHaveBeenCalled();805  });806807  it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {808    for (const webhookUrl of [809      "https://[::ffff:192.168.0.1]/hook",810      "https://[::ffff:c0a8:1]/hook",811      "https://[::ffff:10.0.0.1]/hook",812      "https://[::ffff:127.0.0.1]/hook",813      "https://[::ffff:169.254.1.1]/hook",814      "https://[::ffff:172.16.0.1]/hook",815    ]) {816      await expect(817        dispatch({818          type: "FIRE_CANARY_PING",819          payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },820        })821      ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });822    }823824    expect(fetchMock).not.toHaveBeenCalled();825  });826827  it("GET_IDENTITIES when locked returns vault locked error", async () => {828    const vault = await import("./vault");829    await vault.createVault("correct-pass-12345");830    vault.lock();831    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({832      ok: false,833      error: "vault_locked",834    });835  });836837  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {838    const vault = await import("./vault");839    await vault.createVault("correct-pass-12345");840    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });841    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });842    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });843844    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };845    expect(resp.ok).toBe(true);846    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;847    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({848      type: "email",849      useCount: 2,850    });851    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({852      type: "username",853      useCount: 1,854    });855    // emails appear before usernames856    const emailIdx = data.findIndex((d) => d.value === "[email protected]");857    const userIdx = data.findIndex((d) => d.value === "juni_patel");858    expect(emailIdx).toBeLessThan(userIdx);859  });860861  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {862    const vault = await import("./vault");863    await vault.createVault("correct-pass-12345");864    for (let i = 0; i < 3; i++) {865      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });866    }867868    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };869    const data = resp.data as Array<{ value: string; useCount: number }>;870    const hits = data.filter((d) => d.value === "[email protected]");871    expect(hits).toHaveLength(1);872    expect(hits[0]!.useCount).toBe(3);873  });874875  it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {876    const vault = await import("./vault");877    await vault.createVault("correct-pass-12345");878    await vault.addEntry({879      title: "Private identity",880      username: "[email protected]",881      password: "pw",882      url: "https://private.example",883    });884885    const senders = [886      {887        id: "peach-extension",888        url: "https://private.example/signup",889        tab: { id: 1 } as chrome.tabs.Tab,890      },891      { id: "peach-extension", url: "https://private.example/signup" },892    ] satisfies chrome.runtime.MessageSender[];893    for (const sender of senders) {894      await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({895        ok: false,896        error: "invalid_sender",897      });898    }899  });900901  it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {902    const vault = await import("./vault");903    await vault.createVault("correct-pass-12345");904    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;905906    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer907    alarmsCreate.mockClear();908    await dispatch({ type: "GET_ENTRIES" });909    expect(alarmsCreate).toHaveBeenCalled();910911    // Passive queries are in the exclusion list and must not re-arm912    alarmsCreate.mockClear();913    await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });914    expect(alarmsCreate).not.toHaveBeenCalled();915  });916917  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {918    const vault = await import("./vault");919    await vault.createVault("correct-pass-12345");920    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });921922    // Simulate an activated Pro license + already-celebrated marker.923    storage.peach_license = "PEACH-PRO-TEST-KEY";924    storage.peach_pro_celebrated = true;925926    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });927    expect(res).toMatchObject({ ok: true });928929    // Vault data is gone…930    expect(storage.peach_vault_a).toBeUndefined();931    // …but the paid license and celebration flag survive the wipe.932    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");933    expect(storage.peach_pro_celebrated).toBe(true);934  });935936  it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {937    const messages = await import("./messages");938    const vault = await import("./vault");939940    let resolveGate: () => void = () => {};941    messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));942943    let settled = false;944    const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });945946    // While restore is in flight the handler must NOT have answered yet —947    // this is the window where the old code returned "no vault" → onboarding.948    await new Promise((r) => setTimeout(r, 20));949    expect(settled).toBe(false);950951    // Restore completes (the vault becomes available), gate opens.952    await vault.createVault("correct-pass-12345");953    resolveGate();954955    await expect(pending).resolves.toMatchObject({ ok: true, data: true });956  });957958  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {959    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });960    expect(res.ok).toBe(false);961    // Nothing was written — no vault exists.962    const has = await dispatch({ type: "HAS_VAULT" });963    expect((has as { data?: boolean }).data).toBe(false);964  });965966  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {967    const vault = await import("./vault");968    await vault.createVault("correct-pass-12345");969    const e = await vault.addEntry({970      title: "Mail",971      username: "june",972      password: "pw",973      url: "https://mail.test",974      totp: "JBSWY3DPEHPK3PXP",975    });976977    const res = await dispatch(978      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },979      { id: "peach-extension", url: "https://mail.test/login" },980    );981    expect(res.ok).toBe(true);982    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };983    expect(data.totp).toBeUndefined();984    expect(data.totpCode).toMatch(/^\d{6}$/);985    expect(data.totpRemaining).toBeGreaterThan(0);986    expect(data.totpRemaining).toBeLessThanOrEqual(30);987  });988989  it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {990    const vault = await import("./vault");991    await vault.createVault("correct-pass-12345");992    const e = await vault.addEntry({993      title: "Bank",994      username: "june",995      password: "old-pw",996      url: "https://bank.test",997    });998    await vault.updateEntry(e.id, { password: "new-pw" });9991000    const fill = await dispatch(1001      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1002      { id: "peach-extension", url: "https://bank.test/login" },1003    );1004    expect(fill.ok).toBe(true);1005    // Passive autofill never receives prior passwords (blast-radius containment).1006    expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();10071008    const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1009    expect(edit.ok).toBe(true);1010    const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];1011    expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);1012  });10131014  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {1015    const vault = await import("./vault");1016    await vault.createVault("correct-pass-12345");1017    const e = await vault.addEntry({1018      title: "Mail",1019      username: "june",1020      password: "pw",1021      url: "https://mail.test",1022      totp: "JBSWY3DPEHPK3PXP",1023    });10241025    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1026    expect(res.ok).toBe(true);1027    const data = res.data as { totp?: string };1028    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1029  });10301031  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1032    const vault = await import("./vault");1033    await vault.createVault("correct-pass-12345");10341035    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1036    expect(res).toMatchObject({ ok: true });10371038    expect(storage.peach_vault_a).toBeUndefined();1039    expect(storage.peach_license).toBeUndefined();1040    expect(storage.peach_pro_celebrated).toBeUndefined();1041  });10421043  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---10441045  function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1046    return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1047  }10481049  async function addPasskeyOnlyEntry(title: string, url: string) {1050    const vault = await import("./vault");1051    const generated = await generatePasskeyKeypair(-7);1052    return vault.addEntry({1053      type: "login",1054      title,1055      url,1056      passkey: {1057        rpId: new URL(url).hostname,1058        rpName: title,1059        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1060        publicKeyCose: base64urlEncode(generated.publicKeyCose),1061        privateKeyJwk: generated.privateKeyJwk,1062        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1063        userName: "june",1064        userDisplayName: "June",1065        algorithm: -7,1066        signCount: 0,1067        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1068        backupEligible: true,1069        backupState: false,1070        createdAt: Date.now(),1071      },1072    });1073  }10741075  it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1076    const vault = await import("./vault");1077    await vault.createVault("correct-pass-12345");1078    await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1079    await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });10801081    const result = await dispatch(1082      {1083        type: "AUTOFILL_QUERY",1084        payload: {1085          domain: "github.com",1086          includeAll: true,1087          userGesture: true,1088          explicitSummon: true,1089        },1090      },1091      tabSender("https://evil.example.com/phish"),1092    );10931094    expect(result).toMatchObject({ ok: true });1095    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1096  });10971098  it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1099    const vault = await import("./vault");1100    await vault.createVault("correct-pass-12345");1101    await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1102    await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });11031104    const result = await dispatch(1105      { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1106      tabSender("https://evil.example.com/verify"),1107    );11081109    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1110  });11111112  it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1113    const vault = await import("./vault");1114    const messages = await import("./messages");1115    await vault.createVault("correct-pass-12345");1116    const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1117    const sender = tabSender("https://evil.example.com/phish");1118    const token = messages.createSummonCapability(1, "https://evil.example.com");11191120    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1121    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });11221123    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1124    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1125    const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1126    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1127  });11281129  it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1130    const vault = await import("./vault");1131    const messages = await import("./messages");1132    await vault.createVault("correct-pass-12345");1133    const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1134    const sender = tabSender("https://evil.example.com/login");1135    const token = messages.createSummonCapability(1, "https://evil.example.com");11361137    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1138    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1139    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1140    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });11411142    const offDomainReplay = await dispatch(1143      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1144      tabSender("https://github.com/login"),1145    );1146    expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1147  });11481149  it("PB-26-201: summon capability cannot fill a different entry", async () => {1150    const vault = await import("./vault");1151    const messages = await import("./messages");1152    await vault.createVault("correct-pass-12345");1153    const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1154    const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1155    const sender = tabSender("https://evil.example.com/phish");1156    const token = messages.createSummonCapability(1, "https://evil.example.com");1157    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1158    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1159    expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1160    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1161    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1162    expect(first.id).not.toBe(second.id);1163  });11641165  it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1166    const vault = await import("./vault");1167    const messages = await import("./messages");1168    await vault.createVault("correct-pass-12345");1169    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1170    const token = messages.createSummonCapability(1, "https://evil.example.com");1171    const result = await dispatch(1172      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1173      tabSender("https://evil.example.com/phish"),1174    );1175    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1176  });11771178  it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1179    const vault = await import("./vault");1180    const messages = await import("./messages");1181    await vault.createVault("correct-pass-12345");1182    const entry = await vault.addEntry({1183      title: "Evil",1184      password: "known",1185      url: "https://evil.example.com",1186    });1187    const sender = tabSender("https://evil.example.com/login");1188    const token = messages.createSummonCapability(1, "https://evil.example.com");11891190    const result = await dispatch(1191      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1192      sender,1193    );11941195    expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1196  });11971198  it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1199    vi.useFakeTimers();1200    vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1201    const vault = await import("./vault");1202    const messages = await import("./messages");1203    await vault.createVault("correct-pass-12345");1204    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1205    const sender = tabSender("https://evil.example.com/phish");1206    const token = messages.createSummonCapability(1, "https://evil.example.com");1207    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1208    vi.advanceTimersByTime(30_001);12091210    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1211    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1212  });12131214  it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1215    const vault = await import("./vault");1216    const messages = await import("./messages");1217    await vault.createVault("correct-pass-12345");1218    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1219    const token = messages.createSummonCapability(1, "https://evil.example.com");1220    await dispatch(1221      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1222      tabSender("https://evil.example.com/phish"),1223    );12241225    const result = await dispatch(1226      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1227      tabSender("https://evil.example.com/phish", 2),1228    );1229    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1230  });12311232  it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1233    const vault = await import("./vault");1234    const messages = await import("./messages");1235    await vault.createVault("correct-pass-12345");1236    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1237    const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1238    await dispatch(1239      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1240      tabSender("https://evil.example.com/phish", 1, 3),1241    );12421243    const result = await dispatch(1244      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1245      tabSender("https://evil.example.com/phish", 1, 4),1246    );1247    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1248  });12491250  it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1251    const vault = await import("./vault");1252    const messages = await import("./messages");1253    await vault.createVault("correct-pass-12345");1254    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1255    const token = messages.createSummonCapability(1, "https://evil.example.com");1256    await dispatch(1257      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1258      tabSender("https://evil.example.com/phish"),1259    );12601261    const result = await dispatch(1262      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1263      tabSender("https://other.example.com/phish"),1264    );1265    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1266  });12671268  it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1269    const vault = await import("./vault");1270    const messages = await import("./messages");1271    await vault.createVault("correct-pass-12345");1272    const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1273    const sender = tabSender("https://evil.example.com/phish");1274    const token = messages.createSummonCapability(1, "https://evil.example.com");1275    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12761277    const missing = await dispatch(1278      { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1279      sender,1280    );1281    const existingReplay = await dispatch(1282      { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1283      sender,1284    );1285    expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1286    expect(existingReplay).toEqual(missing);1287  });12881289  it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1290    const vault = await import("./vault");1291    const messages = await import("./messages");1292    await vault.createVault("correct-pass-12345");1293    await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1294    const sender = tabSender("https://evil.example.com/phish");1295    const token = messages.createSummonCapability(1, "https://evil.example.com");1296    const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1297    const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12981299    expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1300    expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1301  });13021303  it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1304    const vault = await import("./vault");1305    const messages = await import("./messages");1306    await vault.createVault("correct-pass-12345");1307    const card = await vault.addEntry({1308      type: "card",1309      title: "Personal Visa",1310      cardholderName: "June Peach",1311      cardNumber: "4111111111111111",1312      cvc: "123",1313      expMonth: "12",1314      expYear: "2030",1315    });1316    const sender = tabSender("https://shop.example.com/checkout");1317    const passive = await dispatch(1318      { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1319      sender,1320    );1321    expect(passive).toMatchObject({ ok: true, data: [] });13221323    const token = messages.createSummonCapability(1, "https://shop.example.com");1324    const inventory = await dispatch(1325      { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1326      sender,1327    );1328    expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1329    expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");13301331    const fill = await dispatch(1332      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1333      sender,1334    );1335    expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1336    const replay = await dispatch(1337      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1338      sender,1339    );1340    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1341  });13421343  it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1344    const result = await dispatch(1345      { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1346      tabSender("https://example.com/login"),1347    );13481349    expect(result).toMatchObject({ ok: true });1350    expect(sessionStorage.peach_open_entry).toBe("entry-123");1351    expect(sessionStorage.peach_inline_unlock).toBeUndefined();1352  });13531354  it("MV-1: tab request with a matching domain returns the entry", async () => {1355    const vault = await import("./vault");1356    await vault.createVault("correct-pass-12345");1357    const entry = await vault.addEntry({1358      title: "GitHub",1359      username: "june",1360      password: "secret123",1361      url: "https://github.com",1362    });13631364    const res = await dispatch(1365      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1366      tabSender("https://github.com/login"),1367    );1368    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1369  });13701371  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1372    const vault = await import("./vault");1373    await vault.createVault("correct-pass-12345");1374    const entry = await vault.addEntry({1375      title: "GitHub",1376      username: "june",1377      password: "secret123",1378      url: "https://github.com",1379    });13801381    const res = await dispatch(1382      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1383      tabSender("https://evil.example.com/phish"),1384    );1385    // Refused with the same error as a missing id — no cross-domain oracle.1386    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1387  });13881389  it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1390    const vault = await import("./vault");1391    await vault.createVault("correct-pass-12345");1392    const entry = await vault.addEntry({1393      title: "GitHub",1394      username: "june",1395      password: "secret123",1396      url: "https://github.com",1397    });13981399    const res = await dispatch(1400      {1401        type: "GET_ENTRY_FOR_FILL",1402        payload: { id: entry.id, explicitSummon: true },1403      },1404      tabSender("https://evil.example.com/phish"),1405    );1406    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1407  });14081409  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1410    const vault = await import("./vault");1411    await vault.createVault("correct-pass-12345");1412    const entry = await vault.addEntry({1413      title: "GitHub",1414      username: "june",1415      password: "secret123",1416      url: "https://github.com",1417    });14181419    const res = await dispatch(1420      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1421      tabSender("not a url"),1422    );1423    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1424  });14251426  // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---14271428  async function seedTwoDomainVault() {1429    const vault = await import("./vault");1430    await vault.createVault("correct-pass-12345");1431    await vault.addEntry({1432      title: "GitHub",1433      username: "june",1434      password: "gh",1435      url: "https://github.com",1436    });1437    await vault.addEntry({1438      title: "Example",1439      username: "june",1440      password: "ex",1441      url: "https://example.com",1442    });1443  }14441445  it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1446    await seedTwoDomainVault();14471448    const res = await dispatch({1449      type: "AUTOFILL_QUERY",1450      payload: { domain: "github.com", includeAll: true },1451    });1452    expect(res.ok).toBe(true);1453    const data = res.data as Array<{ title: string }>;1454    // includeAll was stripped — only the github.com match comes back.1455    expect(data).toHaveLength(1);1456    expect(data[0].title).toBe("GitHub");1457  });14581459  it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1460    await seedTwoDomainVault();14611462    const res = await dispatch({1463      type: "AUTOFILL_QUERY",1464      payload: { domain: "github.com", includeAll: true, userGesture: true },1465    });1466    expect(res.ok).toBe(true);1467    const data = res.data as Array<{ title: string }>;1468    expect(data.map((d) => d.title)).toEqual(["GitHub"]);1469  });14701471  // --- MV-7: vouched device confirmation handlers ---14721473  async function seedPendingVouched(deviceId: string) {1474    await chrome.storage.local.set({1475      peach_pending_vouched_devices_v1: [{1476        deviceId,1477        deviceName: "New Pixel",1478        deviceType: "android",1479        longTermPublicKeyHex: "e".repeat(64),1480        signingPublicKeyHex: "a".repeat(64),1481        wsPort: 51234,1482        pairedAt: 1_700_000_000_020,1483        receivedAt: 1_700_000_000_050,1484        vouchedByDeviceId: "extension-issuer",1485      }],1486    });1487  }14881489  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1490    const vault = await import("./vault");1491    await vault.createVault("correct-pass-12345");1492    await seedPendingVouched("android-new");1493    const { findPairedDeviceById } = await import("./paired-devices");1494    const { listPendingVouchedDevices } = await import(1495      "./pending-vouched-devices"1496    );14971498    const res = await dispatch({1499      type: "CONFIRM_VOUCHED_DEVICE",1500      payload: { deviceId: "android-new" },1501    });1502    expect(res).toMatchObject({ ok: true });15031504    expect(await findPairedDeviceById("android-new")).toMatchObject({1505      deviceName: "New Pixel",1506      trustSource: "vouched",1507      longTermPublicKeyHex: "e".repeat(64),1508    });1509    expect(await listPendingVouchedDevices()).toHaveLength(0);1510  });15111512  it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1513    const vault = await import("./vault");1514    await vault.createVault("correct-pass-12345");1515    await seedPendingVouched("android-relabelled");1516    const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1517      await import("./paired-devices");1518    const { listPendingVouchedDevices } = await import(1519      "./pending-vouched-devices"1520    );15211522    // The queue entry predates the revocation, and it carries a fresh id, so1523    // sweeping the queue by id at revocation time would not have caught it.1524    await upsertPairedDevice({1525      deviceId: "android-old",1526      deviceName: "New Pixel",1527      deviceType: "android",1528      longTermPublicKeyHex: "e".repeat(64),1529      signingPublicKeyHex: "a".repeat(64),1530      pairedAt: 1_700_000_000_000,1531      trustSource: "vouched",1532    });1533    await revokeDeviceById("android-old", "self");15341535    const res = await dispatch({1536      type: "CONFIRM_VOUCHED_DEVICE",1537      payload: { deviceId: "android-relabelled" },1538    });1539    expect(res).toMatchObject({ ok: false, error: "device_revoked" });1540    expect(await findPairedDeviceById("android-relabelled")).toBeNull();1541    expect(await listPendingVouchedDevices()).toHaveLength(0);1542  });15431544  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1545    const vault = await import("./vault");1546    await vault.createVault("correct-pass-12345");1547    await seedPendingVouched("android-new");1548    const { findPairedDeviceById } = await import("./paired-devices");1549    const { listPendingVouchedDevices } = await import(1550      "./pending-vouched-devices"1551    );15521553    const res = await dispatch({1554      type: "REJECT_VOUCHED_DEVICE",1555      payload: { deviceId: "android-new" },1556    });1557    expect(res).toMatchObject({ ok: true });15581559    expect(await findPairedDeviceById("android-new")).toBeNull();1560    expect(await listPendingVouchedDevices()).toHaveLength(0);1561  });15621563  // --- unpair: UNPAIR_DEVICE revokes the device and drops it from the registry ---15641565  it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1566    const vault = await import("./vault");1567    await vault.createVault("correct-pass-12345");1568    const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1569      await import("./paired-devices");15701571    await upsertPairedDevice({1572      deviceId: "phone-1",1573      deviceName: "June's Phone",1574      deviceType: "android",1575      longTermPublicKeyHex: "a".repeat(64),1576      signingPublicKeyHex: "b".repeat(64),1577      pairedAt: 1_700_000_000_000,1578      trustSource: "direct",1579    });15801581    const res = await dispatch({1582      type: "UNPAIR_DEVICE",1583      payload: { deviceId: "phone-1" },1584    });1585    expect(res).toMatchObject({ ok: true });15861587    // The row is gone locally — re-listing must not surface it.1588    expect(await findPairedDeviceById("phone-1")).toBeNull();1589    // The revocation is recorded so a future manifest from a peer cannot1590    // resurrect this device under either the same id or the same key (MV-7 + S1).1591    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1592    const records = await listDeviceRevocations();1593    expect(records).toHaveLength(1);1594    expect(records[0]).toMatchObject({1595      deviceId: "phone-1",1596      longTermPublicKeyHex: "a".repeat(64),1597      revokedByDeviceId: expect.any(String),1598    });1599  });16001601  it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1602    const vault = await import("./vault");1603    await vault.createVault("correct-pass-12345");1604    const { listDeviceRevocations } = await import("./paired-devices");16051606    const res = await dispatch({1607      type: "UNPAIR_DEVICE",1608      payload: { deviceId: "never-paired" },1609    });1610    expect(res).toMatchObject({ ok: true });1611    // A revocation with the all-zero key sentinel is still written — the1612    // contract is that the user-intent is recorded even when the registry had1613    // no row. This is what blocks a peer from later vouched-adopting the same1614    // fresh id back in.1615    const records = await listDeviceRevocations();1616    expect(records).toHaveLength(1);1617    expect(records[0]).toMatchObject({1618      deviceId: "never-paired",1619      longTermPublicKeyHex: "0".repeat(64),1620    });1621  });16221623  it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1624    const res = await dispatch({1625      type: "UNPAIR_DEVICE",1626      payload: { deviceId: "" },1627    });1628    expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1629  });16301631  // The old "fresh direct re-pair clears the old revocation" test dispatched1632  // COMPLETE_PAIRING, a handler deleted on 2026-08-04 (no legitimate senders;1633  // adoption runs in-background via relay-adoption-bg.ts). The live1634  // revocation-overlap repair is pinned in paired-devices.test.ts1635  // (repairPairedRevocationOverlaps, runs at service-worker boot).1636  it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1637    const vault = await import("./vault");1638    await vault.createVault("correct-pass-12345");1639    const contentScriptSender = {1640      id: "peach-extension",1641      url: "https://evil.example/page",1642    };1643    const extensionPageSender = {1644      id: "peach-extension",1645      url: "chrome-extension://peach-extension/popup.html",1646    };1647    const completion = { requestId: 1, rpId: "evil.example" };16481649    // Content-script senders must never reach the gated passkey-store handler.1650    await expect(1651      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1652    ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });16531654    // Extension-page senders still reach it — refused by the pending-ceremony1655    // binding, not by the sender gate.1656    await expect(1657      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1658    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1659  });16601661  it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1662    const vault = await import("./vault");1663    await vault.createVault("correct-pass-12345");1664    const extensionPageSender = {1665      id: "peach-extension",1666      url: "chrome-extension://peach-extension/popup.html",1667    };1668    const completion = {1669      requestId: 123,1670      rpId: "example.com",1671      rpName: "Example",1672      userName: "june",1673      userHandle: "BBBB",1674      credentialId: "cred-1",1675      publicKeyCose: "c3BraQ",1676      algorithm: -7 as const,1677    };16781679    // No pending ceremony at all -> refuse.1680    await expect(1681      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1682    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16831684    // Seed a background-minted pending ceremony.1685    sessionStorage.passkey_pending_firefox = {1686      requestId: 123,1687      type: "create",1688      rpId: "example.com",1689      rpName: "Example",1690      userName: "june",1691      browser: "firefox",1692      challenge: "AAAA",1693      userHandle: "BBBB",1694    };16951696    // Mismatched userHandle (attacker-forged completion) -> refuse.1697    await expect(1698      dispatch(1699        { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1700        extensionPageSender1701      )1702    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });17031704    // Matching completion -> stored.1705    await expect(1706      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1707    ).resolves.toMatchObject({ ok: true });1708    const entries = await dispatch({ type: "GET_ENTRIES" });1709    expect(entries).toMatchObject({ ok: true });1710    const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1711    expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1712  });17131714  it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1715    await expect(1716      dispatch(1717        {1718          type: "INITIATE_FIREFOX_PASSKEY_CREATE",1719          payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1720        },1721        { id: "peach-extension", url: "https://evil.example/page" }1722      )1723    ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1724  });17251726  it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1727    // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts;1728    // here we just pin the storage-level invariant the UNPAIR_DEVICE call1729    // establishes, since the rest of the testbed's revoke coverage rides on1730    // this state.1731    const vault = await import("./vault");1732    await vault.createVault("correct-pass-12345");1733    const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1734      "./paired-devices"1735    );17361737    await upsertPairedDevice({1738      deviceId: "phone-1",1739      deviceName: "June's Phone",1740      deviceType: "android",1741      longTermPublicKeyHex: "a".repeat(64),1742      signingPublicKeyHex: "b".repeat(64),1743      pairedAt: 1_700_000_000_000,1744      trustSource: "direct",1745    });17461747    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1748    await revokeDeviceById("phone-1", "self");1749    // The key check is what blocks a re-vouch under a fresh id.1750    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1751    expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1752  });17531754  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---17551756  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1757    const vault = await import("./vault");1758    await vault.createVault("correct-pass-12345");17591760    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1761    expect(res).toMatchObject({ ok: false, error: "password_required" });1762  });17631764  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1765    const vault = await import("./vault");1766    await vault.createVault("correct-pass-12345");17671768    const res = await dispatch({1769      type: "GET_RECOVERY_WORDS",1770      payload: { password: "not-the-password" },1771    });1772    expect(res).toMatchObject({ ok: false, error: "wrong_password" });1773    expect(res.data).toBeUndefined();1774  });17751776  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1777    const vault = await import("./vault");1778    await vault.createVault("correct-pass-12345");17791780    const res = await dispatch({1781      type: "GET_RECOVERY_WORDS",1782      payload: { password: "correct-pass-12345" },1783    });1784    expect(res.ok).toBe(true);1785    expect((res.data as string[]).length).toBe(12);1786  });17871788  it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1789    const vault = await import("./vault");1790    const password = "correct-pass-12345";1791    const popupSender = {1792      id: "peach-extension",1793      url: "chrome-extension://peach-extension/src/popup/index.html",1794    } as chrome.runtime.MessageSender;17951796    await vault.createVault(password);1797    await vault.setupPinUnlock(password, "1234");1798    await vault.lock();17991800    await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1801      ok: true,1802      data: { configured: true, available: true, attemptsRemaining: 3 },1803    });1804  });1805});