messages.test.ts

23.2 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";4import type { Message, MessageResponse } from "../shared/types";56type StorageShape = Record<string, unknown>;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        },79        session: {80          get: vi.fn(async (key?: string | string[]) => {81            if (!key) return sessionStorage;82            if (typeof key === "string") {83              return { [key]: sessionStorage[key] };84            }85            return key.reduce<Record<string, unknown>>((result, entry) => {86              result[entry] = sessionStorage[entry];87              return result;88            }, {});89          }),90          set: vi.fn(async (next: Record<string, unknown>) => {91            Object.assign(sessionStorage, next);92          }),93          remove: vi.fn(async (keys: string | string[]) => {94            const list = Array.isArray(keys) ? keys : [keys];95            for (const key of list) {96              delete sessionStorage[key];97            }98          }),99          clear: vi.fn(async () => {100            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];101          }),102        },103      },104      alarms: {105        clear: vi.fn(),106        create: vi.fn(),107        onAlarm: { addListener: vi.fn() },108      },109      idle: {110        setDetectionInterval: vi.fn(),111        onStateChanged: { addListener: vi.fn() },112      },113      scripting: {114        executeScript: vi.fn(async () => []),115      },116    } as unknown as typeof chrome;117118    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;119120    const messages = await import("./messages");121    messages.setupMessageHandler();122    const vault = await import("./vault");123    await vault.ensureSettingsLoaded();124  });125126  async function dispatch(127    message: Message,128    sender: chrome.runtime.MessageSender = { id: "peach-extension" }129  ): Promise<MessageResponse> {130    return await new Promise<MessageResponse>((resolve) => {131      const keepChannelOpen = listener(message, sender, (response) => resolve(response));132      expect(keepChannelOpen).toBe(true);133    });134  }135136  it("GET_ENTRIES when unlocked returns the entry array", async () => {137    const vault = await import("./vault");138    await vault.createVault("correct-pass-12345");139    await vault.addEntry({140      title: "GitHub",141      username: "june",142      password: "secret123",143      url: "https://github.com",144    });145146    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({147      ok: true,148      data: [149        expect.objectContaining({150          title: "GitHub",151          username: "june",152        }),153      ],154    });155  });156157  it("GET_ENTRIES when locked returns a vault locked error", async () => {158    const vault = await import("./vault");159    await vault.createVault("correct-pass-12345");160    vault.lock();161162    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({163      ok: false,164      error: "vault_locked",165    });166  });167168  it("ADD_ENTRY with a valid payload returns the created entry", async () => {169    const vault = await import("./vault");170    await vault.createVault("correct-pass-12345");171172    const response = await dispatch({173      type: "ADD_ENTRY",174      payload: {175        title: "GitHub",176        username: "june",177        password: "secret123",178        url: "https://github.com",179      },180    });181182    expect(response).toMatchObject({183      ok: true,184      data: expect.objectContaining({185        id: expect.any(String),186        title: "GitHub",187      }),188    });189    expect(vault.getEntries()).toHaveLength(1);190  });191192  it("ADD_ENTRY when locked returns a vault locked error", async () => {193    const vault = await import("./vault");194    await vault.createVault("correct-pass-12345");195    vault.lock();196197    await expect(198      dispatch({199        type: "ADD_ENTRY",200        payload: {201          title: "GitHub",202          username: "june",203          password: "secret123",204          url: "https://github.com",205        },206      })207    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });208  });209210  it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {211    const vault = await import("./vault");212    await vault.createVault("correct-pass-12345");213214    await expect(215      dispatch({216        type: "ADD_ENTRY",217        payload: {218          title: "GitHub",219          username: "june",220          password: "",221          url: "https://github.com",222        },223      })224    ).resolves.toMatchObject({ ok: true });225  });226227  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {228    const vault = await import("./vault");229    await vault.createVault("correct-pass-12345");230    const entry = await vault.addEntry({231      title: "GitHub",232      username: "june",233      password: "secret123",234      url: "https://github.com",235    });236237    await expect(238      dispatch({239        type: "UPDATE_ENTRY",240        payload: { id: entry.id, patch: { password: "new-secret" } },241      })242    ).resolves.toMatchObject({ ok: true });243244    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");245  });246247  it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {248    const vault = await import("./vault");249    await vault.createVault("correct-pass-12345");250    const entry = await vault.addEntry({251      title: "GitHub",252      username: "june",253      password: "secret123",254      url: "https://github.com",255    });256257    await expect(258      dispatch({259        type: "UPDATE_ENTRY",260        payload: { id: entry.id, patch: { password: "" } },261      })262    ).resolves.toMatchObject({ ok: true });263264    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();265  });266267  it("DELETE_ENTRY soft-deletes the target entry", async () => {268    const vault = await import("./vault");269    await vault.createVault("correct-pass-12345");270    const entry = await vault.addEntry({271      title: "GitHub",272      username: "june",273      password: "secret123",274      url: "https://github.com",275    });276277    await expect(278      dispatch({279        type: "DELETE_ENTRY",280        payload: { id: entry.id },281      })282    ).resolves.toMatchObject({ ok: true });283284    expect(vault.getEntries()).toEqual([]);285    expect(vault.getDeletedEntries()).toHaveLength(1);286  });287288  it("DELETE_ENTRY of a missing id returns an error", async () => {289    const vault = await import("./vault");290    await vault.createVault("correct-pass-12345");291292    await expect(293      dispatch({294        type: "DELETE_ENTRY",295        payload: { id: "missing" },296      })297    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });298  });299300  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {301    const vault = await import("./vault");302    await vault.createVault("correct-pass-12345");303    const entry = await vault.addEntry({304      title: "GitHub",305      username: "june",306      password: "secret123",307      url: "https://github.com",308    });309310    const response = await dispatch({311      type: "GET_ENTRY_FOR_FILL",312      payload: { id: entry.id },313    });314315    expect(response).toMatchObject({316      ok: true,317      data: expect.objectContaining({318        id: entry.id,319        username: "june",320        password: "secret123",321      }),322    });323    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();324  });325326  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {327    const vault = await import("./vault");328    await vault.createVault("correct-pass-12345");329    vault.lock();330331    await expect(332      dispatch({333        type: "GET_ENTRY_FOR_FILL",334        payload: { id: "missing" },335      })336    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });337  });338339  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {340    const vault = await import("./vault");341    await vault.createVault("correct-pass-12345");342    const generated = await generatePasskeyKeypair(-7);343    const entry = await vault.addEntry({344      type: "login",345      title: "WebAuthn.io",346      passkey: {347        rpId: "webauthn.io",348        rpName: "WebAuthn.io",349        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),350        publicKeyCose: base64urlEncode(generated.publicKeyCose),351        privateKeyJwk: generated.privateKeyJwk,352        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),353        userName: "june",354        userDisplayName: "June",355        algorithm: -7,356        signCount: 0,357        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",358        backupEligible: true,359        backupState: false,360        createdAt: Date.now(),361      },362    });363364    await expect(365      dispatch({366        type: "GET_ENTRY_FOR_FILL",367        payload: { id: entry.id },368      })369    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });370  });371372  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {373    const vault = await import("./vault");374    await vault.createVault("correct-pass-12345");375    await vault.addEntry({376      title: "GitHub",377      username: "june",378      password: "secret123",379      url: "https://github.com",380      totp: "JBSWY3DPEHPK3PXP",381    });382383    const response = await dispatch({384      type: "AUTOFILL_QUERY",385      payload: { domain: "github.com" },386    });387388    expect(response).toMatchObject({389      ok: true,390      data: [391        {392          id: expect.any(String),393          title: "GitHub",394          username: "june",395          url: "https://github.com",396          hasTotp: true,397        },398      ],399    });400    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();401    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();402  });403404  it("AUTOFILL_QUERY includeAll returns only id and title for non-note, non-passkey entries", async () => {405    const vault = await import("./vault");406    await vault.createVault("correct-pass-12345");407    await vault.addEntry({408      title: "GitHub",409      username: "june",410      password: "secret123",411      url: "https://github.com",412    });413    await vault.addEntry({414      type: "note",415      title: "Secure note",416      notes: "hello",417    });418419    const response = await dispatch({420      type: "AUTOFILL_QUERY",421      payload: { domain: "github.com", includeAll: true },422    });423424    expect(response).toMatchObject({425      ok: true,426      data: [{ id: expect.any(String), title: "GitHub" }],427    });428    expect(response.data).toHaveLength(1);429  });430431  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {432    const vault = await import("./vault");433    await vault.createVault("correct-pass-12345");434    await vault.addEntry({435      title: "GitHub",436      username: "june",437      password: "secret123",438      url: "https://github.com",439      totp: "JBSWY3DPEHPK3PXP",440    });441    await vault.addEntry({442      title: "No 2FA",443      username: "person",444      password: "secret123",445      url: "https://github.com",446    });447448    const response = await dispatch({449      type: "AUTOFILL_QUERY",450      payload: { domain: "github.com", kind: "totp" },451    });452453    expect(response).toMatchObject({454      ok: true,455      data: [456        {457          id: expect.any(String),458          title: "GitHub",459          username: "june",460          url: "https://github.com",461          hasTotp: true,462        },463      ],464    });465    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();466    expect(response.data).toHaveLength(1);467  });468469  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {470    const vault = await import("./vault");471    await vault.createVault("correct-pass-12345");472    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });473474    const response = await dispatch({475      type: "RESTORE_ENTRIES",476      payload: {477        mode: "replace",478        entries: [479          {480            id: "restored-1",481            type: "login",482            title: "B",483            username: "june",484            password: "two",485            url: "https://b.test",486            createdAt: 1,487            modifiedAt: 1,488          },489        ],490      },491    });492493    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });494    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);495  });496497  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {498    const vault = await import("./vault");499    await vault.createVault("correct-pass-12345");500501    await expect(502      dispatch({503        type: "RESTORE_ENTRIES",504        payload: { mode: "replace", entries: [] },505      })506    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });507  });508509  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {510    const vault = await import("./vault");511    await vault.createVault("correct-pass-12345");512513    await expect(514      dispatch({515        type: "CHANGE_PASSWORD",516        payload: {517          currentPassword: "correct-pass-12345",518          newPassword: "new-pass-67890",519        },520      })521    ).resolves.toMatchObject({ ok: true });522523    vault.lock();524    expect(await vault.unlock("new-pass-67890")).toBe(true);525526    await expect(527      dispatch({528        type: "CHANGE_PASSWORD",529        payload: {530          currentPassword: "wrong-pass",531          newPassword: "another-pass",532        },533      })534    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });535  });536537  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {538    const vault = await import("./vault");539    await vault.createVault("correct-pass-12345");540541    (542      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>543    ).mockClear();544    (545      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>546    ).mockClear();547548    await expect(549      dispatch({550        type: "UPDATE_SETTINGS",551        payload: { autoLockMinutes: 30 },552      })553    ).resolves.toMatchObject({ ok: true });554555    expect(vault.getSettings().autoLockMinutes).toBe(30);556    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);557    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {558      delayInMinutes: 30,559    });560  });561562  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {563    vi.useFakeTimers();564    const vault = await import("./vault");565    await vault.createVault("correct-pass-12345");566    const entry = await vault.addEntry({567      title: "GitHub",568      username: "june",569      password: "secret123",570      url: "https://github.com",571    });572    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);573574    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });575    await vi.runAllTimersAsync();576    const response = await responsePromise;577578    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });579    expect(vault.getEntryById(entry.id)?.breach).toBe(7);580    breachSpy.mockRestore();581  });582583  it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {584    const vault = await import("./vault");585    await vault.createVault("correct-pass-12345");586587    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({588      ok: false,589      error: "invalid_sender",590    });591592    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({593      ok: true,594      data: [],595    });596  });597598  it("LOCK and UNLOCK messages update vault state correctly", async () => {599    const vault = await import("./vault");600601    await expect(602      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })603    ).resolves.toMatchObject({ ok: true });604    expect(vault.isUnlocked()).toBe(true);605606    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });607    expect(vault.isUnlocked()).toBe(false);608609    await expect(610      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })611    ).resolves.toMatchObject({ ok: false });612    expect(vault.isUnlocked()).toBe(false);613614    await expect(615      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })616    ).resolves.toMatchObject({ ok: true });617    expect(vault.isUnlocked()).toBe(true);618  });619620  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {621    const response = await dispatch({622      type: "FIRE_CANARY_PING",623      payload: {624        webhookUrl: "https://example.com/hook",625        entryTitle: "GitHub",626        trigger: "password_copied",627      },628    });629630    expect(response).toMatchObject({ ok: true });631    expect(fetchMock).toHaveBeenCalledWith(632      "https://example.com/hook",633      expect.objectContaining({634        method: "POST",635        headers: { "Content-Type": "application/json" },636        body: expect.any(String),637      })638    );639    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;640    expect(body).toMatchObject({641      entry: "GitHub",642      trigger: "password_copied",643      source: "peach-extension",644    });645  });646647  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {648    await expect(649      dispatch({650        type: "FIRE_CANARY_PING",651        payload: {652          webhookUrl: "http://example.com/hook",653          entryTitle: "GitHub",654          trigger: "password_copied",655        },656      })657    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });658659    expect(fetchMock).not.toHaveBeenCalled();660  });661662  it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {663    const response = await dispatch({664      type: "SUBMIT_FEEDBACK",665      payload: {666        category: "Bug report",667        message: "the popup flashes",668        version: "2.1.0",669        browser: "Chrome",670      },671    });672673    expect(response).toMatchObject({ ok: true });674    expect(fetchMock).not.toHaveBeenCalled();675  });676677  it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {678    const response = await dispatch({679      type: "SUBMIT_FEEDBACK",680      payload: {681        category: "Bug report",682        message: "  ",683        version: "2.1.0",684        browser: "Chrome",685      },686    });687688    expect(response).toMatchObject({ ok: false, error: "message_empty" });689    expect(fetchMock).not.toHaveBeenCalled();690  });691692  it("GET_IDENTITIES when locked returns vault locked error", async () => {693    const vault = await import("./vault");694    await vault.createVault("correct-pass-12345");695    vault.lock();696    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({697      ok: false,698      error: "vault_locked",699    });700  });701702  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {703    const vault = await import("./vault");704    await vault.createVault("correct-pass-12345");705    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });706    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });707    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });708709    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };710    expect(resp.ok).toBe(true);711    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;712    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({713      type: "email",714      useCount: 2,715    });716    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({717      type: "username",718      useCount: 1,719    });720    // emails appear before usernames721    const emailIdx = data.findIndex((d) => d.value === "[email protected]");722    const userIdx = data.findIndex((d) => d.value === "juni_patel");723    expect(emailIdx).toBeLessThan(userIdx);724  });725726  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {727    const vault = await import("./vault");728    await vault.createVault("correct-pass-12345");729    for (let i = 0; i < 3; i++) {730      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });731    }732733    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };734    const data = resp.data as Array<{ value: string; useCount: number }>;735    const hits = data.filter((d) => d.value === "[email protected]");736    expect(hits).toHaveLength(1);737    expect(hits[0]!.useCount).toBe(3);738  });739740  it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {741    const vault = await import("./vault");742    await vault.createVault("correct-pass-12345");743    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;744745    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer746    alarmsCreate.mockClear();747    await dispatch({ type: "GET_ENTRIES" });748    expect(alarmsCreate).toHaveBeenCalled();749750    // SUBMIT_FEEDBACK is in the exclusion list and must not re-arm751    alarmsCreate.mockClear();752    await dispatch({753      type: "SUBMIT_FEEDBACK",754      payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },755    });756    expect(alarmsCreate).not.toHaveBeenCalled();757  });758});