messages.test.ts

37.5 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";4import type { Message, MessageResponse } from "../shared/types";56type StorageShape = Record<string, unknown>;7type MessageListener = (8  msg: unknown,9  sender: chrome.runtime.MessageSender,10  sendResponse: (response: MessageResponse) => void11) => boolean;1213describe("background messages", () => {14  let storage: StorageShape;15  let sessionStorage: StorageShape;16  let listener: MessageListener;17  let fetchMock: ReturnType<typeof vi.fn>;1819  beforeEach(async () => {20    vi.resetModules();21    vi.useRealTimers();22    storage = {23      peach_settings: { breachMonitoring: false },24    };25    sessionStorage = {};2627    fetchMock = vi.fn(async () => ({28      ok: true,29      status: 200,30      text: async () => "",31    }));32    vi.stubGlobal("fetch", fetchMock);33    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3435    const tabs = [{ id: 1 }];36    const chromeMock = {37      runtime: {38        id: "peach-extension",39        getURL: (p: string) => `chrome-extension://peach-extension/${p}`,40        onMessage: {41          addListener: vi.fn((fn: MessageListener) => {42            listener = fn;43          }),44        },45        sendMessage: vi.fn(async () => ({ ok: true })),46      },47      tabs: {48        query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {49          if (typeof callback === "function") {50            callback(tabs);51            return;52          }53          return Promise.resolve(tabs);54        }),55        sendMessage: vi.fn(async () => ({ ok: true })),56      },57      storage: {58        local: {59          get: vi.fn(async (key?: string | string[]) => {60            if (!key) return storage;61            if (typeof key === "string") {62              return { [key]: storage[key] };63            }6465            return key.reduce<Record<string, unknown>>((result, entry) => {66              result[entry] = storage[entry];67              return result;68            }, {});69          }),70          set: vi.fn(async (next: Record<string, unknown>) => {71            Object.assign(storage, next);72          }),73          remove: vi.fn(async (keys: string | string[]) => {74            const list = Array.isArray(keys) ? keys : [keys];75            for (const key of list) {76              delete storage[key];77            }78          }),79          clear: vi.fn(async () => {80            for (const key of Object.keys(storage)) delete storage[key];81          }),82        },83        session: {84          get: vi.fn(async (key?: string | string[]) => {85            if (!key) return sessionStorage;86            if (typeof key === "string") {87              return { [key]: sessionStorage[key] };88            }89            return key.reduce<Record<string, unknown>>((result, entry) => {90              result[entry] = sessionStorage[entry];91              return result;92            }, {});93          }),94          set: vi.fn(async (next: Record<string, unknown>) => {95            Object.assign(sessionStorage, next);96          }),97          remove: vi.fn(async (keys: string | string[]) => {98            const list = Array.isArray(keys) ? keys : [keys];99            for (const key of list) {100              delete sessionStorage[key];101            }102          }),103          clear: vi.fn(async () => {104            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];105          }),106        },107      },108      alarms: {109        clear: vi.fn(),110        create: vi.fn(),111        onAlarm: { addListener: vi.fn() },112      },113      idle: {114        setDetectionInterval: vi.fn(),115        onStateChanged: { addListener: vi.fn() },116      },117      scripting: {118        executeScript: vi.fn(async () => []),119      },120    } as unknown as typeof chrome;121122    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;123124    const messages = await import("./messages");125    messages.setupMessageHandler();126    const vault = await import("./vault");127    await vault.ensureSettingsLoaded();128  });129130  async function dispatch(131    message: Message,132    sender: chrome.runtime.MessageSender = { id: "peach-extension" }133  ): Promise<MessageResponse> {134    return await new Promise<MessageResponse>((resolve) => {135      const keepChannelOpen = listener(message, sender, (response) => resolve(response));136      expect(keepChannelOpen).toBe(true);137    });138  }139140  it("GET_ENTRIES when unlocked returns the entry array", async () => {141    const vault = await import("./vault");142    await vault.createVault("correct-pass-12345");143    await vault.addEntry({144      title: "GitHub",145      username: "june",146      password: "secret123",147      url: "https://github.com",148    });149150    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({151      ok: true,152      data: [153        expect.objectContaining({154          title: "GitHub",155          username: "june",156        }),157      ],158    });159  });160161  it("GET_ENTRIES when locked returns a vault locked error", async () => {162    const vault = await import("./vault");163    await vault.createVault("correct-pass-12345");164    vault.lock();165166    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({167      ok: false,168      error: "vault_locked",169    });170  });171172  it("ADD_ENTRY with a valid payload returns the created entry", async () => {173    const vault = await import("./vault");174    await vault.createVault("correct-pass-12345");175176    const response = await dispatch({177      type: "ADD_ENTRY",178      payload: {179        title: "GitHub",180        username: "june",181        password: "secret123",182        url: "https://github.com",183      },184    });185186    expect(response).toMatchObject({187      ok: true,188      data: expect.objectContaining({189        id: expect.any(String),190        title: "GitHub",191      }),192    });193    expect(vault.getEntries()).toHaveLength(1);194  });195196  it("ADD_ENTRY when locked returns a vault locked error", async () => {197    const vault = await import("./vault");198    await vault.createVault("correct-pass-12345");199    vault.lock();200201    await expect(202      dispatch({203        type: "ADD_ENTRY",204        payload: {205          title: "GitHub",206          username: "june",207          password: "secret123",208          url: "https://github.com",209        },210      })211    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });212  });213214  it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {215    const vault = await import("./vault");216    await vault.createVault("correct-pass-12345");217218    await expect(219      dispatch({220        type: "ADD_ENTRY",221        payload: {222          title: "GitHub",223          username: "june",224          password: "",225          url: "https://github.com",226        },227      })228    ).resolves.toMatchObject({ ok: true });229  });230231  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {232    const vault = await import("./vault");233    await vault.createVault("correct-pass-12345");234    const entry = await vault.addEntry({235      title: "GitHub",236      username: "june",237      password: "secret123",238      url: "https://github.com",239    });240241    await expect(242      dispatch({243        type: "UPDATE_ENTRY",244        payload: { id: entry.id, patch: { password: "new-secret" } },245      })246    ).resolves.toMatchObject({ ok: true });247248    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");249  });250251  it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {252    const vault = await import("./vault");253    await vault.createVault("correct-pass-12345");254    const entry = await vault.addEntry({255      title: "GitHub",256      username: "june",257      password: "secret123",258      url: "https://github.com",259    });260261    await expect(262      dispatch({263        type: "UPDATE_ENTRY",264        payload: { id: entry.id, patch: { password: "" } },265      })266    ).resolves.toMatchObject({ ok: true });267268    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();269  });270271  it("DELETE_ENTRY soft-deletes the target entry", async () => {272    const vault = await import("./vault");273    await vault.createVault("correct-pass-12345");274    const entry = await vault.addEntry({275      title: "GitHub",276      username: "june",277      password: "secret123",278      url: "https://github.com",279    });280281    await expect(282      dispatch({283        type: "DELETE_ENTRY",284        payload: { id: entry.id },285      })286    ).resolves.toMatchObject({ ok: true });287288    expect(vault.getEntries()).toEqual([]);289    expect(vault.getDeletedEntries()).toHaveLength(1);290  });291292  it("DELETE_ENTRY of a missing id returns an error", async () => {293    const vault = await import("./vault");294    await vault.createVault("correct-pass-12345");295296    await expect(297      dispatch({298        type: "DELETE_ENTRY",299        payload: { id: "missing" },300      })301    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });302  });303304  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {305    const vault = await import("./vault");306    await vault.createVault("correct-pass-12345");307    const entry = await vault.addEntry({308      title: "GitHub",309      username: "june",310      password: "secret123",311      url: "https://github.com",312    });313314    const response = await dispatch({315      type: "GET_ENTRY_FOR_FILL",316      payload: { id: entry.id },317    });318319    expect(response).toMatchObject({320      ok: true,321      data: expect.objectContaining({322        id: entry.id,323        username: "june",324        password: "secret123",325      }),326    });327    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();328  });329330  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {331    const vault = await import("./vault");332    await vault.createVault("correct-pass-12345");333    vault.lock();334335    await expect(336      dispatch({337        type: "GET_ENTRY_FOR_FILL",338        payload: { id: "missing" },339      })340    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });341  });342343  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {344    const vault = await import("./vault");345    await vault.createVault("correct-pass-12345");346    const generated = await generatePasskeyKeypair(-7);347    const entry = await vault.addEntry({348      type: "login",349      title: "WebAuthn.io",350      passkey: {351        rpId: "webauthn.io",352        rpName: "WebAuthn.io",353        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),354        publicKeyCose: base64urlEncode(generated.publicKeyCose),355        privateKeyJwk: generated.privateKeyJwk,356        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),357        userName: "june",358        userDisplayName: "June",359        algorithm: -7,360        signCount: 0,361        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",362        backupEligible: true,363        backupState: false,364        createdAt: Date.now(),365      },366    });367368    await expect(369      dispatch({370        type: "GET_ENTRY_FOR_FILL",371        payload: { id: entry.id },372      })373    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });374  });375376  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {377    const vault = await import("./vault");378    await vault.createVault("correct-pass-12345");379    await vault.addEntry({380      title: "GitHub",381      username: "june",382      password: "secret123",383      url: "https://github.com",384      totp: "JBSWY3DPEHPK3PXP",385    });386387    const response = await dispatch({388      type: "AUTOFILL_QUERY",389      payload: { domain: "github.com" },390    });391392    expect(response).toMatchObject({393      ok: true,394      data: [395        {396          id: expect.any(String),397          title: "GitHub",398          username: "june",399          url: "https://github.com",400          hasTotp: true,401        },402      ],403    });404    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();405    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();406  });407408  it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {409    const vault = await import("./vault");410    await vault.createVault("correct-pass-12345");411    await vault.addEntry({412      title: "GitHub",413      username: "june",414      password: "secret123",415      url: "https://github.com",416    });417    await vault.addEntry({418      type: "note",419      title: "Secure note",420      notes: "hello",421    });422423    const response = await dispatch({424      type: "AUTOFILL_QUERY",425      payload: { domain: "github.com", includeAll: true },426    });427428    expect(response).toMatchObject({429      ok: true,430      data: [431        {432          id: expect.any(String),433          title: "GitHub",434          username: "june",435          url: "https://github.com",436          hasPasskey: false,437        },438      ],439    });440    const data = response.data as Array<Record<string, unknown>>;441    expect(data).toHaveLength(1);442    // No secrets cross the boundary.443    expect(data[0]).not.toHaveProperty("password");444    expect(data[0]).not.toHaveProperty("totp");445  });446447  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {448    const vault = await import("./vault");449    await vault.createVault("correct-pass-12345");450    await vault.addEntry({451      title: "GitHub",452      username: "june",453      password: "secret123",454      url: "https://github.com",455      totp: "JBSWY3DPEHPK3PXP",456    });457    await vault.addEntry({458      title: "No 2FA",459      username: "person",460      password: "secret123",461      url: "https://github.com",462    });463464    const response = await dispatch({465      type: "AUTOFILL_QUERY",466      payload: { domain: "github.com", kind: "totp" },467    });468469    expect(response).toMatchObject({470      ok: true,471      data: [472        {473          id: expect.any(String),474          title: "GitHub",475          username: "june",476          url: "https://github.com",477          hasTotp: true,478        },479      ],480    });481    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();482    expect(response.data).toHaveLength(1);483  });484485  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {486    const vault = await import("./vault");487    await vault.createVault("correct-pass-12345");488    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });489490    const response = await dispatch({491      type: "RESTORE_ENTRIES",492      payload: {493        mode: "replace",494        entries: [495          {496            id: "restored-1",497            type: "login",498            title: "B",499            username: "june",500            password: "two",501            url: "https://b.test",502            createdAt: 1,503            modifiedAt: 1,504          },505        ],506      },507    });508509    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });510    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);511  });512513  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {514    const vault = await import("./vault");515    await vault.createVault("correct-pass-12345");516517    await expect(518      dispatch({519        type: "RESTORE_ENTRIES",520        payload: { mode: "replace", entries: [] },521      })522    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });523  });524525  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {526    const vault = await import("./vault");527    await vault.createVault("correct-pass-12345");528529    await expect(530      dispatch({531        type: "CHANGE_PASSWORD",532        payload: {533          currentPassword: "correct-pass-12345",534          newPassword: "new-pass-67890",535        },536      })537    ).resolves.toMatchObject({ ok: true });538539    vault.lock();540    expect(await vault.unlock("new-pass-67890")).toBe(true);541542    await expect(543      dispatch({544        type: "CHANGE_PASSWORD",545        payload: {546          currentPassword: "wrong-pass",547          newPassword: "another-pass",548        },549      })550    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });551  });552553  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {554    const vault = await import("./vault");555    await vault.createVault("correct-pass-12345");556557    (558      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>559    ).mockClear();560    (561      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>562    ).mockClear();563564    await expect(565      dispatch({566        type: "UPDATE_SETTINGS",567        payload: { autoLockMinutes: 30 },568      })569    ).resolves.toMatchObject({ ok: true });570571    expect(vault.getSettings().autoLockMinutes).toBe(30);572    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);573    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {574      delayInMinutes: 30,575    });576  });577578  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {579    vi.useFakeTimers();580    const vault = await import("./vault");581    await vault.createVault("correct-pass-12345");582    const entry = await vault.addEntry({583      title: "GitHub",584      username: "june",585      password: "secret123",586      url: "https://github.com",587    });588    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);589590    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });591    await vi.runAllTimersAsync();592    const response = await responsePromise;593594    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });595    expect(vault.getEntryById(entry.id)?.breach).toBe(7);596    breachSpy.mockRestore();597  });598599  it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {600    const vault = await import("./vault");601    await vault.createVault("correct-pass-12345");602603    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({604      ok: false,605      error: "invalid_sender",606    });607608    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({609      ok: true,610      data: [],611    });612  });613614  it("LOCK and UNLOCK messages update vault state correctly", async () => {615    const vault = await import("./vault");616617    await expect(618      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })619    ).resolves.toMatchObject({ ok: true });620    expect(vault.isUnlocked()).toBe(true);621622    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });623    expect(vault.isUnlocked()).toBe(false);624625    await expect(626      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })627    ).resolves.toMatchObject({ ok: false });628    expect(vault.isUnlocked()).toBe(false);629630    await expect(631      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })632    ).resolves.toMatchObject({ ok: true });633    expect(vault.isUnlocked()).toBe(true);634  });635636  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {637    const response = await dispatch({638      type: "FIRE_CANARY_PING",639      payload: {640        webhookUrl: "https://example.com/hook",641        entryTitle: "GitHub",642        trigger: "password_copied",643      },644    });645646    expect(response).toMatchObject({ ok: true });647    expect(fetchMock).toHaveBeenCalledWith(648      "https://example.com/hook",649      expect.objectContaining({650        method: "POST",651        headers: { "Content-Type": "application/json" },652        body: expect.any(String),653      })654    );655    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;656    expect(body).toMatchObject({657      entry: "GitHub",658      trigger: "password_copied",659      source: "peach-extension",660    });661  });662663  it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {664    const payload = {665      webhookUrl: "https://example.com/hook",666      entryTitle: "GitHub",667      trigger: "autofilled" as const,668    };669    const first = await dispatch({ type: "FIRE_CANARY_PING", payload });670    expect(first).toMatchObject({ ok: true });671    expect(fetchMock).toHaveBeenCalledTimes(1);672673    const second = await dispatch({ type: "FIRE_CANARY_PING", payload });674    expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });675    expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again676  });677678  it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {679    await dispatch({680      type: "FIRE_CANARY_PING",681      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },682    });683    await dispatch({684      type: "FIRE_CANARY_PING",685      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },686    });687    expect(fetchMock).toHaveBeenCalledTimes(2);688  });689690  it("LV-3: test pings are exempt from the debounce", async () => {691    const payload = {692      webhookUrl: "https://example.com/hook",693      entryTitle: "GitHub",694      trigger: "test" as const,695    };696    await dispatch({ type: "FIRE_CANARY_PING", payload });697    await dispatch({ type: "FIRE_CANARY_PING", payload });698    expect(fetchMock).toHaveBeenCalledTimes(2);699  });700701  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {702    await expect(703      dispatch({704        type: "FIRE_CANARY_PING",705        payload: {706          webhookUrl: "http://example.com/hook",707          entryTitle: "GitHub",708          trigger: "password_copied",709        },710      })711    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });712713    expect(fetchMock).not.toHaveBeenCalled();714  });715716  it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {717    const response = await dispatch({718      type: "SUBMIT_FEEDBACK",719      payload: {720        category: "Bug report",721        message: "the popup flashes",722        version: "2.1.0",723        browser: "Chrome",724      },725    });726727    expect(response).toMatchObject({ ok: true });728    expect(fetchMock).not.toHaveBeenCalled();729  });730731  it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {732    const response = await dispatch({733      type: "SUBMIT_FEEDBACK",734      payload: {735        category: "Bug report",736        message: "  ",737        version: "2.1.0",738        browser: "Chrome",739      },740    });741742    expect(response).toMatchObject({ ok: false, error: "message_empty" });743    expect(fetchMock).not.toHaveBeenCalled();744  });745746  it("GET_IDENTITIES when locked returns vault locked error", async () => {747    const vault = await import("./vault");748    await vault.createVault("correct-pass-12345");749    vault.lock();750    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({751      ok: false,752      error: "vault_locked",753    });754  });755756  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {757    const vault = await import("./vault");758    await vault.createVault("correct-pass-12345");759    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });760    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });761    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });762763    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };764    expect(resp.ok).toBe(true);765    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;766    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({767      type: "email",768      useCount: 2,769    });770    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({771      type: "username",772      useCount: 1,773    });774    // emails appear before usernames775    const emailIdx = data.findIndex((d) => d.value === "[email protected]");776    const userIdx = data.findIndex((d) => d.value === "juni_patel");777    expect(emailIdx).toBeLessThan(userIdx);778  });779780  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {781    const vault = await import("./vault");782    await vault.createVault("correct-pass-12345");783    for (let i = 0; i < 3; i++) {784      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });785    }786787    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };788    const data = resp.data as Array<{ value: string; useCount: number }>;789    const hits = data.filter((d) => d.value === "[email protected]");790    expect(hits).toHaveLength(1);791    expect(hits[0]!.useCount).toBe(3);792  });793794  it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {795    const vault = await import("./vault");796    await vault.createVault("correct-pass-12345");797    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;798799    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer800    alarmsCreate.mockClear();801    await dispatch({ type: "GET_ENTRIES" });802    expect(alarmsCreate).toHaveBeenCalled();803804    // SUBMIT_FEEDBACK is in the exclusion list and must not re-arm805    alarmsCreate.mockClear();806    await dispatch({807      type: "SUBMIT_FEEDBACK",808      payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },809    });810    expect(alarmsCreate).not.toHaveBeenCalled();811  });812813  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {814    const vault = await import("./vault");815    await vault.createVault("correct-pass-12345");816    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });817818    // Simulate an activated Pro license + already-celebrated marker.819    storage.peach_license = "PEACH-PRO-TEST-KEY";820    storage.peach_pro_celebrated = true;821822    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });823    expect(res).toMatchObject({ ok: true });824825    // Vault data is gone…826    expect(storage.peach_vault_a).toBeUndefined();827    // …but the paid license and celebration flag survive the wipe.828    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");829    expect(storage.peach_pro_celebrated).toBe(true);830  });831832  it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {833    const messages = await import("./messages");834    const vault = await import("./vault");835836    let resolveGate: () => void = () => {};837    messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));838839    let settled = false;840    const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });841842    // While restore is in flight the handler must NOT have answered yet —843    // this is the window where the old code returned "no vault" → onboarding.844    await new Promise((r) => setTimeout(r, 20));845    expect(settled).toBe(false);846847    // Restore completes (the vault becomes available), gate opens.848    await vault.createVault("correct-pass-12345");849    resolveGate();850851    await expect(pending).resolves.toMatchObject({ ok: true, data: true });852  });853854  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {855    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });856    expect(res.ok).toBe(false);857    // Nothing was written — no vault exists.858    const has = await dispatch({ type: "HAS_VAULT" });859    expect((has as { data?: boolean }).data).toBe(false);860  });861862  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {863    const vault = await import("./vault");864    await vault.createVault("correct-pass-12345");865    const e = await vault.addEntry({866      title: "Mail",867      username: "june",868      password: "pw",869      url: "https://mail.test",870      totp: "JBSWY3DPEHPK3PXP",871    });872873    const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });874    expect(res.ok).toBe(true);875    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };876    expect(data.totp).toBeUndefined();877    expect(data.totpCode).toMatch(/^\d{6}$/);878    expect(data.totpRemaining).toBeGreaterThan(0);879    expect(data.totpRemaining).toBeLessThanOrEqual(30);880  });881882  it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {883    const vault = await import("./vault");884    await vault.createVault("correct-pass-12345");885    const e = await vault.addEntry({886      title: "Bank",887      username: "june",888      password: "old-pw",889      url: "https://bank.test",890    });891    await vault.updateEntry(e.id, { password: "new-pw" });892893    const fill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });894    expect(fill.ok).toBe(true);895    // Passive autofill never receives prior passwords (blast-radius containment).896    expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();897898    const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });899    expect(edit.ok).toBe(true);900    const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];901    expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);902  });903904  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {905    const vault = await import("./vault");906    await vault.createVault("correct-pass-12345");907    const e = await vault.addEntry({908      title: "Mail",909      username: "june",910      password: "pw",911      url: "https://mail.test",912      totp: "JBSWY3DPEHPK3PXP",913    });914915    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });916    expect(res.ok).toBe(true);917    const data = res.data as { totp?: string };918    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");919  });920921  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {922    const vault = await import("./vault");923    await vault.createVault("correct-pass-12345");924925    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });926    expect(res).toMatchObject({ ok: true });927928    expect(storage.peach_vault_a).toBeUndefined();929    expect(storage.peach_license).toBeUndefined();930    expect(storage.peach_pro_celebrated).toBeUndefined();931  });932933  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---934935  function tabSender(url: string): chrome.runtime.MessageSender {936    return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };937  }938939  it("MV-1: tab request with a matching domain returns the entry", async () => {940    const vault = await import("./vault");941    await vault.createVault("correct-pass-12345");942    const entry = await vault.addEntry({943      title: "GitHub",944      username: "june",945      password: "secret123",946      url: "https://github.com",947    });948949    const res = await dispatch(950      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },951      tabSender("https://github.com/login"),952    );953    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });954  });955956  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {957    const vault = await import("./vault");958    await vault.createVault("correct-pass-12345");959    const entry = await vault.addEntry({960      title: "GitHub",961      username: "june",962      password: "secret123",963      url: "https://github.com",964    });965966    const res = await dispatch(967      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },968      tabSender("https://evil.example.com/phish"),969    );970    // Refused with the same error as a missing id — no cross-domain oracle.971    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });972  });973974  it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {975    const vault = await import("./vault");976    await vault.createVault("correct-pass-12345");977    const entry = await vault.addEntry({978      title: "GitHub",979      username: "june",980      password: "secret123",981      url: "https://github.com",982    });983984    const res = await dispatch(985      {986        type: "GET_ENTRY_FOR_FILL",987        payload: { id: entry.id, explicitSummon: true },988      },989      tabSender("https://evil.example.com/phish"),990    );991    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });992  });993994  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {995    const vault = await import("./vault");996    await vault.createVault("correct-pass-12345");997    const entry = await vault.addEntry({998      title: "GitHub",999      username: "june",1000      password: "secret123",1001      url: "https://github.com",1002    });10031004    const res = await dispatch(1005      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1006      tabSender("not a url"),1007    );1008    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1009  });10101011  // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---10121013  async function seedTwoDomainVault() {1014    const vault = await import("./vault");1015    await vault.createVault("correct-pass-12345");1016    await vault.addEntry({1017      title: "GitHub",1018      username: "june",1019      password: "gh",1020      url: "https://github.com",1021    });1022    await vault.addEntry({1023      title: "Example",1024      username: "june",1025      password: "ex",1026      url: "https://example.com",1027    });1028  }10291030  it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1031    await seedTwoDomainVault();10321033    const res = await dispatch({1034      type: "AUTOFILL_QUERY",1035      payload: { domain: "github.com", includeAll: true },1036    });1037    expect(res.ok).toBe(true);1038    const data = res.data as Array<{ title: string }>;1039    // includeAll was stripped — only the github.com match comes back.1040    expect(data).toHaveLength(1);1041    expect(data[0].title).toBe("GitHub");1042  });10431044  it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1045    await seedTwoDomainVault();10461047    const res = await dispatch({1048      type: "AUTOFILL_QUERY",1049      payload: { domain: "github.com", includeAll: true, userGesture: true },1050    });1051    expect(res.ok).toBe(true);1052    const data = res.data as Array<{ title: string }>;1053    expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1054  });10551056  // --- MV-7: vouched device confirmation handlers ---10571058  async function seedPendingVouched(deviceId: string) {1059    const { addPendingVouchedDevice } = await import(1060      "./pending-vouched-devices"1061    );1062    await addPendingVouchedDevice({1063      deviceId,1064      deviceName: "New Pixel",1065      deviceType: "android",1066      longTermPublicKeyHex: "e".repeat(64),1067      signingPublicKeyHex: "a".repeat(64),1068      wsPort: 51234,1069      pairedAt: 1_700_000_000_020,1070      receivedAt: 1_700_000_000_050,1071      vouchedByDeviceId: "extension-issuer",1072    });1073  }10741075  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1076    const vault = await import("./vault");1077    await vault.createVault("correct-pass-12345");1078    await seedPendingVouched("android-new");1079    const { findPairedDeviceById } = await import("./paired-devices");1080    const { listPendingVouchedDevices } = await import(1081      "./pending-vouched-devices"1082    );10831084    const res = await dispatch({1085      type: "CONFIRM_VOUCHED_DEVICE",1086      payload: { deviceId: "android-new" },1087    });1088    expect(res).toMatchObject({ ok: true });10891090    expect(await findPairedDeviceById("android-new")).toMatchObject({1091      deviceName: "New Pixel",1092      trustSource: "vouched",1093      longTermPublicKeyHex: "e".repeat(64),1094    });1095    expect(await listPendingVouchedDevices()).toHaveLength(0);1096  });10971098  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1099    const vault = await import("./vault");1100    await vault.createVault("correct-pass-12345");1101    await seedPendingVouched("android-new");1102    const { findPairedDeviceById } = await import("./paired-devices");1103    const { listPendingVouchedDevices } = await import(1104      "./pending-vouched-devices"1105    );11061107    const res = await dispatch({1108      type: "REJECT_VOUCHED_DEVICE",1109      payload: { deviceId: "android-new" },1110    });1111    expect(res).toMatchObject({ ok: true });11121113    expect(await findPairedDeviceById("android-new")).toBeNull();1114    expect(await listPendingVouchedDevices()).toHaveLength(0);1115  });11161117  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---11181119  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1120    const vault = await import("./vault");1121    await vault.createVault("correct-pass-12345");11221123    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1124    expect(res).toMatchObject({ ok: false, error: "password_required" });1125  });11261127  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1128    const vault = await import("./vault");1129    await vault.createVault("correct-pass-12345");11301131    const res = await dispatch({1132      type: "GET_RECOVERY_WORDS",1133      payload: { password: "not-the-password" },1134    });1135    expect(res).toMatchObject({ ok: false, error: "wrong_password" });1136    expect(res.data).toBeUndefined();1137  });11381139  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1140    const vault = await import("./vault");1141    await vault.createVault("correct-pass-12345");11421143    const res = await dispatch({1144      type: "GET_RECOVERY_WORDS",1145      payload: { password: "correct-pass-12345" },1146    });1147    expect(res.ok).toBe(true);1148    expect((res.data as string[]).length).toBe(12);1149  });1150});