messages.test.ts

23.2 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23import { base64urlEncode, generatePasskeyKeypair } from "./passkey-crypto";4import type { Message, MessageResponse } from "../shared/types";56type StorageShape = Record<string, unknown>;7type MessageListener = (8  msg: unknown,9  sender: chrome.runtime.MessageSender,10  sendResponse: (response: MessageResponse) => void11) => boolean;1213describe("background messages", () => {14  let storage: StorageShape;15  let sessionStorage: StorageShape;16  let listener: MessageListener;17  let fetchMock: ReturnType<typeof vi.fn>;1819  beforeEach(async () => {20    vi.resetModules();21    vi.useRealTimers();22    storage = {23      peach_settings: { breachMonitoring: false },24    };25    sessionStorage = {};2627    fetchMock = vi.fn(async () => ({28      ok: true,29      status: 200,30      text: async () => "",31    }));32    vi.stubGlobal("fetch", fetchMock);33    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3435    const tabs = [{ id: 1 }];36    const chromeMock = {37      runtime: {38        id: "peach-extension",39        onMessage: {40          addListener: vi.fn((fn: MessageListener) => {41            listener = fn;42          }),43        },44        sendMessage: vi.fn(async () => ({ ok: true })),45      },46      tabs: {47        query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {48          if (typeof callback === "function") {49            callback(tabs);50            return;51          }52          return Promise.resolve(tabs);53        }),54        sendMessage: vi.fn(async () => ({ ok: true })),55      },56      storage: {57        local: {58          get: vi.fn(async (key?: string | string[]) => {59            if (!key) return storage;60            if (typeof key === "string") {61              return { [key]: storage[key] };62            }6364            return key.reduce<Record<string, unknown>>((result, entry) => {65              result[entry] = storage[entry];66              return result;67            }, {});68          }),69          set: vi.fn(async (next: Record<string, unknown>) => {70            Object.assign(storage, next);71          }),72          remove: vi.fn(async (keys: string | string[]) => {73            const list = Array.isArray(keys) ? keys : [keys];74            for (const key of list) {75              delete storage[key];76            }77          }),78        },79        session: {80          get: vi.fn(async (key?: string | string[]) => {81            if (!key) return sessionStorage;82            if (typeof key === "string") {83              return { [key]: sessionStorage[key] };84            }85            return key.reduce<Record<string, unknown>>((result, entry) => {86              result[entry] = sessionStorage[entry];87              return result;88            }, {});89          }),90          set: vi.fn(async (next: Record<string, unknown>) => {91            Object.assign(sessionStorage, next);92          }),93          remove: vi.fn(async (keys: string | string[]) => {94            const list = Array.isArray(keys) ? keys : [keys];95            for (const key of list) {96              delete sessionStorage[key];97            }98          }),99          clear: vi.fn(async () => {100            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];101          }),102        },103      },104      alarms: {105        clear: vi.fn(),106        create: vi.fn(),107        onAlarm: { addListener: vi.fn() },108      },109      idle: {110        setDetectionInterval: vi.fn(),111        onStateChanged: { addListener: vi.fn() },112      },113      scripting: {114        executeScript: vi.fn(async () => []),115      },116    } as unknown as typeof chrome;117118    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;119120    const messages = await import("./messages");121    messages.setupMessageHandler();122    const vault = await import("./vault");123    await vault.ensureSettingsLoaded();124  });125126  async function dispatch(127    message: Message,128    sender: chrome.runtime.MessageSender = { id: "peach-extension" }129  ): Promise<MessageResponse> {130    return await new Promise<MessageResponse>((resolve) => {131      const keepChannelOpen = listener(message, sender, (response) => resolve(response));132      expect(keepChannelOpen).toBe(true);133    });134  }135136  it("GET_ENTRIES when unlocked returns the entry array", async () => {137    const vault = await import("./vault");138    await vault.createVault("correct-pass-12345");139    await vault.addEntry({140      title: "GitHub",141      username: "june",142      password: "secret123",143      url: "https://github.com",144    });145146    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({147      ok: true,148      data: [149        expect.objectContaining({150          title: "GitHub",151          username: "june",152        }),153      ],154    });155  });156157  it("GET_ENTRIES when locked returns a vault locked error", async () => {158    const vault = await import("./vault");159    await vault.createVault("correct-pass-12345");160    vault.lock();161162    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({163      ok: false,164      error: "vault_locked",165    });166  });167168  it("ADD_ENTRY with a valid payload returns the created entry", async () => {169    const vault = await import("./vault");170    await vault.createVault("correct-pass-12345");171172    const response = await dispatch({173      type: "ADD_ENTRY",174      payload: {175        title: "GitHub",176        username: "june",177        password: "secret123",178        url: "https://github.com",179      },180    });181182    expect(response).toMatchObject({183      ok: true,184      data: expect.objectContaining({185        id: expect.any(String),186        title: "GitHub",187      }),188    });189    expect(vault.getEntries()).toHaveLength(1);190  });191192  it("ADD_ENTRY when locked returns a vault locked error", async () => {193    const vault = await import("./vault");194    await vault.createVault("correct-pass-12345");195    vault.lock();196197    await expect(198      dispatch({199        type: "ADD_ENTRY",200        payload: {201          title: "GitHub",202          username: "june",203          password: "secret123",204          url: "https://github.com",205        },206      })207    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });208  });209210  it("ADD_ENTRY with 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("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {430    const vault = await import("./vault");431    await vault.createVault("correct-pass-12345");432    await vault.addEntry({433      title: "GitHub",434      username: "june",435      password: "secret123",436      url: "https://github.com",437      totp: "JBSWY3DPEHPK3PXP",438    });439    await vault.addEntry({440      title: "No 2FA",441      username: "person",442      password: "secret123",443      url: "https://github.com",444    });445446    const response = await dispatch({447      type: "AUTOFILL_QUERY",448      payload: { domain: "github.com", kind: "totp" },449    });450451    expect(response).toMatchObject({452      ok: true,453      data: [454        {455          id: expect.any(String),456          title: "GitHub",457          username: "june",458          url: "https://github.com",459          hasTotp: true,460        },461      ],462    });463    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();464    expect(response.data).toHaveLength(1);465  });466467  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {468    const vault = await import("./vault");469    await vault.createVault("correct-pass-12345");470    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });471472    const response = await dispatch({473      type: "RESTORE_ENTRIES",474      payload: {475        mode: "replace",476        entries: [477          {478            id: "restored-1",479            type: "login",480            title: "B",481            username: "june",482            password: "two",483            url: "https://b.test",484            createdAt: 1,485            modifiedAt: 1,486          },487        ],488      },489    });490491    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });492    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);493  });494495  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {496    const vault = await import("./vault");497    await vault.createVault("correct-pass-12345");498499    await expect(500      dispatch({501        type: "RESTORE_ENTRIES",502        payload: { mode: "replace", entries: [] },503      })504    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });505  });506507  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {508    const vault = await import("./vault");509    await vault.createVault("correct-pass-12345");510511    await expect(512      dispatch({513        type: "CHANGE_PASSWORD",514        payload: {515          currentPassword: "correct-pass-12345",516          newPassword: "new-pass-67890",517        },518      })519    ).resolves.toMatchObject({ ok: true });520521    vault.lock();522    expect(await vault.unlock("new-pass-67890")).toBe(true);523524    await expect(525      dispatch({526        type: "CHANGE_PASSWORD",527        payload: {528          currentPassword: "wrong-pass",529          newPassword: "another-pass",530        },531      })532    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });533  });534535  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {536    const vault = await import("./vault");537    await vault.createVault("correct-pass-12345");538539    (540      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>541    ).mockClear();542    (543      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>544    ).mockClear();545546    await expect(547      dispatch({548        type: "UPDATE_SETTINGS",549        payload: { autoLockMinutes: 30 },550      })551    ).resolves.toMatchObject({ ok: true });552553    expect(vault.getSettings().autoLockMinutes).toBe(30);554    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);555    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {556      delayInMinutes: 30,557    });558  });559560  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {561    vi.useFakeTimers();562    const vault = await import("./vault");563    await vault.createVault("correct-pass-12345");564    const entry = await vault.addEntry({565      title: "GitHub",566      username: "june",567      password: "secret123",568      url: "https://github.com",569    });570    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);571572    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });573    await vi.runAllTimersAsync();574    const response = await responsePromise;575576    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });577    expect(vault.getEntryById(entry.id)?.breach).toBe(7);578    breachSpy.mockRestore();579  });580581  it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {582    const vault = await import("./vault");583    await vault.createVault("correct-pass-12345");584585    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({586      ok: false,587      error: "invalid_sender",588    });589590    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({591      ok: true,592      data: [],593    });594  });595596  it("LOCK and UNLOCK messages update vault state correctly", async () => {597    const vault = await import("./vault");598599    await expect(600      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })601    ).resolves.toMatchObject({ ok: true });602    expect(vault.isUnlocked()).toBe(true);603604    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });605    expect(vault.isUnlocked()).toBe(false);606607    await expect(608      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })609    ).resolves.toMatchObject({ ok: false });610    expect(vault.isUnlocked()).toBe(false);611612    await expect(613      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })614    ).resolves.toMatchObject({ ok: true });615    expect(vault.isUnlocked()).toBe(true);616  });617618  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {619    const response = await dispatch({620      type: "FIRE_CANARY_PING",621      payload: {622        webhookUrl: "https://example.com/hook",623        entryTitle: "GitHub",624        trigger: "password_copied",625      },626    });627628    expect(response).toMatchObject({ ok: true });629    expect(fetchMock).toHaveBeenCalledWith(630      "https://example.com/hook",631      expect.objectContaining({632        method: "POST",633        headers: { "Content-Type": "application/json" },634        body: expect.any(String),635      })636    );637    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;638    expect(body).toMatchObject({639      entry: "GitHub",640      trigger: "password_copied",641      source: "peach-extension",642    });643  });644645  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {646    await expect(647      dispatch({648        type: "FIRE_CANARY_PING",649        payload: {650          webhookUrl: "http://example.com/hook",651          entryTitle: "GitHub",652          trigger: "password_copied",653        },654      })655    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });656657    expect(fetchMock).not.toHaveBeenCalled();658  });659660  it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {661    const response = await dispatch({662      type: "SUBMIT_FEEDBACK",663      payload: {664        category: "Bug report",665        message: "the popup flashes",666        version: "2.1.0",667        browser: "Chrome",668      },669    });670671    expect(response).toMatchObject({ ok: true });672    expect(fetchMock).not.toHaveBeenCalled();673  });674675  it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {676    const response = await dispatch({677      type: "SUBMIT_FEEDBACK",678      payload: {679        category: "Bug report",680        message: "  ",681        version: "2.1.0",682        browser: "Chrome",683      },684    });685686    expect(response).toMatchObject({ ok: false, error: "message_empty" });687    expect(fetchMock).not.toHaveBeenCalled();688  });689690  it("GET_IDENTITIES when locked returns vault locked error", async () => {691    const vault = await import("./vault");692    await vault.createVault("correct-pass-12345");693    vault.lock();694    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({695      ok: false,696      error: "vault_locked",697    });698  });699700  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {701    const vault = await import("./vault");702    await vault.createVault("correct-pass-12345");703    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });704    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });705    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });706707    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };708    expect(resp.ok).toBe(true);709    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;710    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({711      type: "email",712      useCount: 2,713    });714    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({715      type: "username",716      useCount: 1,717    });718    // emails appear before usernames719    const emailIdx = data.findIndex((d) => d.value === "[email protected]");720    const userIdx = data.findIndex((d) => d.value === "juni_patel");721    expect(emailIdx).toBeLessThan(userIdx);722  });723724  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {725    const vault = await import("./vault");726    await vault.createVault("correct-pass-12345");727    for (let i = 0; i < 3; i++) {728      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });729    }730731    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };732    const data = resp.data as Array<{ value: string; useCount: number }>;733    const hits = data.filter((d) => d.value === "[email protected]");734    expect(hits).toHaveLength(1);735    expect(hits[0]!.useCount).toBe(3);736  });737738  it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {739    const vault = await import("./vault");740    await vault.createVault("correct-pass-12345");741    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;742743    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer744    alarmsCreate.mockClear();745    await dispatch({ type: "GET_ENTRIES" });746    expect(alarmsCreate).toHaveBeenCalled();747748    // SUBMIT_FEEDBACK is in the exclusion list and must not re-arm749    alarmsCreate.mockClear();750    await dispatch({751      type: "SUBMIT_FEEDBACK",752      payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },753    });754    expect(alarmsCreate).not.toHaveBeenCalled();755  });756});