messages.test.ts

67.2 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";4import type { Message, MessageResponse } from "../shared/types";56type StorageShape = Record<string, unknown>;78function deferred(): { promise: Promise<void>; resolve: () => void } {9  let resolve!: () => void;10  const promise = new Promise<void>((done) => {11    resolve = done;12  });13  return { promise, resolve };14}15type MessageListener = (16  msg: unknown,17  sender: chrome.runtime.MessageSender,18  sendResponse: (response: MessageResponse) => void19) => boolean;2021describe("background messages", () => {22  let storage: StorageShape;23  let sessionStorage: StorageShape;24  let listener: MessageListener;25  let fetchMock: ReturnType<typeof vi.fn>;2627  beforeEach(async () => {28    vi.resetModules();29    vi.useRealTimers();30    storage = {31      peach_settings: { breachMonitoring: false },32    };33    sessionStorage = {};3435    fetchMock = vi.fn(async () => ({36      ok: true,37      status: 200,38      text: async () => "",39    }));40    vi.stubGlobal("fetch", fetchMock);41    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);4243    const tabs = [{ id: 1 }];44    const chromeMock = {45      runtime: {46        id: "peach-extension",47        getURL: (p: string) => `chrome-extension://peach-extension/${p}`,48        onMessage: {49          addListener: vi.fn((fn: MessageListener) => {50            listener = fn;51          }),52        },53        sendMessage: vi.fn(async () => ({ ok: true })),54      },55      tabs: {56        query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {57          if (typeof callback === "function") {58            callback(tabs);59            return;60          }61          return Promise.resolve(tabs);62        }),63        sendMessage: vi.fn(async () => ({ ok: true })),64      },65      storage: {66        local: {67          get: vi.fn(async (key?: string | string[]) => {68            if (!key) return storage;69            if (typeof key === "string") {70              return { [key]: storage[key] };71            }7273            return key.reduce<Record<string, unknown>>((result, entry) => {74              result[entry] = storage[entry];75              return result;76            }, {});77          }),78          set: vi.fn(async (next: Record<string, unknown>) => {79            Object.assign(storage, next);80          }),81          remove: vi.fn(async (keys: string | string[]) => {82            const list = Array.isArray(keys) ? keys : [keys];83            for (const key of list) {84              delete storage[key];85            }86          }),87          clear: vi.fn(async () => {88            for (const key of Object.keys(storage)) delete storage[key];89          }),90        },91        session: {92          get: vi.fn(async (key?: string | string[]) => {93            if (!key) return sessionStorage;94            if (typeof key === "string") {95              return { [key]: sessionStorage[key] };96            }97            return key.reduce<Record<string, unknown>>((result, entry) => {98              result[entry] = sessionStorage[entry];99              return result;100            }, {});101          }),102          set: vi.fn(async (next: Record<string, unknown>) => {103            Object.assign(sessionStorage, next);104          }),105          remove: vi.fn(async (keys: string | string[]) => {106            const list = Array.isArray(keys) ? keys : [keys];107            for (const key of list) {108              delete sessionStorage[key];109            }110          }),111          clear: vi.fn(async () => {112            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];113          }),114        },115        sync: {116          get: vi.fn(async (_key?: string | string[]) => ({})),117          set: vi.fn(async (_values: Record<string, unknown>) => undefined),118          remove: vi.fn(async (_keys: string | string[]) => undefined),119          clear: vi.fn(async () => undefined),120        },121      },122      alarms: {123        clear: vi.fn(),124        create: vi.fn(),125        onAlarm: { addListener: vi.fn() },126      },127      idle: {128        setDetectionInterval: vi.fn(),129        onStateChanged: { addListener: vi.fn() },130      },131      scripting: {132        executeScript: vi.fn(async () => []),133      },134    } as unknown as typeof chrome;135136    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;137138    const messages = await import("./messages");139    messages.setupMessageHandler();140    const vault = await import("./vault");141    await vault.ensureSettingsLoaded();142  });143144  async function dispatch(145    message: Message,146    sender: chrome.runtime.MessageSender = {147      id: "peach-extension",148      url: "chrome-extension://peach-extension/popup.html",149    }150  ): Promise<MessageResponse> {151    return await new Promise<MessageResponse>((resolve) => {152      const keepChannelOpen = listener(message, sender, (response) => resolve(response));153      expect(keepChannelOpen).toBe(true);154    });155  }156157  it("PB-26-303: page GET_SETTINGS returns only theme, accent, and its own summon record", async () => {158    storage.peach_settings = {159      theme: "dark",160      accentColor: "peach",161      autoLockMinutes: 5,162      pinEnabled: true,163      manualSummonDomains: {164        "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },165        "other.example": { firstSummonedAt: 4, summonCount: 5, lastSummonedAt: 6 },166      },167    };168    const response = await dispatch(169      { type: "GET_SETTINGS" },170      {171        id: "peach-extension",172        url: "https://login.example.com/form",173        tab: { id: 1 },174        frameId: 0,175      } as chrome.runtime.MessageSender,176    );177178    expect(response).toEqual({179      ok: true,180      data: {181        theme: "dark",182        accentColor: "peach",183        manualSummonDomains: {184          "example.com": { firstSummonedAt: 1, summonCount: 2, lastSummonedAt: 3 },185        },186      },187    });188    await expect(dispatch({ type: "GET_SETTINGS" })).resolves.toMatchObject({189      ok: true,190      data: {191        autoLockMinutes: 5,192        pinEnabled: true,193        manualSummonDomains: {194          "example.com": expect.anything(),195          "other.example": expect.anything(),196        },197      },198    });199  });200201  it("GET_ENTRIES when unlocked returns the entry array", async () => {202    const vault = await import("./vault");203    await vault.createVault("correct-pass-12345");204    await vault.addEntry({205      title: "GitHub",206      username: "june",207      password: "secret123",208      url: "https://github.com",209    });210211    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({212      ok: true,213      data: [214        expect.objectContaining({215          title: "GitHub",216          username: "june",217        }),218      ],219    });220  });221222  it("GET_ENTRIES when locked returns a vault locked error", async () => {223    const vault = await import("./vault");224    await vault.createVault("correct-pass-12345");225    vault.lock();226227    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({228      ok: false,229      error: "vault_locked",230    });231  });232233  it("ADD_ENTRY with a valid payload returns the created entry", async () => {234    const vault = await import("./vault");235    await vault.createVault("correct-pass-12345");236237    const response = await dispatch({238      type: "ADD_ENTRY",239      payload: {240        title: "GitHub",241        username: "june",242        password: "secret123",243        url: "https://github.com",244      },245    });246247    expect(response).toMatchObject({248      ok: true,249      data: expect.objectContaining({250        id: expect.any(String),251        title: "GitHub",252      }),253    });254    expect(vault.getEntries()).toHaveLength(1);255  });256257  it("ADD_ENTRY when locked returns a vault locked error", async () => {258    const vault = await import("./vault");259    await vault.createVault("correct-pass-12345");260    vault.lock();261262    await expect(263      dispatch({264        type: "ADD_ENTRY",265        payload: {266          title: "GitHub",267          username: "june",268          password: "secret123",269          url: "https://github.com",270        },271      })272    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });273  });274275  it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {276    const vault = await import("./vault");277    await vault.createVault("correct-pass-12345");278279    await expect(280      dispatch({281        type: "ADD_ENTRY",282        payload: {283          title: "GitHub",284          username: "june",285          password: "",286          url: "https://github.com",287        },288      })289    ).resolves.toMatchObject({ ok: true });290  });291292  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {293    const vault = await import("./vault");294    await vault.createVault("correct-pass-12345");295    const entry = await vault.addEntry({296      title: "GitHub",297      username: "june",298      password: "secret123",299      url: "https://github.com",300    });301302    await expect(303      dispatch({304        type: "UPDATE_ENTRY",305        payload: { id: entry.id, patch: { password: "new-secret" } },306      })307    ).resolves.toMatchObject({ ok: true });308309    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");310  });311312  it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {313    const vault = await import("./vault");314    await vault.createVault("correct-pass-12345");315    const entry = await vault.addEntry({316      title: "GitHub",317      username: "june",318      password: "secret123",319      url: "https://github.com",320    });321322    await expect(323      dispatch({324        type: "UPDATE_ENTRY",325        payload: { id: entry.id, patch: { password: "" } },326      })327    ).resolves.toMatchObject({ ok: true });328329    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();330  });331332  it("DELETE_ENTRY soft-deletes the target entry", async () => {333    const vault = await import("./vault");334    await vault.createVault("correct-pass-12345");335    const entry = await vault.addEntry({336      title: "GitHub",337      username: "june",338      password: "secret123",339      url: "https://github.com",340    });341342    await expect(343      dispatch({344        type: "DELETE_ENTRY",345        payload: { id: entry.id },346      })347    ).resolves.toMatchObject({ ok: true });348349    expect(vault.getEntries()).toEqual([]);350    expect(vault.getDeletedEntries()).toHaveLength(1);351  });352353  it("DELETE_ENTRY of a missing id returns an error", async () => {354    const vault = await import("./vault");355    await vault.createVault("correct-pass-12345");356357    await expect(358      dispatch({359        type: "DELETE_ENTRY",360        payload: { id: "missing" },361      })362    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });363  });364365  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {366    const vault = await import("./vault");367    await vault.createVault("correct-pass-12345");368    const entry = await vault.addEntry({369      title: "GitHub",370      username: "june",371      password: "secret123",372      url: "https://github.com",373    });374375    const response = await dispatch(376      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },377      { id: "peach-extension", url: "https://github.com/login" },378    );379380    expect(response).toMatchObject({381      ok: true,382      data: expect.objectContaining({383        id: entry.id,384        username: "june",385        password: "secret123",386      }),387    });388    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();389  });390391  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {392    const vault = await import("./vault");393    await vault.createVault("correct-pass-12345");394    vault.lock();395396    await expect(397      dispatch({398        type: "GET_ENTRY_FOR_FILL",399        payload: { id: "missing" },400      })401    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });402  });403404  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {405    const vault = await import("./vault");406    await vault.createVault("correct-pass-12345");407    const generated = await generatePasskeyKeypair(-7);408    const entry = await vault.addEntry({409      type: "login",410      title: "WebAuthn.io",411      passkey: {412        rpId: "webauthn.io",413        rpName: "WebAuthn.io",414        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),415        publicKeyCose: base64urlEncode(generated.publicKeyCose),416        privateKeyJwk: generated.privateKeyJwk,417        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),418        userName: "june",419        userDisplayName: "June",420        algorithm: -7,421        signCount: 0,422        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",423        backupEligible: true,424        backupState: false,425        createdAt: Date.now(),426      },427    });428429    await expect(430      dispatch({431        type: "GET_ENTRY_FOR_FILL",432        payload: { id: entry.id },433      })434    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });435  });436437  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {438    const vault = await import("./vault");439    await vault.createVault("correct-pass-12345");440    await vault.addEntry({441      title: "GitHub",442      username: "june",443      password: "secret123",444      url: "https://github.com",445      totp: "JBSWY3DPEHPK3PXP",446    });447448    const response = await dispatch({449      type: "AUTOFILL_QUERY",450      payload: { domain: "github.com" },451    });452453    expect(response).toMatchObject({454      ok: true,455      data: [456        {457          id: expect.any(String),458          title: "GitHub",459          username: "june",460          url: "https://github.com",461          hasTotp: true,462        },463      ],464    });465    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();466    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();467  });468469  it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {470    const vault = await import("./vault");471    await vault.createVault("correct-pass-12345");472    await vault.addEntry({473      title: "GitHub",474      username: "june",475      password: "secret123",476      url: "https://github.com",477    });478    await vault.addEntry({479      type: "note",480      title: "Secure note",481      notes: "hello",482    });483484    const response = await dispatch({485      type: "AUTOFILL_QUERY",486      payload: { domain: "github.com", includeAll: true },487    });488489    expect(response).toMatchObject({490      ok: true,491      data: [492        {493          id: expect.any(String),494          title: "GitHub",495          username: "june",496          url: "https://github.com",497          hasPasskey: false,498        },499      ],500    });501    const data = response.data as Array<Record<string, unknown>>;502    expect(data).toHaveLength(1);503    // No secrets cross the boundary.504    expect(data[0]).not.toHaveProperty("password");505    expect(data[0]).not.toHaveProperty("totp");506  });507508  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {509    const vault = await import("./vault");510    await vault.createVault("correct-pass-12345");511    await vault.addEntry({512      title: "GitHub",513      username: "june",514      password: "secret123",515      url: "https://github.com",516      totp: "JBSWY3DPEHPK3PXP",517    });518    await vault.addEntry({519      title: "No 2FA",520      username: "person",521      password: "secret123",522      url: "https://github.com",523    });524525    const response = await dispatch({526      type: "AUTOFILL_QUERY",527      payload: { domain: "github.com", kind: "totp" },528    });529530    expect(response).toMatchObject({531      ok: true,532      data: [533        {534          id: expect.any(String),535          title: "GitHub",536          username: "june",537          url: "https://github.com",538          hasTotp: true,539        },540      ],541    });542    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();543    expect(response.data).toHaveLength(1);544  });545546  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {547    const vault = await import("./vault");548    await vault.createVault("correct-pass-12345");549    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });550551    const response = await dispatch({552      type: "RESTORE_ENTRIES",553      payload: {554        mode: "replace",555        entries: [556          {557            id: "restored-1",558            type: "login",559            title: "B",560            username: "june",561            password: "two",562            url: "https://b.test",563            createdAt: 1,564            modifiedAt: 1,565          },566        ],567      },568    });569570    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });571    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);572  });573574  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {575    const vault = await import("./vault");576    await vault.createVault("correct-pass-12345");577578    await expect(579      dispatch({580        type: "RESTORE_ENTRIES",581        payload: { mode: "replace", entries: [] },582      })583    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });584  });585586  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {587    const vault = await import("./vault");588    await vault.createVault("correct-pass-12345");589590    await expect(591      dispatch({592        type: "CHANGE_PASSWORD",593        payload: {594          currentPassword: "correct-pass-12345",595          newPassword: "new-pass-67890",596        },597      })598    ).resolves.toMatchObject({ ok: true });599600    vault.lock();601    expect(await vault.unlock("new-pass-67890")).toBe(true);602603    await expect(604      dispatch({605        type: "CHANGE_PASSWORD",606        payload: {607          currentPassword: "wrong-pass",608          newPassword: "another-pass",609        },610      })611    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });612  });613614  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {615    const vault = await import("./vault");616    await vault.createVault("correct-pass-12345");617618    (619      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>620    ).mockClear();621    (622      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>623    ).mockClear();624625    await expect(626      dispatch({627        type: "UPDATE_SETTINGS",628        payload: { autoLockMinutes: 30 },629      })630    ).resolves.toMatchObject({ ok: true });631632    expect(vault.getSettings().autoLockMinutes).toBe(30);633    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);634    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {635      delayInMinutes: 30,636    });637  });638639  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {640    vi.useFakeTimers();641    const vault = await import("./vault");642    await vault.createVault("correct-pass-12345");643    const entry = await vault.addEntry({644      title: "GitHub",645      username: "june",646      password: "secret123",647      url: "https://github.com",648    });649    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);650651    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });652    await vi.runAllTimersAsync();653    const response = await responsePromise;654655    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });656    expect(vault.getEntryById(entry.id)?.breach).toBe(7);657    breachSpy.mockRestore();658  });659660  it("rejects passkey approval from a different tab than the ceremony", async () => {661    sessionStorage.passkey_pending_301 = {662      requestId: 301,663      type: "get",664      rpId: "example.com",665      rpName: "Example",666      userName: "[email protected]",667      tabId: 7,668      origin: "https://example.com",669    };670671    await expect(672      dispatch(673        { type: "PASSKEY_AUTH_RESPONSE", payload: { requestId: 301, approved: true } },674        { id: "peach-extension", tab: { id: 8 }, url: "https://example.com/login" } as chrome.runtime.MessageSender,675      ),676    ).resolves.toMatchObject({ ok: false, error: "invalid_passkey_approval_sender" });677  });678679  it("messages from foreign or URL-less senders are rejected", async () => {680    const vault = await import("./vault");681    await vault.createVault("correct-pass-12345");682683    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({684      ok: false,685      error: "invalid_sender",686    });687688    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({689      ok: false,690      error: "invalid_sender",691    });692  });693694  it("LOCK and UNLOCK messages update vault state correctly", async () => {695    const vault = await import("./vault");696697    await expect(698      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })699    ).resolves.toMatchObject({ ok: true });700    expect(vault.isUnlocked()).toBe(true);701702    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });703    expect(vault.isUnlocked()).toBe(false);704705    await expect(706      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })707    ).resolves.toMatchObject({ ok: false });708    expect(vault.isUnlocked()).toBe(false);709710    await expect(711      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })712    ).resolves.toMatchObject({ ok: true });713    expect(vault.isUnlocked()).toBe(true);714  });715716  it("does not acknowledge LOCK until session cleanup finishes", async () => {717    await expect(718      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })719    ).resolves.toMatchObject({ ok: true });720721    const removalStarted = deferred();722    const releaseRemoval = deferred();723    const remove = chrome.storage.session.remove as unknown as ReturnType<typeof vi.fn>;724    remove.mockImplementation(async (keys: string | string[]) => {725      removalStarted.resolve();726      await releaseRemoval.promise;727      for (const key of Array.isArray(keys) ? keys : [keys]) delete sessionStorage[key];728    });729730    let responded = false;731    const response = dispatch({ type: "LOCK" }).then((result) => {732      responded = true;733      return result;734    });735    await removalStarted.promise;736737    try {738      await Promise.resolve();739      expect(responded).toBe(false);740    } finally {741      releaseRemoval.resolve();742    }743744    await expect(response).resolves.toMatchObject({ ok: true });745  });746747  it("remembers the last successful unlock method", async () => {748    const vault = await import("./vault");749    const password = "correct-pass-12345";750    const popupSender = {751      id: "peach-extension",752      url: "chrome-extension://peach-extension/src/popup/index.html",753    } as chrome.runtime.MessageSender;754755    await vault.createVault(password);756    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);757    vault.lock();758759    await expect(760      dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),761    ).resolves.toMatchObject({ ok: true });762    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);763764    vault.lock();765    await expect(766      dispatch({ type: "UNLOCK", payload: { password } }, popupSender),767    ).resolves.toMatchObject({ ok: true });768    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);769  });770771  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {772    const response = await dispatch({773      type: "FIRE_CANARY_PING",774      payload: {775        webhookUrl: "https://example.com/hook",776        entryTitle: "GitHub",777        trigger: "password_copied",778      },779    });780781    expect(response).toMatchObject({ ok: true });782    expect(fetchMock).toHaveBeenCalledWith(783      "https://example.com/hook",784      expect.objectContaining({785        method: "POST",786        headers: { "Content-Type": "application/json" },787        body: expect.any(String),788      })789    );790    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;791    expect(body).toMatchObject({792      entry: "GitHub",793      trigger: "password_copied",794      source: "peach-extension",795    });796  });797798  it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {799    const payload = {800      webhookUrl: "https://example.com/hook",801      entryTitle: "GitHub",802      trigger: "autofilled" as const,803    };804    const first = await dispatch({ type: "FIRE_CANARY_PING", payload });805    expect(first).toMatchObject({ ok: true });806    expect(fetchMock).toHaveBeenCalledTimes(1);807808    const second = await dispatch({ type: "FIRE_CANARY_PING", payload });809    expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });810    expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again811  });812813  it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {814    await dispatch({815      type: "FIRE_CANARY_PING",816      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },817    });818    await dispatch({819      type: "FIRE_CANARY_PING",820      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },821    });822    expect(fetchMock).toHaveBeenCalledTimes(2);823  });824825  it("LV-3: test pings are exempt from the debounce", async () => {826    const payload = {827      webhookUrl: "https://example.com/hook",828      entryTitle: "GitHub",829      trigger: "test" as const,830    };831    await dispatch({ type: "FIRE_CANARY_PING", payload });832    await dispatch({ type: "FIRE_CANARY_PING", payload });833    expect(fetchMock).toHaveBeenCalledTimes(2);834  });835836  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {837    await expect(838      dispatch({839        type: "FIRE_CANARY_PING",840        payload: {841          webhookUrl: "http://example.com/hook",842          entryTitle: "GitHub",843          trigger: "password_copied",844        },845      })846    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });847848    expect(fetchMock).not.toHaveBeenCalled();849  });850851  it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {852    for (const webhookUrl of [853      "https://[::ffff:192.168.0.1]/hook",854      "https://[::ffff:c0a8:1]/hook",855      "https://[::ffff:10.0.0.1]/hook",856      "https://[::ffff:127.0.0.1]/hook",857      "https://[::ffff:169.254.1.1]/hook",858      "https://[::ffff:172.16.0.1]/hook",859    ]) {860      await expect(861        dispatch({862          type: "FIRE_CANARY_PING",863          payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },864        })865      ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });866    }867868    expect(fetchMock).not.toHaveBeenCalled();869  });870871  it("GET_IDENTITIES when locked returns vault locked error", async () => {872    const vault = await import("./vault");873    await vault.createVault("correct-pass-12345");874    vault.lock();875    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({876      ok: false,877      error: "vault_locked",878    });879  });880881  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {882    const vault = await import("./vault");883    await vault.createVault("correct-pass-12345");884    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });885    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });886    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });887888    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };889    expect(resp.ok).toBe(true);890    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;891    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({892      type: "email",893      useCount: 2,894    });895    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({896      type: "username",897      useCount: 1,898    });899    // emails appear before usernames900    const emailIdx = data.findIndex((d) => d.value === "[email protected]");901    const userIdx = data.findIndex((d) => d.value === "juni_patel");902    expect(emailIdx).toBeLessThan(userIdx);903  });904905  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {906    const vault = await import("./vault");907    await vault.createVault("correct-pass-12345");908    for (let i = 0; i < 3; i++) {909      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });910    }911912    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };913    const data = resp.data as Array<{ value: string; useCount: number }>;914    const hits = data.filter((d) => d.value === "[email protected]");915    expect(hits).toHaveLength(1);916    expect(hits[0]!.useCount).toBe(3);917  });918919  it("GET_IDENTITIES rejects content-script and page-relay senders", async () => {920    const vault = await import("./vault");921    await vault.createVault("correct-pass-12345");922    await vault.addEntry({923      title: "Private identity",924      username: "[email protected]",925      password: "pw",926      url: "https://private.example",927    });928929    const senders = [930      {931        id: "peach-extension",932        url: "https://private.example/signup",933        tab: { id: 1 } as chrome.tabs.Tab,934      },935      { id: "peach-extension", url: "https://private.example/signup" },936    ] satisfies chrome.runtime.MessageSender[];937    for (const sender of senders) {938      await expect(dispatch({ type: "GET_IDENTITIES" }, sender)).resolves.toMatchObject({939        ok: false,940        error: "invalid_sender",941      });942    }943  });944945  it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {946    const vault = await import("./vault");947    await vault.createVault("correct-pass-12345");948    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;949950    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer951    alarmsCreate.mockClear();952    await dispatch({ type: "GET_ENTRIES" });953    expect(alarmsCreate).toHaveBeenCalled();954955    // Passive queries are in the exclusion list and must not re-arm956    alarmsCreate.mockClear();957    await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });958    expect(alarmsCreate).not.toHaveBeenCalled();959  });960961  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {962    const vault = await import("./vault");963    await vault.createVault("correct-pass-12345");964    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });965966    // Simulate an activated Pro license + already-celebrated marker.967    storage.peach_license = "PEACH-PRO-TEST-KEY";968    storage.peach_pro_celebrated = true;969970    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });971    expect(res).toMatchObject({ ok: true });972973    // Vault data is gone…974    expect(storage.peach_vault_a).toBeUndefined();975    // …but the paid license and celebration flag survive the wipe.976    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");977    expect(storage.peach_pro_celebrated).toBe(true);978  });979980  it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {981    const messages = await import("./messages");982    const vault = await import("./vault");983984    let resolveGate: () => void = () => {};985    messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));986987    let settled = false;988    const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });989990    // While restore is in flight the handler must NOT have answered yet —991    // this is the window where the old code returned "no vault" → onboarding.992    await new Promise((r) => setTimeout(r, 20));993    expect(settled).toBe(false);994995    // Restore completes (the vault becomes available), gate opens.996    await vault.createVault("correct-pass-12345");997    resolveGate();998999    await expect(pending).resolves.toMatchObject({ ok: true, data: true });1000  });10011002  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {1003    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });1004    expect(res.ok).toBe(false);1005    // Nothing was written — no vault exists.1006    const has = await dispatch({ type: "HAS_VAULT" });1007    expect((has as { data?: boolean }).data).toBe(false);1008  });10091010  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {1011    const vault = await import("./vault");1012    await vault.createVault("correct-pass-12345");1013    const e = await vault.addEntry({1014      title: "Mail",1015      username: "june",1016      password: "pw",1017      url: "https://mail.test",1018      totp: "JBSWY3DPEHPK3PXP",1019    });10201021    const res = await dispatch(1022      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1023      { id: "peach-extension", url: "https://mail.test/login" },1024    );1025    expect(res.ok).toBe(true);1026    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };1027    expect(data.totp).toBeUndefined();1028    expect(data.totpCode).toMatch(/^\d{6}$/);1029    expect(data.totpRemaining).toBeGreaterThan(0);1030    expect(data.totpRemaining).toBeLessThanOrEqual(30);1031  });10321033  it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {1034    const vault = await import("./vault");1035    await vault.createVault("correct-pass-12345");1036    const e = await vault.addEntry({1037      title: "Bank",1038      username: "june",1039      password: "old-pw",1040      url: "https://bank.test",1041    });1042    await vault.updateEntry(e.id, { password: "new-pw" });10431044    const fill = await dispatch(1045      { type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } },1046      { id: "peach-extension", url: "https://bank.test/login" },1047    );1048    expect(fill.ok).toBe(true);1049    // Passive autofill never receives prior passwords (blast-radius containment).1050    expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();10511052    const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1053    expect(edit.ok).toBe(true);1054    const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];1055    expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);1056  });10571058  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {1059    const vault = await import("./vault");1060    await vault.createVault("correct-pass-12345");1061    const e = await vault.addEntry({1062      title: "Mail",1063      username: "june",1064      password: "pw",1065      url: "https://mail.test",1066      totp: "JBSWY3DPEHPK3PXP",1067    });10681069    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });1070    expect(res.ok).toBe(true);1071    const data = res.data as { totp?: string };1072    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");1073  });10741075  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {1076    const vault = await import("./vault");1077    await vault.createVault("correct-pass-12345");10781079    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });1080    expect(res).toMatchObject({ ok: true });10811082    expect(storage.peach_vault_a).toBeUndefined();1083    expect(storage.peach_license).toBeUndefined();1084    expect(storage.peach_pro_celebrated).toBeUndefined();1085  });10861087  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---10881089  function tabSender(url: string, tabId = 1, frameId = 0): chrome.runtime.MessageSender {1090    return { id: "peach-extension", tab: { id: tabId, url } as chrome.tabs.Tab, url, frameId };1091  }10921093  async function addPasskeyOnlyEntry(title: string, url: string) {1094    const vault = await import("./vault");1095    const generated = await generatePasskeyKeypair(-7);1096    return vault.addEntry({1097      type: "login",1098      title,1099      url,1100      passkey: {1101        rpId: new URL(url).hostname,1102        rpName: title,1103        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1104        publicKeyCose: base64urlEncode(generated.publicKeyCose),1105        privateKeyJwk: generated.privateKeyJwk,1106        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),1107        userName: "june",1108        userDisplayName: "June",1109        algorithm: -7,1110        signCount: 0,1111        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",1112        backupEligible: true,1113        backupState: false,1114        createdAt: Date.now(),1115      },1116    });1117  }11181119  it("PB-26-201: normal inventory ignores forged domain and gesture booleans", async () => {1120    const vault = await import("./vault");1121    await vault.createVault("correct-pass-12345");1122    await vault.addEntry({ title: "GitHub", username: "victim", password: "secret", url: "https://github.com" });1123    await vault.addEntry({ title: "Evil", username: "attacker", password: "known", url: "https://evil.example.com" });11241125    const result = await dispatch(1126      {1127        type: "AUTOFILL_QUERY",1128        payload: {1129          domain: "github.com",1130          includeAll: true,1131          userGesture: true,1132          explicitSummon: true,1133        },1134      },1135      tabSender("https://evil.example.com/phish"),1136    );11371138    expect(result).toMatchObject({ ok: true });1139    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil"]);1140  });11411142  it("PB-26-201: normal TOTP inventory is scoped to the attested frame origin", async () => {1143    const vault = await import("./vault");1144    await vault.createVault("correct-pass-12345");1145    await vault.addEntry({ title: "GitHub 2FA", password: "secret", url: "https://github.com", totp: "JBSWY3DPEHPK3PXP" });1146    await vault.addEntry({ title: "Evil 2FA", password: "known", url: "https://evil.example.com", totp: "JBSWY3DPEHPK3PXP" });11471148    const result = await dispatch(1149      { type: "AUTOFILL_QUERY", payload: { domain: "github.com", kind: "totp" } },1150      tabSender("https://evil.example.com/verify"),1151    );11521153    expect((result.data as Array<{ title: string }>).map((entry) => entry.title)).toEqual(["Evil 2FA"]);1154  });11551156  it("PB-26-201: summon capability is one-shot after inventory authorization", async () => {1157    const vault = await import("./vault");1158    const messages = await import("./messages");1159    await vault.createVault("correct-pass-12345");1160    const entry = await vault.addEntry({ title: "GitHub", username: "june", password: "secret123", url: "https://github.com" });1161    const sender = tabSender("https://evil.example.com/phish");1162    const token = messages.createSummonCapability(1, "https://evil.example.com");11631164    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1165    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });11661167    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1168    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });1169    const replay = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1170    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1171  });11721173  it("PB-26-201: summon capability is consumed by a same-domain fill", async () => {1174    const vault = await import("./vault");1175    const messages = await import("./messages");1176    await vault.createVault("correct-pass-12345");1177    const entry = await vault.addEntry({ title: "GitHub", password: "secret123", url: "https://evil.example.com" });1178    const sender = tabSender("https://evil.example.com/login");1179    const token = messages.createSummonCapability(1, "https://evil.example.com");11801181    const inventory = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1182    expect(inventory).toMatchObject({ ok: true, data: [{ id: entry.id }] });1183    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1184    expect(firstFill).toMatchObject({ ok: true, data: { id: entry.id } });11851186    const offDomainReplay = await dispatch(1187      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1188      tabSender("https://github.com/login"),1189    );1190    expect(offDomainReplay).toMatchObject({ ok: false, error: "entry_not_found" });1191  });11921193  it("PB-26-201: summon capability cannot fill a different entry", async () => {1194    const vault = await import("./vault");1195    const messages = await import("./messages");1196    await vault.createVault("correct-pass-12345");1197    const first = await vault.addEntry({ title: "First", password: "one", url: "https://first.example" });1198    const second = await vault.addEntry({ title: "Second", password: "two", url: "https://second.example" });1199    const sender = tabSender("https://evil.example.com/phish");1200    const token = messages.createSummonCapability(1, "https://evil.example.com");1201    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1202    const firstFill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: first.id, capability: token } }, sender);1203    expect(firstFill).toMatchObject({ ok: true, data: { id: first.id } });1204    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: second.id, capability: token } }, sender);1205    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1206    expect(first.id).not.toBe(second.id);1207  });12081209  it("PB-26-201: fill before inventory is rejected without an entry oracle", async () => {1210    const vault = await import("./vault");1211    const messages = await import("./messages");1212    await vault.createVault("correct-pass-12345");1213    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1214    const token = messages.createSummonCapability(1, "https://evil.example.com");1215    const result = await dispatch(1216      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1217      tabSender("https://evil.example.com/phish"),1218    );1219    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1220  });12211222  it("PB-26-201: an unused summon capability does not block a normal same-origin fill", async () => {1223    const vault = await import("./vault");1224    const messages = await import("./messages");1225    await vault.createVault("correct-pass-12345");1226    const entry = await vault.addEntry({1227      title: "Evil",1228      password: "known",1229      url: "https://evil.example.com",1230    });1231    const sender = tabSender("https://evil.example.com/login");1232    const token = messages.createSummonCapability(1, "https://evil.example.com");12331234    const result = await dispatch(1235      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1236      sender,1237    );12381239    expect(result).toMatchObject({ ok: true, data: { id: entry.id } });1240  });12411242  it("PB-26-201: expired fill capability is rejected before entry-specific errors", async () => {1243    vi.useFakeTimers();1244    vi.setSystemTime(new Date("2026-09-02T12:00:00Z"));1245    const vault = await import("./vault");1246    const messages = await import("./messages");1247    await vault.createVault("correct-pass-12345");1248    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1249    const sender = tabSender("https://evil.example.com/phish");1250    const token = messages.createSummonCapability(1, "https://evil.example.com");1251    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1252    vi.advanceTimersByTime(30_001);12531254    const result = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } }, sender);1255    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1256  });12571258  it("PB-26-201: wrong-tab fill capability is rejected before entry-specific errors", async () => {1259    const vault = await import("./vault");1260    const messages = await import("./messages");1261    await vault.createVault("correct-pass-12345");1262    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1263    const token = messages.createSummonCapability(1, "https://evil.example.com");1264    await dispatch(1265      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1266      tabSender("https://evil.example.com/phish"),1267    );12681269    const result = await dispatch(1270      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1271      tabSender("https://evil.example.com/phish", 2),1272    );1273    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1274  });12751276  it("PB-26-201: wrong-frame fill capability is rejected before entry-specific errors", async () => {1277    const vault = await import("./vault");1278    const messages = await import("./messages");1279    await vault.createVault("correct-pass-12345");1280    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1281    const token = messages.createSummonCapability(1, "https://evil.example.com", 3);1282    await dispatch(1283      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1284      tabSender("https://evil.example.com/phish", 1, 3),1285    );12861287    const result = await dispatch(1288      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1289      tabSender("https://evil.example.com/phish", 1, 4),1290    );1291    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1292  });12931294  it("PB-26-201: wrong-origin fill capability is rejected before entry-specific errors", async () => {1295    const vault = await import("./vault");1296    const messages = await import("./messages");1297    await vault.createVault("correct-pass-12345");1298    const entry = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1299    const token = messages.createSummonCapability(1, "https://evil.example.com");1300    await dispatch(1301      { type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } },1302      tabSender("https://evil.example.com/phish"),1303    );13041305    const result = await dispatch(1306      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id, capability: token } },1307      tabSender("https://other.example.com/phish"),1308    );1309    expect(result).toMatchObject({ ok: false, error: "entry_not_found" });1310  });13111312  it("PB-26-201: selecting a missing id consumes the one-shot capability without an oracle", async () => {1313    const vault = await import("./vault");1314    const messages = await import("./messages");1315    await vault.createVault("correct-pass-12345");1316    const existing = await addPasskeyOnlyEntry("GitHub passkey", "https://github.com");1317    const sender = tabSender("https://evil.example.com/phish");1318    const token = messages.createSummonCapability(1, "https://evil.example.com");1319    await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);13201321    const missing = await dispatch(1322      { type: "GET_ENTRY_FOR_FILL", payload: { id: "missing", capability: token } },1323      sender,1324    );1325    const existingReplay = await dispatch(1326      { type: "GET_ENTRY_FOR_FILL", payload: { id: existing.id, capability: token } },1327      sender,1328    );1329    expect(missing).toMatchObject({ ok: false, error: "entry_not_found" });1330    expect(existingReplay).toEqual(missing);1331  });13321333  it("PB-26-201: inventory capability cannot be reused for a second inventory", async () => {1334    const vault = await import("./vault");1335    const messages = await import("./messages");1336    await vault.createVault("correct-pass-12345");1337    await vault.addEntry({ title: "GitHub", password: "secret", url: "https://github.com" });1338    const sender = tabSender("https://evil.example.com/phish");1339    const token = messages.createSummonCapability(1, "https://evil.example.com");1340    const first = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);1341    const second = await dispatch({ type: "AUTOFILL_QUERY", payload: { domain: "evil.example.com", capability: token } }, sender);13421343    expect(first).toMatchObject({ ok: true, data: [{ title: "GitHub" }] });1344    expect(second).toMatchObject({ ok: false, error: "entry_not_found" });1345  });13461347  it("PB-26-201: cards require a capability and consume it on the selected fill", async () => {1348    const vault = await import("./vault");1349    const messages = await import("./messages");1350    await vault.createVault("correct-pass-12345");1351    const card = await vault.addEntry({1352      type: "card",1353      title: "Personal Visa",1354      cardholderName: "June Peach",1355      cardNumber: "4111111111111111",1356      cvc: "123",1357      expMonth: "12",1358      expYear: "2030",1359    });1360    const sender = tabSender("https://shop.example.com/checkout");1361    const passive = await dispatch(1362      { type: "AUTOFILL_QUERY", payload: { domain: "shop.example.com", kind: "card" } },1363      sender,1364    );1365    expect(passive).toMatchObject({ ok: true, data: [] });13661367    const token = messages.createSummonCapability(1, "https://shop.example.com");1368    const inventory = await dispatch(1369      { type: "AUTOFILL_QUERY", payload: { domain: "forged.example", kind: "card", capability: token } },1370      sender,1371    );1372    expect(inventory).toMatchObject({ ok: true, data: [{ id: card.id, title: "Personal Visa" }] });1373    expect((inventory.data as Array<Record<string, unknown>>)[0]).not.toHaveProperty("cardNumber");13741375    const fill = await dispatch(1376      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1377      sender,1378    );1379    expect(fill).toMatchObject({ ok: true, data: { id: card.id, cardNumber: "4111111111111111" } });1380    const replay = await dispatch(1381      { type: "GET_ENTRY_FOR_FILL", payload: { id: card.id, capability: token } },1382      sender,1383    );1384    expect(replay).toMatchObject({ ok: false, error: "entry_not_found" });1385  });13861387  it("PB-26-201: inline edit popup request stores only the selected entry id", async () => {1388    const result = await dispatch(1389      { type: "OPEN_POPUP", payload: { entryId: "entry-123" } },1390      tabSender("https://example.com/login"),1391    );13921393    expect(result).toMatchObject({ ok: true });1394    expect(sessionStorage.peach_open_entry).toBe("entry-123");1395    expect(sessionStorage.peach_inline_unlock).toBeUndefined();1396  });13971398  it("MV-1: tab request with a matching domain returns the entry", async () => {1399    const vault = await import("./vault");1400    await vault.createVault("correct-pass-12345");1401    const entry = await vault.addEntry({1402      title: "GitHub",1403      username: "june",1404      password: "secret123",1405      url: "https://github.com",1406    });14071408    const res = await dispatch(1409      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1410      tabSender("https://github.com/login"),1411    );1412    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1413  });14141415  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {1416    const vault = await import("./vault");1417    await vault.createVault("correct-pass-12345");1418    const entry = await vault.addEntry({1419      title: "GitHub",1420      username: "june",1421      password: "secret123",1422      url: "https://github.com",1423    });14241425    const res = await dispatch(1426      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1427      tabSender("https://evil.example.com/phish"),1428    );1429    // Refused with the same error as a missing id — no cross-domain oracle.1430    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1431  });14321433  it("PB-26-201: explicitSummon cannot authorize off-domain fill", async () => {1434    const vault = await import("./vault");1435    await vault.createVault("correct-pass-12345");1436    const entry = await vault.addEntry({1437      title: "GitHub",1438      username: "june",1439      password: "secret123",1440      url: "https://github.com",1441    });14421443    const res = await dispatch(1444      {1445        type: "GET_ENTRY_FOR_FILL",1446        payload: { id: entry.id, explicitSummon: true },1447      },1448      tabSender("https://evil.example.com/phish"),1449    );1450    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });1451  });14521453  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1454    const vault = await import("./vault");1455    await vault.createVault("correct-pass-12345");1456    const entry = await vault.addEntry({1457      title: "GitHub",1458      username: "june",1459      password: "secret123",1460      url: "https://github.com",1461    });14621463    const res = await dispatch(1464      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1465      tabSender("not a url"),1466    );1467    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1468  });14691470  // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---14711472  async function seedTwoDomainVault() {1473    const vault = await import("./vault");1474    await vault.createVault("correct-pass-12345");1475    await vault.addEntry({1476      title: "GitHub",1477      username: "june",1478      password: "gh",1479      url: "https://github.com",1480    });1481    await vault.addEntry({1482      title: "Example",1483      username: "june",1484      password: "ex",1485      url: "https://example.com",1486    });1487  }14881489  it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1490    await seedTwoDomainVault();14911492    const res = await dispatch({1493      type: "AUTOFILL_QUERY",1494      payload: { domain: "github.com", includeAll: true },1495    });1496    expect(res.ok).toBe(true);1497    const data = res.data as Array<{ title: string }>;1498    // includeAll was stripped — only the github.com match comes back.1499    expect(data).toHaveLength(1);1500    expect(data[0].title).toBe("GitHub");1501  });15021503  it("PB-26-201: userGesture cannot authorize vault-wide inventory", async () => {1504    await seedTwoDomainVault();15051506    const res = await dispatch({1507      type: "AUTOFILL_QUERY",1508      payload: { domain: "github.com", includeAll: true, userGesture: true },1509    });1510    expect(res.ok).toBe(true);1511    const data = res.data as Array<{ title: string }>;1512    expect(data.map((d) => d.title)).toEqual(["GitHub"]);1513  });15141515  // --- MV-7: vouched device confirmation handlers ---15161517  async function seedPendingVouched(deviceId: string) {1518    await chrome.storage.local.set({1519      peach_pending_vouched_devices_v1: [{1520        deviceId,1521        deviceName: "New Pixel",1522        deviceType: "android",1523        longTermPublicKeyHex: "e".repeat(64),1524        signingPublicKeyHex: "a".repeat(64),1525        wsPort: 51234,1526        pairedAt: 1_700_000_000_020,1527        receivedAt: 1_700_000_000_050,1528        vouchedByDeviceId: "extension-issuer",1529      }],1530    });1531  }15321533  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1534    const vault = await import("./vault");1535    await vault.createVault("correct-pass-12345");1536    await seedPendingVouched("android-new");1537    const { findPairedDeviceById } = await import("./paired-devices");1538    const { listPendingVouchedDevices } = await import(1539      "./pending-vouched-devices"1540    );15411542    const res = await dispatch({1543      type: "CONFIRM_VOUCHED_DEVICE",1544      payload: { deviceId: "android-new" },1545    });1546    expect(res).toMatchObject({ ok: true });15471548    expect(await findPairedDeviceById("android-new")).toMatchObject({1549      deviceName: "New Pixel",1550      trustSource: "vouched",1551      longTermPublicKeyHex: "e".repeat(64),1552    });1553    expect(await listPendingVouchedDevices()).toHaveLength(0);1554  });15551556  it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1557    const vault = await import("./vault");1558    await vault.createVault("correct-pass-12345");1559    await seedPendingVouched("android-relabelled");1560    const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1561      await import("./paired-devices");1562    const { listPendingVouchedDevices } = await import(1563      "./pending-vouched-devices"1564    );15651566    // The queue entry predates the revocation, and it carries a fresh id, so1567    // sweeping the queue by id at revocation time would not have caught it.1568    await upsertPairedDevice({1569      deviceId: "android-old",1570      deviceName: "New Pixel",1571      deviceType: "android",1572      longTermPublicKeyHex: "e".repeat(64),1573      signingPublicKeyHex: "a".repeat(64),1574      pairedAt: 1_700_000_000_000,1575      trustSource: "vouched",1576    });1577    await revokeDeviceById("android-old", "self");15781579    const res = await dispatch({1580      type: "CONFIRM_VOUCHED_DEVICE",1581      payload: { deviceId: "android-relabelled" },1582    });1583    expect(res).toMatchObject({ ok: false, error: "device_revoked" });1584    expect(await findPairedDeviceById("android-relabelled")).toBeNull();1585    expect(await listPendingVouchedDevices()).toHaveLength(0);1586  });15871588  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1589    const vault = await import("./vault");1590    await vault.createVault("correct-pass-12345");1591    await seedPendingVouched("android-new");1592    const { findPairedDeviceById } = await import("./paired-devices");1593    const { listPendingVouchedDevices } = await import(1594      "./pending-vouched-devices"1595    );15961597    const res = await dispatch({1598      type: "REJECT_VOUCHED_DEVICE",1599      payload: { deviceId: "android-new" },1600    });1601    expect(res).toMatchObject({ ok: true });16021603    expect(await findPairedDeviceById("android-new")).toBeNull();1604    expect(await listPendingVouchedDevices()).toHaveLength(0);1605  });16061607  // --- unpair: UNPAIR_DEVICE revokes the device and drops it from the registry ---16081609  it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1610    const vault = await import("./vault");1611    await vault.createVault("correct-pass-12345");1612    const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1613      await import("./paired-devices");16141615    await upsertPairedDevice({1616      deviceId: "phone-1",1617      deviceName: "June's Phone",1618      deviceType: "android",1619      longTermPublicKeyHex: "a".repeat(64),1620      signingPublicKeyHex: "b".repeat(64),1621      pairedAt: 1_700_000_000_000,1622      trustSource: "direct",1623    });16241625    const res = await dispatch({1626      type: "UNPAIR_DEVICE",1627      payload: { deviceId: "phone-1" },1628    });1629    expect(res).toMatchObject({ ok: true });16301631    // The row is gone locally — re-listing must not surface it.1632    expect(await findPairedDeviceById("phone-1")).toBeNull();1633    // The revocation is recorded so a future manifest from a peer cannot1634    // resurrect this device under either the same id or the same key (MV-7 + S1).1635    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1636    const records = await listDeviceRevocations();1637    expect(records).toHaveLength(1);1638    expect(records[0]).toMatchObject({1639      deviceId: "phone-1",1640      longTermPublicKeyHex: "a".repeat(64),1641      revokedByDeviceId: expect.any(String),1642    });1643  });16441645  it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1646    const vault = await import("./vault");1647    await vault.createVault("correct-pass-12345");1648    const { listDeviceRevocations } = await import("./paired-devices");16491650    const res = await dispatch({1651      type: "UNPAIR_DEVICE",1652      payload: { deviceId: "never-paired" },1653    });1654    expect(res).toMatchObject({ ok: true });1655    // A revocation with the all-zero key sentinel is still written — the1656    // contract is that the user-intent is recorded even when the registry had1657    // no row. This is what blocks a peer from later vouched-adopting the same1658    // fresh id back in.1659    const records = await listDeviceRevocations();1660    expect(records).toHaveLength(1);1661    expect(records[0]).toMatchObject({1662      deviceId: "never-paired",1663      longTermPublicKeyHex: "0".repeat(64),1664    });1665  });16661667  it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1668    const res = await dispatch({1669      type: "UNPAIR_DEVICE",1670      payload: { deviceId: "" },1671    });1672    expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1673  });16741675  // The old "fresh direct re-pair clears the old revocation" test dispatched1676  // COMPLETE_PAIRING, a handler deleted on 2026-08-04 (no legitimate senders;1677  // adoption runs in-background via relay-adoption-bg.ts). The live1678  // revocation-overlap repair is pinned in paired-devices.test.ts1679  // (repairPairedRevocationOverlaps, runs at service-worker boot).1680  it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1681    const vault = await import("./vault");1682    await vault.createVault("correct-pass-12345");1683    const contentScriptSender = {1684      id: "peach-extension",1685      url: "https://evil.example/page",1686    };1687    const extensionPageSender = {1688      id: "peach-extension",1689      url: "chrome-extension://peach-extension/popup.html",1690    };1691    const completion = { requestId: 1, rpId: "evil.example" };16921693    // Content-script senders must never reach the gated passkey-store handler.1694    await expect(1695      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1696    ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });16971698    // Extension-page senders still reach it — refused by the pending-ceremony1699    // binding, not by the sender gate.1700    await expect(1701      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1702    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1703  });17041705  it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1706    const vault = await import("./vault");1707    await vault.createVault("correct-pass-12345");1708    const extensionPageSender = {1709      id: "peach-extension",1710      url: "chrome-extension://peach-extension/popup.html",1711    };1712    const completion = {1713      requestId: 123,1714      rpId: "example.com",1715      rpName: "Example",1716      userName: "june",1717      userHandle: "BBBB",1718      credentialId: "cred-1",1719      publicKeyCose: "c3BraQ",1720      algorithm: -7 as const,1721    };17221723    // No pending ceremony at all -> refuse.1724    await expect(1725      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1726    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });17271728    // Seed a background-minted pending ceremony.1729    sessionStorage.passkey_pending_firefox = {1730      requestId: 123,1731      type: "create",1732      rpId: "example.com",1733      rpName: "Example",1734      userName: "june",1735      browser: "firefox",1736      challenge: "AAAA",1737      userHandle: "BBBB",1738    };17391740    // Mismatched userHandle (attacker-forged completion) -> refuse.1741    await expect(1742      dispatch(1743        { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1744        extensionPageSender1745      )1746    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });17471748    // Matching completion -> stored.1749    await expect(1750      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1751    ).resolves.toMatchObject({ ok: true });1752    const entries = await dispatch({ type: "GET_ENTRIES" });1753    expect(entries).toMatchObject({ ok: true });1754    const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1755    expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1756  });17571758  it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1759    await expect(1760      dispatch(1761        {1762          type: "INITIATE_FIREFOX_PASSKEY_CREATE",1763          payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1764        },1765        { id: "peach-extension", url: "https://evil.example/page" }1766      )1767    ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1768  });17691770  it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1771    // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts;1772    // here we just pin the storage-level invariant the UNPAIR_DEVICE call1773    // establishes, since the rest of the testbed's revoke coverage rides on1774    // this state.1775    const vault = await import("./vault");1776    await vault.createVault("correct-pass-12345");1777    const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1778      "./paired-devices"1779    );17801781    await upsertPairedDevice({1782      deviceId: "phone-1",1783      deviceName: "June's Phone",1784      deviceType: "android",1785      longTermPublicKeyHex: "a".repeat(64),1786      signingPublicKeyHex: "b".repeat(64),1787      pairedAt: 1_700_000_000_000,1788      trustSource: "direct",1789    });17901791    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1792    await revokeDeviceById("phone-1", "self");1793    // The key check is what blocks a re-vouch under a fresh id.1794    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1795    expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1796  });17971798  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---17991800  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1801    const vault = await import("./vault");1802    await vault.createVault("correct-pass-12345");18031804    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1805    expect(res).toMatchObject({ ok: false, error: "password_required" });1806  });18071808  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1809    const vault = await import("./vault");1810    await vault.createVault("correct-pass-12345");18111812    const res = await dispatch({1813      type: "GET_RECOVERY_WORDS",1814      payload: { password: "not-the-password" },1815    });1816    expect(res).toMatchObject({ ok: false, error: "wrong_password" });1817    expect(res.data).toBeUndefined();1818  });18191820  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1821    const vault = await import("./vault");1822    await vault.createVault("correct-pass-12345");18231824    const res = await dispatch({1825      type: "GET_RECOVERY_WORDS",1826      payload: { password: "correct-pass-12345" },1827    });1828    expect(res.ok).toBe(true);1829    expect((res.data as string[]).length).toBe(12);1830  });18311832  it("PB-26-311: cleartext export requires a fresh master-password proof", async () => {1833    const vault = await import("./vault");1834    await vault.createVault("correct-pass-12345");18351836    await expect(1837      dispatch({ type: "EXPORT_ENTRIES" as never, payload: {} }),1838    ).resolves.toMatchObject({ ok: false, error: "password_required" });1839    await expect(1840      dispatch({1841        type: "EXPORT_ENTRIES" as never,1842        payload: { password: "not-the-password" },1843      }),1844    ).resolves.toMatchObject({ ok: false, error: "wrong_password" });1845    await expect(1846      dispatch({1847        type: "EXPORT_ENTRIES" as never,1848        payload: { password: "correct-pass-12345" },1849      }),1850    ).resolves.toMatchObject({ ok: true, data: [] });1851  });18521853  it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1854    const vault = await import("./vault");1855    const password = "correct-pass-12345";1856    const popupSender = {1857      id: "peach-extension",1858      url: "chrome-extension://peach-extension/src/popup/index.html",1859    } as chrome.runtime.MessageSender;18601861    await vault.createVault(password);1862    await vault.setupPinUnlock(password, "1234");1863    await vault.lock();18641865    await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1866      ok: true,1867      data: { configured: true, available: true, attemptsRemaining: 3 },1868    });1869  });1870});