messages.test.ts

21.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 listener: MessageListener;16  let fetchMock: ReturnType<typeof vi.fn>;1718  beforeEach(async () => {19    vi.resetModules();20    vi.useRealTimers();21    storage = {22      peach_settings: { breachMonitoring: false },23    };2425    fetchMock = vi.fn(async () => ({26      ok: true,27      status: 200,28      text: async () => "",29    }));30    vi.stubGlobal("fetch", fetchMock);31    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3233    const tabs = [{ id: 1 }];34    const chromeMock = {35      runtime: {36        id: "peach-extension",37        onMessage: {38          addListener: vi.fn((fn: MessageListener) => {39            listener = fn;40          }),41        },42        sendMessage: vi.fn(async () => ({ ok: true })),43      },44      tabs: {45        query: vi.fn((query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {46          if (typeof callback === "function") {47            callback(tabs);48            return;49          }50          return Promise.resolve(tabs);51        }),52        sendMessage: vi.fn(async () => ({ ok: true })),53      },54      storage: {55        local: {56          get: vi.fn(async (key?: string | string[]) => {57            if (!key) return storage;58            if (typeof key === "string") {59              return { [key]: storage[key] };60            }6162            return key.reduce<Record<string, unknown>>((result, entry) => {63              result[entry] = storage[entry];64              return result;65            }, {});66          }),67          set: vi.fn(async (next: Record<string, unknown>) => {68            Object.assign(storage, next);69          }),70          remove: vi.fn(async (keys: string | string[]) => {71            const list = Array.isArray(keys) ? keys : [keys];72            for (const key of list) {73              delete storage[key];74            }75          }),76        },77      },78      alarms: {79        clear: vi.fn(),80        create: vi.fn(),81        onAlarm: { addListener: vi.fn() },82      },83      idle: {84        setDetectionInterval: vi.fn(),85        onStateChanged: { addListener: vi.fn() },86      },87      scripting: {88        executeScript: vi.fn(async () => []),89      },90    } as unknown as typeof chrome;9192    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;9394    const messages = await import("./messages");95    messages.setupMessageHandler();96    const vault = await import("./vault");97    await vault.ensureSettingsLoaded();98  });99100  async function dispatch(101    message: Message,102    sender: chrome.runtime.MessageSender = { id: "peach-extension" }103  ): Promise<MessageResponse> {104    return await new Promise<MessageResponse>((resolve) => {105      const keepChannelOpen = listener(message, sender, (response) => resolve(response));106      expect(keepChannelOpen).toBe(true);107    });108  }109110  it("GET_ENTRIES when unlocked returns the entry array", async () => {111    const vault = await import("./vault");112    await vault.createVault("correct-pass-12345");113    await vault.addEntry({114      title: "GitHub",115      username: "june",116      password: "secret123",117      url: "https://github.com",118    });119120    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({121      ok: true,122      data: [123        expect.objectContaining({124          title: "GitHub",125          username: "june",126        }),127      ],128    });129  });130131  it("GET_ENTRIES when locked returns a vault locked error", async () => {132    const vault = await import("./vault");133    await vault.createVault("correct-pass-12345");134    vault.lock();135136    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({137      ok: false,138      error: "vault locked",139    });140  });141142  it("ADD_ENTRY with a valid payload returns the created entry", async () => {143    const vault = await import("./vault");144    await vault.createVault("correct-pass-12345");145146    const response = await dispatch({147      type: "ADD_ENTRY",148      payload: {149        title: "GitHub",150        username: "june",151        password: "secret123",152        url: "https://github.com",153      },154    });155156    expect(response).toMatchObject({157      ok: true,158      data: expect.objectContaining({159        id: expect.any(String),160        title: "GitHub",161      }),162    });163    expect(vault.getEntries()).toHaveLength(1);164  });165166  it("ADD_ENTRY when locked returns a vault locked error", async () => {167    const vault = await import("./vault");168    await vault.createVault("correct-pass-12345");169    vault.lock();170171    await expect(172      dispatch({173        type: "ADD_ENTRY",174        payload: {175          title: "GitHub",176          username: "june",177          password: "secret123",178          url: "https://github.com",179        },180      })181    ).resolves.toMatchObject({ ok: false, error: "vault locked" });182  });183184  it("ADD_ENTRY with a missing password returns a validation error", async () => {185    const vault = await import("./vault");186    await vault.createVault("correct-pass-12345");187188    await expect(189      dispatch({190        type: "ADD_ENTRY",191        payload: {192          title: "GitHub",193          username: "june",194          password: "",195          url: "https://github.com",196        },197      })198    ).resolves.toMatchObject({ ok: false, error: "password or passkey required" });199  });200201  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {202    const vault = await import("./vault");203    await vault.createVault("correct-pass-12345");204    const entry = await vault.addEntry({205      title: "GitHub",206      username: "june",207      password: "secret123",208      url: "https://github.com",209    });210211    await expect(212      dispatch({213        type: "UPDATE_ENTRY",214        payload: { id: entry.id, patch: { password: "new-secret" } },215      })216    ).resolves.toMatchObject({ ok: true });217218    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");219  });220221  it("UPDATE_ENTRY with an empty login password returns an error", async () => {222    const vault = await import("./vault");223    await vault.createVault("correct-pass-12345");224    const entry = await vault.addEntry({225      title: "GitHub",226      username: "june",227      password: "secret123",228      url: "https://github.com",229    });230231    await expect(232      dispatch({233        type: "UPDATE_ENTRY",234        payload: { id: entry.id, patch: { password: "" } },235      })236    ).resolves.toMatchObject({ ok: false, error: "password is required for login entries" });237  });238239  it("DELETE_ENTRY soft-deletes the target entry", async () => {240    const vault = await import("./vault");241    await vault.createVault("correct-pass-12345");242    const entry = await vault.addEntry({243      title: "GitHub",244      username: "june",245      password: "secret123",246      url: "https://github.com",247    });248249    await expect(250      dispatch({251        type: "DELETE_ENTRY",252        payload: { id: entry.id },253      })254    ).resolves.toMatchObject({ ok: true });255256    expect(vault.getEntries()).toEqual([]);257    expect(vault.getDeletedEntries()).toHaveLength(1);258  });259260  it("DELETE_ENTRY of a missing id returns an error", async () => {261    const vault = await import("./vault");262    await vault.createVault("correct-pass-12345");263264    await expect(265      dispatch({266        type: "DELETE_ENTRY",267        payload: { id: "missing" },268      })269    ).resolves.toMatchObject({ ok: false, error: "entry not found" });270  });271272  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {273    const vault = await import("./vault");274    await vault.createVault("correct-pass-12345");275    const entry = await vault.addEntry({276      title: "GitHub",277      username: "june",278      password: "secret123",279      url: "https://github.com",280    });281282    const response = await dispatch({283      type: "GET_ENTRY_FOR_FILL",284      payload: { id: entry.id },285    });286287    expect(response).toMatchObject({288      ok: true,289      data: expect.objectContaining({290        id: entry.id,291        username: "june",292        password: "secret123",293      }),294    });295    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();296  });297298  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {299    const vault = await import("./vault");300    await vault.createVault("correct-pass-12345");301    vault.lock();302303    await expect(304      dispatch({305        type: "GET_ENTRY_FOR_FILL",306        payload: { id: "missing" },307      })308    ).resolves.toMatchObject({ ok: false, error: "vault locked" });309  });310311  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {312    const vault = await import("./vault");313    await vault.createVault("correct-pass-12345");314    const generated = await generatePasskeyKeypair(-7);315    const entry = await vault.addEntry({316      type: "login",317      title: "WebAuthn.io",318      passkey: {319        rpId: "webauthn.io",320        rpName: "WebAuthn.io",321        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),322        publicKeyCose: base64urlEncode(generated.publicKeyCose),323        privateKeyJwk: generated.privateKeyJwk,324        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),325        userName: "june",326        userDisplayName: "June",327        algorithm: -7,328        signCount: 0,329        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",330        backupEligible: true,331        backupState: false,332        createdAt: Date.now(),333      },334    });335336    await expect(337      dispatch({338        type: "GET_ENTRY_FOR_FILL",339        payload: { id: entry.id },340      })341    ).resolves.toMatchObject({ ok: false, error: "passkey not fillable" });342  });343344  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {345    const vault = await import("./vault");346    await vault.createVault("correct-pass-12345");347    await vault.addEntry({348      title: "GitHub",349      username: "june",350      password: "secret123",351      url: "https://github.com",352      totp: "JBSWY3DPEHPK3PXP",353    });354355    const response = await dispatch({356      type: "AUTOFILL_QUERY",357      payload: { domain: "github.com" },358    });359360    expect(response).toMatchObject({361      ok: true,362      data: [363        {364          id: expect.any(String),365          title: "GitHub",366          username: "june",367          url: "https://github.com",368          hasTotp: true,369        },370      ],371    });372    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();373    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();374  });375376  it("AUTOFILL_QUERY includeAll returns only id and title for non-note, non-passkey entries", 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    });385    await vault.addEntry({386      type: "note",387      title: "Secure note",388      notes: "hello",389    });390391    const response = await dispatch({392      type: "AUTOFILL_QUERY",393      payload: { domain: "github.com", includeAll: true },394    });395396    expect(response).toMatchObject({397      ok: true,398      data: [{ id: expect.any(String), title: "GitHub" }],399    });400    expect(response.data).toHaveLength(1);401  });402403  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {404    const vault = await import("./vault");405    await vault.createVault("correct-pass-12345");406    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });407408    const response = await dispatch({409      type: "RESTORE_ENTRIES",410      payload: {411        mode: "replace",412        entries: [413          {414            id: "restored-1",415            type: "login",416            title: "B",417            username: "june",418            password: "two",419            url: "https://b.test",420            createdAt: 1,421            modifiedAt: 1,422          },423        ],424      },425    });426427    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });428    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);429  });430431  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {432    const vault = await import("./vault");433    await vault.createVault("correct-pass-12345");434435    await expect(436      dispatch({437        type: "RESTORE_ENTRIES",438        payload: { mode: "replace", entries: [] },439      })440    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });441  });442443  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {444    const vault = await import("./vault");445    await vault.createVault("correct-pass-12345");446447    await expect(448      dispatch({449        type: "CHANGE_PASSWORD",450        payload: {451          currentPassword: "correct-pass-12345",452          newPassword: "new-pass-67890",453        },454      })455    ).resolves.toMatchObject({ ok: true });456457    vault.lock();458    expect(await vault.unlock("new-pass-67890")).toBe(true);459460    await expect(461      dispatch({462        type: "CHANGE_PASSWORD",463        payload: {464          currentPassword: "wrong-pass",465          newPassword: "another-pass",466        },467      })468    ).resolves.toMatchObject({ ok: false, error: "incorrect current password" });469  });470471  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {472    const vault = await import("./vault");473    await vault.createVault("correct-pass-12345");474475    (476      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>477    ).mockClear();478    (479      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>480    ).mockClear();481482    await expect(483      dispatch({484        type: "UPDATE_SETTINGS",485        payload: { autoLockMinutes: 30 },486      })487    ).resolves.toMatchObject({ ok: true });488489    expect(vault.getSettings().autoLockMinutes).toBe(30);490    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);491    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {492      delayInMinutes: 30,493    });494  });495496  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {497    vi.useFakeTimers();498    const vault = await import("./vault");499    await vault.createVault("correct-pass-12345");500    const entry = await vault.addEntry({501      title: "GitHub",502      username: "june",503      password: "secret123",504      url: "https://github.com",505    });506    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);507508    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });509    await vi.runAllTimersAsync();510    const response = await responsePromise;511512    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });513    expect(vault.getEntryById(entry.id)?.breach).toBe(7);514    breachSpy.mockRestore();515  });516517  it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {518    const vault = await import("./vault");519    await vault.createVault("correct-pass-12345");520521    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({522      ok: false,523      error: "invalid sender",524    });525526    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({527      ok: true,528      data: [],529    });530  });531532  it("LOCK and UNLOCK messages update vault state correctly", async () => {533    const vault = await import("./vault");534535    await expect(536      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })537    ).resolves.toMatchObject({ ok: true });538    expect(vault.isUnlocked()).toBe(true);539540    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });541    expect(vault.isUnlocked()).toBe(false);542543    await expect(544      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })545    ).resolves.toMatchObject({ ok: false });546    expect(vault.isUnlocked()).toBe(false);547548    await expect(549      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })550    ).resolves.toMatchObject({ ok: true });551    expect(vault.isUnlocked()).toBe(true);552  });553554  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {555    const response = await dispatch({556      type: "FIRE_CANARY_PING",557      payload: {558        webhookUrl: "https://example.com/hook",559        entryTitle: "GitHub",560        trigger: "password_copied",561      },562    });563564    expect(response).toMatchObject({ ok: true });565    expect(fetchMock).toHaveBeenCalledWith(566      "https://example.com/hook",567      expect.objectContaining({568        method: "POST",569        headers: { "Content-Type": "application/json" },570        body: expect.any(String),571      })572    );573    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;574    expect(body).toMatchObject({575      entry: "GitHub",576      trigger: "password_copied",577      source: "peach-extension",578    });579  });580581  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {582    await expect(583      dispatch({584        type: "FIRE_CANARY_PING",585        payload: {586          webhookUrl: "http://example.com/hook",587          entryTitle: "GitHub",588          trigger: "password_copied",589        },590      })591    ).resolves.toMatchObject({ ok: false, error: "invalid webhook url" });592593    expect(fetchMock).not.toHaveBeenCalled();594  });595596  it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {597    const response = await dispatch({598      type: "SUBMIT_FEEDBACK",599      payload: {600        category: "Bug report",601        message: "the popup flashes",602        version: "2.1.0",603        browser: "Chrome",604      },605    });606607    expect(response).toMatchObject({ ok: true });608    expect(fetchMock).not.toHaveBeenCalled();609  });610611  it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {612    const response = await dispatch({613      type: "SUBMIT_FEEDBACK",614      payload: {615        category: "Bug report",616        message: "  ",617        version: "2.1.0",618        browser: "Chrome",619      },620    });621622    expect(response).toMatchObject({ ok: false, error: "message is empty" });623    expect(fetchMock).not.toHaveBeenCalled();624  });625626  it("GET_IDENTITIES when locked returns vault locked error", async () => {627    const vault = await import("./vault");628    await vault.createVault("correct-pass-12345");629    vault.lock();630    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({631      ok: false,632      error: "vault locked",633    });634  });635636  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {637    const vault = await import("./vault");638    await vault.createVault("correct-pass-12345");639    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });640    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });641    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });642643    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };644    expect(resp.ok).toBe(true);645    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;646    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({647      type: "email",648      useCount: 2,649    });650    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({651      type: "username",652      useCount: 1,653    });654    // emails appear before usernames655    const emailIdx = data.findIndex((d) => d.value === "[email protected]");656    const userIdx = data.findIndex((d) => d.value === "juni_patel");657    expect(emailIdx).toBeLessThan(userIdx);658  });659660  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {661    const vault = await import("./vault");662    await vault.createVault("correct-pass-12345");663    for (let i = 0; i < 3; i++) {664      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });665    }666667    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };668    const data = resp.data as Array<{ value: string; useCount: number }>;669    const hits = data.filter((d) => d.value === "[email protected]");670    expect(hits).toHaveLength(1);671    expect(hits[0]!.useCount).toBe(3);672  });673674  it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {675    const vault = await import("./vault");676    await vault.createVault("correct-pass-12345");677    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;678679    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer680    alarmsCreate.mockClear();681    await dispatch({ type: "GET_ENTRIES" });682    expect(alarmsCreate).toHaveBeenCalled();683684    // SUBMIT_FEEDBACK is in the exclusion list and must not re-arm685    alarmsCreate.mockClear();686    await dispatch({687      type: "SUBMIT_FEEDBACK",688      payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },689    });690    expect(alarmsCreate).not.toHaveBeenCalled();691  });692});