messages.test.ts

36.6 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";4import type { Message, MessageResponse } from "../shared/types";56type StorageShape = Record<string, unknown>;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("GET_IDENTITIES when locked returns vault locked error", async () => {717    const vault = await import("./vault");718    await vault.createVault("correct-pass-12345");719    vault.lock();720    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({721      ok: false,722      error: "vault_locked",723    });724  });725726  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {727    const vault = await import("./vault");728    await vault.createVault("correct-pass-12345");729    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });730    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });731    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });732733    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };734    expect(resp.ok).toBe(true);735    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;736    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({737      type: "email",738      useCount: 2,739    });740    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({741      type: "username",742      useCount: 1,743    });744    // emails appear before usernames745    const emailIdx = data.findIndex((d) => d.value === "[email protected]");746    const userIdx = data.findIndex((d) => d.value === "juni_patel");747    expect(emailIdx).toBeLessThan(userIdx);748  });749750  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {751    const vault = await import("./vault");752    await vault.createVault("correct-pass-12345");753    for (let i = 0; i < 3; i++) {754      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });755    }756757    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };758    const data = resp.data as Array<{ value: string; useCount: number }>;759    const hits = data.filter((d) => d.value === "[email protected]");760    expect(hits).toHaveLength(1);761    expect(hits[0]!.useCount).toBe(3);762  });763764  it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {765    const vault = await import("./vault");766    await vault.createVault("correct-pass-12345");767    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;768769    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer770    alarmsCreate.mockClear();771    await dispatch({ type: "GET_ENTRIES" });772    expect(alarmsCreate).toHaveBeenCalled();773774    // Passive queries are in the exclusion list and must not re-arm775    alarmsCreate.mockClear();776    await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });777    expect(alarmsCreate).not.toHaveBeenCalled();778  });779780  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {781    const vault = await import("./vault");782    await vault.createVault("correct-pass-12345");783    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });784785    // Simulate an activated Pro license + already-celebrated marker.786    storage.peach_license = "PEACH-PRO-TEST-KEY";787    storage.peach_pro_celebrated = true;788789    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });790    expect(res).toMatchObject({ ok: true });791792    // Vault data is gone…793    expect(storage.peach_vault_a).toBeUndefined();794    // …but the paid license and celebration flag survive the wipe.795    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");796    expect(storage.peach_pro_celebrated).toBe(true);797  });798799  it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {800    const messages = await import("./messages");801    const vault = await import("./vault");802803    let resolveGate: () => void = () => {};804    messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));805806    let settled = false;807    const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });808809    // While restore is in flight the handler must NOT have answered yet —810    // this is the window where the old code returned "no vault" → onboarding.811    await new Promise((r) => setTimeout(r, 20));812    expect(settled).toBe(false);813814    // Restore completes (the vault becomes available), gate opens.815    await vault.createVault("correct-pass-12345");816    resolveGate();817818    await expect(pending).resolves.toMatchObject({ ok: true, data: true });819  });820821  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {822    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });823    expect(res.ok).toBe(false);824    // Nothing was written — no vault exists.825    const has = await dispatch({ type: "HAS_VAULT" });826    expect((has as { data?: boolean }).data).toBe(false);827  });828829  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {830    const vault = await import("./vault");831    await vault.createVault("correct-pass-12345");832    const e = await vault.addEntry({833      title: "Mail",834      username: "june",835      password: "pw",836      url: "https://mail.test",837      totp: "JBSWY3DPEHPK3PXP",838    });839840    const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });841    expect(res.ok).toBe(true);842    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };843    expect(data.totp).toBeUndefined();844    expect(data.totpCode).toMatch(/^\d{6}$/);845    expect(data.totpRemaining).toBeGreaterThan(0);846    expect(data.totpRemaining).toBeLessThanOrEqual(30);847  });848849  it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {850    const vault = await import("./vault");851    await vault.createVault("correct-pass-12345");852    const e = await vault.addEntry({853      title: "Bank",854      username: "june",855      password: "old-pw",856      url: "https://bank.test",857    });858    await vault.updateEntry(e.id, { password: "new-pw" });859860    const fill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });861    expect(fill.ok).toBe(true);862    // Passive autofill never receives prior passwords (blast-radius containment).863    expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();864865    const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });866    expect(edit.ok).toBe(true);867    const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];868    expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);869  });870871  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {872    const vault = await import("./vault");873    await vault.createVault("correct-pass-12345");874    const e = await vault.addEntry({875      title: "Mail",876      username: "june",877      password: "pw",878      url: "https://mail.test",879      totp: "JBSWY3DPEHPK3PXP",880    });881882    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });883    expect(res.ok).toBe(true);884    const data = res.data as { totp?: string };885    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");886  });887888  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {889    const vault = await import("./vault");890    await vault.createVault("correct-pass-12345");891892    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });893    expect(res).toMatchObject({ ok: true });894895    expect(storage.peach_vault_a).toBeUndefined();896    expect(storage.peach_license).toBeUndefined();897    expect(storage.peach_pro_celebrated).toBeUndefined();898  });899900  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---901902  function tabSender(url: string): chrome.runtime.MessageSender {903    return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };904  }905906  it("MV-1: tab request with a matching domain returns the entry", async () => {907    const vault = await import("./vault");908    await vault.createVault("correct-pass-12345");909    const entry = await vault.addEntry({910      title: "GitHub",911      username: "june",912      password: "secret123",913      url: "https://github.com",914    });915916    const res = await dispatch(917      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },918      tabSender("https://github.com/login"),919    );920    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });921  });922923  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {924    const vault = await import("./vault");925    await vault.createVault("correct-pass-12345");926    const entry = await vault.addEntry({927      title: "GitHub",928      username: "june",929      password: "secret123",930      url: "https://github.com",931    });932933    const res = await dispatch(934      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },935      tabSender("https://evil.example.com/phish"),936    );937    // Refused with the same error as a missing id — no cross-domain oracle.938    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });939  });940941  it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {942    const vault = await import("./vault");943    await vault.createVault("correct-pass-12345");944    const entry = await vault.addEntry({945      title: "GitHub",946      username: "june",947      password: "secret123",948      url: "https://github.com",949    });950951    const res = await dispatch(952      {953        type: "GET_ENTRY_FOR_FILL",954        payload: { id: entry.id, explicitSummon: true },955      },956      tabSender("https://evil.example.com/phish"),957    );958    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });959  });960961  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {962    const vault = await import("./vault");963    await vault.createVault("correct-pass-12345");964    const entry = await vault.addEntry({965      title: "GitHub",966      username: "june",967      password: "secret123",968      url: "https://github.com",969    });970971    const res = await dispatch(972      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },973      tabSender("not a url"),974    );975    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });976  });977978  // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---979980  async function seedTwoDomainVault() {981    const vault = await import("./vault");982    await vault.createVault("correct-pass-12345");983    await vault.addEntry({984      title: "GitHub",985      username: "june",986      password: "gh",987      url: "https://github.com",988    });989    await vault.addEntry({990      title: "Example",991      username: "june",992      password: "ex",993      url: "https://example.com",994    });995  }996997  it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {998    await seedTwoDomainVault();9991000    const res = await dispatch({1001      type: "AUTOFILL_QUERY",1002      payload: { domain: "github.com", includeAll: true },1003    });1004    expect(res.ok).toBe(true);1005    const data = res.data as Array<{ title: string }>;1006    // includeAll was stripped — only the github.com match comes back.1007    expect(data).toHaveLength(1);1008    expect(data[0].title).toBe("GitHub");1009  });10101011  it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1012    await seedTwoDomainVault();10131014    const res = await dispatch({1015      type: "AUTOFILL_QUERY",1016      payload: { domain: "github.com", includeAll: true, userGesture: true },1017    });1018    expect(res.ok).toBe(true);1019    const data = res.data as Array<{ title: string }>;1020    expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1021  });10221023  // --- MV-7: vouched device confirmation handlers ---10241025  async function seedPendingVouched(deviceId: string) {1026    const { addPendingVouchedDevice } = await import(1027      "./pending-vouched-devices"1028    );1029    await addPendingVouchedDevice({1030      deviceId,1031      deviceName: "New Pixel",1032      deviceType: "android",1033      longTermPublicKeyHex: "e".repeat(64),1034      signingPublicKeyHex: "a".repeat(64),1035      wsPort: 51234,1036      pairedAt: 1_700_000_000_020,1037      receivedAt: 1_700_000_000_050,1038      vouchedByDeviceId: "extension-issuer",1039    });1040  }10411042  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1043    const vault = await import("./vault");1044    await vault.createVault("correct-pass-12345");1045    await seedPendingVouched("android-new");1046    const { findPairedDeviceById } = await import("./paired-devices");1047    const { listPendingVouchedDevices } = await import(1048      "./pending-vouched-devices"1049    );10501051    const res = await dispatch({1052      type: "CONFIRM_VOUCHED_DEVICE",1053      payload: { deviceId: "android-new" },1054    });1055    expect(res).toMatchObject({ ok: true });10561057    expect(await findPairedDeviceById("android-new")).toMatchObject({1058      deviceName: "New Pixel",1059      trustSource: "vouched",1060      longTermPublicKeyHex: "e".repeat(64),1061    });1062    expect(await listPendingVouchedDevices()).toHaveLength(0);1063  });10641065  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1066    const vault = await import("./vault");1067    await vault.createVault("correct-pass-12345");1068    await seedPendingVouched("android-new");1069    const { findPairedDeviceById } = await import("./paired-devices");1070    const { listPendingVouchedDevices } = await import(1071      "./pending-vouched-devices"1072    );10731074    const res = await dispatch({1075      type: "REJECT_VOUCHED_DEVICE",1076      payload: { deviceId: "android-new" },1077    });1078    expect(res).toMatchObject({ ok: true });10791080    expect(await findPairedDeviceById("android-new")).toBeNull();1081    expect(await listPendingVouchedDevices()).toHaveLength(0);1082  });10831084  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---10851086  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1087    const vault = await import("./vault");1088    await vault.createVault("correct-pass-12345");10891090    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1091    expect(res).toMatchObject({ ok: false, error: "password_required" });1092  });10931094  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1095    const vault = await import("./vault");1096    await vault.createVault("correct-pass-12345");10971098    const res = await dispatch({1099      type: "GET_RECOVERY_WORDS",1100      payload: { password: "not-the-password" },1101    });1102    expect(res).toMatchObject({ ok: false, error: "wrong_password" });1103    expect(res.data).toBeUndefined();1104  });11051106  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1107    const vault = await import("./vault");1108    await vault.createVault("correct-pass-12345");11091110    const res = await dispatch({1111      type: "GET_RECOVERY_WORDS",1112      payload: { password: "correct-pass-12345" },1113    });1114    expect(res.ok).toBe(true);1115    expect((res.data as string[]).length).toBe(12);1116  });1117});