messages.test.ts

23.6 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";4import type { Message, MessageResponse } from "../shared/types";56type StorageShape = Record<string, unknown>;7type MessageListener = (8  msg: unknown,9  sender: chrome.runtime.MessageSender,10  sendResponse: (response: MessageResponse) => void11) => boolean;1213describe("background messages", () => {14  let storage: StorageShape;15  let sessionStorage: StorageShape;16  let listener: MessageListener;17  let fetchMock: ReturnType<typeof vi.fn>;1819  beforeEach(async () => {20    vi.resetModules();21    vi.useRealTimers();22    storage = {23      peach_settings: { breachMonitoring: false },24    };25    sessionStorage = {};2627    fetchMock = vi.fn(async () => ({28      ok: true,29      status: 200,30      text: async () => "",31    }));32    vi.stubGlobal("fetch", fetchMock);33    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3435    const tabs = [{ id: 1 }];36    const chromeMock = {37      runtime: {38        id: "peach-extension",39        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 search-ready summaries 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: [427        {428          id: expect.any(String),429          title: "GitHub",430          username: "june",431          url: "https://github.com",432          hasPasskey: false,433        },434      ],435    });436    const data = response.data as Array<Record<string, unknown>>;437    expect(data).toHaveLength(1);438    // No secrets cross the boundary.439    expect(data[0]).not.toHaveProperty("password");440    expect(data[0]).not.toHaveProperty("totp");441  });442443  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {444    const vault = await import("./vault");445    await vault.createVault("correct-pass-12345");446    await vault.addEntry({447      title: "GitHub",448      username: "june",449      password: "secret123",450      url: "https://github.com",451      totp: "JBSWY3DPEHPK3PXP",452    });453    await vault.addEntry({454      title: "No 2FA",455      username: "person",456      password: "secret123",457      url: "https://github.com",458    });459460    const response = await dispatch({461      type: "AUTOFILL_QUERY",462      payload: { domain: "github.com", kind: "totp" },463    });464465    expect(response).toMatchObject({466      ok: true,467      data: [468        {469          id: expect.any(String),470          title: "GitHub",471          username: "june",472          url: "https://github.com",473          hasTotp: true,474        },475      ],476    });477    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();478    expect(response.data).toHaveLength(1);479  });480481  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {482    const vault = await import("./vault");483    await vault.createVault("correct-pass-12345");484    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });485486    const response = await dispatch({487      type: "RESTORE_ENTRIES",488      payload: {489        mode: "replace",490        entries: [491          {492            id: "restored-1",493            type: "login",494            title: "B",495            username: "june",496            password: "two",497            url: "https://b.test",498            createdAt: 1,499            modifiedAt: 1,500          },501        ],502      },503    });504505    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });506    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);507  });508509  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {510    const vault = await import("./vault");511    await vault.createVault("correct-pass-12345");512513    await expect(514      dispatch({515        type: "RESTORE_ENTRIES",516        payload: { mode: "replace", entries: [] },517      })518    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });519  });520521  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {522    const vault = await import("./vault");523    await vault.createVault("correct-pass-12345");524525    await expect(526      dispatch({527        type: "CHANGE_PASSWORD",528        payload: {529          currentPassword: "correct-pass-12345",530          newPassword: "new-pass-67890",531        },532      })533    ).resolves.toMatchObject({ ok: true });534535    vault.lock();536    expect(await vault.unlock("new-pass-67890")).toBe(true);537538    await expect(539      dispatch({540        type: "CHANGE_PASSWORD",541        payload: {542          currentPassword: "wrong-pass",543          newPassword: "another-pass",544        },545      })546    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });547  });548549  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {550    const vault = await import("./vault");551    await vault.createVault("correct-pass-12345");552553    (554      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>555    ).mockClear();556    (557      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>558    ).mockClear();559560    await expect(561      dispatch({562        type: "UPDATE_SETTINGS",563        payload: { autoLockMinutes: 30 },564      })565    ).resolves.toMatchObject({ ok: true });566567    expect(vault.getSettings().autoLockMinutes).toBe(30);568    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);569    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {570      delayInMinutes: 30,571    });572  });573574  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {575    vi.useFakeTimers();576    const vault = await import("./vault");577    await vault.createVault("correct-pass-12345");578    const entry = await vault.addEntry({579      title: "GitHub",580      username: "june",581      password: "secret123",582      url: "https://github.com",583    });584    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);585586    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });587    await vi.runAllTimersAsync();588    const response = await responsePromise;589590    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });591    expect(vault.getEntryById(entry.id)?.breach).toBe(7);592    breachSpy.mockRestore();593  });594595  it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {596    const vault = await import("./vault");597    await vault.createVault("correct-pass-12345");598599    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({600      ok: false,601      error: "invalid_sender",602    });603604    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({605      ok: true,606      data: [],607    });608  });609610  it("LOCK and UNLOCK messages update vault state correctly", async () => {611    const vault = await import("./vault");612613    await expect(614      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })615    ).resolves.toMatchObject({ ok: true });616    expect(vault.isUnlocked()).toBe(true);617618    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });619    expect(vault.isUnlocked()).toBe(false);620621    await expect(622      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })623    ).resolves.toMatchObject({ ok: false });624    expect(vault.isUnlocked()).toBe(false);625626    await expect(627      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })628    ).resolves.toMatchObject({ ok: true });629    expect(vault.isUnlocked()).toBe(true);630  });631632  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {633    const response = await dispatch({634      type: "FIRE_CANARY_PING",635      payload: {636        webhookUrl: "https://example.com/hook",637        entryTitle: "GitHub",638        trigger: "password_copied",639      },640    });641642    expect(response).toMatchObject({ ok: true });643    expect(fetchMock).toHaveBeenCalledWith(644      "https://example.com/hook",645      expect.objectContaining({646        method: "POST",647        headers: { "Content-Type": "application/json" },648        body: expect.any(String),649      })650    );651    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;652    expect(body).toMatchObject({653      entry: "GitHub",654      trigger: "password_copied",655      source: "peach-extension",656    });657  });658659  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {660    await expect(661      dispatch({662        type: "FIRE_CANARY_PING",663        payload: {664          webhookUrl: "http://example.com/hook",665          entryTitle: "GitHub",666          trigger: "password_copied",667        },668      })669    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });670671    expect(fetchMock).not.toHaveBeenCalled();672  });673674  it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {675    const response = await dispatch({676      type: "SUBMIT_FEEDBACK",677      payload: {678        category: "Bug report",679        message: "the popup flashes",680        version: "2.1.0",681        browser: "Chrome",682      },683    });684685    expect(response).toMatchObject({ ok: true });686    expect(fetchMock).not.toHaveBeenCalled();687  });688689  it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {690    const response = await dispatch({691      type: "SUBMIT_FEEDBACK",692      payload: {693        category: "Bug report",694        message: "  ",695        version: "2.1.0",696        browser: "Chrome",697      },698    });699700    expect(response).toMatchObject({ ok: false, error: "message_empty" });701    expect(fetchMock).not.toHaveBeenCalled();702  });703704  it("GET_IDENTITIES when locked returns vault locked error", async () => {705    const vault = await import("./vault");706    await vault.createVault("correct-pass-12345");707    vault.lock();708    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({709      ok: false,710      error: "vault_locked",711    });712  });713714  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {715    const vault = await import("./vault");716    await vault.createVault("correct-pass-12345");717    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });718    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });719    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });720721    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };722    expect(resp.ok).toBe(true);723    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;724    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({725      type: "email",726      useCount: 2,727    });728    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({729      type: "username",730      useCount: 1,731    });732    // emails appear before usernames733    const emailIdx = data.findIndex((d) => d.value === "[email protected]");734    const userIdx = data.findIndex((d) => d.value === "juni_patel");735    expect(emailIdx).toBeLessThan(userIdx);736  });737738  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {739    const vault = await import("./vault");740    await vault.createVault("correct-pass-12345");741    for (let i = 0; i < 3; i++) {742      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });743    }744745    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };746    const data = resp.data as Array<{ value: string; useCount: number }>;747    const hits = data.filter((d) => d.value === "[email protected]");748    expect(hits).toHaveLength(1);749    expect(hits[0]!.useCount).toBe(3);750  });751752  it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {753    const vault = await import("./vault");754    await vault.createVault("correct-pass-12345");755    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;756757    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer758    alarmsCreate.mockClear();759    await dispatch({ type: "GET_ENTRIES" });760    expect(alarmsCreate).toHaveBeenCalled();761762    // SUBMIT_FEEDBACK is in the exclusion list and must not re-arm763    alarmsCreate.mockClear();764    await dispatch({765      type: "SUBMIT_FEEDBACK",766      payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },767    });768    expect(alarmsCreate).not.toHaveBeenCalled();769  });770});