messages.test.ts

19.0 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 is 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 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: "passkey",317      title: "WebAuthn.io",318      rpId: "webauthn.io",319      rpName: "WebAuthn.io",320      credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),321      publicKeyCose: base64urlEncode(generated.publicKeyCose),322      privateKeyJwk: generated.privateKeyJwk,323      userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),324      userName: "june",325      userDisplayName: "June",326      algorithm: -7,327      signCount: 0,328      aaguid: "AAAAAAAAAAAAAAAAAAAAAA",329      backupEligible: true,330      backupState: false,331    });332333    await expect(334      dispatch({335        type: "GET_ENTRY_FOR_FILL",336        payload: { id: entry.id },337      })338    ).resolves.toMatchObject({ ok: false, error: "passkey not fillable" });339  });340341  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {342    const vault = await import("./vault");343    await vault.createVault("correct-pass-12345");344    await vault.addEntry({345      title: "GitHub",346      username: "june",347      password: "secret123",348      url: "https://github.com",349      totp: "JBSWY3DPEHPK3PXP",350    });351352    const response = await dispatch({353      type: "AUTOFILL_QUERY",354      payload: { domain: "github.com" },355    });356357    expect(response).toMatchObject({358      ok: true,359      data: [360        {361          id: expect.any(String),362          title: "GitHub",363          username: "june",364          url: "https://github.com",365          hasTotp: true,366        },367      ],368    });369    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();370    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();371  });372373  it("AUTOFILL_QUERY includeAll returns only id and title for non-note, non-passkey entries", async () => {374    const vault = await import("./vault");375    await vault.createVault("correct-pass-12345");376    await vault.addEntry({377      title: "GitHub",378      username: "june",379      password: "secret123",380      url: "https://github.com",381    });382    await vault.addEntry({383      type: "note",384      title: "Secure note",385      notes: "hello",386    });387388    const response = await dispatch({389      type: "AUTOFILL_QUERY",390      payload: { domain: "github.com", includeAll: true },391    });392393    expect(response).toMatchObject({394      ok: true,395      data: [{ id: expect.any(String), title: "GitHub" }],396    });397    expect(response.data).toHaveLength(1);398  });399400  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {401    const vault = await import("./vault");402    await vault.createVault("correct-pass-12345");403    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });404405    const response = await dispatch({406      type: "RESTORE_ENTRIES",407      payload: {408        mode: "replace",409        entries: [410          {411            id: "restored-1",412            type: "login",413            title: "B",414            username: "june",415            password: "two",416            url: "https://b.test",417            createdAt: 1,418            modifiedAt: 1,419          },420        ],421      },422    });423424    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });425    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);426  });427428  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {429    const vault = await import("./vault");430    await vault.createVault("correct-pass-12345");431432    await expect(433      dispatch({434        type: "RESTORE_ENTRIES",435        payload: { mode: "replace", entries: [] },436      })437    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });438  });439440  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {441    const vault = await import("./vault");442    await vault.createVault("correct-pass-12345");443444    await expect(445      dispatch({446        type: "CHANGE_PASSWORD",447        payload: {448          currentPassword: "correct-pass-12345",449          newPassword: "new-pass-67890",450        },451      })452    ).resolves.toMatchObject({ ok: true });453454    vault.lock();455    expect(await vault.unlock("new-pass-67890")).toBe(true);456457    await expect(458      dispatch({459        type: "CHANGE_PASSWORD",460        payload: {461          currentPassword: "wrong-pass",462          newPassword: "another-pass",463        },464      })465    ).resolves.toMatchObject({ ok: false, error: "incorrect current password" });466  });467468  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {469    const vault = await import("./vault");470    await vault.createVault("correct-pass-12345");471472    (473      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>474    ).mockClear();475    (476      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>477    ).mockClear();478479    await expect(480      dispatch({481        type: "UPDATE_SETTINGS",482        payload: { autoLockMinutes: 30 },483      })484    ).resolves.toMatchObject({ ok: true });485486    expect(vault.getSettings().autoLockMinutes).toBe(30);487    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);488    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {489      delayInMinutes: 30,490    });491  });492493  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {494    vi.useFakeTimers();495    const vault = await import("./vault");496    await vault.createVault("correct-pass-12345");497    const entry = await vault.addEntry({498      title: "GitHub",499      username: "june",500      password: "secret123",501      url: "https://github.com",502    });503    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);504505    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });506    await vi.runAllTimersAsync();507    const response = await responsePromise;508509    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });510    expect(vault.getEntryById(entry.id)?.breach).toBe(7);511    breachSpy.mockRestore();512  });513514  it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {515    const vault = await import("./vault");516    await vault.createVault("correct-pass-12345");517518    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({519      ok: false,520      error: "invalid sender",521    });522523    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({524      ok: true,525      data: [],526    });527  });528529  it("LOCK and UNLOCK messages update vault state correctly", async () => {530    const vault = await import("./vault");531532    await expect(533      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })534    ).resolves.toMatchObject({ ok: true });535    expect(vault.isUnlocked()).toBe(true);536537    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });538    expect(vault.isUnlocked()).toBe(false);539540    await expect(541      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })542    ).resolves.toMatchObject({ ok: false });543    expect(vault.isUnlocked()).toBe(false);544545    await expect(546      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })547    ).resolves.toMatchObject({ ok: true });548    expect(vault.isUnlocked()).toBe(true);549  });550551  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {552    const response = await dispatch({553      type: "FIRE_CANARY_PING",554      payload: {555        webhookUrl: "https://example.com/hook",556        entryTitle: "GitHub",557        trigger: "password_copied",558      },559    });560561    expect(response).toMatchObject({ ok: true });562    expect(fetchMock).toHaveBeenCalledWith(563      "https://example.com/hook",564      expect.objectContaining({565        method: "POST",566        headers: { "Content-Type": "application/json" },567        body: expect.any(String),568      })569    );570    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;571    expect(body).toMatchObject({572      entry: "GitHub",573      trigger: "password_copied",574      source: "peach-extension",575    });576  });577578  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {579    await expect(580      dispatch({581        type: "FIRE_CANARY_PING",582        payload: {583          webhookUrl: "http://example.com/hook",584          entryTitle: "GitHub",585          trigger: "password_copied",586        },587      })588    ).resolves.toMatchObject({ ok: false, error: "invalid webhook url" });589590    expect(fetchMock).not.toHaveBeenCalled();591  });592593  it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {594    const response = await dispatch({595      type: "SUBMIT_FEEDBACK",596      payload: {597        category: "Bug report",598        message: "the popup flashes",599        version: "2.1.0",600        browser: "Chrome",601      },602    });603604    expect(response).toMatchObject({ ok: true });605    expect(fetchMock).not.toHaveBeenCalled();606  });607608  it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {609    const response = await dispatch({610      type: "SUBMIT_FEEDBACK",611      payload: {612        category: "Bug report",613        message: "  ",614        version: "2.1.0",615        browser: "Chrome",616      },617    });618619    expect(response).toMatchObject({ ok: false, error: "message is empty" });620    expect(fetchMock).not.toHaveBeenCalled();621  });622623  it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {624    const vault = await import("./vault");625    await vault.createVault("correct-pass-12345");626    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;627628    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer629    alarmsCreate.mockClear();630    await dispatch({ type: "GET_ENTRIES" });631    expect(alarmsCreate).toHaveBeenCalled();632633    // SUBMIT_FEEDBACK is in the exclusion list and must not re-arm634    alarmsCreate.mockClear();635    await dispatch({636      type: "SUBMIT_FEEDBACK",637      payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },638    });639    expect(alarmsCreate).not.toHaveBeenCalled();640  });641});