messages.test.ts

26.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          clear: vi.fn(async () => {79            for (const key of Object.keys(storage)) delete storage[key];80          }),81        },82        session: {83          get: vi.fn(async (key?: string | string[]) => {84            if (!key) return sessionStorage;85            if (typeof key === "string") {86              return { [key]: sessionStorage[key] };87            }88            return key.reduce<Record<string, unknown>>((result, entry) => {89              result[entry] = sessionStorage[entry];90              return result;91            }, {});92          }),93          set: vi.fn(async (next: Record<string, unknown>) => {94            Object.assign(sessionStorage, next);95          }),96          remove: vi.fn(async (keys: string | string[]) => {97            const list = Array.isArray(keys) ? keys : [keys];98            for (const key of list) {99              delete sessionStorage[key];100            }101          }),102          clear: vi.fn(async () => {103            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];104          }),105        },106      },107      alarms: {108        clear: vi.fn(),109        create: vi.fn(),110        onAlarm: { addListener: vi.fn() },111      },112      idle: {113        setDetectionInterval: vi.fn(),114        onStateChanged: { addListener: vi.fn() },115      },116      scripting: {117        executeScript: vi.fn(async () => []),118      },119    } as unknown as typeof chrome;120121    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;122123    const messages = await import("./messages");124    messages.setupMessageHandler();125    const vault = await import("./vault");126    await vault.ensureSettingsLoaded();127  });128129  async function dispatch(130    message: Message,131    sender: chrome.runtime.MessageSender = { id: "peach-extension" }132  ): Promise<MessageResponse> {133    return await new Promise<MessageResponse>((resolve) => {134      const keepChannelOpen = listener(message, sender, (response) => resolve(response));135      expect(keepChannelOpen).toBe(true);136    });137  }138139  it("GET_ENTRIES when unlocked returns the entry array", async () => {140    const vault = await import("./vault");141    await vault.createVault("correct-pass-12345");142    await vault.addEntry({143      title: "GitHub",144      username: "june",145      password: "secret123",146      url: "https://github.com",147    });148149    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({150      ok: true,151      data: [152        expect.objectContaining({153          title: "GitHub",154          username: "june",155        }),156      ],157    });158  });159160  it("GET_ENTRIES when locked returns a vault locked error", async () => {161    const vault = await import("./vault");162    await vault.createVault("correct-pass-12345");163    vault.lock();164165    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({166      ok: false,167      error: "vault_locked",168    });169  });170171  it("ADD_ENTRY with a valid payload returns the created entry", async () => {172    const vault = await import("./vault");173    await vault.createVault("correct-pass-12345");174175    const response = await dispatch({176      type: "ADD_ENTRY",177      payload: {178        title: "GitHub",179        username: "june",180        password: "secret123",181        url: "https://github.com",182      },183    });184185    expect(response).toMatchObject({186      ok: true,187      data: expect.objectContaining({188        id: expect.any(String),189        title: "GitHub",190      }),191    });192    expect(vault.getEntries()).toHaveLength(1);193  });194195  it("ADD_ENTRY when locked returns a vault locked error", async () => {196    const vault = await import("./vault");197    await vault.createVault("correct-pass-12345");198    vault.lock();199200    await expect(201      dispatch({202        type: "ADD_ENTRY",203        payload: {204          title: "GitHub",205          username: "june",206          password: "secret123",207          url: "https://github.com",208        },209      })210    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });211  });212213  it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {214    const vault = await import("./vault");215    await vault.createVault("correct-pass-12345");216217    await expect(218      dispatch({219        type: "ADD_ENTRY",220        payload: {221          title: "GitHub",222          username: "june",223          password: "",224          url: "https://github.com",225        },226      })227    ).resolves.toMatchObject({ ok: true });228  });229230  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {231    const vault = await import("./vault");232    await vault.createVault("correct-pass-12345");233    const entry = await vault.addEntry({234      title: "GitHub",235      username: "june",236      password: "secret123",237      url: "https://github.com",238    });239240    await expect(241      dispatch({242        type: "UPDATE_ENTRY",243        payload: { id: entry.id, patch: { password: "new-secret" } },244      })245    ).resolves.toMatchObject({ ok: true });246247    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");248  });249250  it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {251    const vault = await import("./vault");252    await vault.createVault("correct-pass-12345");253    const entry = await vault.addEntry({254      title: "GitHub",255      username: "june",256      password: "secret123",257      url: "https://github.com",258    });259260    await expect(261      dispatch({262        type: "UPDATE_ENTRY",263        payload: { id: entry.id, patch: { password: "" } },264      })265    ).resolves.toMatchObject({ ok: true });266267    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();268  });269270  it("DELETE_ENTRY soft-deletes the target entry", async () => {271    const vault = await import("./vault");272    await vault.createVault("correct-pass-12345");273    const entry = await vault.addEntry({274      title: "GitHub",275      username: "june",276      password: "secret123",277      url: "https://github.com",278    });279280    await expect(281      dispatch({282        type: "DELETE_ENTRY",283        payload: { id: entry.id },284      })285    ).resolves.toMatchObject({ ok: true });286287    expect(vault.getEntries()).toEqual([]);288    expect(vault.getDeletedEntries()).toHaveLength(1);289  });290291  it("DELETE_ENTRY of a missing id returns an error", async () => {292    const vault = await import("./vault");293    await vault.createVault("correct-pass-12345");294295    await expect(296      dispatch({297        type: "DELETE_ENTRY",298        payload: { id: "missing" },299      })300    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });301  });302303  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {304    const vault = await import("./vault");305    await vault.createVault("correct-pass-12345");306    const entry = await vault.addEntry({307      title: "GitHub",308      username: "june",309      password: "secret123",310      url: "https://github.com",311    });312313    const response = await dispatch({314      type: "GET_ENTRY_FOR_FILL",315      payload: { id: entry.id },316    });317318    expect(response).toMatchObject({319      ok: true,320      data: expect.objectContaining({321        id: entry.id,322        username: "june",323        password: "secret123",324      }),325    });326    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();327  });328329  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {330    const vault = await import("./vault");331    await vault.createVault("correct-pass-12345");332    vault.lock();333334    await expect(335      dispatch({336        type: "GET_ENTRY_FOR_FILL",337        payload: { id: "missing" },338      })339    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });340  });341342  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {343    const vault = await import("./vault");344    await vault.createVault("correct-pass-12345");345    const generated = await generatePasskeyKeypair(-7);346    const entry = await vault.addEntry({347      type: "login",348      title: "WebAuthn.io",349      passkey: {350        rpId: "webauthn.io",351        rpName: "WebAuthn.io",352        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),353        publicKeyCose: base64urlEncode(generated.publicKeyCose),354        privateKeyJwk: generated.privateKeyJwk,355        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),356        userName: "june",357        userDisplayName: "June",358        algorithm: -7,359        signCount: 0,360        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",361        backupEligible: true,362        backupState: false,363        createdAt: Date.now(),364      },365    });366367    await expect(368      dispatch({369        type: "GET_ENTRY_FOR_FILL",370        payload: { id: entry.id },371      })372    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });373  });374375  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {376    const vault = await import("./vault");377    await vault.createVault("correct-pass-12345");378    await vault.addEntry({379      title: "GitHub",380      username: "june",381      password: "secret123",382      url: "https://github.com",383      totp: "JBSWY3DPEHPK3PXP",384    });385386    const response = await dispatch({387      type: "AUTOFILL_QUERY",388      payload: { domain: "github.com" },389    });390391    expect(response).toMatchObject({392      ok: true,393      data: [394        {395          id: expect.any(String),396          title: "GitHub",397          username: "june",398          url: "https://github.com",399          hasTotp: true,400        },401      ],402    });403    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();404    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();405  });406407  it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {408    const vault = await import("./vault");409    await vault.createVault("correct-pass-12345");410    await vault.addEntry({411      title: "GitHub",412      username: "june",413      password: "secret123",414      url: "https://github.com",415    });416    await vault.addEntry({417      type: "note",418      title: "Secure note",419      notes: "hello",420    });421422    const response = await dispatch({423      type: "AUTOFILL_QUERY",424      payload: { domain: "github.com", includeAll: true },425    });426427    expect(response).toMatchObject({428      ok: true,429      data: [430        {431          id: expect.any(String),432          title: "GitHub",433          username: "june",434          url: "https://github.com",435          hasPasskey: false,436        },437      ],438    });439    const data = response.data as Array<Record<string, unknown>>;440    expect(data).toHaveLength(1);441    // No secrets cross the boundary.442    expect(data[0]).not.toHaveProperty("password");443    expect(data[0]).not.toHaveProperty("totp");444  });445446  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {447    const vault = await import("./vault");448    await vault.createVault("correct-pass-12345");449    await vault.addEntry({450      title: "GitHub",451      username: "june",452      password: "secret123",453      url: "https://github.com",454      totp: "JBSWY3DPEHPK3PXP",455    });456    await vault.addEntry({457      title: "No 2FA",458      username: "person",459      password: "secret123",460      url: "https://github.com",461    });462463    const response = await dispatch({464      type: "AUTOFILL_QUERY",465      payload: { domain: "github.com", kind: "totp" },466    });467468    expect(response).toMatchObject({469      ok: true,470      data: [471        {472          id: expect.any(String),473          title: "GitHub",474          username: "june",475          url: "https://github.com",476          hasTotp: true,477        },478      ],479    });480    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();481    expect(response.data).toHaveLength(1);482  });483484  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {485    const vault = await import("./vault");486    await vault.createVault("correct-pass-12345");487    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });488489    const response = await dispatch({490      type: "RESTORE_ENTRIES",491      payload: {492        mode: "replace",493        entries: [494          {495            id: "restored-1",496            type: "login",497            title: "B",498            username: "june",499            password: "two",500            url: "https://b.test",501            createdAt: 1,502            modifiedAt: 1,503          },504        ],505      },506    });507508    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });509    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);510  });511512  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {513    const vault = await import("./vault");514    await vault.createVault("correct-pass-12345");515516    await expect(517      dispatch({518        type: "RESTORE_ENTRIES",519        payload: { mode: "replace", entries: [] },520      })521    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });522  });523524  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {525    const vault = await import("./vault");526    await vault.createVault("correct-pass-12345");527528    await expect(529      dispatch({530        type: "CHANGE_PASSWORD",531        payload: {532          currentPassword: "correct-pass-12345",533          newPassword: "new-pass-67890",534        },535      })536    ).resolves.toMatchObject({ ok: true });537538    vault.lock();539    expect(await vault.unlock("new-pass-67890")).toBe(true);540541    await expect(542      dispatch({543        type: "CHANGE_PASSWORD",544        payload: {545          currentPassword: "wrong-pass",546          newPassword: "another-pass",547        },548      })549    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });550  });551552  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {553    const vault = await import("./vault");554    await vault.createVault("correct-pass-12345");555556    (557      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>558    ).mockClear();559    (560      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>561    ).mockClear();562563    await expect(564      dispatch({565        type: "UPDATE_SETTINGS",566        payload: { autoLockMinutes: 30 },567      })568    ).resolves.toMatchObject({ ok: true });569570    expect(vault.getSettings().autoLockMinutes).toBe(30);571    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);572    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {573      delayInMinutes: 30,574    });575  });576577  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {578    vi.useFakeTimers();579    const vault = await import("./vault");580    await vault.createVault("correct-pass-12345");581    const entry = await vault.addEntry({582      title: "GitHub",583      username: "june",584      password: "secret123",585      url: "https://github.com",586    });587    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);588589    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });590    await vi.runAllTimersAsync();591    const response = await responsePromise;592593    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });594    expect(vault.getEntryById(entry.id)?.breach).toBe(7);595    breachSpy.mockRestore();596  });597598  it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {599    const vault = await import("./vault");600    await vault.createVault("correct-pass-12345");601602    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({603      ok: false,604      error: "invalid_sender",605    });606607    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({608      ok: true,609      data: [],610    });611  });612613  it("LOCK and UNLOCK messages update vault state correctly", async () => {614    const vault = await import("./vault");615616    await expect(617      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })618    ).resolves.toMatchObject({ ok: true });619    expect(vault.isUnlocked()).toBe(true);620621    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });622    expect(vault.isUnlocked()).toBe(false);623624    await expect(625      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })626    ).resolves.toMatchObject({ ok: false });627    expect(vault.isUnlocked()).toBe(false);628629    await expect(630      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })631    ).resolves.toMatchObject({ ok: true });632    expect(vault.isUnlocked()).toBe(true);633  });634635  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {636    const response = await dispatch({637      type: "FIRE_CANARY_PING",638      payload: {639        webhookUrl: "https://example.com/hook",640        entryTitle: "GitHub",641        trigger: "password_copied",642      },643    });644645    expect(response).toMatchObject({ ok: true });646    expect(fetchMock).toHaveBeenCalledWith(647      "https://example.com/hook",648      expect.objectContaining({649        method: "POST",650        headers: { "Content-Type": "application/json" },651        body: expect.any(String),652      })653    );654    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;655    expect(body).toMatchObject({656      entry: "GitHub",657      trigger: "password_copied",658      source: "peach-extension",659    });660  });661662  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {663    await expect(664      dispatch({665        type: "FIRE_CANARY_PING",666        payload: {667          webhookUrl: "http://example.com/hook",668          entryTitle: "GitHub",669          trigger: "password_copied",670        },671      })672    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });673674    expect(fetchMock).not.toHaveBeenCalled();675  });676677  it("SUBMIT_FEEDBACK returns ok:true and skips network when FEEDBACK_ENDPOINT is empty", async () => {678    const response = await dispatch({679      type: "SUBMIT_FEEDBACK",680      payload: {681        category: "Bug report",682        message: "the popup flashes",683        version: "2.1.0",684        browser: "Chrome",685      },686    });687688    expect(response).toMatchObject({ ok: true });689    expect(fetchMock).not.toHaveBeenCalled();690  });691692  it("SUBMIT_FEEDBACK returns ok:false when message is empty whitespace", async () => {693    const response = await dispatch({694      type: "SUBMIT_FEEDBACK",695      payload: {696        category: "Bug report",697        message: "  ",698        version: "2.1.0",699        browser: "Chrome",700      },701    });702703    expect(response).toMatchObject({ ok: false, error: "message_empty" });704    expect(fetchMock).not.toHaveBeenCalled();705  });706707  it("GET_IDENTITIES when locked returns vault locked error", async () => {708    const vault = await import("./vault");709    await vault.createVault("correct-pass-12345");710    vault.lock();711    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({712      ok: false,713      error: "vault_locked",714    });715  });716717  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {718    const vault = await import("./vault");719    await vault.createVault("correct-pass-12345");720    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });721    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });722    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });723724    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };725    expect(resp.ok).toBe(true);726    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;727    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({728      type: "email",729      useCount: 2,730    });731    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({732      type: "username",733      useCount: 1,734    });735    // emails appear before usernames736    const emailIdx = data.findIndex((d) => d.value === "[email protected]");737    const userIdx = data.findIndex((d) => d.value === "juni_patel");738    expect(emailIdx).toBeLessThan(userIdx);739  });740741  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {742    const vault = await import("./vault");743    await vault.createVault("correct-pass-12345");744    for (let i = 0; i < 3; i++) {745      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });746    }747748    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };749    const data = resp.data as Array<{ value: string; useCount: number }>;750    const hits = data.filter((d) => d.value === "[email protected]");751    expect(hits).toHaveLength(1);752    expect(hits[0]!.useCount).toBe(3);753  });754755  it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {756    const vault = await import("./vault");757    await vault.createVault("correct-pass-12345");758    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;759760    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer761    alarmsCreate.mockClear();762    await dispatch({ type: "GET_ENTRIES" });763    expect(alarmsCreate).toHaveBeenCalled();764765    // SUBMIT_FEEDBACK is in the exclusion list and must not re-arm766    alarmsCreate.mockClear();767    await dispatch({768      type: "SUBMIT_FEEDBACK",769      payload: { category: "Bug report", message: "hi", version: "2.1.0", browser: "Chrome" },770    });771    expect(alarmsCreate).not.toHaveBeenCalled();772  });773774  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {775    const vault = await import("./vault");776    await vault.createVault("correct-pass-12345");777    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });778779    // Simulate an activated Pro license + already-celebrated marker.780    storage.peach_license = "PEACH-PRO-TEST-KEY";781    storage.peach_pro_celebrated = true;782783    const res = await dispatch({ type: "DESTROY_VAULT" });784    expect(res).toMatchObject({ ok: true });785786    // Vault data is gone…787    expect(storage.peach_vault_a).toBeUndefined();788    // …but the paid license and celebration flag survive the wipe.789    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");790    expect(storage.peach_pro_celebrated).toBe(true);791  });792793  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {794    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });795    expect(res.ok).toBe(false);796    // Nothing was written — no vault exists.797    const has = await dispatch({ type: "HAS_VAULT" });798    expect((has as { data?: boolean }).data).toBe(false);799  });800801  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {802    const vault = await import("./vault");803    await vault.createVault("correct-pass-12345");804    const e = await vault.addEntry({805      title: "Mail",806      username: "june",807      password: "pw",808      url: "https://mail.test",809      totp: "JBSWY3DPEHPK3PXP",810    });811812    const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });813    expect(res.ok).toBe(true);814    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };815    expect(data.totp).toBeUndefined();816    expect(data.totpCode).toMatch(/^\d{6}$/);817    expect(data.totpRemaining).toBeGreaterThan(0);818    expect(data.totpRemaining).toBeLessThanOrEqual(30);819  });820821  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {822    const vault = await import("./vault");823    await vault.createVault("correct-pass-12345");824    const e = await vault.addEntry({825      title: "Mail",826      username: "june",827      password: "pw",828      url: "https://mail.test",829      totp: "JBSWY3DPEHPK3PXP",830    });831832    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });833    expect(res.ok).toBe(true);834    const data = res.data as { totp?: string };835    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");836  });837838  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {839    const vault = await import("./vault");840    await vault.createVault("correct-pass-12345");841842    const res = await dispatch({ type: "DESTROY_VAULT" });843    expect(res).toMatchObject({ ok: true });844845    expect(storage.peach_vault_a).toBeUndefined();846    expect(storage.peach_license).toBeUndefined();847    expect(storage.peach_pro_celebrated).toBeUndefined();848  });849});