123456789import { beforeEach, describe, expect, it, vi } from "vitest";1011import type { Message, MessageResponse } from "../shared/types";1213type StorageShape = Record<string, unknown>;14type MessageListener = (15 msg: unknown,16 sender: chrome.runtime.MessageSender,17 sendResponse: (response: MessageResponse) => void18) => boolean;1920describe("PAIRING_COMPLETED gate (M13/SEC-005)", () => {21 let storage: StorageShape;22 let listener: MessageListener | undefined;2324 beforeEach(async () => {25 vi.resetModules();26 vi.useRealTimers();27 storage = { peach_settings: { breachMonitoring: false } };28 listener = undefined;2930 vi.stubGlobal(31 "fetch",32 vi.fn(async () => ({ ok: true, status: 200, text: async () => "" })),33 );34 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);3536 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 storage: {47 local: {48 get: vi.fn(async (key?: string | string[]) => {49 if (!key) return storage;50 if (typeof key === "string") return { [key]: storage[key] };51 return key.reduce<Record<string, unknown>>((acc, k) => {52 acc[k] = storage[k];53 return acc;54 }, {});55 }),56 set: vi.fn(async (next: Record<string, unknown>) => {57 Object.assign(storage, next);58 }),59 remove: vi.fn(async (keys: string | string[]) => {60 const list = Array.isArray(keys) ? keys : [keys];61 for (const k of list) delete storage[k];62 }),63 },64 session: {65 get: vi.fn(async () => ({})),66 set: vi.fn(async () => {}),67 remove: vi.fn(async () => {}),68 },69 },70 alarms: {71 clear: vi.fn(),72 create: vi.fn(),73 onAlarm: { addListener: vi.fn() },74 },75 idle: {76 setDetectionInterval: vi.fn(),77 onStateChanged: { addListener: vi.fn() },78 },79 scripting: { executeScript: vi.fn(async () => []) },80 } as unknown as typeof chrome;81 (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;8283 const messages = await import("./messages");84 messages.setupMessageHandler();85 });8687 async function dispatch(message: Message): Promise<MessageResponse> {88 if (!listener) throw new Error("listener not registered");89 return new Promise<MessageResponse>((resolve) => {90 listener!(message, { id: "peach-extension" } as chrome.runtime.MessageSender, resolve);91 });92 }9394 it("rejects PAIRING_COMPLETED for an unverified, unpaired publicKeyHex", async () => {95 const fakeKey = "deadbeef".repeat(8);96 const r = await dispatch({97 type: "PAIRING_COMPLETED",98 payload: { deviceName: "Attacker", publicKeyHex: fakeKey },99 });100 expect(r.ok).toBe(false);101 expect(r.error).toBe("pairing_not_verified");102103 104 const paired = (storage.peach_paired_devices as unknown[] | undefined) ?? [];105 expect(paired.length).toBe(0);106 });107108 it("accepts PAIRING_COMPLETED for a freshly verified handshake", async () => {109 const messages = await import("./messages");110 const goodKey = "abcdef01".repeat(8);111 messages.markPairingHandshakeVerified(goodKey);112113 const r = await dispatch({114 type: "PAIRING_COMPLETED",115 payload: { deviceName: "RealDevice", publicKeyHex: goodKey },116 });117 expect(r.ok).toBe(true);118119 const paired = (storage.peach_paired_devices as Array<Record<string, unknown>> | undefined) ?? [];120 expect(paired.some(d => d.x25519PublicKey === goodKey)).toBe(true);121 });122123 it("verified marker is single-use — second PAIRING_COMPLETED after the first device is removed is rejected", async () => {124 const messages = await import("./messages");125 const goodKey = "12345678".repeat(8);126 messages.markPairingHandshakeVerified(goodKey);127128 const first = await dispatch({129 type: "PAIRING_COMPLETED",130 payload: { deviceName: "Dev", publicKeyHex: goodKey },131 });132 expect(first.ok).toBe(true);133134 135 delete storage.peach_paired_devices;136137 const second = await dispatch({138 type: "PAIRING_COMPLETED",139 payload: { deviceName: "Dev", publicKeyHex: goodKey },140 });141 expect(second.ok).toBe(false);142 expect(second.error).toBe("pairing_not_verified");143 });144145 it("missing publicKeyHex is rejected", async () => {146 const r = await dispatch({147 type: "PAIRING_COMPLETED",148 payload: { deviceName: "X" },149 });150 expect(r.ok).toBe(false);151 expect(r.error).toBe("publicKeyHex required");152 });153});