messages.test.ts

22.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 a missing password returns a validation error", 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: false, error: "password or passkey required" });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 login password returns an error", 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: false, error: "password or passkey required" });263  });264265  it("DELETE_ENTRY soft-deletes the target entry", async () => {266    const vault = await import("./vault");267    await vault.createVault("correct-pass-12345");268    const entry = await vault.addEntry({269      title: "GitHub",270      username: "june",271      password: "secret123",272      url: "https://github.com",273    });274275    await expect(276      dispatch({277        type: "DELETE_ENTRY",278        payload: { id: entry.id },279      })280    ).resolves.toMatchObject({ ok: true });281282    expect(vault.getEntries()).toEqual([]);283    expect(vault.getDeletedEntries()).toHaveLength(1);284  });285286  it("DELETE_ENTRY of a missing id returns an error", async () => {287    const vault = await import("./vault");288    await vault.createVault("correct-pass-12345");289290    await expect(291      dispatch({292        type: "DELETE_ENTRY",293        payload: { id: "missing" },294      })295    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });296  });297298  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {299    const vault = await import("./vault");300    await vault.createVault("correct-pass-12345");301    const entry = await vault.addEntry({302      title: "GitHub",303      username: "june",304      password: "secret123",305      url: "https://github.com",306    });307308    const response = await dispatch({309      type: "GET_ENTRY_FOR_FILL",310      payload: { id: entry.id },311    });312313    expect(response).toMatchObject({314      ok: true,315      data: expect.objectContaining({316        id: entry.id,317        username: "june",318        password: "secret123",319      }),320    });321    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();322  });323324  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {325    const vault = await import("./vault");326    await vault.createVault("correct-pass-12345");327    vault.lock();328329    await expect(330      dispatch({331        type: "GET_ENTRY_FOR_FILL",332        payload: { id: "missing" },333      })334    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });335  });336337  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {338    const vault = await import("./vault");339    await vault.createVault("correct-pass-12345");340    const generated = await generatePasskeyKeypair(-7);341    const entry = await vault.addEntry({342      type: "login",343      title: "WebAuthn.io",344      passkey: {345        rpId: "webauthn.io",346        rpName: "WebAuthn.io",347        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),348        publicKeyCose: base64urlEncode(generated.publicKeyCose),349        privateKeyJwk: generated.privateKeyJwk,350        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),351        userName: "june",352        userDisplayName: "June",353        algorithm: -7,354        signCount: 0,355        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",356        backupEligible: true,357        backupState: false,358        createdAt: Date.now(),359      },360    });361362    await expect(363      dispatch({364        type: "GET_ENTRY_FOR_FILL",365        payload: { id: entry.id },366      })367    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });368  });369370  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {371    const vault = await import("./vault");372    await vault.createVault("correct-pass-12345");373    await vault.addEntry({374      title: "GitHub",375      username: "june",376      password: "secret123",377      url: "https://github.com",378      totp: "JBSWY3DPEHPK3PXP",379    });380381    const response = await dispatch({382      type: "AUTOFILL_QUERY",383      payload: { domain: "github.com" },384    });385386    expect(response).toMatchObject({387      ok: true,388      data: [389        {390          id: expect.any(String),391          title: "GitHub",392          username: "june",393          url: "https://github.com",394          hasTotp: true,395        },396      ],397    });398    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();399    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();400  });401402  it("AUTOFILL_QUERY includeAll returns only id and title for non-note, non-passkey entries", async () => {403    const vault = await import("./vault");404    await vault.createVault("correct-pass-12345");405    await vault.addEntry({406      title: "GitHub",407      username: "june",408      password: "secret123",409      url: "https://github.com",410    });411    await vault.addEntry({412      type: "note",413      title: "Secure note",414      notes: "hello",415    });416417    const response = await dispatch({418      type: "AUTOFILL_QUERY",419      payload: { domain: "github.com", includeAll: true },420    });421422    expect(response).toMatchObject({423      ok: true,424      data: [{ id: expect.any(String), title: "GitHub" }],425    });426    expect(response.data).toHaveLength(1);427  });428429  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {430    const vault = await import("./vault");431    await vault.createVault("correct-pass-12345");432    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });433434    const response = await dispatch({435      type: "RESTORE_ENTRIES",436      payload: {437        mode: "replace",438        entries: [439          {440            id: "restored-1",441            type: "login",442            title: "B",443            username: "june",444            password: "two",445            url: "https://b.test",446            createdAt: 1,447            modifiedAt: 1,448          },449        ],450      },451    });452453    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });454    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);455  });456457  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {458    const vault = await import("./vault");459    await vault.createVault("correct-pass-12345");460461    await expect(462      dispatch({463        type: "RESTORE_ENTRIES",464        payload: { mode: "replace", entries: [] },465      })466    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });467  });468469  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {470    const vault = await import("./vault");471    await vault.createVault("correct-pass-12345");472473    await expect(474      dispatch({475        type: "CHANGE_PASSWORD",476        payload: {477          currentPassword: "correct-pass-12345",478          newPassword: "new-pass-67890",479        },480      })481    ).resolves.toMatchObject({ ok: true });482483    vault.lock();484    expect(await vault.unlock("new-pass-67890")).toBe(true);485486    await expect(487      dispatch({488        type: "CHANGE_PASSWORD",489        payload: {490          currentPassword: "wrong-pass",491          newPassword: "another-pass",492        },493      })494    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });495  });496497  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {498    const vault = await import("./vault");499    await vault.createVault("correct-pass-12345");500501    (502      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>503    ).mockClear();504    (505      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>506    ).mockClear();507508    await expect(509      dispatch({510        type: "UPDATE_SETTINGS",511        payload: { autoLockMinutes: 30 },512      })513    ).resolves.toMatchObject({ ok: true });514515    expect(vault.getSettings().autoLockMinutes).toBe(30);516    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);517    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {518      delayInMinutes: 30,519    });520  });521522  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {523    vi.useFakeTimers();524    const vault = await import("./vault");525    await vault.createVault("correct-pass-12345");526    const entry = await vault.addEntry({527      title: "GitHub",528      username: "june",529      password: "secret123",530      url: "https://github.com",531    });532    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);533534    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });535    await vi.runAllTimersAsync();536    const response = await responsePromise;537538    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });539    expect(vault.getEntryById(entry.id)?.breach).toBe(7);540    breachSpy.mockRestore();541  });542543  it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {544    const vault = await import("./vault");545    await vault.createVault("correct-pass-12345");546547    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({548      ok: false,549      error: "invalid_sender",550    });551552    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({553      ok: true,554      data: [],555    });556  });557558  it("LOCK and UNLOCK messages update vault state correctly", async () => {559    const vault = await import("./vault");560561    await expect(562      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })563    ).resolves.toMatchObject({ ok: true });564    expect(vault.isUnlocked()).toBe(true);565566    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });567    expect(vault.isUnlocked()).toBe(false);568569    await expect(570      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })571    ).resolves.toMatchObject({ ok: false });572    expect(vault.isUnlocked()).toBe(false);573574    await expect(575      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })576    ).resolves.toMatchObject({ ok: true });577    expect(vault.isUnlocked()).toBe(true);578  });579580  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {581    const response = await dispatch({582      type: "FIRE_CANARY_PING",583      payload: {584        webhookUrl: "https://example.com/hook",585        entryTitle: "GitHub",586        trigger: "password_copied",587      },588    });589590    expect(response).toMatchObject({ ok: true });591    expect(fetchMock).toHaveBeenCalledWith(592      "https://example.com/hook",593      expect.objectContaining({594        method: "POST",595        headers: { "Content-Type": "application/json" },596        body: expect.any(String),597      })598    );599    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;600    expect(body).toMatchObject({601      entry: "GitHub",602      trigger: "password_copied",603      source: "peach-extension",604    });605  });606607  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {608    await expect(609      dispatch({610        type: "FIRE_CANARY_PING",611        payload: {612          webhookUrl: "http://example.com/hook",613          entryTitle: "GitHub",614          trigger: "password_copied",615        },616      })617    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });618619    expect(fetchMock).not.toHaveBeenCalled();620  });621622  it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {623    const response = await dispatch({624      type: "SUBMIT_FEEDBACK",625      payload: {626        category: "Bug report",627        message: "the popup flashes",628        version: "2.1.0",629        browser: "Chrome",630      },631    });632633    expect(response).toMatchObject({ ok: true });634    expect(fetchMock).not.toHaveBeenCalled();635  });636637  it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {638    const response = await dispatch({639      type: "SUBMIT_FEEDBACK",640      payload: {641        category: "Bug report",642        message: "  ",643        version: "2.1.0",644        browser: "Chrome",645      },646    });647648    expect(response).toMatchObject({ ok: false, error: "message_empty" });649    expect(fetchMock).not.toHaveBeenCalled();650  });651652  it("GET_IDENTITIES when locked returns vault locked error", async () => {653    const vault = await import("./vault");654    await vault.createVault("correct-pass-12345");655    vault.lock();656    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({657      ok: false,658      error: "vault_locked",659    });660  });661662  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {663    const vault = await import("./vault");664    await vault.createVault("correct-pass-12345");665    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });666    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });667    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });668669    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };670    expect(resp.ok).toBe(true);671    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;672    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({673      type: "email",674      useCount: 2,675    });676    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({677      type: "username",678      useCount: 1,679    });680    // emails appear before usernames681    const emailIdx = data.findIndex((d) => d.value === "[email protected]");682    const userIdx = data.findIndex((d) => d.value === "juni_patel");683    expect(emailIdx).toBeLessThan(userIdx);684  });685686  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {687    const vault = await import("./vault");688    await vault.createVault("correct-pass-12345");689    for (let i = 0; i < 3; i++) {690      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });691    }692693    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };694    const data = resp.data as Array<{ value: string; useCount: number }>;695    const hits = data.filter((d) => d.value === "[email protected]");696    expect(hits).toHaveLength(1);697    expect(hits[0]!.useCount).toBe(3);698  });699700  it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {701    const vault = await import("./vault");702    await vault.createVault("correct-pass-12345");703    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;704705    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer706    alarmsCreate.mockClear();707    await dispatch({ type: "GET_ENTRIES" });708    expect(alarmsCreate).toHaveBeenCalled();709710    // SUBMIT_FEEDBACK is in the exclusion list and must not re-arm711    alarmsCreate.mockClear();712    await dispatch({713      type: "SUBMIT_FEEDBACK",714      payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },715    });716    expect(alarmsCreate).not.toHaveBeenCalled();717  });718});