messages.test.ts

47.9 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        getURL: (p: string) => `chrome-extension://peach-extension/${p}`,40        onMessage: {41          addListener: vi.fn((fn: MessageListener) => {42            listener = fn;43          }),44        },45        sendMessage: vi.fn(async () => ({ ok: true })),46      },47      tabs: {48        query: vi.fn((_query: unknown, callback?: (tabs: Array<{ id?: number }>) => void) => {49          if (typeof callback === "function") {50            callback(tabs);51            return;52          }53          return Promise.resolve(tabs);54        }),55        sendMessage: vi.fn(async () => ({ ok: true })),56      },57      storage: {58        local: {59          get: vi.fn(async (key?: string | string[]) => {60            if (!key) return storage;61            if (typeof key === "string") {62              return { [key]: storage[key] };63            }6465            return key.reduce<Record<string, unknown>>((result, entry) => {66              result[entry] = storage[entry];67              return result;68            }, {});69          }),70          set: vi.fn(async (next: Record<string, unknown>) => {71            Object.assign(storage, next);72          }),73          remove: vi.fn(async (keys: string | string[]) => {74            const list = Array.isArray(keys) ? keys : [keys];75            for (const key of list) {76              delete storage[key];77            }78          }),79          clear: vi.fn(async () => {80            for (const key of Object.keys(storage)) delete storage[key];81          }),82        },83        session: {84          get: vi.fn(async (key?: string | string[]) => {85            if (!key) return sessionStorage;86            if (typeof key === "string") {87              return { [key]: sessionStorage[key] };88            }89            return key.reduce<Record<string, unknown>>((result, entry) => {90              result[entry] = sessionStorage[entry];91              return result;92            }, {});93          }),94          set: vi.fn(async (next: Record<string, unknown>) => {95            Object.assign(sessionStorage, next);96          }),97          remove: vi.fn(async (keys: string | string[]) => {98            const list = Array.isArray(keys) ? keys : [keys];99            for (const key of list) {100              delete sessionStorage[key];101            }102          }),103          clear: vi.fn(async () => {104            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];105          }),106        },107        sync: {108          get: vi.fn(async (_key?: string | string[]) => ({})),109          set: vi.fn(async (_values: Record<string, unknown>) => undefined),110          remove: vi.fn(async (_keys: string | string[]) => undefined),111          clear: vi.fn(async () => undefined),112        },113      },114      alarms: {115        clear: vi.fn(),116        create: vi.fn(),117        onAlarm: { addListener: vi.fn() },118      },119      idle: {120        setDetectionInterval: vi.fn(),121        onStateChanged: { addListener: vi.fn() },122      },123      scripting: {124        executeScript: vi.fn(async () => []),125      },126    } as unknown as typeof chrome;127128    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;129130    const messages = await import("./messages");131    messages.setupMessageHandler();132    const vault = await import("./vault");133    await vault.ensureSettingsLoaded();134  });135136  async function dispatch(137    message: Message,138    sender: chrome.runtime.MessageSender = { id: "peach-extension" }139  ): Promise<MessageResponse> {140    return await new Promise<MessageResponse>((resolve) => {141      const keepChannelOpen = listener(message, sender, (response) => resolve(response));142      expect(keepChannelOpen).toBe(true);143    });144  }145146  it("GET_ENTRIES when unlocked returns the entry array", async () => {147    const vault = await import("./vault");148    await vault.createVault("correct-pass-12345");149    await vault.addEntry({150      title: "GitHub",151      username: "june",152      password: "secret123",153      url: "https://github.com",154    });155156    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({157      ok: true,158      data: [159        expect.objectContaining({160          title: "GitHub",161          username: "june",162        }),163      ],164    });165  });166167  it("GET_ENTRIES when locked returns a vault locked error", async () => {168    const vault = await import("./vault");169    await vault.createVault("correct-pass-12345");170    vault.lock();171172    await expect(dispatch({ type: "GET_ENTRIES" })).resolves.toMatchObject({173      ok: false,174      error: "vault_locked",175    });176  });177178  it("ADD_ENTRY with a valid payload returns the created entry", async () => {179    const vault = await import("./vault");180    await vault.createVault("correct-pass-12345");181182    const response = await dispatch({183      type: "ADD_ENTRY",184      payload: {185        title: "GitHub",186        username: "june",187        password: "secret123",188        url: "https://github.com",189      },190    });191192    expect(response).toMatchObject({193      ok: true,194      data: expect.objectContaining({195        id: expect.any(String),196        title: "GitHub",197      }),198    });199    expect(vault.getEntries()).toHaveLength(1);200  });201202  it("ADD_ENTRY when locked returns a vault locked error", async () => {203    const vault = await import("./vault");204    await vault.createVault("correct-pass-12345");205    vault.lock();206207    await expect(208      dispatch({209        type: "ADD_ENTRY",210        payload: {211          title: "GitHub",212          username: "june",213          password: "secret123",214          url: "https://github.com",215        },216      })217    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });218  });219220  it("ADD_ENTRY with no password succeeds for email-only accounts", async () => {221    const vault = await import("./vault");222    await vault.createVault("correct-pass-12345");223224    await expect(225      dispatch({226        type: "ADD_ENTRY",227        payload: {228          title: "GitHub",229          username: "june",230          password: "",231          url: "https://github.com",232        },233      })234    ).resolves.toMatchObject({ ok: true });235  });236237  it("UPDATE_ENTRY updates an existing entry and returns ok", async () => {238    const vault = await import("./vault");239    await vault.createVault("correct-pass-12345");240    const entry = await vault.addEntry({241      title: "GitHub",242      username: "june",243      password: "secret123",244      url: "https://github.com",245    });246247    await expect(248      dispatch({249        type: "UPDATE_ENTRY",250        payload: { id: entry.id, patch: { password: "new-secret" } },251      })252    ).resolves.toMatchObject({ ok: true });253254    expect(vault.getEntryById(entry.id)?.password).toBe("new-secret");255  });256257  it("UPDATE_ENTRY with an empty password clears it for email-only accounts", async () => {258    const vault = await import("./vault");259    await vault.createVault("correct-pass-12345");260    const entry = await vault.addEntry({261      title: "GitHub",262      username: "june",263      password: "secret123",264      url: "https://github.com",265    });266267    await expect(268      dispatch({269        type: "UPDATE_ENTRY",270        payload: { id: entry.id, patch: { password: "" } },271      })272    ).resolves.toMatchObject({ ok: true });273274    expect(vault.getEntryById(entry.id)?.password).toBeUndefined();275  });276277  it("DELETE_ENTRY soft-deletes the target entry", async () => {278    const vault = await import("./vault");279    await vault.createVault("correct-pass-12345");280    const entry = await vault.addEntry({281      title: "GitHub",282      username: "june",283      password: "secret123",284      url: "https://github.com",285    });286287    await expect(288      dispatch({289        type: "DELETE_ENTRY",290        payload: { id: entry.id },291      })292    ).resolves.toMatchObject({ ok: true });293294    expect(vault.getEntries()).toEqual([]);295    expect(vault.getDeletedEntries()).toHaveLength(1);296  });297298  it("DELETE_ENTRY of a missing id returns an error", async () => {299    const vault = await import("./vault");300    await vault.createVault("correct-pass-12345");301302    await expect(303      dispatch({304        type: "DELETE_ENTRY",305        payload: { id: "missing" },306      })307    ).resolves.toMatchObject({ ok: false, error: "entry_not_found" });308  });309310  it("GET_ENTRY_FOR_FILL returns a safe login entry without privateKeyJwk", async () => {311    const vault = await import("./vault");312    await vault.createVault("correct-pass-12345");313    const entry = await vault.addEntry({314      title: "GitHub",315      username: "june",316      password: "secret123",317      url: "https://github.com",318    });319320    const response = await dispatch({321      type: "GET_ENTRY_FOR_FILL",322      payload: { id: entry.id },323    });324325    expect(response).toMatchObject({326      ok: true,327      data: expect.objectContaining({328        id: entry.id,329        username: "june",330        password: "secret123",331      }),332    });333    expect((response.data as Record<string, unknown>).privateKeyJwk).toBeUndefined();334  });335336  it("GET_ENTRY_FOR_FILL when locked returns an error", async () => {337    const vault = await import("./vault");338    await vault.createVault("correct-pass-12345");339    vault.lock();340341    await expect(342      dispatch({343        type: "GET_ENTRY_FOR_FILL",344        payload: { id: "missing" },345      })346    ).resolves.toMatchObject({ ok: false, error: "vault_locked" });347  });348349  it("GET_ENTRY_FOR_FILL rejects passkey-only entries", async () => {350    const vault = await import("./vault");351    await vault.createVault("correct-pass-12345");352    const generated = await generatePasskeyKeypair(-7);353    const entry = await vault.addEntry({354      type: "login",355      title: "WebAuthn.io",356      passkey: {357        rpId: "webauthn.io",358        rpName: "WebAuthn.io",359        credentialId: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),360        publicKeyCose: base64urlEncode(generated.publicKeyCose),361        privateKeyJwk: generated.privateKeyJwk,362        userHandle: base64urlEncode(crypto.getRandomValues(new Uint8Array(32))),363        userName: "june",364        userDisplayName: "June",365        algorithm: -7,366        signCount: 0,367        aaguid: "AAAAAAAAAAAAAAAAAAAAAA",368        backupEligible: true,369        backupState: false,370        createdAt: Date.now(),371      },372    });373374    await expect(375      dispatch({376        type: "GET_ENTRY_FOR_FILL",377        payload: { id: entry.id },378      })379    ).resolves.toMatchObject({ ok: false, error: "passkey_not_fillable" });380  });381382  it("AUTOFILL_QUERY returns matched domain metadata without secrets", async () => {383    const vault = await import("./vault");384    await vault.createVault("correct-pass-12345");385    await vault.addEntry({386      title: "GitHub",387      username: "june",388      password: "secret123",389      url: "https://github.com",390      totp: "JBSWY3DPEHPK3PXP",391    });392393    const response = await dispatch({394      type: "AUTOFILL_QUERY",395      payload: { domain: "github.com" },396    });397398    expect(response).toMatchObject({399      ok: true,400      data: [401        {402          id: expect.any(String),403          title: "GitHub",404          username: "june",405          url: "https://github.com",406          hasTotp: true,407        },408      ],409    });410    expect((response.data as Array<Record<string, unknown>>)[0].password).toBeUndefined();411    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();412  });413414  it("AUTOFILL_QUERY includeAll returns search-ready summaries for non-note, non-passkey entries", async () => {415    const vault = await import("./vault");416    await vault.createVault("correct-pass-12345");417    await vault.addEntry({418      title: "GitHub",419      username: "june",420      password: "secret123",421      url: "https://github.com",422    });423    await vault.addEntry({424      type: "note",425      title: "Secure note",426      notes: "hello",427    });428429    const response = await dispatch({430      type: "AUTOFILL_QUERY",431      payload: { domain: "github.com", includeAll: true },432    });433434    expect(response).toMatchObject({435      ok: true,436      data: [437        {438          id: expect.any(String),439          title: "GitHub",440          username: "june",441          url: "https://github.com",442          hasPasskey: false,443        },444      ],445    });446    const data = response.data as Array<Record<string, unknown>>;447    expect(data).toHaveLength(1);448    // No secrets cross the boundary.449    expect(data[0]).not.toHaveProperty("password");450    expect(data[0]).not.toHaveProperty("totp");451  });452453  it("AUTOFILL_QUERY kind=totp returns only matching entries with 2FA summaries", async () => {454    const vault = await import("./vault");455    await vault.createVault("correct-pass-12345");456    await vault.addEntry({457      title: "GitHub",458      username: "june",459      password: "secret123",460      url: "https://github.com",461      totp: "JBSWY3DPEHPK3PXP",462    });463    await vault.addEntry({464      title: "No 2FA",465      username: "person",466      password: "secret123",467      url: "https://github.com",468    });469470    const response = await dispatch({471      type: "AUTOFILL_QUERY",472      payload: { domain: "github.com", kind: "totp" },473    });474475    expect(response).toMatchObject({476      ok: true,477      data: [478        {479          id: expect.any(String),480          title: "GitHub",481          username: "june",482          url: "https://github.com",483          hasTotp: true,484        },485      ],486    });487    expect((response.data as Array<Record<string, unknown>>)[0].totp).toBeUndefined();488    expect(response.data).toHaveLength(1);489  });490491  it("RESTORE_ENTRIES replace mode restores the provided entries", async () => {492    const vault = await import("./vault");493    await vault.createVault("correct-pass-12345");494    await vault.addEntry({ title: "A", username: "", password: "one", url: "" });495496    const response = await dispatch({497      type: "RESTORE_ENTRIES",498      payload: {499        mode: "replace",500        entries: [501          {502            id: "restored-1",503            type: "login",504            title: "B",505            username: "june",506            password: "two",507            url: "https://b.test",508            createdAt: 1,509            modifiedAt: 1,510          },511        ],512      },513    });514515    expect(response).toMatchObject({ ok: true, data: { added: 1, updated: 0, skipped: 0 } });516    expect(vault.getEntries().map((entry) => entry.id)).toEqual(["restored-1"]);517  });518519  it("RESTORE_ENTRIES replace mode with an empty array returns an error", async () => {520    const vault = await import("./vault");521    await vault.createVault("correct-pass-12345");522523    await expect(524      dispatch({525        type: "RESTORE_ENTRIES",526        payload: { mode: "replace", entries: [] },527      })528    ).resolves.toMatchObject({ ok: false, error: "refuse to replace with empty entry set" });529  });530531  it("CHANGE_PASSWORD responds true for the happy path and false for a wrong current password", async () => {532    const vault = await import("./vault");533    await vault.createVault("correct-pass-12345");534535    await expect(536      dispatch({537        type: "CHANGE_PASSWORD",538        payload: {539          currentPassword: "correct-pass-12345",540          newPassword: "new-pass-67890",541        },542      })543    ).resolves.toMatchObject({ ok: true });544545    vault.lock();546    expect(await vault.unlock("new-pass-67890")).toBe(true);547548    await expect(549      dispatch({550        type: "CHANGE_PASSWORD",551        payload: {552          currentPassword: "wrong-pass",553          newPassword: "another-pass",554        },555      })556    ).resolves.toMatchObject({ ok: false, error: "incorrect_current_password" });557  });558559  it("UPDATE_SETTINGS reschedules auto-lock using the new minutes value", async () => {560    const vault = await import("./vault");561    await vault.createVault("correct-pass-12345");562563    (564      chrome.alarms.create as unknown as ReturnType<typeof vi.fn>565    ).mockClear();566    (567      chrome.idle.setDetectionInterval as unknown as ReturnType<typeof vi.fn>568    ).mockClear();569570    await expect(571      dispatch({572        type: "UPDATE_SETTINGS",573        payload: { autoLockMinutes: 30 },574      })575    ).resolves.toMatchObject({ ok: true });576577    expect(vault.getSettings().autoLockMinutes).toBe(30);578    expect(chrome.idle.setDetectionInterval).toHaveBeenLastCalledWith(1800);579    expect(chrome.alarms.create).toHaveBeenLastCalledWith("autolock", {580      delayInMinutes: 30,581    });582  });583584  it("CHECK_ALL_BREACHES updates matching entries without corrupting the vault", async () => {585    vi.useFakeTimers();586    const vault = await import("./vault");587    await vault.createVault("correct-pass-12345");588    const entry = await vault.addEntry({589      title: "GitHub",590      username: "june",591      password: "secret123",592      url: "https://github.com",593    });594    const breachSpy = vi.spyOn(vault, "checkBreach").mockResolvedValue(7);595596    const responsePromise = dispatch({ type: "CHECK_ALL_BREACHES" });597    await vi.runAllTimersAsync();598    const response = await responsePromise;599600    expect(response).toMatchObject({ ok: true, data: { [entry.id]: 7 } });601    expect(vault.getEntryById(entry.id)?.breach).toBe(7);602    breachSpy.mockRestore();603  });604605  it("messages from a different extension id are rejected while undefined sender.id is allowed", async () => {606    const vault = await import("./vault");607    await vault.createVault("correct-pass-12345");608609    await expect(dispatch({ type: "GET_ENTRIES" }, { id: "other-extension" })).resolves.toMatchObject({610      ok: false,611      error: "invalid_sender",612    });613614    await expect(dispatch({ type: "GET_ENTRIES" }, {})).resolves.toMatchObject({615      ok: true,616      data: [],617    });618  });619620  it("LOCK and UNLOCK messages update vault state correctly", async () => {621    const vault = await import("./vault");622623    await expect(624      dispatch({ type: "CREATE_VAULT", payload: { password: "correct-pass-12345" } })625    ).resolves.toMatchObject({ ok: true });626    expect(vault.isUnlocked()).toBe(true);627628    await expect(dispatch({ type: "LOCK" })).resolves.toMatchObject({ ok: true });629    expect(vault.isUnlocked()).toBe(false);630631    await expect(632      dispatch({ type: "UNLOCK", payload: { password: "wrong-pass" } })633    ).resolves.toMatchObject({ ok: false });634    expect(vault.isUnlocked()).toBe(false);635636    await expect(637      dispatch({ type: "UNLOCK", payload: { password: "correct-pass-12345" } })638    ).resolves.toMatchObject({ ok: true });639    expect(vault.isUnlocked()).toBe(true);640  });641642  it("remembers the last successful unlock method", async () => {643    const vault = await import("./vault");644    const password = "correct-pass-12345";645    const popupSender = {646      id: "peach-extension",647      url: "chrome-extension://peach-extension/src/popup/index.html",648    } as chrome.runtime.MessageSender;649650    await vault.createVault(password);651    expect(await vault.setupPinUnlock(password, "1234")).toBe(true);652    vault.lock();653654    await expect(655      dispatch({ type: "UNLOCK_WITH_PIN", payload: { pin: "1234" } }, popupSender),656    ).resolves.toMatchObject({ ok: true });657    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(true);658659    vault.lock();660    await expect(661      dispatch({ type: "UNLOCK", payload: { password } }, popupSender),662    ).resolves.toMatchObject({ ok: true });663    expect((await vault.ensureSettingsLoaded()).pinPreferred).toBe(false);664  });665666  it("FIRE_CANARY_PING sends a minimal payload to allowed HTTPS webhooks", async () => {667    const response = await dispatch({668      type: "FIRE_CANARY_PING",669      payload: {670        webhookUrl: "https://example.com/hook",671        entryTitle: "GitHub",672        trigger: "password_copied",673      },674    });675676    expect(response).toMatchObject({ ok: true });677    expect(fetchMock).toHaveBeenCalledWith(678      "https://example.com/hook",679      expect.objectContaining({680        method: "POST",681        headers: { "Content-Type": "application/json" },682        body: expect.any(String),683      })684    );685    const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) as Record<string, unknown>;686    expect(body).toMatchObject({687      entry: "GitHub",688      trigger: "password_copied",689      source: "peach-extension",690    });691  });692693  it("LV-3: debounces a repeat non-test ping for the same webhook+entry", async () => {694    const payload = {695      webhookUrl: "https://example.com/hook",696      entryTitle: "GitHub",697      trigger: "autofilled" as const,698    };699    const first = await dispatch({ type: "FIRE_CANARY_PING", payload });700    expect(first).toMatchObject({ ok: true });701    expect(fetchMock).toHaveBeenCalledTimes(1);702703    const second = await dispatch({ type: "FIRE_CANARY_PING", payload });704    expect(second).toMatchObject({ ok: true, data: { rateLimited: true } });705    expect(fetchMock).toHaveBeenCalledTimes(1); // not fired again706  });707708  it("LV-3: debounce is per-entry — a different entry on the same webhook still fires", async () => {709    await dispatch({710      type: "FIRE_CANARY_PING",711      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitHub", trigger: "autofilled" },712    });713    await dispatch({714      type: "FIRE_CANARY_PING",715      payload: { webhookUrl: "https://example.com/hook", entryTitle: "GitLab", trigger: "autofilled" },716    });717    expect(fetchMock).toHaveBeenCalledTimes(2);718  });719720  it("LV-3: test pings are exempt from the debounce", async () => {721    const payload = {722      webhookUrl: "https://example.com/hook",723      entryTitle: "GitHub",724      trigger: "test" as const,725    };726    await dispatch({ type: "FIRE_CANARY_PING", payload });727    await dispatch({ type: "FIRE_CANARY_PING", payload });728    expect(fetchMock).toHaveBeenCalledTimes(2);729  });730731  it("FIRE_CANARY_PING rejects insecure webhook urls", async () => {732    await expect(733      dispatch({734        type: "FIRE_CANARY_PING",735        payload: {736          webhookUrl: "http://example.com/hook",737          entryTitle: "GitHub",738          trigger: "password_copied",739        },740      })741    ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });742743    expect(fetchMock).not.toHaveBeenCalled();744  });745746  it("F3: FIRE_CANARY_PING rejects IPv4-mapped IPv6 webhooks pointing at private ranges (Codex Security 2026-08-04)", async () => {747    for (const webhookUrl of [748      "https://[::ffff:192.168.0.1]/hook",749      "https://[::ffff:c0a8:1]/hook",750      "https://[::ffff:10.0.0.1]/hook",751      "https://[::ffff:127.0.0.1]/hook",752      "https://[::ffff:169.254.1.1]/hook",753      "https://[::ffff:172.16.0.1]/hook",754    ]) {755      await expect(756        dispatch({757          type: "FIRE_CANARY_PING",758          payload: { webhookUrl, entryTitle: "GitHub", trigger: "password_copied" },759        })760      ).resolves.toMatchObject({ ok: false, error: "invalid_webhook_url" });761    }762763    expect(fetchMock).not.toHaveBeenCalled();764  });765766  it("GET_IDENTITIES when locked returns vault locked error", async () => {767    const vault = await import("./vault");768    await vault.createVault("correct-pass-12345");769    vault.lock();770    await expect(dispatch({ type: "GET_IDENTITIES" })).resolves.toMatchObject({771      ok: false,772      error: "vault_locked",773    });774  });775776  it("GET_IDENTITIES returns emails and usernames derived from login entries", async () => {777    const vault = await import("./vault");778    await vault.createVault("correct-pass-12345");779    await vault.addEntry({ title: "Gmail", username: "[email protected]", password: "pw1", url: "https://gmail.com" });780    await vault.addEntry({ title: "GitHub", username: "[email protected]", password: "pw2", url: "https://github.com" });781    await vault.addEntry({ title: "Slack", username: "juni_patel", password: "pw3", url: "https://slack.com" });782783    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };784    expect(resp.ok).toBe(true);785    const data = resp.data as Array<{ value: string; type: string; useCount: number }>;786    expect(data.find((d) => d.value === "[email protected]")).toMatchObject({787      type: "email",788      useCount: 2,789    });790    expect(data.find((d) => d.value === "juni_patel")).toMatchObject({791      type: "username",792      useCount: 1,793    });794    // emails appear before usernames795    const emailIdx = data.findIndex((d) => d.value === "[email protected]");796    const userIdx = data.findIndex((d) => d.value === "juni_patel");797    expect(emailIdx).toBeLessThan(userIdx);798  });799800  it("GET_IDENTITIES deduplicates correctly — same email across 3 entries appears once with useCount 3", async () => {801    const vault = await import("./vault");802    await vault.createVault("correct-pass-12345");803    for (let i = 0; i < 3; i++) {804      await vault.addEntry({ title: `Site${i}`, username: "[email protected]", password: "pw", url: `https://site${i}.com` });805    }806807    const resp = await dispatch({ type: "GET_IDENTITIES" }) as { ok: boolean; data: unknown[] };808    const data = resp.data as Array<{ value: string; useCount: number }>;809    const hits = data.filter((d) => d.value === "[email protected]");810    expect(hits).toHaveLength(1);811    expect(hits[0]!.useCount).toBe(3);812  });813814  it("IS_CARD_DECLINED does not refresh the auto-lock timer", async () => {815    const vault = await import("./vault");816    await vault.createVault("correct-pass-12345");817    const alarmsCreate = chrome.alarms.create as unknown as ReturnType<typeof vi.fn>;818819    // Sanity: a non-excluded message (GET_ENTRIES) DOES refresh the timer820    alarmsCreate.mockClear();821    await dispatch({ type: "GET_ENTRIES" });822    expect(alarmsCreate).toHaveBeenCalled();823824    // Passive queries are in the exclusion list and must not re-arm825    alarmsCreate.mockClear();826    await dispatch({ type: "IS_CARD_DECLINED", payload: { origin: "https://example.com" } });827    expect(alarmsCreate).not.toHaveBeenCalled();828  });829830  it("DESTROY_VAULT preserves the Pro license and celebration flag", async () => {831    const vault = await import("./vault");832    await vault.createVault("correct-pass-12345");833    await vault.addEntry({ title: "Gmail", username: "june", password: "s", url: "https://mail.google.com" });834835    // Simulate an activated Pro license + already-celebrated marker.836    storage.peach_license = "PEACH-PRO-TEST-KEY";837    storage.peach_pro_celebrated = true;838839    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });840    expect(res).toMatchObject({ ok: true });841842    // Vault data is gone…843    expect(storage.peach_vault_a).toBeUndefined();844    // …but the paid license and celebration flag survive the wipe.845    expect(storage.peach_license).toBe("PEACH-PRO-TEST-KEY");846    expect(storage.peach_pro_celebrated).toBe(true);847  });848849  it("handleMessage waits for the restore gate before serving (cold-start: no premature onboarding/lock)", async () => {850    const messages = await import("./messages");851    const vault = await import("./vault");852853    let resolveGate: () => void = () => {};854    messages.setRestoreReady(new Promise<void>((r) => { resolveGate = r; }));855856    let settled = false;857    const pending = dispatch({ type: "HAS_VAULT" }).then((r) => { settled = true; return r; });858859    // While restore is in flight the handler must NOT have answered yet —860    // this is the window where the old code returned "no vault" → onboarding.861    await new Promise((r) => setTimeout(r, 20));862    expect(settled).toBe(false);863864    // Restore completes (the vault becomes available), gate opens.865    await vault.createVault("correct-pass-12345");866    resolveGate();867868    await expect(pending).resolves.toMatchObject({ ok: true, data: true });869  });870871  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {872    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });873    expect(res.ok).toBe(false);874    // Nothing was written — no vault exists.875    const has = await dispatch({ type: "HAS_VAULT" });876    expect((has as { data?: boolean }).data).toBe(false);877  });878879  it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {880    const vault = await import("./vault");881    await vault.createVault("correct-pass-12345");882    const e = await vault.addEntry({883      title: "Mail",884      username: "june",885      password: "pw",886      url: "https://mail.test",887      totp: "JBSWY3DPEHPK3PXP",888    });889890    const res = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });891    expect(res.ok).toBe(true);892    const data = res.data as { totp?: string; totpCode?: string; totpRemaining?: number };893    expect(data.totp).toBeUndefined();894    expect(data.totpCode).toMatch(/^\d{6}$/);895    expect(data.totpRemaining).toBeGreaterThan(0);896    expect(data.totpRemaining).toBeLessThanOrEqual(30);897  });898899  it("GET_ENTRY_FOR_FILL strips passwordHistory; GET_ENTRY_FOR_EDIT keeps it", async () => {900    const vault = await import("./vault");901    await vault.createVault("correct-pass-12345");902    const e = await vault.addEntry({903      title: "Bank",904      username: "june",905      password: "old-pw",906      url: "https://bank.test",907    });908    await vault.updateEntry(e.id, { password: "new-pw" });909910    const fill = await dispatch({ type: "GET_ENTRY_FOR_FILL", payload: { id: e.id } });911    expect(fill.ok).toBe(true);912    // Passive autofill never receives prior passwords (blast-radius containment).913    expect((fill.data as Record<string, unknown>).passwordHistory).toBeUndefined();914915    const edit = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });916    expect(edit.ok).toBe(true);917    const editHist = (edit.data as { passwordHistory?: Array<{ password: string }> }).passwordHistory ?? [];918    expect(editHist.map((h) => h.password)).toEqual(["old-pw"]);919  });920921  it("GET_ENTRY_FOR_EDIT returns the raw TOTP seed for the trusted edit form", async () => {922    const vault = await import("./vault");923    await vault.createVault("correct-pass-12345");924    const e = await vault.addEntry({925      title: "Mail",926      username: "june",927      password: "pw",928      url: "https://mail.test",929      totp: "JBSWY3DPEHPK3PXP",930    });931932    const res = await dispatch({ type: "GET_ENTRY_FOR_EDIT", payload: { id: e.id } });933    expect(res.ok).toBe(true);934    const data = res.data as { totp?: string };935    expect(data.totp).toBe("JBSWY3DPEHPK3PXP");936  });937938  it("DESTROY_VAULT with no license clears storage without resurrecting keys", async () => {939    const vault = await import("./vault");940    await vault.createVault("correct-pass-12345");941942    const res = await dispatch({ type: "DESTROY_VAULT" }, { id: "peach-extension", url: "chrome-extension://peach-extension/popup.html" });943    expect(res).toMatchObject({ ok: true });944945    expect(storage.peach_vault_a).toBeUndefined();946    expect(storage.peach_license).toBeUndefined();947    expect(storage.peach_pro_celebrated).toBeUndefined();948  });949950  // --- MV-1: GET_ENTRY_FOR_FILL re-validates the requesting tab's origin ---951952  function tabSender(url: string): chrome.runtime.MessageSender {953    return { id: "peach-extension", tab: { url } as chrome.tabs.Tab };954  }955956  it("MV-1: tab request with a matching domain returns the entry", async () => {957    const vault = await import("./vault");958    await vault.createVault("correct-pass-12345");959    const entry = await vault.addEntry({960      title: "GitHub",961      username: "june",962      password: "secret123",963      url: "https://github.com",964    });965966    const res = await dispatch(967      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },968      tabSender("https://github.com/login"),969    );970    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });971  });972973  it("MV-1: tab request with a mismatched domain and no explicitSummon is refused as entry_not_found", async () => {974    const vault = await import("./vault");975    await vault.createVault("correct-pass-12345");976    const entry = await vault.addEntry({977      title: "GitHub",978      username: "june",979      password: "secret123",980      url: "https://github.com",981    });982983    const res = await dispatch(984      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },985      tabSender("https://evil.example.com/phish"),986    );987    // Refused with the same error as a missing id — no cross-domain oracle.988    expect(res).toMatchObject({ ok: false, error: "entry_not_found" });989  });990991  it("MV-1: mismatched domain with explicitSummon:true returns the entry (user-chosen off-domain fill)", async () => {992    const vault = await import("./vault");993    await vault.createVault("correct-pass-12345");994    const entry = await vault.addEntry({995      title: "GitHub",996      username: "june",997      password: "secret123",998      url: "https://github.com",999    });10001001    const res = await dispatch(1002      {1003        type: "GET_ENTRY_FOR_FILL",1004        payload: { id: entry.id, explicitSummon: true },1005      },1006      tabSender("https://evil.example.com/phish"),1007    );1008    expect(res).toMatchObject({ ok: true, data: { id: entry.id } });1009  });10101011  it("MV-1: tab context with an unparseable URL is refused as origin_unverified", async () => {1012    const vault = await import("./vault");1013    await vault.createVault("correct-pass-12345");1014    const entry = await vault.addEntry({1015      title: "GitHub",1016      username: "june",1017      password: "secret123",1018      url: "https://github.com",1019    });10201021    const res = await dispatch(1022      { type: "GET_ENTRY_FOR_FILL", payload: { id: entry.id } },1023      tabSender("not a url"),1024    );1025    expect(res).toMatchObject({ ok: false, error: "origin_unverified" });1026  });10271028  // --- MV-2: vault-wide inventory only ships on an explicit user gesture ---10291030  async function seedTwoDomainVault() {1031    const vault = await import("./vault");1032    await vault.createVault("correct-pass-12345");1033    await vault.addEntry({1034      title: "GitHub",1035      username: "june",1036      password: "gh",1037      url: "https://github.com",1038    });1039    await vault.addEntry({1040      title: "Example",1041      username: "june",1042      password: "ex",1043      url: "https://example.com",1044    });1045  }10461047  it("MV-2: AUTOFILL_QUERY with includeAll but no userGesture returns only the domain-scoped list", async () => {1048    await seedTwoDomainVault();10491050    const res = await dispatch({1051      type: "AUTOFILL_QUERY",1052      payload: { domain: "github.com", includeAll: true },1053    });1054    expect(res.ok).toBe(true);1055    const data = res.data as Array<{ title: string }>;1056    // includeAll was stripped — only the github.com match comes back.1057    expect(data).toHaveLength(1);1058    expect(data[0].title).toBe("GitHub");1059  });10601061  it("MV-2: AUTOFILL_QUERY with includeAll + userGesture returns the vault-wide list", async () => {1062    await seedTwoDomainVault();10631064    const res = await dispatch({1065      type: "AUTOFILL_QUERY",1066      payload: { domain: "github.com", includeAll: true, userGesture: true },1067    });1068    expect(res.ok).toBe(true);1069    const data = res.data as Array<{ title: string }>;1070    expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);1071  });10721073  // --- MV-7: vouched device confirmation handlers ---10741075  async function seedPendingVouched(deviceId: string) {1076    await chrome.storage.local.set({1077      peach_pending_vouched_devices_v1: [{1078        deviceId,1079        deviceName: "New Pixel",1080        deviceType: "android",1081        longTermPublicKeyHex: "e".repeat(64),1082        signingPublicKeyHex: "a".repeat(64),1083        wsPort: 51234,1084        pairedAt: 1_700_000_000_020,1085        receivedAt: 1_700_000_000_050,1086        vouchedByDeviceId: "extension-issuer",1087      }],1088    });1089  }10901091  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1092    const vault = await import("./vault");1093    await vault.createVault("correct-pass-12345");1094    await seedPendingVouched("android-new");1095    const { findPairedDeviceById } = await import("./paired-devices");1096    const { listPendingVouchedDevices } = await import(1097      "./pending-vouched-devices"1098    );10991100    const res = await dispatch({1101      type: "CONFIRM_VOUCHED_DEVICE",1102      payload: { deviceId: "android-new" },1103    });1104    expect(res).toMatchObject({ ok: true });11051106    expect(await findPairedDeviceById("android-new")).toMatchObject({1107      deviceName: "New Pixel",1108      trustSource: "vouched",1109      longTermPublicKeyHex: "e".repeat(64),1110    });1111    expect(await listPendingVouchedDevices()).toHaveLength(0);1112  });11131114  it("S1: CONFIRM_VOUCHED_DEVICE refuses a pending device whose key is revoked", async () => {1115    const vault = await import("./vault");1116    await vault.createVault("correct-pass-12345");1117    await seedPendingVouched("android-relabelled");1118    const { findPairedDeviceById, revokeDeviceById, upsertPairedDevice } =1119      await import("./paired-devices");1120    const { listPendingVouchedDevices } = await import(1121      "./pending-vouched-devices"1122    );11231124    // The queue entry predates the revocation, and it carries a fresh id, so1125    // sweeping the queue by id at revocation time would not have caught it.1126    await upsertPairedDevice({1127      deviceId: "android-old",1128      deviceName: "New Pixel",1129      deviceType: "android",1130      longTermPublicKeyHex: "e".repeat(64),1131      signingPublicKeyHex: "a".repeat(64),1132      pairedAt: 1_700_000_000_000,1133      trustSource: "vouched",1134    });1135    await revokeDeviceById("android-old", "self");11361137    const res = await dispatch({1138      type: "CONFIRM_VOUCHED_DEVICE",1139      payload: { deviceId: "android-relabelled" },1140    });1141    expect(res).toMatchObject({ ok: false, error: "device_revoked" });1142    expect(await findPairedDeviceById("android-relabelled")).toBeNull();1143    expect(await listPendingVouchedDevices()).toHaveLength(0);1144  });11451146  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1147    const vault = await import("./vault");1148    await vault.createVault("correct-pass-12345");1149    await seedPendingVouched("android-new");1150    const { findPairedDeviceById } = await import("./paired-devices");1151    const { listPendingVouchedDevices } = await import(1152      "./pending-vouched-devices"1153    );11541155    const res = await dispatch({1156      type: "REJECT_VOUCHED_DEVICE",1157      payload: { deviceId: "android-new" },1158    });1159    expect(res).toMatchObject({ ok: true });11601161    expect(await findPairedDeviceById("android-new")).toBeNull();1162    expect(await listPendingVouchedDevices()).toHaveLength(0);1163  });11641165  // --- unpair: UNPAIR_DEVICE revokes the device and drops it from the registry ---11661167  it("UNPAIR_DEVICE revokes a paired device, drops the row, and lists the revocation", async () => {1168    const vault = await import("./vault");1169    await vault.createVault("correct-pass-12345");1170    const { findPairedDeviceById, isDeviceRevoked, listDeviceRevocations, upsertPairedDevice } =1171      await import("./paired-devices");11721173    await upsertPairedDevice({1174      deviceId: "phone-1",1175      deviceName: "June's Phone",1176      deviceType: "android",1177      longTermPublicKeyHex: "a".repeat(64),1178      signingPublicKeyHex: "b".repeat(64),1179      pairedAt: 1_700_000_000_000,1180      trustSource: "direct",1181    });11821183    const res = await dispatch({1184      type: "UNPAIR_DEVICE",1185      payload: { deviceId: "phone-1" },1186    });1187    expect(res).toMatchObject({ ok: true });11881189    // The row is gone locally — re-listing must not surface it.1190    expect(await findPairedDeviceById("phone-1")).toBeNull();1191    // The revocation is recorded so a future manifest from a peer cannot1192    // resurrect this device under either the same id or the same key (MV-7 + S1).1193    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1194    const records = await listDeviceRevocations();1195    expect(records).toHaveLength(1);1196    expect(records[0]).toMatchObject({1197      deviceId: "phone-1",1198      longTermPublicKeyHex: "a".repeat(64),1199      revokedByDeviceId: expect.any(String),1200    });1201  });12021203  it("UNPAIR_DEVICE on an unknown id is a no-op (ok) and writes no revocation", async () => {1204    const vault = await import("./vault");1205    await vault.createVault("correct-pass-12345");1206    const { listDeviceRevocations } = await import("./paired-devices");12071208    const res = await dispatch({1209      type: "UNPAIR_DEVICE",1210      payload: { deviceId: "never-paired" },1211    });1212    expect(res).toMatchObject({ ok: true });1213    // A revocation with the all-zero key sentinel is still written — the1214    // contract is that the user-intent is recorded even when the registry had1215    // no row. This is what blocks a peer from later vouched-adopting the same1216    // fresh id back in.1217    const records = await listDeviceRevocations();1218    expect(records).toHaveLength(1);1219    expect(records[0]).toMatchObject({1220      deviceId: "never-paired",1221      longTermPublicKeyHex: "0".repeat(64),1222    });1223  });12241225  it("UNPAIR_DEVICE refuses an empty payload with invalid_payload", async () => {1226    const res = await dispatch({1227      type: "UNPAIR_DEVICE",1228      payload: { deviceId: "" },1229    });1230    expect(res).toMatchObject({ ok: false, error: "invalid_payload" });1231  });12321233  // The old "fresh direct re-pair clears the old revocation" test dispatched1234  // COMPLETE_PAIRING, a handler deleted on 2026-08-04 (no legitimate senders;1235  // adoption runs in-background via relay-adoption-bg.ts). The live1236  // revocation-overlap repair is pinned in paired-devices.test.ts1237  // (repairPairedRevocationOverlaps, runs at service-worker boot).1238  it("F1: privileged handlers reject content-script senders but keep extension-page senders (Codex Security 2026-08-04)", async () => {1239    const vault = await import("./vault");1240    await vault.createVault("correct-pass-12345");1241    const contentScriptSender = {1242      id: "peach-extension",1243      url: "https://evil.example/page",1244    };1245    const extensionPageSender = {1246      id: "peach-extension",1247      url: "chrome-extension://peach-extension/popup.html",1248    };1249    const completion = { requestId: 1, rpId: "evil.example" };12501251    // Content-script senders must never reach the gated passkey-store handler.1252    await expect(1253      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, contentScriptSender)1254    ).resolves.toMatchObject({ ok: false, error: "invalid_sender" });12551256    // Extension-page senders still reach it — refused by the pending-ceremony1257    // binding, not by the sender gate.1258    await expect(1259      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1260    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });1261  });12621263  it("F2: COMPLETE_FIREFOX_PASSKEY_CREATE must match the pending ceremony (Codex Security 2026-08-04)", async () => {1264    const vault = await import("./vault");1265    await vault.createVault("correct-pass-12345");1266    const extensionPageSender = {1267      id: "peach-extension",1268      url: "chrome-extension://peach-extension/popup.html",1269    };1270    const completion = {1271      requestId: 123,1272      rpId: "example.com",1273      rpName: "Example",1274      userName: "june",1275      userHandle: "BBBB",1276      credentialId: "cred-1",1277      publicKeyCose: "c3BraQ",1278      algorithm: -7 as const,1279    };12801281    // No pending ceremony at all -> refuse.1282    await expect(1283      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1284    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });12851286    // Seed a background-minted pending ceremony.1287    sessionStorage.passkey_pending_firefox = {1288      requestId: 123,1289      type: "create",1290      rpId: "example.com",1291      rpName: "Example",1292      userName: "june",1293      browser: "firefox",1294      challenge: "AAAA",1295      userHandle: "BBBB",1296    };12971298    // Mismatched userHandle (attacker-forged completion) -> refuse.1299    await expect(1300      dispatch(1301        { type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: { ...completion, userHandle: "CCCC" } },1302        extensionPageSender1303      )1304    ).resolves.toMatchObject({ ok: false, error: "no_pending_request" });13051306    // Matching completion -> stored.1307    await expect(1308      dispatch({ type: "COMPLETE_FIREFOX_PASSKEY_CREATE", payload: completion }, extensionPageSender)1309    ).resolves.toMatchObject({ ok: true });1310    const entries = await dispatch({ type: "GET_ENTRIES" });1311    expect(entries).toMatchObject({ ok: true });1312    const stored = (entries as { data: Array<{ passkey?: { credentialId?: string } }> }).data;1313    expect(stored.some((entry) => entry.passkey?.credentialId === "cred-1")).toBe(true);1314  });13151316  it("F2: INITIATE_FIREFOX_PASSKEY_CREATE rejects an rpId the caller origin does not own (Codex Security 2026-08-04)", async () => {1317    await expect(1318      dispatch(1319        {1320          type: "INITIATE_FIREFOX_PASSKEY_CREATE",1321          payload: { rpId: "bank.example", rpName: "Bank", userName: "june" },1322        },1323        { id: "peach-extension", url: "https://evil.example/page" }1324      )1325    ).resolves.toMatchObject({ ok: false, error: "rpid_origin_mismatch" });1326  });13271328  it("after UNPAIR_DEVICE, a fresh id carrying the same revoked key is also refused (S1)", async () => {1329    // The cross-manifest refusal is exercised end-to-end in sync-vault-adapter.test.ts;1330    // here we just pin the storage-level invariant the UNPAIR_DEVICE call1331    // establishes, since the rest of the testbed's revoke coverage rides on1332    // this state.1333    const vault = await import("./vault");1334    await vault.createVault("correct-pass-12345");1335    const { isDeviceRevoked, revokeDeviceById, upsertPairedDevice } = await import(1336      "./paired-devices"1337    );13381339    await upsertPairedDevice({1340      deviceId: "phone-1",1341      deviceName: "June's Phone",1342      deviceType: "android",1343      longTermPublicKeyHex: "a".repeat(64),1344      signingPublicKeyHex: "b".repeat(64),1345      pairedAt: 1_700_000_000_000,1346      trustSource: "direct",1347    });13481349    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(false);1350    await revokeDeviceById("phone-1", "self");1351    // The key check is what blocks a re-vouch under a fresh id.1352    expect(await isDeviceRevoked("phone-1", "a".repeat(64))).toBe(true);1353    expect(await isDeviceRevoked("fresh-id", "a".repeat(64))).toBe(true);1354  });13551356  // --- LV-4: GET_RECOVERY_WORDS requires a fresh password proof ---13571358  it("LV-4: GET_RECOVERY_WORDS without a password returns password_required", async () => {1359    const vault = await import("./vault");1360    await vault.createVault("correct-pass-12345");13611362    const res = await dispatch({ type: "GET_RECOVERY_WORDS", payload: {} });1363    expect(res).toMatchObject({ ok: false, error: "password_required" });1364  });13651366  it("LV-4: GET_RECOVERY_WORDS with a wrong password returns wrong_password and no words", async () => {1367    const vault = await import("./vault");1368    await vault.createVault("correct-pass-12345");13691370    const res = await dispatch({1371      type: "GET_RECOVERY_WORDS",1372      payload: { password: "not-the-password" },1373    });1374    expect(res).toMatchObject({ ok: false, error: "wrong_password" });1375    expect(res.data).toBeUndefined();1376  });13771378  it("LV-4: GET_RECOVERY_WORDS with the correct password returns 12 words", async () => {1379    const vault = await import("./vault");1380    await vault.createVault("correct-pass-12345");13811382    const res = await dispatch({1383      type: "GET_RECOVERY_WORDS",1384      payload: { password: "correct-pass-12345" },1385    });1386    expect(res.ok).toBe(true);1387    expect((res.data as string[]).length).toBe(12);1388  });13891390  it("GET_PIN_STATUS reports only a session-available PIN wrapper", async () => {1391    const vault = await import("./vault");1392    const password = "correct-pass-12345";1393    const popupSender = {1394      id: "peach-extension",1395      url: "chrome-extension://peach-extension/src/popup/index.html",1396    } as chrome.runtime.MessageSender;13971398    await vault.createVault(password);1399    await vault.setupPinUnlock(password, "1234");1400    await vault.lock();14011402    await expect(dispatch({ type: "GET_PIN_STATUS" }, popupSender)).resolves.toMatchObject({1403      ok: true,1404      data: { configured: true, available: true, attemptsRemaining: 3 },1405    });1406  });1407});