messages.test.ts

63.4 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>;7type MessageListener = (8  msg: unknown,9  sender: chrome.runtime.MessageSender,10  sendResponse: (response: MessageResponse) => void11) => boolean;1213describe("background messages", () => {14  let storage: StorageShape;15  let sessionStorage: StorageShape;16  let listener: MessageListener;17  let fetchMock: ReturnType<typeof vi.fn>;1819  beforeEach(async () => {20    vi.resetModules();21    vi.useRealTimers();22    storage = {23      peach_settings: { breachMonitoring: false },24    };25    sessionStorage = {};2627    fetchMock = vi.fn(async () => ({28      ok: true,29      status: 200,30      text: async () => "",31    }));32    vi.stubGlobal("fetch", fetchMock);33    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3435    const tabs = [{ id: 1 }];36    const chromeMock = {37      runtime: {38        id: "peach-extension",39        getURL: (p: string) => `chrome-extension://peach-extension/${p}`,40        onMessage: {41          addListener: vi.fn((fn: MessageListener) => {42            listener = fn;43          }),44        },45        sendMessage: vi.fn(async () => ({ ok: true })),46      },47      tabs: {48        query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {49          if (typeof callback === "function") {50            callback(tabs);51            return;52          }53          return Promise.resolve(tabs);54        }),55        sendMessage: vi.fn(async () => ({ ok: true })),56      },57      storage: {58        local: {59          get: vi.fn(async (key?: string | string[]) => {60            if (!key) return storage;61            if (typeof key === "string") {62              return { [key]: storage[key] };63            }6465            return key.reduce<Record<string, unknown>>((result, entry) => {66              result[entry] = storage[entry];67              return result;68            }, {});69          }),70          set: vi.fn(async (next: Record<string, unknown>) => {71            Object.assign(storage, next);72          }),73          remove: vi.fn(async (keys: string | string[]) => {74            const list = Array.isArray(keys) ? keys : [keys];75            for (const key of list) {76              delete storage[key];77            }78          }),79          clear: vi.fn(async () => {80            for (const key of Object.keys(storage)) delete storage[key];81          }),82        },83        session: {84          get: vi.fn(async (key?: string | string[]) => {85            if (!key) return sessionStorage;86            if (typeof key === "string") {87              return { [key]: sessionStorage[key] };88            }89            return key.reduce<Record<string, unknown>>((result, entry) => {90              result[entry] = sessionStorage[entry];91              return result;92            }, {});93          }),94          set: vi.fn(async (next: Record<string, unknown>) => {95            Object.assign(sessionStorage, next);96          }),97          remove: vi.fn(async (keys: string | string[]) => {98            const list = Array.isArray(keys) ? keys : [keys];99            for (const key of list) {100              delete sessionStorage[key];101            }102          }),103          clear: vi.fn(async () => {104            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];105          }),106        },107        sync: {108          get: vi.fn(async (_key?: string | string[]) => ({})),109          set: vi.fn(async (_values: Record<string, unknown>) => undefined),110          remove: vi.fn(async (_keys: string | string[]) => undefined),111          clear: vi.fn(async () => undefined),112        },113      },114      alarms: {115        clear: vi.fn(),116        create: vi.fn(),117        onAlarm: { addListener: vi.fn() },118      },119      idle: {120        setDetectionInterval: vi.fn(),121        onStateChanged: { addListener: vi.fn() },122      },123      scripting: {124        executeScript: vi.fn(async () => []),125      },126    } as unknown as typeof chrome;127128    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;129130    const messages = await import("./messages");131    messages.setupMessageHandler();132    const vault = await import("./vault");133    await vault.ensureSettingsLoaded();134  });135136  async function dispatch(137    message: Message,138    sender: chrome.runtime.MessageSender = {139      id: "peach-extension",140      url: "chrome-extension://peach-extension/popup.html",141    }142  ): Promise<MessageResponse> {143    return await new Promise<MessageResponse>((resolve) => {144      const keepChannelOpen = listener(message, sender, (response) => resolve(response));145      expect(keepChannelOpen).toBe(true);146    });147  }148149  it("GET_ENTRIES when unlocked returns the entry array", async () => {150    const vault = await import("./vault");151    await vault.createVault("correct-pass-12345");152    await vault.addEntry({153      title: "GitHub",154      username: "june",155      password: "secret123",156      url: "https://github.com",157    });158159    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({160      ok: true,161      data: [162        expect.objectContaining({163          title: "GitHub",164          username: "june",165        }),166      ],167    });168  });169170  it("GET_ENTRIES when locked returns a vault locked error", async () => {171    const vault = await import("./vault");172    await vault.createVault("correct-pass-12345");173    vault.lock();174175    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({176      ok: false,177      error: "vault_locked",178    });179  });180181  it("ADD_ENTRY with a valid payload returns the created entry", async () => {182    const vault = await import("./vault");183    await vault.createVault("correct-pass-12345");184185    const response = await dispatch({186      type: "ADD_ENTRY",187      payload: {188        title: "GitHub",189        username: "june",190        password: "secret123",191        url: "https://github.com",192      },193    });194195    expect(response).toMatchObject({196      ok: true,197      data: expect.objectContaining({198        id: expect.any(String),199        title: "GitHub",200      }),201    });202    expect(vault.getEntries()).toHaveLength(1);203  });204205  it("ADD_ENTRY when locked returns a vault locked error", async () => {206    const vault = await import("./vault");207    await vault.createVault("correct-pass-12345");208    vault.lock();209210    await expect(211      dispatch({212        type: "ADD_ENTRY",213        payload: {214          title: "GitHub",215          username: "june",216          password: "secret123",217          url: "https://github.com",218        },219      })220    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });221  });222223  it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {224    const vault = await import("./vault");225    await vault.createVault("correct-pass-12345");226227    await expect(228      dispatch({229        type: "ADD_ENTRY",230        payload: {231          title: "GitHub",232          username: "june",233          password: "",234          url: "https://github.com",235        },236      })237    ).resolves.toMatchObject({ ok: true });238  });239240  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {241    const vault = await import("./vault");242    await vault.createVault("correct-pass-12345");243    const entry = await vault.addEntry({244      title: "GitHub",245      username: "june",246      password: "secret123",247      url: "https://github.com",248    });249250    await expect(251      dispatch({252        type: "UPDATE_ENTRY",253        payload: { id: entry.id, patch: { password: "new-secret" } },254      })255    ).resolves.toMatchObject({ ok: true });256257    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");258  });259260  it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {261    const vault = await import("./vault");262    await vault.createVault("correct-pass-12345");263    const entry = await vault.addEntry({264      title: "GitHub",265      username: "june",266      password: "secret123",267      url: "https://github.com",268    });269270    await expect(271      dispatch({272        type: "UPDATE_ENTRY",273        payload: { id: entry.id, patch: { password: "" } },274      })275    ).resolves.toMatchObject({ ok: true });276277    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();278  });279280  it("DELETE_ENTRY soft-deletes the target entry", async () => {281    const vault = await import("./vault");282    await vault.createVault("correct-pass-12345");283    const entry = await vault.addEntry({284      title: "GitHub",285      username: "june",286      password: "secret123",287      url: "https://github.com",288    });289290    await expect(291      dispatch({292        type: "DELETE_ENTRY",293        payload: { id: entry.id },294      })295    ).resolves.toMatchObject({ ok: true });296297    expect(vault.getEntries()).toEqual([]);298    expect(vault.getDeletedEntries()).toHaveLength(1);299  });300301  it("DELETE_ENTRY of a missing id returns an error", async () => {302    const vault = await import("./vault");303    await vault.createVault("correct-pass-12345");304305    await expect(306      dispatch({307        type: "DELETE_ENTRY",308        payload: { id: "missing" },309      })310    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });311  });312313  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {314    const vault = await import("./vault");315    await vault.createVault("correct-pass-12345");316    const entry = await vault.addEntry({317      title: "GitHub",318      username: "june",319      password: "secret123",320      url: "https://github.com",321    });322323    const response = await dispatch(324      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },325      { id: "peach-extension", url: "https://github.com/login" },326    );327328    expect(response).toMatchObject({329      ok: true,330      data: expect.objectContaining({331        id: entry.id,332        username: "june",333        password: "secret123",334      }),335    });336    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();337  });338339  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {340    const vault = await import("./vault");341    await vault.createVault("correct-pass-12345");342    vault.lock();343344    await expect(345      dispatch({346        type: "GET_ENTRY_FOR_FILL",347        payload: { id: "missing" },348      })349    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });350  });351352  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {353    const vault = await import("./vault");354    await vault.createVault("correct-pass-12345");355    const generated = await generatePasskeyKeypair(-7);356    const entry = await vault.addEntry({357      type: "login",358      title: "WebAuthn.io",359      passkey: {360        rpId: "webauthn.io",361        rpName: "WebAuthn.io",362        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),363        publicKeyCose: base64urlEncode(generated.publicKeyCose),364        privateKeyJwk: generated.privateKeyJwk,365        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),366        userName: "june",367        userDisplayName: "June",368        algorithm: -7,369        signCount: 0,370        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",371        backupEligible: true,372        backupState: false,373        createdAt: Date.now(),374      },375    });376377    await expect(378      dispatch({379        type: "GET_ENTRY_FOR_FILL",380        payload: { id: entry.id },381      })382    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });383  });384385  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {386    const vault = await import("./vault");387    await vault.createVault("correct-pass-12345");388    await vault.addEntry({389      title: "GitHub",390      username: "june",391      password: "secret123",392      url: "https://github.com",393      totp: "JBSWY3DPEHPK3PXP",394    });395396    const response = await dispatch({397      type: "AUTOFILL_QUERY",398      payload: { domain: "github.com" },399    });400401    expect(response).toMatchObject({402      ok: true,403      data: [404        {405          id: expect.any(String),406          title: "GitHub",407          username: "june",408          url: "https://github.com",409          hasTotp: true,410        },411      ],412    });413    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();414    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();415  });416417  it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {418    const vault = await import("./vault");419    await vault.createVault("correct-pass-12345");420    await vault.addEntry({421      title: "GitHub",422      username: "june",423      password: "secret123",424      url: "https://github.com",425    });426    await vault.addEntry({427      type: "note",428      title: "Secure note",429      notes: "hello",430    });431432    const response = await dispatch({433      type: "AUTOFILL_QUERY",434      payload: { domain: "github.com", includeAll: true },435    });436437    expect(response).toMatchObject({438      ok: true,439      data: [440        {441          id: expect.any(String),442          title: "GitHub",443          username: "june",444          url: "https://github.com",445          hasPasskey: false,446        },447      ],448    });449    const data = response.data as Array<Record<string, unknown>>;450    expect(data).toHaveLength(1);451    // No secrets cross the boundary.452    expect(data[0]).not.toHaveProperty("password");453    expect(data[0]).not.toHaveProperty("totp");454  });455456  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {457    const vault = await import("./vault");458    await vault.createVault("correct-pass-12345");459    await vault.addEntry({460      title: "GitHub",461      username: "june",462      password: "secret123",463      url: "https://github.com",464      totp: "JBSWY3DPEHPK3PXP",465    });466    await vault.addEntry({467      title: "No 2FA",468      username: "person",469      password: "secret123",470      url: "https://github.com",471    });472473    const response = await dispatch({474      type: "AUTOFILL_QUERY",475      payload: { domain: "github.com", kind: "totp" },476    });477478    expect(response).toMatchObject({479      ok: true,480      data: [481        {482          id: expect.any(String),483          title: "GitHub",484          username: "june",485          url: "https://github.com",486          hasTotp: true,487        },488      ],489    });490    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();491    expect(response.data).toHaveLength(1);492  });493494  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {495    const vault = await import("./vault");496    await vault.createVault("correct-pass-12345");497    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });498499    const response = await dispatch({500      type: "RESTORE_ENTRIES",501      payload: {502        mode: "replace",503        entries: [504          {505            id: "restored-1",506            type: "login",507            title: "B",508            username: "june",509            password: "two",510            url: "https://b.test",511            createdAt: 1,512            modifiedAt: 1,513          },514        ],515      },516    });517518    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });519    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);520  });521522  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {523    const vault = await import("./vault");524    await vault.createVault("correct-pass-12345");525526    await expect(527      dispatch({528        type: "RESTORE_ENTRIES",529        payload: { mode: "replace", entries: [] },530      })531    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });532  });533534  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {535    const vault = await import("./vault");536    await vault.createVault("correct-pass-12345");537538    await expect(539      dispatch({540        type: "CHANGE_PASSWORD",541        payload: {542          currentPassword: "correct-pass-12345",543          newPassword: "new-pass-67890",544        },545      })546    ).resolves.toMatchObject({ ok: true });547548    vault.lock();549    expect(await vault.unlock("new-pass-67890")).toBe(true);550551    await expect(552      dispatch({553        type: "CHANGE_PASSWORD",554        payload: {555          currentPassword: "wrong-pass",556          newPassword: "another-pass",557        },558      })559    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });560  });561562  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {563    const vault = await import("./vault");564    await vault.createVault("correct-pass-12345");565566    (567      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>568    ).mockClear();569    (570      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>571    ).mockClear();572573    await expect(574      dispatch({575        type: "UPDATE_SETTINGS",576        payload: { autoLockMinutes: 30 },577      })578    ).resolves.toMatchObject({ ok: true });579580    expect(vault.getSettings().autoLockMinutes).toBe(30);581    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);582    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {583      delayInMinutes: 30,584    });585  });586587  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {588    vi.useFakeTimers();589    const vault = await import("./vault");590    await vault.createVault("correct-pass-12345");591    const entry = await vault.addEntry({592      title: "GitHub",593      username: "june",594      password: "secret123",595      url: "https://github.com",596    });597    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);598599    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });600    await vi.runAllTimersAsync();601    const response = await responsePromise;602603    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });604    expect(vault.getEntryById(entry.id)?.breach).toBe(7);605    breachSpy.mockRestore();606  });607608  it("messages from foreign or URL-less senders are rejected", async () => {609    const vault = await import("./vault");610    await vault.createVault("correct-pass-12345");611612    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({613      ok: false,614      error: "invalid_sender",615    });616617    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({618      ok: false,619      error: "invalid_sender",620    });621  });622623  it("LOCK and UNLOCK messages update vault state correctly", async () => {624    const vault = await import("./vault");625626    await expect(627      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })628    ).resolves.toMatchObject({ ok: true });629    expect(vault.isUnlocked()).toBe(true);630631    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });632    expect(vault.isUnlocked()).toBe(false);633634    await expect(635      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })636    ).resolves.toMatchObject({ ok: false });637    expect(vault.isUnlocked()).toBe(false);638639    await expect(640      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })641    ).resolves.toMatchObject({ ok: true });642    expect(vault.isUnlocked()).toBe(true);643  });644645  it("remembers the last successful unlock method", async () => {646    const vault = await import("./vault");647    const password = "correct-pass-12345";648    const popupSender = {649      id: "peach-extension",650      url: "chrome-extension://peach-extension/src/popup/index.html",651    } as chrome.runtime.MessageSender;652653    await vault.createVault(password);654    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);655    vault.lock();656657    await expect(658      dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),659    ).resolves.toMatchObject({ ok: true });660    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);661662    vault.lock();663    await expect(664      dispatch({ type: "UNLOCK", payload: { password } }, popupSender),665    ).resolves.toMatchObject({ ok: true });666    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);667  });668669  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {670    const response = await dispatch({671      type: "FIRE_CANARY_PING",672      payload: {673        webhookUrl: "https://example.com/hook",674        entryTitle: "GitHub",675        trigger: "password_copied",676      },677    });678679    expect(response).toMatchObject({ ok: true });680    expect(fetchMock).toHaveBeenCalledWith(681      "https://example.com/hook",682      expect.objectContaining({683        method: "POST",684        headers: { "Content-Type": "application/json" },685        body: expect.any(String),686      })687    );688    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;689    expect(body).toMatchObject({690      entry: "GitHub",691      trigger: "password_copied",692      source: "peach-extension",693    });694  });695696  it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {697    const payload = {698      webhookUrl: "https://example.com/hook",699      entryTitle: "GitHub",700      trigger: "autofilled" as const,701    };702    const first = await dispatch({ type: "FIRE_CANARY_PING", payload });703    expect(first).toMatchObject({ ok: true });704    expect(fetchMock).toHaveBeenCalledTimes(1);705706    const second = await dispatch({ type: "FIRE_CANARY_PING", payload });707    expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });708    expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again709  });710711  it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {712    await dispatch({713      type: "FIRE_CANARY_PING",714      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },715    });716    await dispatch({717      type: "FIRE_CANARY_PING",718      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },719    });720    expect(fetchMock).toHaveBeenCalledTimes(2);721  });722723  it("LV-3: test pings are exempt from the debounce", async () => {724    const payload = {725      webhookUrl: "https://example.com/hook",726      entryTitle: "GitHub",727      trigger: "test" as const,728    };729    await dispatch({ type: "FIRE_CANARY_PING", payload });730    await dispatch({ type: "FIRE_CANARY_PING", payload });731    expect(fetchMock).toHaveBeenCalledTimes(2);732  });733734  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {735    await expect(736      dispatch({737        type: "FIRE_CANARY_PING",738        payload: {739          webhookUrl: "http://example.com/hook",740          entryTitle: "GitHub",741          trigger: "password_copied",742        },743      })744    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });745746    expect(fetchMock).not.toHaveBeenCalled();747  });748749  it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {750    for (const webhookUrl of [751      "https://[::ffff:192.168.0.1]/hook",752      "https://[::ffff:c0a8:1]/hook",753      "https://[::ffff:10.0.0.1]/hook",754      "https://[::ffff:127.0.0.1]/hook",755      "https://[::ffff:169.254.1.1]/hook",756      "https://[::ffff:172.16.0.1]/hook",757    ]) {758      await expect(759        dispatch({760          type: "FIRE_CANARY_PING",761          payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },762        })763      ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });764    }765766    expect(fetchMock).not.toHaveBeenCalled();767  });768769  it("GET_IDENTITIES when locked returns vault locked error", async () => {770    const vault = await import("./vault");771    await vault.createVault("correct-pass-12345");772    vault.lock();773    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({774      ok: false,775      error: "vault_locked",776    });777  });778779  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {780    const vault = await import("./vault");781    await vault.createVault("correct-pass-12345");782    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });783    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });784    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });785786    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };787    expect(resp.ok).toBe(true);788    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;789    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({790      type: "email",791      useCount: 2,792    });793    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({794      type: "username",795      useCount: 1,796    });797    // emails appear before usernames798    const emailIdx = data.findIndex((d) => d.value === "[email protected]");799    const userIdx = data.findIndex((d) => d.value === "juni_patel");800    expect(emailIdx).toBeLessThan(userIdx);801  });802803  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {804    const vault = await import("./vault");805    await vault.createVault("correct-pass-12345");806    for (let i = 0; i < 3; i++) {807      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });808    }809810    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };811    const data = resp.data as Array<{ value: string; useCount: number }>;812    const hits = data.filter((d) => d.value === "[email protected]");813    expect(hits).toHaveLength(1);814    expect(hits[0]!.useCount).toBe(3);815  });816817  it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {818    const vault = await import("./vault");819    await vault.createVault("correct-pass-12345");820    await vault.addEntry({821      title: "Private identity",822      username: "[email protected]",823      password: "pw",824      url: "https://private.example",825    });826827    const senders = [828      {829        id: "peach-extension",830        url: "https://private.example/signup",831        tab: { id: 1 } as chrome.tabs.Tab,832      },833      { id: "peach-extension", url: "https://private.example/signup" },834    ] satisfies chrome.runtime.MessageSender[];835    for (const sender of senders) {836      await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({837        ok: false,838        error: "invalid_sender",839      });840    }841  });842843  it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {844    const vault = await import("./vault");845    await vault.createVault("correct-pass-12345");846    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;847848    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer849    alarmsCreate.mockClear();850    await dispatch({ type: "GET_ENTRIES" });851    expect(alarmsCreate).toHaveBeenCalled();852853    // Passive queries are in the exclusion list and must not re-arm854    alarmsCreate.mockClear();855    await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });856    expect(alarmsCreate).not.toHaveBeenCalled();857  });858859  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {860    const vault = await import("./vault");861    await vault.createVault("correct-pass-12345");862    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });863864    // Simulate an activated Pro license + already-celebrated marker.865    storage.peach_license = "PEACH-PRO-TEST-KEY";866    storage.peach_pro_celebrated = true;867868    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });869    expect(res).toMatchObject({ ok: true });870871    // Vault data is gone…872    expect(storage.peach_vault_a).toBeUndefined();873    // …but the paid license and celebration flag survive the wipe.874    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");875    expect(storage.peach_pro_celebrated).toBe(true);876  });877878  it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {879    const messages = await import("./messages");880    const vault = await import("./vault");881882    let resolveGate: () => void = () => {};883    messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));884885    let settled = false;886    const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });887888    // While restore is in flight the handler must NOT have answered yet —889    // this is the window where the old code returned "no vault" → onboarding.890    await new Promise((r) => setTimeout(r, 20));891    expect(settled).toBe(false);892893    // Restore completes (the vault becomes available), gate opens.894    await vault.createVault("correct-pass-12345");895    resolveGate();896897    await expect(pending).resolves.toMatchObject({ ok: true, data: true });898  });899900  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {901    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });902    expect(res.ok).toBe(false);903    // Nothing was written — no vault exists.904    const has = await dispatch({ type: "HAS_VAULT" });905    expect((has as { data?: boolean }).data).toBe(false);906  });907908  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {909    const vault = await import("./vault");910    await vault.createVault("correct-pass-12345");911    const e = await vault.addEntry({912      title: "Mail",913      username: "june",914      password: "pw",915      url: "https://mail.test",916      totp: "JBSWY3DPEHPK3PXP",917    });918919    const res = await dispatch(920      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },921      { id: "peach-extension", url: "https://mail.test/login" },922    );923    expect(res.ok).toBe(true);924    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };925    expect(data.totp).toBeUndefined();926    expect(data.totpCode).toMatch(/^\d{6}$/);927    expect(data.totpRemaining).toBeGreaterThan(0);928    expect(data.totpRemaining).toBeLessThanOrEqual(30);929  });930931  it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {932    const vault = await import("./vault");933    await vault.createVault("correct-pass-12345");934    const e = await vault.addEntry({935      title: "Bank",936      username: "june",937      password: "old-pw",938      url: "https://bank.test",939    });940    await vault.updateEntry(e.id, { password: "new-pw" });941942    const fill = await dispatch(943      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },944      { id: "peach-extension", url: "https://bank.test/login" },945    );946    expect(fill.ok).toBe(true);947    // Passive autofill never receives prior passwords (blast-radius containment).948    expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();949950    const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });951    expect(edit.ok).toBe(true);952    const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];953    expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);954  });955956  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {957    const vault = await import("./vault");958    await vault.createVault("correct-pass-12345");959    const e = await vault.addEntry({960      title: "Mail",961      username: "june",962      password: "pw",963      url: "https://mail.test",964      totp: "JBSWY3DPEHPK3PXP",965    });966967    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });968    expect(res.ok).toBe(true);969    const data = res.data as { totp?: string };970    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");971  });972973  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {974    const vault = await import("./vault");975    await vault.createVault("correct-pass-12345");976977    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });978    expect(res).toMatchObject({ ok: true });979980    expect(storage.peach_vault_a).toBeUndefined();981    expect(storage.peach_license).toBeUndefined();982    expect(storage.peach_pro_celebrated).toBeUndefined();983  });984985  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---986987  function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {988    return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };989  }990991  async function addPasskeyOnlyEntry(title: string, url: string) {992    const vault = await import("./vault");993    const generated = await generatePasskeyKeypair(-7);994    return vault.addEntry({995      type: "login",996      title,997      url,998      passkey: {999        rpId: new URL(url).hostname,1000        rpName: title,1001        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1002        publicKeyCose: base64urlEncode(generated.publicKeyCose),1003        privateKeyJwk: generated.privateKeyJwk,1004        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1005        userName: "june",1006        userDisplayName: "June",1007        algorithm: -7,1008        signCount: 0,1009        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1010        backupEligible: true,1011        backupState: false,1012        createdAt: Date.now(),1013      },1014    });1015  }10161017  it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1018    const vault = await import("./vault");1019    await vault.createVault("correct-pass-12345");1020    await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1021    await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });10221023    const result = await dispatch(1024      {1025        type: "AUTOFILL_QUERY",1026        payload: {1027          domain: "github.com",1028          includeAll: true,1029          userGesture: true,1030          explicitSummon: true,1031        },1032      },1033      tabSender("https://evil.example.com/phish"),1034    );10351036    expect(result).toMatchObject({ ok: true });1037    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1038  });10391040  it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1041    const vault = await import("./vault");1042    await vault.createVault("correct-pass-12345");1043    await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1044    await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });10451046    const result = await dispatch(1047      { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1048      tabSender("https://evil.example.com/verify"),1049    );10501051    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1052  });10531054  it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1055    const vault = await import("./vault");1056    const messages = await import("./messages");1057    await vault.createVault("correct-pass-12345");1058    const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1059    const sender = tabSender("https://evil.example.com/phish");1060    const token = messages.createSummonCapability(1, "https://evil.example.com");10611062    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1063    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });10641065    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1066    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1067    const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1068    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1069  });10701071  it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1072    const vault = await import("./vault");1073    const messages = await import("./messages");1074    await vault.createVault("correct-pass-12345");1075    const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1076    const sender = tabSender("https://evil.example.com/login");1077    const token = messages.createSummonCapability(1, "https://evil.example.com");10781079    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1080    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1081    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1082    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });10831084    const offDomainReplay = await dispatch(1085      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1086      tabSender("https://github.com/login"),1087    );1088    expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1089  });10901091  it("PB-26-201: summon capability cannot fill a different entry", async () => {1092    const vault = await import("./vault");1093    const messages = await import("./messages");1094    await vault.createVault("correct-pass-12345");1095    const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1096    const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1097    const sender = tabSender("https://evil.example.com/phish");1098    const token = messages.createSummonCapability(1, "https://evil.example.com");1099    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1100    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1101    expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1102    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1103    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1104    expect(first.id).not.toBe(second.id);1105  });11061107  it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1108    const vault = await import("./vault");1109    const messages = await import("./messages");1110    await vault.createVault("correct-pass-12345");1111    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1112    const token = messages.createSummonCapability(1, "https://evil.example.com");1113    const result = await dispatch(1114      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1115      tabSender("https://evil.example.com/phish"),1116    );1117    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1118  });11191120  it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1121    const vault = await import("./vault");1122    const messages = await import("./messages");1123    await vault.createVault("correct-pass-12345");1124    const entry = await vault.addEntry({1125      title: "Evil",1126      password: "known",1127      url: "https://evil.example.com",1128    });1129    const sender = tabSender("https://evil.example.com/login");1130    const token = messages.createSummonCapability(1, "https://evil.example.com");11311132    const result = await dispatch(1133      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1134      sender,1135    );11361137    expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1138  });11391140  it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1141    vi.useFakeTimers();1142    vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1143    const vault = await import("./vault");1144    const messages = await import("./messages");1145    await vault.createVault("correct-pass-12345");1146    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1147    const sender = tabSender("https://evil.example.com/phish");1148    const token = messages.createSummonCapability(1, "https://evil.example.com");1149    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1150    vi.advanceTimersByTime(30_001);11511152    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1153    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1154  });11551156  it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1157    const vault = await import("./vault");1158    const messages = await import("./messages");1159    await vault.createVault("correct-pass-12345");1160    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1161    const token = messages.createSummonCapability(1, "https://evil.example.com");1162    await dispatch(1163      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1164      tabSender("https://evil.example.com/phish"),1165    );11661167    const result = await dispatch(1168      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1169      tabSender("https://evil.example.com/phish", 2),1170    );1171    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1172  });11731174  it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1175    const vault = await import("./vault");1176    const messages = await import("./messages");1177    await vault.createVault("correct-pass-12345");1178    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1179    const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1180    await dispatch(1181      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1182      tabSender("https://evil.example.com/phish", 1, 3),1183    );11841185    const result = await dispatch(1186      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1187      tabSender("https://evil.example.com/phish", 1, 4),1188    );1189    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1190  });11911192  it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1193    const vault = await import("./vault");1194    const messages = await import("./messages");1195    await vault.createVault("correct-pass-12345");1196    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1197    const token = messages.createSummonCapability(1, "https://evil.example.com");1198    await dispatch(1199      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1200      tabSender("https://evil.example.com/phish"),1201    );12021203    const result = await dispatch(1204      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1205      tabSender("https://other.example.com/phish"),1206    );1207    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1208  });12091210  it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1211    const vault = await import("./vault");1212    const messages = await import("./messages");1213    await vault.createVault("correct-pass-12345");1214    const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1215    const sender = tabSender("https://evil.example.com/phish");1216    const token = messages.createSummonCapability(1, "https://evil.example.com");1217    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12181219    const missing = await dispatch(1220      { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1221      sender,1222    );1223    const existingReplay = await dispatch(1224      { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1225      sender,1226    );1227    expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1228    expect(existingReplay).toEqual(missing);1229  });12301231  it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1232    const vault = await import("./vault");1233    const messages = await import("./messages");1234    await vault.createVault("correct-pass-12345");1235    await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1236    const sender = tabSender("https://evil.example.com/phish");1237    const token = messages.createSummonCapability(1, "https://evil.example.com");1238    const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1239    const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12401241    expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1242    expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1243  });12441245  it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1246    const vault = await import("./vault");1247    const messages = await import("./messages");1248    await vault.createVault("correct-pass-12345");1249    const card = await vault.addEntry({1250      type: "card",1251      title: "Personal Visa",1252      cardholderName: "June Peach",1253      cardNumber: "4111111111111111",1254      cvc: "123",1255      expMonth: "12",1256      expYear: "2030",1257    });1258    const sender = tabSender("https://shop.example.com/checkout");1259    const passive = await dispatch(1260      { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1261      sender,1262    );1263    expect(passive).toMatchObject({ ok: true, data: [] });12641265    const token = messages.createSummonCapability(1, "https://shop.example.com");1266    const inventory = await dispatch(1267      { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1268      sender,1269    );1270    expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1271    expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");12721273    const fill = await dispatch(1274      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1275      sender,1276    );1277    expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1278    const replay = await dispatch(1279      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1280      sender,1281    );1282    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1283  });12841285  it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1286    const result = await dispatch(1287      { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1288      tabSender("https://example.com/login"),1289    );12901291    expect(result).toMatchObject({ ok: true });1292    expect(sessionStorage.peach_open_entry).toBe("entry-123");1293    expect(sessionStorage.peach_inline_unlock).toBeUndefined();1294  });12951296  it("MV-1: tab request with a matching domain returns the entry", async () => {1297    const vault = await import("./vault");1298    await vault.createVault("correct-pass-12345");1299    const entry = await vault.addEntry({1300      title: "GitHub",1301      username: "june",1302      password: "secret123",1303      url: "https://github.com",1304    });13051306    const res = await dispatch(1307      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1308      tabSender("https://github.com/login"),1309    );1310    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1311  });13121313  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1314    const vault = await import("./vault");1315    await vault.createVault("correct-pass-12345");1316    const entry = await vault.addEntry({1317      title: "GitHub",1318      username: "june",1319      password: "secret123",1320      url: "https://github.com",1321    });13221323    const res = await dispatch(1324      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1325      tabSender("https://evil.example.com/phish"),1326    );1327    // Refused with the same error as a missing id — no cross-domain oracle.1328    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1329  });13301331  it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1332    const vault = await import("./vault");1333    await vault.createVault("correct-pass-12345");1334    const entry = await vault.addEntry({1335      title: "GitHub",1336      username: "june",1337      password: "secret123",1338      url: "https://github.com",1339    });13401341    const res = await dispatch(1342      {1343        type: "GET_ENTRY_FOR_FILL",1344        payload: { id: entry.id, explicitSummon: true },1345      },1346      tabSender("https://evil.example.com/phish"),1347    );1348    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1349  });13501351  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1352    const vault = await import("./vault");1353    await vault.createVault("correct-pass-12345");1354    const entry = await vault.addEntry({1355      title: "GitHub",1356      username: "june",1357      password: "secret123",1358      url: "https://github.com",1359    });13601361    const res = await dispatch(1362      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1363      tabSender("not a url"),1364    );1365    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1366  });13671368  // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---13691370  async function seedTwoDomainVault() {1371    const vault = await import("./vault");1372    await vault.createVault("correct-pass-12345");1373    await vault.addEntry({1374      title: "GitHub",1375      username: "june",1376      password: "gh",1377      url: "https://github.com",1378    });1379    await vault.addEntry({1380      title: "Example",1381      username: "june",1382      password: "ex",1383      url: "https://example.com",1384    });1385  }13861387  it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1388    await seedTwoDomainVault();13891390    const res = await dispatch({1391      type: "AUTOFILL_QUERY",1392      payload: { domain: "github.com", includeAll: true },1393    });1394    expect(res.ok).toBe(true);1395    const data = res.data as Array<{ title: string }>;1396    // includeAll was stripped — only the github.com match comes back.1397    expect(data).toHaveLength(1);1398    expect(data[0].title).toBe("GitHub");1399  });14001401  it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1402    await seedTwoDomainVault();14031404    const res = await dispatch({1405      type: "AUTOFILL_QUERY",1406      payload: { domain: "github.com", includeAll: true, userGesture: true },1407    });1408    expect(res.ok).toBe(true);1409    const data = res.data as Array<{ title: string }>;1410    expect(data.map((d) => d.title)).toEqual(["GitHub"]);1411  });14121413  // --- MV-7: vouched device confirmation handlers ---14141415  async function seedPendingVouched(deviceId: string) {1416    await chrome.storage.local.set({1417      peach_pending_vouched_devices_v1: [{1418        deviceId,1419        deviceName: "New Pixel",1420        deviceType: "android",1421        longTermPublicKeyHex: "e".repeat(64),1422        signingPublicKeyHex: "a".repeat(64),1423        wsPort: 51234,1424        pairedAt: 1_700_000_000_020,1425        receivedAt: 1_700_000_000_050,1426        vouchedByDeviceId: "extension-issuer",1427      }],1428    });1429  }14301431  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1432    const vault = await import("./vault");1433    await vault.createVault("correct-pass-12345");1434    await seedPendingVouched("android-new");1435    const { findPairedDeviceById } = await import("./paired-devices");1436    const { listPendingVouchedDevices } = await import(1437      "./pending-vouched-devices"1438    );14391440    const res = await dispatch({1441      type: "CONFIRM_VOUCHED_DEVICE",1442      payload: { deviceId: "android-new" },1443    });1444    expect(res).toMatchObject({ ok: true });14451446    expect(await findPairedDeviceById("android-new")).toMatchObject({1447      deviceName: "New Pixel",1448      trustSource: "vouched",1449      longTermPublicKeyHex: "e".repeat(64),1450    });1451    expect(await listPendingVouchedDevices()).toHaveLength(0);1452  });14531454  it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1455    const vault = await import("./vault");1456    await vault.createVault("correct-pass-12345");1457    await seedPendingVouched("android-relabelled");1458    const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1459      await import("./paired-devices");1460    const { listPendingVouchedDevices } = await import(1461      "./pending-vouched-devices"1462    );14631464    // The queue entry predates the revocation, and it carries a fresh id, so1465    // sweeping the queue by id at revocation time would not have caught it.1466    await upsertPairedDevice({1467      deviceId: "android-old",1468      deviceName: "New Pixel",1469      deviceType: "android",1470      longTermPublicKeyHex: "e".repeat(64),1471      signingPublicKeyHex: "a".repeat(64),1472      pairedAt: 1_700_000_000_000,1473      trustSource: "vouched",1474    });1475    await revokeDeviceById("android-old", "self");14761477    const res = await dispatch({1478      type: "CONFIRM_VOUCHED_DEVICE",1479      payload: { deviceId: "android-relabelled" },1480    });1481    expect(res).toMatchObject({ ok: false, error: "device_revoked" });1482    expect(await findPairedDeviceById("android-relabelled")).toBeNull();1483    expect(await listPendingVouchedDevices()).toHaveLength(0);1484  });14851486  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1487    const vault = await import("./vault");1488    await vault.createVault("correct-pass-12345");1489    await seedPendingVouched("android-new");1490    const { findPairedDeviceById } = await import("./paired-devices");1491    const { listPendingVouchedDevices } = await import(1492      "./pending-vouched-devices"1493    );14941495    const res = await dispatch({1496      type: "REJECT_VOUCHED_DEVICE",1497      payload: { deviceId: "android-new" },1498    });1499    expect(res).toMatchObject({ ok: true });15001501    expect(await findPairedDeviceById("android-new")).toBeNull();1502    expect(await listPendingVouchedDevices()).toHaveLength(0);1503  });15041505  // --- unpair: UNPAIR_DEVICE revokes the device and drops it from the registry ---15061507  it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1508    const vault = await import("./vault");1509    await vault.createVault("correct-pass-12345");1510    const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1511      await import("./paired-devices");15121513    await upsertPairedDevice({1514      deviceId: "phone-1",1515      deviceName: "June's Phone",1516      deviceType: "android",1517      longTermPublicKeyHex: "a".repeat(64),1518      signingPublicKeyHex: "b".repeat(64),1519      pairedAt: 1_700_000_000_000,1520      trustSource: "direct",1521    });15221523    const res = await dispatch({1524      type: "UNPAIR_DEVICE",1525      payload: { deviceId: "phone-1" },1526    });1527    expect(res).toMatchObject({ ok: true });15281529    // The row is gone locally — re-listing must not surface it.1530    expect(await findPairedDeviceById("phone-1")).toBeNull();1531    // The revocation is recorded so a future manifest from a peer cannot1532    // resurrect this device under either the same id or the same key (MV-7 + S1).1533    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1534    const records = await listDeviceRevocations();1535    expect(records).toHaveLength(1);1536    expect(records[0]).toMatchObject({1537      deviceId: "phone-1",1538      longTermPublicKeyHex: "a".repeat(64),1539      revokedByDeviceId: expect.any(String),1540    });1541  });15421543  it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1544    const vault = await import("./vault");1545    await vault.createVault("correct-pass-12345");1546    const { listDeviceRevocations } = await import("./paired-devices");15471548    const res = await dispatch({1549      type: "UNPAIR_DEVICE",1550      payload: { deviceId: "never-paired" },1551    });1552    expect(res).toMatchObject({ ok: true });1553    // A revocation with the all-zero key sentinel is still written — the1554    // contract is that the user-intent is recorded even when the registry had1555    // no row. This is what blocks a peer from later vouched-adopting the same1556    // fresh id back in.1557    const records = await listDeviceRevocations();1558    expect(records).toHaveLength(1);1559    expect(records[0]).toMatchObject({1560      deviceId: "never-paired",1561      longTermPublicKeyHex: "0".repeat(64),1562    });1563  });15641565  it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1566    const res = await dispatch({1567      type: "UNPAIR_DEVICE",1568      payload: { deviceId: "" },1569    });1570    expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1571  });15721573  // The old "fresh direct re-pair clears the old revocation" test dispatched1574  // COMPLETE_PAIRING, a handler deleted on 2026-08-04 (no legitimate senders;1575  // adoption runs in-background via relay-adoption-bg.ts). The live1576  // revocation-overlap repair is pinned in paired-devices.test.ts1577  // (repairPairedRevocationOverlaps, runs at service-worker boot).1578  it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1579    const vault = await import("./vault");1580    await vault.createVault("correct-pass-12345");1581    const contentScriptSender = {1582      id: "peach-extension",1583      url: "https://evil.example/page",1584    };1585    const extensionPageSender = {1586      id: "peach-extension",1587      url: "chrome-extension://peach-extension/popup.html",1588    };1589    const completion = { requestId: 1, rpId: "evil.example" };15901591    // Content-script senders must never reach the gated passkey-store handler.1592    await expect(1593      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1594    ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });15951596    // Extension-page senders still reach it — refused by the pending-ceremony1597    // binding, not by the sender gate.1598    await expect(1599      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1600    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1601  });16021603  it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1604    const vault = await import("./vault");1605    await vault.createVault("correct-pass-12345");1606    const extensionPageSender = {1607      id: "peach-extension",1608      url: "chrome-extension://peach-extension/popup.html",1609    };1610    const completion = {1611      requestId: 123,1612      rpId: "example.com",1613      rpName: "Example",1614      userName: "june",1615      userHandle: "BBBB",1616      credentialId: "cred-1",1617      publicKeyCose: "c3BraQ",1618      algorithm: -7 as const,1619    };16201621    // No pending ceremony at all -> refuse.1622    await expect(1623      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1624    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16251626    // Seed a background-minted pending ceremony.1627    sessionStorage.passkey_pending_firefox = {1628      requestId: 123,1629      type: "create",1630      rpId: "example.com",1631      rpName: "Example",1632      userName: "june",1633      browser: "firefox",1634      challenge: "AAAA",1635      userHandle: "BBBB",1636    };16371638    // Mismatched userHandle (attacker-forged completion) -> refuse.1639    await expect(1640      dispatch(1641        { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1642        extensionPageSender1643      )1644    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16451646    // Matching completion -> stored.1647    await expect(1648      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1649    ).resolves.toMatchObject({ ok: true });1650    const entries = await dispatch({ type: "GET_ENTRIES" });1651    expect(entries).toMatchObject({ ok: true });1652    const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1653    expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1654  });16551656  it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1657    await expect(1658      dispatch(1659        {1660          type: "INITIATE_FIREFOX_PASSKEY_CREATE",1661          payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1662        },1663        { id: "peach-extension", url: "https://evil.example/page" }1664      )1665    ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1666  });16671668  it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1669    // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts;1670    // here we just pin the storage-level invariant the UNPAIR_DEVICE call1671    // establishes, since the rest of the testbed's revoke coverage rides on1672    // this state.1673    const vault = await import("./vault");1674    await vault.createVault("correct-pass-12345");1675    const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1676      "./paired-devices"1677    );16781679    await upsertPairedDevice({1680      deviceId: "phone-1",1681      deviceName: "June's Phone",1682      deviceType: "android",1683      longTermPublicKeyHex: "a".repeat(64),1684      signingPublicKeyHex: "b".repeat(64),1685      pairedAt: 1_700_000_000_000,1686      trustSource: "direct",1687    });16881689    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1690    await revokeDeviceById("phone-1", "self");1691    // The key check is what blocks a re-vouch under a fresh id.1692    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1693    expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1694  });16951696  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---16971698  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1699    const vault = await import("./vault");1700    await vault.createVault("correct-pass-12345");17011702    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1703    expect(res).toMatchObject({ ok: false, error: "password_required" });1704  });17051706  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1707    const vault = await import("./vault");1708    await vault.createVault("correct-pass-12345");17091710    const res = await dispatch({1711      type: "GET_RECOVERY_WORDS",1712      payload: { password: "not-the-password" },1713    });1714    expect(res).toMatchObject({ ok: false, error: "wrong_password" });1715    expect(res.data).toBeUndefined();1716  });17171718  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1719    const vault = await import("./vault");1720    await vault.createVault("correct-pass-12345");17211722    const res = await dispatch({1723      type: "GET_RECOVERY_WORDS",1724      payload: { password: "correct-pass-12345" },1725    });1726    expect(res.ok).toBe(true);1727    expect((res.data as string[]).length).toBe(12);1728  });17291730  it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1731    const vault = await import("./vault");1732    const password = "correct-pass-12345";1733    const popupSender = {1734      id: "peach-extension",1735      url: "chrome-extension://peach-extension/src/popup/index.html",1736    } as chrome.runtime.MessageSender;17371738    await vault.createVault(password);1739    await vault.setupPinUnlock(password, "1234");1740    await vault.lock();17411742    await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1743      ok: true,1744      data: { configured: true, available: true, attemptsRemaining: 3 },1745    });1746  });1747});