messages.test.ts

37.3 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        onMessage: {40          addListener: vi.fn((fn: MessageListener) => {41            listener = fn;42          }),43        },44        sendMessage: vi.fn(async () => ({ ok: true })),45      },46      tabs: {47        query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {48          if (typeof callback === "function") {49            callback(tabs);50            return;51          }52          return Promise.resolve(tabs);53        }),54        sendMessage: vi.fn(async () => ({ ok: true })),55      },56      storage: {57        local: {58          get: vi.fn(async (key?: string | string[]) => {59            if (!key) return storage;60            if (typeof key === "string") {61              return { [key]: storage[key] };62            }6364            return key.reduce<Record<string, unknown>>((result, entry) => {65              result[entry] = storage[entry];66              return result;67            }, {});68          }),69          set: vi.fn(async (next: Record<string, unknown>) => {70            Object.assign(storage, next);71          }),72          remove: vi.fn(async (keys: string | string[]) => {73            const list = Array.isArray(keys) ? keys : [keys];74            for (const key of list) {75              delete storage[key];76            }77          }),78          clear: vi.fn(async () => {79            for (const key of Object.keys(storage)) delete storage[key];80          }),81        },82        session: {83          get: vi.fn(async (key?: string | string[]) => {84            if (!key) return sessionStorage;85            if (typeof key === "string") {86              return { [key]: sessionStorage[key] };87            }88            return key.reduce<Record<string, unknown>>((result, entry) => {89              result[entry] = sessionStorage[entry];90              return result;91            }, {});92          }),93          set: vi.fn(async (next: Record<string, unknown>) => {94            Object.assign(sessionStorage, next);95          }),96          remove: vi.fn(async (keys: string | string[]) => {97            const list = Array.isArray(keys) ? keys : [keys];98            for (const key of list) {99              delete sessionStorage[key];100            }101          }),102          clear: vi.fn(async () => {103            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];104          }),105        },106      },107      alarms: {108        clear: vi.fn(),109        create: vi.fn(),110        onAlarm: { addListener: vi.fn() },111      },112      idle: {113        setDetectionInterval: vi.fn(),114        onStateChanged: { addListener: vi.fn() },115      },116      scripting: {117        executeScript: vi.fn(async () => []),118      },119    } as unknown as typeof chrome;120121    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;122123    const messages = await import("./messages");124    messages.setupMessageHandler();125    const vault = await import("./vault");126    await vault.ensureSettingsLoaded();127  });128129  async function dispatch(130    message: Message,131    sender: chrome.runtime.MessageSender = { id: "peach-extension" }132  ): Promise<MessageResponse> {133    return await new Promise<MessageResponse>((resolve) => {134      const keepChannelOpen = listener(message, sender, (response) => resolve(response));135      expect(keepChannelOpen).toBe(true);136    });137  }138139  it("GET_ENTRIES when unlocked returns the entry array", async () => {140    const vault = await import("./vault");141    await vault.createVault("correct-pass-12345");142    await vault.addEntry({143      title: "GitHub",144      username: "june",145      password: "secret123",146      url: "https://github.com",147    });148149    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({150      ok: true,151      data: [152        expect.objectContaining({153          title: "GitHub",154          username: "june",155        }),156      ],157    });158  });159160  it("GET_ENTRIES when locked returns a vault locked error", async () => {161    const vault = await import("./vault");162    await vault.createVault("correct-pass-12345");163    vault.lock();164165    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({166      ok: false,167      error: "vault_locked",168    });169  });170171  it("ADD_ENTRY with a valid payload returns the created entry", async () => {172    const vault = await import("./vault");173    await vault.createVault("correct-pass-12345");174175    const response = await dispatch({176      type: "ADD_ENTRY",177      payload: {178        title: "GitHub",179        username: "june",180        password: "secret123",181        url: "https://github.com",182      },183    });184185    expect(response).toMatchObject({186      ok: true,187      data: expect.objectContaining({188        id: expect.any(String),189        title: "GitHub",190      }),191    });192    expect(vault.getEntries()).toHaveLength(1);193  });194195  it("ADD_ENTRY when locked returns a vault locked error", async () => {196    const vault = await import("./vault");197    await vault.createVault("correct-pass-12345");198    vault.lock();199200    await expect(201      dispatch({202        type: "ADD_ENTRY",203        payload: {204          title: "GitHub",205          username: "june",206          password: "secret123",207          url: "https://github.com",208        },209      })210    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });211  });212213  it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {214    const vault = await import("./vault");215    await vault.createVault("correct-pass-12345");216217    await expect(218      dispatch({219        type: "ADD_ENTRY",220        payload: {221          title: "GitHub",222          username: "june",223          password: "",224          url: "https://github.com",225        },226      })227    ).resolves.toMatchObject({ ok: true });228  });229230  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {231    const vault = await import("./vault");232    await vault.createVault("correct-pass-12345");233    const entry = await vault.addEntry({234      title: "GitHub",235      username: "june",236      password: "secret123",237      url: "https://github.com",238    });239240    await expect(241      dispatch({242        type: "UPDATE_ENTRY",243        payload: { id: entry.id, patch: { password: "new-secret" } },244      })245    ).resolves.toMatchObject({ ok: true });246247    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");248  });249250  it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {251    const vault = await import("./vault");252    await vault.createVault("correct-pass-12345");253    const entry = await vault.addEntry({254      title: "GitHub",255      username: "june",256      password: "secret123",257      url: "https://github.com",258    });259260    await expect(261      dispatch({262        type: "UPDATE_ENTRY",263        payload: { id: entry.id, patch: { password: "" } },264      })265    ).resolves.toMatchObject({ ok: true });266267    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();268  });269270  it("DELETE_ENTRY soft-deletes the target entry", async () => {271    const vault = await import("./vault");272    await vault.createVault("correct-pass-12345");273    const entry = await vault.addEntry({274      title: "GitHub",275      username: "june",276      password: "secret123",277      url: "https://github.com",278    });279280    await expect(281      dispatch({282        type: "DELETE_ENTRY",283        payload: { id: entry.id },284      })285    ).resolves.toMatchObject({ ok: true });286287    expect(vault.getEntries()).toEqual([]);288    expect(vault.getDeletedEntries()).toHaveLength(1);289  });290291  it("DELETE_ENTRY of a missing id returns an error", async () => {292    const vault = await import("./vault");293    await vault.createVault("correct-pass-12345");294295    await expect(296      dispatch({297        type: "DELETE_ENTRY",298        payload: { id: "missing" },299      })300    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });301  });302303  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {304    const vault = await import("./vault");305    await vault.createVault("correct-pass-12345");306    const entry = await vault.addEntry({307      title: "GitHub",308      username: "june",309      password: "secret123",310      url: "https://github.com",311    });312313    const response = await dispatch({314      type: "GET_ENTRY_FOR_FILL",315      payload: { id: entry.id },316    });317318    expect(response).toMatchObject({319      ok: true,320      data: expect.objectContaining({321        id: entry.id,322        username: "june",323        password: "secret123",324      }),325    });326    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();327  });328329  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {330    const vault = await import("./vault");331    await vault.createVault("correct-pass-12345");332    vault.lock();333334    await expect(335      dispatch({336        type: "GET_ENTRY_FOR_FILL",337        payload: { id: "missing" },338      })339    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });340  });341342  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {343    const vault = await import("./vault");344    await vault.createVault("correct-pass-12345");345    const generated = await generatePasskeyKeypair(-7);346    const entry = await vault.addEntry({347      type: "login",348      title: "WebAuthn.io",349      passkey: {350        rpId: "webauthn.io",351        rpName: "WebAuthn.io",352        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),353        publicKeyCose: base64urlEncode(generated.publicKeyCose),354        privateKeyJwk: generated.privateKeyJwk,355        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),356        userName: "june",357        userDisplayName: "June",358        algorithm: -7,359        signCount: 0,360        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",361        backupEligible: true,362        backupState: false,363        createdAt: Date.now(),364      },365    });366367    await expect(368      dispatch({369        type: "GET_ENTRY_FOR_FILL",370        payload: { id: entry.id },371      })372    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });373  });374375  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {376    const vault = await import("./vault");377    await vault.createVault("correct-pass-12345");378    await vault.addEntry({379      title: "GitHub",380      username: "june",381      password: "secret123",382      url: "https://github.com",383      totp: "JBSWY3DPEHPK3PXP",384    });385386    const response = await dispatch({387      type: "AUTOFILL_QUERY",388      payload: { domain: "github.com" },389    });390391    expect(response).toMatchObject({392      ok: true,393      data: [394        {395          id: expect.any(String),396          title: "GitHub",397          username: "june",398          url: "https://github.com",399          hasTotp: true,400        },401      ],402    });403    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();404    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();405  });406407  it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {408    const vault = await import("./vault");409    await vault.createVault("correct-pass-12345");410    await vault.addEntry({411      title: "GitHub",412      username: "june",413      password: "secret123",414      url: "https://github.com",415    });416    await vault.addEntry({417      type: "note",418      title: "Secure note",419      notes: "hello",420    });421422    const response = await dispatch({423      type: "AUTOFILL_QUERY",424      payload: { domain: "github.com", includeAll: true },425    });426427    expect(response).toMatchObject({428      ok: true,429      data: [430        {431          id: expect.any(String),432          title: "GitHub",433          username: "june",434          url: "https://github.com",435          hasPasskey: false,436        },437      ],438    });439    const data = response.data as Array<Record<string, unknown>>;440    expect(data).toHaveLength(1);441    // No secrets cross the boundary.442    expect(data[0]).not.toHaveProperty("password");443    expect(data[0]).not.toHaveProperty("totp");444  });445446  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {447    const vault = await import("./vault");448    await vault.createVault("correct-pass-12345");449    await vault.addEntry({450      title: "GitHub",451      username: "june",452      password: "secret123",453      url: "https://github.com",454      totp: "JBSWY3DPEHPK3PXP",455    });456    await vault.addEntry({457      title: "No 2FA",458      username: "person",459      password: "secret123",460      url: "https://github.com",461    });462463    const response = await dispatch({464      type: "AUTOFILL_QUERY",465      payload: { domain: "github.com", kind: "totp" },466    });467468    expect(response).toMatchObject({469      ok: true,470      data: [471        {472          id: expect.any(String),473          title: "GitHub",474          username: "june",475          url: "https://github.com",476          hasTotp: true,477        },478      ],479    });480    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();481    expect(response.data).toHaveLength(1);482  });483484  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {485    const vault = await import("./vault");486    await vault.createVault("correct-pass-12345");487    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });488489    const response = await dispatch({490      type: "RESTORE_ENTRIES",491      payload: {492        mode: "replace",493        entries: [494          {495            id: "restored-1",496            type: "login",497            title: "B",498            username: "june",499            password: "two",500            url: "https://b.test",501            createdAt: 1,502            modifiedAt: 1,503          },504        ],505      },506    });507508    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });509    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);510  });511512  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {513    const vault = await import("./vault");514    await vault.createVault("correct-pass-12345");515516    await expect(517      dispatch({518        type: "RESTORE_ENTRIES",519        payload: { mode: "replace", entries: [] },520      })521    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });522  });523524  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {525    const vault = await import("./vault");526    await vault.createVault("correct-pass-12345");527528    await expect(529      dispatch({530        type: "CHANGE_PASSWORD",531        payload: {532          currentPassword: "correct-pass-12345",533          newPassword: "new-pass-67890",534        },535      })536    ).resolves.toMatchObject({ ok: true });537538    vault.lock();539    expect(await vault.unlock("new-pass-67890")).toBe(true);540541    await expect(542      dispatch({543        type: "CHANGE_PASSWORD",544        payload: {545          currentPassword: "wrong-pass",546          newPassword: "another-pass",547        },548      })549    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });550  });551552  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {553    const vault = await import("./vault");554    await vault.createVault("correct-pass-12345");555556    (557      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>558    ).mockClear();559    (560      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>561    ).mockClear();562563    await expect(564      dispatch({565        type: "UPDATE_SETTINGS",566        payload: { autoLockMinutes: 30 },567      })568    ).resolves.toMatchObject({ ok: true });569570    expect(vault.getSettings().autoLockMinutes).toBe(30);571    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);572    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {573      delayInMinutes: 30,574    });575  });576577  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {578    vi.useFakeTimers();579    const vault = await import("./vault");580    await vault.createVault("correct-pass-12345");581    const entry = await vault.addEntry({582      title: "GitHub",583      username: "june",584      password: "secret123",585      url: "https://github.com",586    });587    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);588589    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });590    await vi.runAllTimersAsync();591    const response = await responsePromise;592593    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });594    expect(vault.getEntryById(entry.id)?.breach).toBe(7);595    breachSpy.mockRestore();596  });597598  it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {599    const vault = await import("./vault");600    await vault.createVault("correct-pass-12345");601602    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({603      ok: false,604      error: "invalid_sender",605    });606607    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({608      ok: true,609      data: [],610    });611  });612613  it("LOCK and UNLOCK messages update vault state correctly", async () => {614    const vault = await import("./vault");615616    await expect(617      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })618    ).resolves.toMatchObject({ ok: true });619    expect(vault.isUnlocked()).toBe(true);620621    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });622    expect(vault.isUnlocked()).toBe(false);623624    await expect(625      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })626    ).resolves.toMatchObject({ ok: false });627    expect(vault.isUnlocked()).toBe(false);628629    await expect(630      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })631    ).resolves.toMatchObject({ ok: true });632    expect(vault.isUnlocked()).toBe(true);633  });634635  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {636    const response = await dispatch({637      type: "FIRE_CANARY_PING",638      payload: {639        webhookUrl: "https://example.com/hook",640        entryTitle: "GitHub",641        trigger: "password_copied",642      },643    });644645    expect(response).toMatchObject({ ok: true });646    expect(fetchMock).toHaveBeenCalledWith(647      "https://example.com/hook",648      expect.objectContaining({649        method: "POST",650        headers: { "Content-Type": "application/json" },651        body: expect.any(String),652      })653    );654    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;655    expect(body).toMatchObject({656      entry: "GitHub",657      trigger: "password_copied",658      source: "peach-extension",659    });660  });661662  it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {663    const payload = {664      webhookUrl: "https://example.com/hook",665      entryTitle: "GitHub",666      trigger: "autofilled" as const,667    };668    const first = await dispatch({ type: "FIRE_CANARY_PING", payload });669    expect(first).toMatchObject({ ok: true });670    expect(fetchMock).toHaveBeenCalledTimes(1);671672    const second = await dispatch({ type: "FIRE_CANARY_PING", payload });673    expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });674    expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again675  });676677  it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {678    await dispatch({679      type: "FIRE_CANARY_PING",680      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },681    });682    await dispatch({683      type: "FIRE_CANARY_PING",684      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },685    });686    expect(fetchMock).toHaveBeenCalledTimes(2);687  });688689  it("LV-3: test pings are exempt from the debounce", async () => {690    const payload = {691      webhookUrl: "https://example.com/hook",692      entryTitle: "GitHub",693      trigger: "test" as const,694    };695    await dispatch({ type: "FIRE_CANARY_PING", payload });696    await dispatch({ type: "FIRE_CANARY_PING", payload });697    expect(fetchMock).toHaveBeenCalledTimes(2);698  });699700  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {701    await expect(702      dispatch({703        type: "FIRE_CANARY_PING",704        payload: {705          webhookUrl: "http://example.com/hook",706          entryTitle: "GitHub",707          trigger: "password_copied",708        },709      })710    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });711712    expect(fetchMock).not.toHaveBeenCalled();713  });714715  it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {716    const response = await dispatch({717      type: "SUBMIT_FEEDBACK",718      payload: {719        category: "Bug report",720        message: "the popup flashes",721        version: "2.1.0",722        browser: "Chrome",723      },724    });725726    expect(response).toMatchObject({ ok: true });727    expect(fetchMock).not.toHaveBeenCalled();728  });729730  it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {731    const response = await dispatch({732      type: "SUBMIT_FEEDBACK",733      payload: {734        category: "Bug report",735        message: "  ",736        version: "2.1.0",737        browser: "Chrome",738      },739    });740741    expect(response).toMatchObject({ ok: false, error: "message_empty" });742    expect(fetchMock).not.toHaveBeenCalled();743  });744745  it("GET_IDENTITIES when locked returns vault locked error", async () => {746    const vault = await import("./vault");747    await vault.createVault("correct-pass-12345");748    vault.lock();749    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({750      ok: false,751      error: "vault_locked",752    });753  });754755  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {756    const vault = await import("./vault");757    await vault.createVault("correct-pass-12345");758    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });759    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });760    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });761762    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };763    expect(resp.ok).toBe(true);764    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;765    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({766      type: "email",767      useCount: 2,768    });769    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({770      type: "username",771      useCount: 1,772    });773    // emails appear before usernames774    const emailIdx = data.findIndex((d) => d.value === "[email protected]");775    const userIdx = data.findIndex((d) => d.value === "juni_patel");776    expect(emailIdx).toBeLessThan(userIdx);777  });778779  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {780    const vault = await import("./vault");781    await vault.createVault("correct-pass-12345");782    for (let i = 0; i < 3; i++) {783      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });784    }785786    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };787    const data = resp.data as Array<{ value: string; useCount: number }>;788    const hits = data.filter((d) => d.value === "[email protected]");789    expect(hits).toHaveLength(1);790    expect(hits[0]!.useCount).toBe(3);791  });792793  it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {794    const vault = await import("./vault");795    await vault.createVault("correct-pass-12345");796    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;797798    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer799    alarmsCreate.mockClear();800    await dispatch({ type: "GET_ENTRIES" });801    expect(alarmsCreate).toHaveBeenCalled();802803    // SUBMIT_FEEDBACK is in the exclusion list and must not re-arm804    alarmsCreate.mockClear();805    await dispatch({806      type: "SUBMIT_FEEDBACK",807      payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },808    });809    expect(alarmsCreate).not.toHaveBeenCalled();810  });811812  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {813    const vault = await import("./vault");814    await vault.createVault("correct-pass-12345");815    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });816817    // Simulate an activated Pro license + already-celebrated marker.818    storage.peach_license = "PEACH-PRO-TEST-KEY";819    storage.peach_pro_celebrated = true;820821    const res = await dispatch({ type: "DESTROY_VAULT" });822    expect(res).toMatchObject({ ok: true });823824    // Vault data is gone…825    expect(storage.peach_vault_a).toBeUndefined();826    // …but the paid license and celebration flag survive the wipe.827    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");828    expect(storage.peach_pro_celebrated).toBe(true);829  });830831  it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {832    const messages = await import("./messages");833    const vault = await import("./vault");834835    let resolveGate: () => void = () => {};836    messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));837838    let settled = false;839    const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });840841    // While restore is in flight the handler must NOT have answered yet —842    // this is the window where the old code returned "no vault" → onboarding.843    await new Promise((r) => setTimeout(r, 20));844    expect(settled).toBe(false);845846    // Restore completes (the vault becomes available), gate opens.847    await vault.createVault("correct-pass-12345");848    resolveGate();849850    await expect(pending).resolves.toMatchObject({ ok: true, data: true });851  });852853  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {854    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });855    expect(res.ok).toBe(false);856    // Nothing was written — no vault exists.857    const has = await dispatch({ type: "HAS_VAULT" });858    expect((has as { data?: boolean }).data).toBe(false);859  });860861  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {862    const vault = await import("./vault");863    await vault.createVault("correct-pass-12345");864    const e = await vault.addEntry({865      title: "Mail",866      username: "june",867      password: "pw",868      url: "https://mail.test",869      totp: "JBSWY3DPEHPK3PXP",870    });871872    const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });873    expect(res.ok).toBe(true);874    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };875    expect(data.totp).toBeUndefined();876    expect(data.totpCode).toMatch(/^\d{6}$/);877    expect(data.totpRemaining).toBeGreaterThan(0);878    expect(data.totpRemaining).toBeLessThanOrEqual(30);879  });880881  it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {882    const vault = await import("./vault");883    await vault.createVault("correct-pass-12345");884    const e = await vault.addEntry({885      title: "Bank",886      username: "june",887      password: "old-pw",888      url: "https://bank.test",889    });890    await vault.updateEntry(e.id, { password: "new-pw" });891892    const fill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });893    expect(fill.ok).toBe(true);894    // Passive autofill never receives prior passwords (blast-radius containment).895    expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();896897    const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });898    expect(edit.ok).toBe(true);899    const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];900    expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);901  });902903  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {904    const vault = await import("./vault");905    await vault.createVault("correct-pass-12345");906    const e = await vault.addEntry({907      title: "Mail",908      username: "june",909      password: "pw",910      url: "https://mail.test",911      totp: "JBSWY3DPEHPK3PXP",912    });913914    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });915    expect(res.ok).toBe(true);916    const data = res.data as { totp?: string };917    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");918  });919920  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {921    const vault = await import("./vault");922    await vault.createVault("correct-pass-12345");923924    const res = await dispatch({ type: "DESTROY_VAULT" });925    expect(res).toMatchObject({ ok: true });926927    expect(storage.peach_vault_a).toBeUndefined();928    expect(storage.peach_license).toBeUndefined();929    expect(storage.peach_pro_celebrated).toBeUndefined();930  });931932  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---933934  function tabSender(url: string): chrome.runtime.MessageSender {935    return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };936  }937938  it("MV-1: tab request with a matching domain returns the entry", async () => {939    const vault = await import("./vault");940    await vault.createVault("correct-pass-12345");941    const entry = await vault.addEntry({942      title: "GitHub",943      username: "june",944      password: "secret123",945      url: "https://github.com",946    });947948    const res = await dispatch(949      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },950      tabSender("https://github.com/login"),951    );952    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });953  });954955  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {956    const vault = await import("./vault");957    await vault.createVault("correct-pass-12345");958    const entry = await vault.addEntry({959      title: "GitHub",960      username: "june",961      password: "secret123",962      url: "https://github.com",963    });964965    const res = await dispatch(966      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },967      tabSender("https://evil.example.com/phish"),968    );969    // Refused with the same error as a missing id — no cross-domain oracle.970    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });971  });972973  it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {974    const vault = await import("./vault");975    await vault.createVault("correct-pass-12345");976    const entry = await vault.addEntry({977      title: "GitHub",978      username: "june",979      password: "secret123",980      url: "https://github.com",981    });982983    const res = await dispatch(984      {985        type: "GET_ENTRY_FOR_FILL",986        payload: { id: entry.id, explicitSummon: true },987      },988      tabSender("https://evil.example.com/phish"),989    );990    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });991  });992993  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {994    const vault = await import("./vault");995    await vault.createVault("correct-pass-12345");996    const entry = await vault.addEntry({997      title: "GitHub",998      username: "june",999      password: "secret123",1000      url: "https://github.com",1001    });10021003    const res = await dispatch(1004      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1005      tabSender("not a url"),1006    );1007    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1008  });10091010  // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---10111012  async function seedTwoDomainVault() {1013    const vault = await import("./vault");1014    await vault.createVault("correct-pass-12345");1015    await vault.addEntry({1016      title: "GitHub",1017      username: "june",1018      password: "gh",1019      url: "https://github.com",1020    });1021    await vault.addEntry({1022      title: "Example",1023      username: "june",1024      password: "ex",1025      url: "https://example.com",1026    });1027  }10281029  it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1030    await seedTwoDomainVault();10311032    const res = await dispatch({1033      type: "AUTOFILL_QUERY",1034      payload: { domain: "github.com", includeAll: true },1035    });1036    expect(res.ok).toBe(true);1037    const data = res.data as Array<{ title: string }>;1038    // includeAll was stripped — only the github.com match comes back.1039    expect(data).toHaveLength(1);1040    expect(data[0].title).toBe("GitHub");1041  });10421043  it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1044    await seedTwoDomainVault();10451046    const res = await dispatch({1047      type: "AUTOFILL_QUERY",1048      payload: { domain: "github.com", includeAll: true, userGesture: true },1049    });1050    expect(res.ok).toBe(true);1051    const data = res.data as Array<{ title: string }>;1052    expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1053  });10541055  // --- MV-7: vouched device confirmation handlers ---10561057  async function seedPendingVouched(deviceId: string) {1058    const { addPendingVouchedDevice } = await import(1059      "./pending-vouched-devices"1060    );1061    await addPendingVouchedDevice({1062      deviceId,1063      deviceName: "New Pixel",1064      deviceType: "android",1065      longTermPublicKeyHex: "e".repeat(64),1066      signingPublicKeyHex: "a".repeat(64),1067      wsPort: 51234,1068      pairedAt: 1_700_000_000_020,1069      receivedAt: 1_700_000_000_050,1070      vouchedByDeviceId: "extension-issuer",1071    });1072  }10731074  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1075    const vault = await import("./vault");1076    await vault.createVault("correct-pass-12345");1077    await seedPendingVouched("android-new");1078    const { findPairedDeviceById } = await import("./paired-devices");1079    const { listPendingVouchedDevices } = await import(1080      "./pending-vouched-devices"1081    );10821083    const res = await dispatch({1084      type: "CONFIRM_VOUCHED_DEVICE",1085      payload: { deviceId: "android-new" },1086    });1087    expect(res).toMatchObject({ ok: true });10881089    expect(await findPairedDeviceById("android-new")).toMatchObject({1090      deviceName: "New Pixel",1091      trustSource: "vouched",1092      longTermPublicKeyHex: "e".repeat(64),1093    });1094    expect(await listPendingVouchedDevices()).toHaveLength(0);1095  });10961097  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1098    const vault = await import("./vault");1099    await vault.createVault("correct-pass-12345");1100    await seedPendingVouched("android-new");1101    const { findPairedDeviceById } = await import("./paired-devices");1102    const { listPendingVouchedDevices } = await import(1103      "./pending-vouched-devices"1104    );11051106    const res = await dispatch({1107      type: "REJECT_VOUCHED_DEVICE",1108      payload: { deviceId: "android-new" },1109    });1110    expect(res).toMatchObject({ ok: true });11111112    expect(await findPairedDeviceById("android-new")).toBeNull();1113    expect(await listPendingVouchedDevices()).toHaveLength(0);1114  });11151116  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---11171118  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1119    const vault = await import("./vault");1120    await vault.createVault("correct-pass-12345");11211122    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1123    expect(res).toMatchObject({ ok: false, error: "password_required" });1124  });11251126  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1127    const vault = await import("./vault");1128    await vault.createVault("correct-pass-12345");11291130    const res = await dispatch({1131      type: "GET_RECOVERY_WORDS",1132      payload: { password: "not-the-password" },1133    });1134    expect(res).toMatchObject({ ok: false, error: "wrong_password" });1135    expect(res.data).toBeUndefined();1136  });11371138  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1139    const vault = await import("./vault");1140    await vault.createVault("correct-pass-12345");11411142    const res = await dispatch({1143      type: "GET_RECOVERY_WORDS",1144      payload: { password: "correct-pass-12345" },1145    });1146    expect(res.ok).toBe(true);1147    expect((res.data as string[]).length).toBe(12);1148  });1149});