messages.test.ts

64.6 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("messages from foreign or URL-less senders are rejected", async () => {617    const vault = await import("./vault");618    await vault.createVault("correct-pass-12345");619620    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({621      ok: false,622      error: "invalid_sender",623    });624625    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({626      ok: false,627      error: "invalid_sender",628    });629  });630631  it("LOCK and UNLOCK messages update vault state correctly", async () => {632    const vault = await import("./vault");633634    await expect(635      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })636    ).resolves.toMatchObject({ ok: true });637    expect(vault.isUnlocked()).toBe(true);638639    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });640    expect(vault.isUnlocked()).toBe(false);641642    await expect(643      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })644    ).resolves.toMatchObject({ ok: false });645    expect(vault.isUnlocked()).toBe(false);646647    await expect(648      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })649    ).resolves.toMatchObject({ ok: true });650    expect(vault.isUnlocked()).toBe(true);651  });652653  it("does not acknowledge LOCK until session cleanup finishes", async () => {654    await expect(655      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })656    ).resolves.toMatchObject({ ok: true });657658    const removalStarted = deferred();659    const releaseRemoval = deferred();660    const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;661    remove.mockImplementation(async (keys: string | string[]) => {662      removalStarted.resolve();663      await releaseRemoval.promise;664      for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];665    });666667    let responded = false;668    const response = dispatch({ type: "LOCK" }).then((result) => {669      responded = true;670      return result;671    });672    await removalStarted.promise;673674    try {675      await Promise.resolve();676      expect(responded).toBe(false);677    } finally {678      releaseRemoval.resolve();679    }680681    await expect(response).resolves.toMatchObject({ ok: true });682  });683684  it("remembers the last successful unlock method", async () => {685    const vault = await import("./vault");686    const password = "correct-pass-12345";687    const popupSender = {688      id: "peach-extension",689      url: "chrome-extension://peach-extension/src/popup/index.html",690    } as chrome.runtime.MessageSender;691692    await vault.createVault(password);693    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);694    vault.lock();695696    await expect(697      dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),698    ).resolves.toMatchObject({ ok: true });699    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);700701    vault.lock();702    await expect(703      dispatch({ type: "UNLOCK", payload: { password } }, popupSender),704    ).resolves.toMatchObject({ ok: true });705    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);706  });707708  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {709    const response = await dispatch({710      type: "FIRE_CANARY_PING",711      payload: {712        webhookUrl: "https://example.com/hook",713        entryTitle: "GitHub",714        trigger: "password_copied",715      },716    });717718    expect(response).toMatchObject({ ok: true });719    expect(fetchMock).toHaveBeenCalledWith(720      "https://example.com/hook",721      expect.objectContaining({722        method: "POST",723        headers: { "Content-Type": "application/json" },724        body: expect.any(String),725      })726    );727    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;728    expect(body).toMatchObject({729      entry: "GitHub",730      trigger: "password_copied",731      source: "peach-extension",732    });733  });734735  it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {736    const payload = {737      webhookUrl: "https://example.com/hook",738      entryTitle: "GitHub",739      trigger: "autofilled" as const,740    };741    const first = await dispatch({ type: "FIRE_CANARY_PING", payload });742    expect(first).toMatchObject({ ok: true });743    expect(fetchMock).toHaveBeenCalledTimes(1);744745    const second = await dispatch({ type: "FIRE_CANARY_PING", payload });746    expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });747    expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again748  });749750  it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {751    await dispatch({752      type: "FIRE_CANARY_PING",753      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },754    });755    await dispatch({756      type: "FIRE_CANARY_PING",757      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },758    });759    expect(fetchMock).toHaveBeenCalledTimes(2);760  });761762  it("LV-3: test pings are exempt from the debounce", async () => {763    const payload = {764      webhookUrl: "https://example.com/hook",765      entryTitle: "GitHub",766      trigger: "test" as const,767    };768    await dispatch({ type: "FIRE_CANARY_PING", payload });769    await dispatch({ type: "FIRE_CANARY_PING", payload });770    expect(fetchMock).toHaveBeenCalledTimes(2);771  });772773  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {774    await expect(775      dispatch({776        type: "FIRE_CANARY_PING",777        payload: {778          webhookUrl: "http://example.com/hook",779          entryTitle: "GitHub",780          trigger: "password_copied",781        },782      })783    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });784785    expect(fetchMock).not.toHaveBeenCalled();786  });787788  it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {789    for (const webhookUrl of [790      "https://[::ffff:192.168.0.1]/hook",791      "https://[::ffff:c0a8:1]/hook",792      "https://[::ffff:10.0.0.1]/hook",793      "https://[::ffff:127.0.0.1]/hook",794      "https://[::ffff:169.254.1.1]/hook",795      "https://[::ffff:172.16.0.1]/hook",796    ]) {797      await expect(798        dispatch({799          type: "FIRE_CANARY_PING",800          payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },801        })802      ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });803    }804805    expect(fetchMock).not.toHaveBeenCalled();806  });807808  it("GET_IDENTITIES when locked returns vault locked error", async () => {809    const vault = await import("./vault");810    await vault.createVault("correct-pass-12345");811    vault.lock();812    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({813      ok: false,814      error: "vault_locked",815    });816  });817818  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {819    const vault = await import("./vault");820    await vault.createVault("correct-pass-12345");821    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });822    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });823    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });824825    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };826    expect(resp.ok).toBe(true);827    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;828    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({829      type: "email",830      useCount: 2,831    });832    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({833      type: "username",834      useCount: 1,835    });836    // emails appear before usernames837    const emailIdx = data.findIndex((d) => d.value === "[email protected]");838    const userIdx = data.findIndex((d) => d.value === "juni_patel");839    expect(emailIdx).toBeLessThan(userIdx);840  });841842  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {843    const vault = await import("./vault");844    await vault.createVault("correct-pass-12345");845    for (let i = 0; i < 3; i++) {846      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });847    }848849    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };850    const data = resp.data as Array<{ value: string; useCount: number }>;851    const hits = data.filter((d) => d.value === "[email protected]");852    expect(hits).toHaveLength(1);853    expect(hits[0]!.useCount).toBe(3);854  });855856  it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {857    const vault = await import("./vault");858    await vault.createVault("correct-pass-12345");859    await vault.addEntry({860      title: "Private identity",861      username: "[email protected]",862      password: "pw",863      url: "https://private.example",864    });865866    const senders = [867      {868        id: "peach-extension",869        url: "https://private.example/signup",870        tab: { id: 1 } as chrome.tabs.Tab,871      },872      { id: "peach-extension", url: "https://private.example/signup" },873    ] satisfies chrome.runtime.MessageSender[];874    for (const sender of senders) {875      await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({876        ok: false,877        error: "invalid_sender",878      });879    }880  });881882  it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {883    const vault = await import("./vault");884    await vault.createVault("correct-pass-12345");885    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;886887    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer888    alarmsCreate.mockClear();889    await dispatch({ type: "GET_ENTRIES" });890    expect(alarmsCreate).toHaveBeenCalled();891892    // Passive queries are in the exclusion list and must not re-arm893    alarmsCreate.mockClear();894    await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });895    expect(alarmsCreate).not.toHaveBeenCalled();896  });897898  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {899    const vault = await import("./vault");900    await vault.createVault("correct-pass-12345");901    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });902903    // Simulate an activated Pro license + already-celebrated marker.904    storage.peach_license = "PEACH-PRO-TEST-KEY";905    storage.peach_pro_celebrated = true;906907    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });908    expect(res).toMatchObject({ ok: true });909910    // Vault data is gone…911    expect(storage.peach_vault_a).toBeUndefined();912    // …but the paid license and celebration flag survive the wipe.913    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");914    expect(storage.peach_pro_celebrated).toBe(true);915  });916917  it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {918    const messages = await import("./messages");919    const vault = await import("./vault");920921    let resolveGate: () => void = () => {};922    messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));923924    let settled = false;925    const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });926927    // While restore is in flight the handler must NOT have answered yet —928    // this is the window where the old code returned "no vault" → onboarding.929    await new Promise((r) => setTimeout(r, 20));930    expect(settled).toBe(false);931932    // Restore completes (the vault becomes available), gate opens.933    await vault.createVault("correct-pass-12345");934    resolveGate();935936    await expect(pending).resolves.toMatchObject({ ok: true, data: true });937  });938939  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {940    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });941    expect(res.ok).toBe(false);942    // Nothing was written — no vault exists.943    const has = await dispatch({ type: "HAS_VAULT" });944    expect((has as { data?: boolean }).data).toBe(false);945  });946947  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {948    const vault = await import("./vault");949    await vault.createVault("correct-pass-12345");950    const e = await vault.addEntry({951      title: "Mail",952      username: "june",953      password: "pw",954      url: "https://mail.test",955      totp: "JBSWY3DPEHPK3PXP",956    });957958    const res = await dispatch(959      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },960      { id: "peach-extension", url: "https://mail.test/login" },961    );962    expect(res.ok).toBe(true);963    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };964    expect(data.totp).toBeUndefined();965    expect(data.totpCode).toMatch(/^\d{6}$/);966    expect(data.totpRemaining).toBeGreaterThan(0);967    expect(data.totpRemaining).toBeLessThanOrEqual(30);968  });969970  it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {971    const vault = await import("./vault");972    await vault.createVault("correct-pass-12345");973    const e = await vault.addEntry({974      title: "Bank",975      username: "june",976      password: "old-pw",977      url: "https://bank.test",978    });979    await vault.updateEntry(e.id, { password: "new-pw" });980981    const fill = await dispatch(982      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },983      { id: "peach-extension", url: "https://bank.test/login" },984    );985    expect(fill.ok).toBe(true);986    // Passive autofill never receives prior passwords (blast-radius containment).987    expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();988989    const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });990    expect(edit.ok).toBe(true);991    const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];992    expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);993  });994995  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {996    const vault = await import("./vault");997    await vault.createVault("correct-pass-12345");998    const e = await vault.addEntry({999      title: "Mail",1000      username: "june",1001      password: "pw",1002      url: "https://mail.test",1003      totp: "JBSWY3DPEHPK3PXP",1004    });10051006    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1007    expect(res.ok).toBe(true);1008    const data = res.data as { totp?: string };1009    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1010  });10111012  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1013    const vault = await import("./vault");1014    await vault.createVault("correct-pass-12345");10151016    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1017    expect(res).toMatchObject({ ok: true });10181019    expect(storage.peach_vault_a).toBeUndefined();1020    expect(storage.peach_license).toBeUndefined();1021    expect(storage.peach_pro_celebrated).toBeUndefined();1022  });10231024  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---10251026  function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1027    return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1028  }10291030  async function addPasskeyOnlyEntry(title: string, url: string) {1031    const vault = await import("./vault");1032    const generated = await generatePasskeyKeypair(-7);1033    return vault.addEntry({1034      type: "login",1035      title,1036      url,1037      passkey: {1038        rpId: new URL(url).hostname,1039        rpName: title,1040        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1041        publicKeyCose: base64urlEncode(generated.publicKeyCose),1042        privateKeyJwk: generated.privateKeyJwk,1043        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1044        userName: "june",1045        userDisplayName: "June",1046        algorithm: -7,1047        signCount: 0,1048        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1049        backupEligible: true,1050        backupState: false,1051        createdAt: Date.now(),1052      },1053    });1054  }10551056  it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1057    const vault = await import("./vault");1058    await vault.createVault("correct-pass-12345");1059    await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1060    await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });10611062    const result = await dispatch(1063      {1064        type: "AUTOFILL_QUERY",1065        payload: {1066          domain: "github.com",1067          includeAll: true,1068          userGesture: true,1069          explicitSummon: true,1070        },1071      },1072      tabSender("https://evil.example.com/phish"),1073    );10741075    expect(result).toMatchObject({ ok: true });1076    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1077  });10781079  it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1080    const vault = await import("./vault");1081    await vault.createVault("correct-pass-12345");1082    await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1083    await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });10841085    const result = await dispatch(1086      { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1087      tabSender("https://evil.example.com/verify"),1088    );10891090    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1091  });10921093  it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1094    const vault = await import("./vault");1095    const messages = await import("./messages");1096    await vault.createVault("correct-pass-12345");1097    const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1098    const sender = tabSender("https://evil.example.com/phish");1099    const token = messages.createSummonCapability(1, "https://evil.example.com");11001101    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1102    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });11031104    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1105    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1106    const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1107    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1108  });11091110  it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1111    const vault = await import("./vault");1112    const messages = await import("./messages");1113    await vault.createVault("correct-pass-12345");1114    const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1115    const sender = tabSender("https://evil.example.com/login");1116    const token = messages.createSummonCapability(1, "https://evil.example.com");11171118    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1119    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1120    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1121    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });11221123    const offDomainReplay = await dispatch(1124      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1125      tabSender("https://github.com/login"),1126    );1127    expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1128  });11291130  it("PB-26-201: summon capability cannot fill a different entry", async () => {1131    const vault = await import("./vault");1132    const messages = await import("./messages");1133    await vault.createVault("correct-pass-12345");1134    const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1135    const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1136    const sender = tabSender("https://evil.example.com/phish");1137    const token = messages.createSummonCapability(1, "https://evil.example.com");1138    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1139    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1140    expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1141    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1142    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1143    expect(first.id).not.toBe(second.id);1144  });11451146  it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1147    const vault = await import("./vault");1148    const messages = await import("./messages");1149    await vault.createVault("correct-pass-12345");1150    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1151    const token = messages.createSummonCapability(1, "https://evil.example.com");1152    const result = await dispatch(1153      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1154      tabSender("https://evil.example.com/phish"),1155    );1156    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1157  });11581159  it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1160    const vault = await import("./vault");1161    const messages = await import("./messages");1162    await vault.createVault("correct-pass-12345");1163    const entry = await vault.addEntry({1164      title: "Evil",1165      password: "known",1166      url: "https://evil.example.com",1167    });1168    const sender = tabSender("https://evil.example.com/login");1169    const token = messages.createSummonCapability(1, "https://evil.example.com");11701171    const result = await dispatch(1172      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1173      sender,1174    );11751176    expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1177  });11781179  it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1180    vi.useFakeTimers();1181    vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1182    const vault = await import("./vault");1183    const messages = await import("./messages");1184    await vault.createVault("correct-pass-12345");1185    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1186    const sender = tabSender("https://evil.example.com/phish");1187    const token = messages.createSummonCapability(1, "https://evil.example.com");1188    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1189    vi.advanceTimersByTime(30_001);11901191    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1192    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1193  });11941195  it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1196    const vault = await import("./vault");1197    const messages = await import("./messages");1198    await vault.createVault("correct-pass-12345");1199    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1200    const token = messages.createSummonCapability(1, "https://evil.example.com");1201    await dispatch(1202      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1203      tabSender("https://evil.example.com/phish"),1204    );12051206    const result = await dispatch(1207      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1208      tabSender("https://evil.example.com/phish", 2),1209    );1210    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1211  });12121213  it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1214    const vault = await import("./vault");1215    const messages = await import("./messages");1216    await vault.createVault("correct-pass-12345");1217    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1218    const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1219    await dispatch(1220      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1221      tabSender("https://evil.example.com/phish", 1, 3),1222    );12231224    const result = await dispatch(1225      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1226      tabSender("https://evil.example.com/phish", 1, 4),1227    );1228    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1229  });12301231  it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1232    const vault = await import("./vault");1233    const messages = await import("./messages");1234    await vault.createVault("correct-pass-12345");1235    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1236    const token = messages.createSummonCapability(1, "https://evil.example.com");1237    await dispatch(1238      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1239      tabSender("https://evil.example.com/phish"),1240    );12411242    const result = await dispatch(1243      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1244      tabSender("https://other.example.com/phish"),1245    );1246    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1247  });12481249  it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1250    const vault = await import("./vault");1251    const messages = await import("./messages");1252    await vault.createVault("correct-pass-12345");1253    const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1254    const sender = tabSender("https://evil.example.com/phish");1255    const token = messages.createSummonCapability(1, "https://evil.example.com");1256    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12571258    const missing = await dispatch(1259      { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1260      sender,1261    );1262    const existingReplay = await dispatch(1263      { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1264      sender,1265    );1266    expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1267    expect(existingReplay).toEqual(missing);1268  });12691270  it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1271    const vault = await import("./vault");1272    const messages = await import("./messages");1273    await vault.createVault("correct-pass-12345");1274    await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1275    const sender = tabSender("https://evil.example.com/phish");1276    const token = messages.createSummonCapability(1, "https://evil.example.com");1277    const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1278    const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);12791280    expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1281    expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1282  });12831284  it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1285    const vault = await import("./vault");1286    const messages = await import("./messages");1287    await vault.createVault("correct-pass-12345");1288    const card = await vault.addEntry({1289      type: "card",1290      title: "Personal Visa",1291      cardholderName: "June Peach",1292      cardNumber: "4111111111111111",1293      cvc: "123",1294      expMonth: "12",1295      expYear: "2030",1296    });1297    const sender = tabSender("https://shop.example.com/checkout");1298    const passive = await dispatch(1299      { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1300      sender,1301    );1302    expect(passive).toMatchObject({ ok: true, data: [] });13031304    const token = messages.createSummonCapability(1, "https://shop.example.com");1305    const inventory = await dispatch(1306      { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1307      sender,1308    );1309    expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1310    expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");13111312    const fill = await dispatch(1313      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1314      sender,1315    );1316    expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1317    const replay = await dispatch(1318      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1319      sender,1320    );1321    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1322  });13231324  it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1325    const result = await dispatch(1326      { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1327      tabSender("https://example.com/login"),1328    );13291330    expect(result).toMatchObject({ ok: true });1331    expect(sessionStorage.peach_open_entry).toBe("entry-123");1332    expect(sessionStorage.peach_inline_unlock).toBeUndefined();1333  });13341335  it("MV-1: tab request with a matching domain returns the entry", async () => {1336    const vault = await import("./vault");1337    await vault.createVault("correct-pass-12345");1338    const entry = await vault.addEntry({1339      title: "GitHub",1340      username: "june",1341      password: "secret123",1342      url: "https://github.com",1343    });13441345    const res = await dispatch(1346      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1347      tabSender("https://github.com/login"),1348    );1349    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1350  });13511352  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1353    const vault = await import("./vault");1354    await vault.createVault("correct-pass-12345");1355    const entry = await vault.addEntry({1356      title: "GitHub",1357      username: "june",1358      password: "secret123",1359      url: "https://github.com",1360    });13611362    const res = await dispatch(1363      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1364      tabSender("https://evil.example.com/phish"),1365    );1366    // Refused with the same error as a missing id — no cross-domain oracle.1367    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1368  });13691370  it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1371    const vault = await import("./vault");1372    await vault.createVault("correct-pass-12345");1373    const entry = await vault.addEntry({1374      title: "GitHub",1375      username: "june",1376      password: "secret123",1377      url: "https://github.com",1378    });13791380    const res = await dispatch(1381      {1382        type: "GET_ENTRY_FOR_FILL",1383        payload: { id: entry.id, explicitSummon: true },1384      },1385      tabSender("https://evil.example.com/phish"),1386    );1387    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1388  });13891390  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1391    const vault = await import("./vault");1392    await vault.createVault("correct-pass-12345");1393    const entry = await vault.addEntry({1394      title: "GitHub",1395      username: "june",1396      password: "secret123",1397      url: "https://github.com",1398    });13991400    const res = await dispatch(1401      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1402      tabSender("not a url"),1403    );1404    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1405  });14061407  // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---14081409  async function seedTwoDomainVault() {1410    const vault = await import("./vault");1411    await vault.createVault("correct-pass-12345");1412    await vault.addEntry({1413      title: "GitHub",1414      username: "june",1415      password: "gh",1416      url: "https://github.com",1417    });1418    await vault.addEntry({1419      title: "Example",1420      username: "june",1421      password: "ex",1422      url: "https://example.com",1423    });1424  }14251426  it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1427    await seedTwoDomainVault();14281429    const res = await dispatch({1430      type: "AUTOFILL_QUERY",1431      payload: { domain: "github.com", includeAll: true },1432    });1433    expect(res.ok).toBe(true);1434    const data = res.data as Array<{ title: string }>;1435    // includeAll was stripped — only the github.com match comes back.1436    expect(data).toHaveLength(1);1437    expect(data[0].title).toBe("GitHub");1438  });14391440  it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1441    await seedTwoDomainVault();14421443    const res = await dispatch({1444      type: "AUTOFILL_QUERY",1445      payload: { domain: "github.com", includeAll: true, userGesture: true },1446    });1447    expect(res.ok).toBe(true);1448    const data = res.data as Array<{ title: string }>;1449    expect(data.map((d) => d.title)).toEqual(["GitHub"]);1450  });14511452  // --- MV-7: vouched device confirmation handlers ---14531454  async function seedPendingVouched(deviceId: string) {1455    await chrome.storage.local.set({1456      peach_pending_vouched_devices_v1: [{1457        deviceId,1458        deviceName: "New Pixel",1459        deviceType: "android",1460        longTermPublicKeyHex: "e".repeat(64),1461        signingPublicKeyHex: "a".repeat(64),1462        wsPort: 51234,1463        pairedAt: 1_700_000_000_020,1464        receivedAt: 1_700_000_000_050,1465        vouchedByDeviceId: "extension-issuer",1466      }],1467    });1468  }14691470  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1471    const vault = await import("./vault");1472    await vault.createVault("correct-pass-12345");1473    await seedPendingVouched("android-new");1474    const { findPairedDeviceById } = await import("./paired-devices");1475    const { listPendingVouchedDevices } = await import(1476      "./pending-vouched-devices"1477    );14781479    const res = await dispatch({1480      type: "CONFIRM_VOUCHED_DEVICE",1481      payload: { deviceId: "android-new" },1482    });1483    expect(res).toMatchObject({ ok: true });14841485    expect(await findPairedDeviceById("android-new")).toMatchObject({1486      deviceName: "New Pixel",1487      trustSource: "vouched",1488      longTermPublicKeyHex: "e".repeat(64),1489    });1490    expect(await listPendingVouchedDevices()).toHaveLength(0);1491  });14921493  it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1494    const vault = await import("./vault");1495    await vault.createVault("correct-pass-12345");1496    await seedPendingVouched("android-relabelled");1497    const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1498      await import("./paired-devices");1499    const { listPendingVouchedDevices } = await import(1500      "./pending-vouched-devices"1501    );15021503    // The queue entry predates the revocation, and it carries a fresh id, so1504    // sweeping the queue by id at revocation time would not have caught it.1505    await upsertPairedDevice({1506      deviceId: "android-old",1507      deviceName: "New Pixel",1508      deviceType: "android",1509      longTermPublicKeyHex: "e".repeat(64),1510      signingPublicKeyHex: "a".repeat(64),1511      pairedAt: 1_700_000_000_000,1512      trustSource: "vouched",1513    });1514    await revokeDeviceById("android-old", "self");15151516    const res = await dispatch({1517      type: "CONFIRM_VOUCHED_DEVICE",1518      payload: { deviceId: "android-relabelled" },1519    });1520    expect(res).toMatchObject({ ok: false, error: "device_revoked" });1521    expect(await findPairedDeviceById("android-relabelled")).toBeNull();1522    expect(await listPendingVouchedDevices()).toHaveLength(0);1523  });15241525  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1526    const vault = await import("./vault");1527    await vault.createVault("correct-pass-12345");1528    await seedPendingVouched("android-new");1529    const { findPairedDeviceById } = await import("./paired-devices");1530    const { listPendingVouchedDevices } = await import(1531      "./pending-vouched-devices"1532    );15331534    const res = await dispatch({1535      type: "REJECT_VOUCHED_DEVICE",1536      payload: { deviceId: "android-new" },1537    });1538    expect(res).toMatchObject({ ok: true });15391540    expect(await findPairedDeviceById("android-new")).toBeNull();1541    expect(await listPendingVouchedDevices()).toHaveLength(0);1542  });15431544  // --- unpair: UNPAIR_DEVICE revokes the device and drops it from the registry ---15451546  it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1547    const vault = await import("./vault");1548    await vault.createVault("correct-pass-12345");1549    const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1550      await import("./paired-devices");15511552    await upsertPairedDevice({1553      deviceId: "phone-1",1554      deviceName: "June's Phone",1555      deviceType: "android",1556      longTermPublicKeyHex: "a".repeat(64),1557      signingPublicKeyHex: "b".repeat(64),1558      pairedAt: 1_700_000_000_000,1559      trustSource: "direct",1560    });15611562    const res = await dispatch({1563      type: "UNPAIR_DEVICE",1564      payload: { deviceId: "phone-1" },1565    });1566    expect(res).toMatchObject({ ok: true });15671568    // The row is gone locally — re-listing must not surface it.1569    expect(await findPairedDeviceById("phone-1")).toBeNull();1570    // The revocation is recorded so a future manifest from a peer cannot1571    // resurrect this device under either the same id or the same key (MV-7 + S1).1572    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1573    const records = await listDeviceRevocations();1574    expect(records).toHaveLength(1);1575    expect(records[0]).toMatchObject({1576      deviceId: "phone-1",1577      longTermPublicKeyHex: "a".repeat(64),1578      revokedByDeviceId: expect.any(String),1579    });1580  });15811582  it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1583    const vault = await import("./vault");1584    await vault.createVault("correct-pass-12345");1585    const { listDeviceRevocations } = await import("./paired-devices");15861587    const res = await dispatch({1588      type: "UNPAIR_DEVICE",1589      payload: { deviceId: "never-paired" },1590    });1591    expect(res).toMatchObject({ ok: true });1592    // A revocation with the all-zero key sentinel is still written — the1593    // contract is that the user-intent is recorded even when the registry had1594    // no row. This is what blocks a peer from later vouched-adopting the same1595    // fresh id back in.1596    const records = await listDeviceRevocations();1597    expect(records).toHaveLength(1);1598    expect(records[0]).toMatchObject({1599      deviceId: "never-paired",1600      longTermPublicKeyHex: "0".repeat(64),1601    });1602  });16031604  it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1605    const res = await dispatch({1606      type: "UNPAIR_DEVICE",1607      payload: { deviceId: "" },1608    });1609    expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1610  });16111612  // The old "fresh direct re-pair clears the old revocation" test dispatched1613  // COMPLETE_PAIRING, a handler deleted on 2026-08-04 (no legitimate senders;1614  // adoption runs in-background via relay-adoption-bg.ts). The live1615  // revocation-overlap repair is pinned in paired-devices.test.ts1616  // (repairPairedRevocationOverlaps, runs at service-worker boot).1617  it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1618    const vault = await import("./vault");1619    await vault.createVault("correct-pass-12345");1620    const contentScriptSender = {1621      id: "peach-extension",1622      url: "https://evil.example/page",1623    };1624    const extensionPageSender = {1625      id: "peach-extension",1626      url: "chrome-extension://peach-extension/popup.html",1627    };1628    const completion = { requestId: 1, rpId: "evil.example" };16291630    // Content-script senders must never reach the gated passkey-store handler.1631    await expect(1632      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1633    ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });16341635    // Extension-page senders still reach it — refused by the pending-ceremony1636    // binding, not by the sender gate.1637    await expect(1638      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1639    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1640  });16411642  it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1643    const vault = await import("./vault");1644    await vault.createVault("correct-pass-12345");1645    const extensionPageSender = {1646      id: "peach-extension",1647      url: "chrome-extension://peach-extension/popup.html",1648    };1649    const completion = {1650      requestId: 123,1651      rpId: "example.com",1652      rpName: "Example",1653      userName: "june",1654      userHandle: "BBBB",1655      credentialId: "cred-1",1656      publicKeyCose: "c3BraQ",1657      algorithm: -7 as const,1658    };16591660    // No pending ceremony at all -> refuse.1661    await expect(1662      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1663    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16641665    // Seed a background-minted pending ceremony.1666    sessionStorage.passkey_pending_firefox = {1667      requestId: 123,1668      type: "create",1669      rpId: "example.com",1670      rpName: "Example",1671      userName: "june",1672      browser: "firefox",1673      challenge: "AAAA",1674      userHandle: "BBBB",1675    };16761677    // Mismatched userHandle (attacker-forged completion) -> refuse.1678    await expect(1679      dispatch(1680        { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1681        extensionPageSender1682      )1683    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });16841685    // Matching completion -> stored.1686    await expect(1687      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1688    ).resolves.toMatchObject({ ok: true });1689    const entries = await dispatch({ type: "GET_ENTRIES" });1690    expect(entries).toMatchObject({ ok: true });1691    const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1692    expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1693  });16941695  it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1696    await expect(1697      dispatch(1698        {1699          type: "INITIATE_FIREFOX_PASSKEY_CREATE",1700          payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1701        },1702        { id: "peach-extension", url: "https://evil.example/page" }1703      )1704    ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1705  });17061707  it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1708    // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts;1709    // here we just pin the storage-level invariant the UNPAIR_DEVICE call1710    // establishes, since the rest of the testbed's revoke coverage rides on1711    // this state.1712    const vault = await import("./vault");1713    await vault.createVault("correct-pass-12345");1714    const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1715      "./paired-devices"1716    );17171718    await upsertPairedDevice({1719      deviceId: "phone-1",1720      deviceName: "June's Phone",1721      deviceType: "android",1722      longTermPublicKeyHex: "a".repeat(64),1723      signingPublicKeyHex: "b".repeat(64),1724      pairedAt: 1_700_000_000_000,1725      trustSource: "direct",1726    });17271728    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1729    await revokeDeviceById("phone-1", "self");1730    // The key check is what blocks a re-vouch under a fresh id.1731    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1732    expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1733  });17341735  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---17361737  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1738    const vault = await import("./vault");1739    await vault.createVault("correct-pass-12345");17401741    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1742    expect(res).toMatchObject({ ok: false, error: "password_required" });1743  });17441745  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1746    const vault = await import("./vault");1747    await vault.createVault("correct-pass-12345");17481749    const res = await dispatch({1750      type: "GET_RECOVERY_WORDS",1751      payload: { password: "not-the-password" },1752    });1753    expect(res).toMatchObject({ ok: false, error: "wrong_password" });1754    expect(res.data).toBeUndefined();1755  });17561757  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1758    const vault = await import("./vault");1759    await vault.createVault("correct-pass-12345");17601761    const res = await dispatch({1762      type: "GET_RECOVERY_WORDS",1763      payload: { password: "correct-pass-12345" },1764    });1765    expect(res.ok).toBe(true);1766    expect((res.data as string[]).length).toBe(12);1767  });17681769  it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1770    const vault = await import("./vault");1771    const password = "correct-pass-12345";1772    const popupSender = {1773      id: "peach-extension",1774      url: "chrome-extension://peach-extension/src/popup/index.html",1775    } as chrome.runtime.MessageSender;17761777    await vault.createVault(password);1778    await vault.setupPinUnlock(password, "1234");1779    await vault.lock();17801781    await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1782      ok: true,1783      data: { configured: true, available: true, attemptsRemaining: 3 },1784    });1785  });1786});