123456789101112131415import { beforeEach, describe, expect, it, vi } from "vitest";1617type StorageShape = Record<string, unknown>;1819const DECLINE_PREFIX = "peach_passkey_offer_declined:";2021function makeChromeMock(storage: StorageShape) {22 const chrome = {23 storage: {24 local: {25 get: vi.fn(async (key?: string | string[]) => {26 if (!key) return { ...storage };27 const keys = Array.isArray(key) ? key : [key];28 return keys.reduce<StorageShape>((acc, k) => { acc[k] = storage[k]; return acc; }, {});29 }),30 set: vi.fn(async (next: StorageShape) => { Object.assign(storage, next); }),31 remove: vi.fn(async (keys: string | string[]) => {32 for (const k of Array.isArray(keys) ? keys : [keys]) delete storage[k];33 }),34 },35 session: {36 get: vi.fn(async () => ({})),37 set: vi.fn(async () => {}),38 remove: vi.fn(async () => {}),39 },40 },41 alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },42 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },43 runtime: {44 id: "test-extension-id",45 getURL: vi.fn((path: string) => `chrome-extension://test-extension-id/${path}`),46 },47 } as unknown as typeof globalThis.chrome;48 return chrome;49}5051function pageSender(url: string): chrome.runtime.MessageSender {52 return { tab: { url }, url } as unknown as chrome.runtime.MessageSender;53}5455describe("PB-26-109: passkey offer-decline origin binding", () => {56 let storage: StorageShape;57 let messages: typeof import("./messages");5859 beforeEach(async () => {60 vi.resetModules();61 storage = {};62 (globalThis as Record<string, unknown>).chrome = makeChromeMock(storage);63 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("no network"); }));64 vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);65 messages = await import("./messages");66 });6768 it("a cross-origin decline is refused and writes nothing", async () => {69 const res = await messages.handleMessage(70 { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "bank.example" } },71 pageSender("https://attacker.example.net/page"),72 );7374 expect(res).toMatchObject({ ok: false, error: "invalid_domain" });75 expect(storage[`${DECLINE_PREFIX}bank.example`]).toBeUndefined();76 });7778 it("an own-origin decline is accepted", async () => {79 const res = await messages.handleMessage(80 { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } },81 pageSender("https://example.com/login"),82 );8384 expect(res).toMatchObject({ ok: true });85 expect(storage[`${DECLINE_PREFIX}example.com`]).toEqual(expect.any(Number));86 });8788 89 90 91 it("a subdomain may decline its registrable parent", async () => {92 const res = await messages.handleMessage(93 { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } },94 pageSender("https://login.example.com/auth"),95 );9697 expect(res).toMatchObject({ ok: true });98 expect(storage[`${DECLINE_PREFIX}example.com`]).toEqual(expect.any(Number));99 });100101 102 it("a lookalike host cannot decline example.com", async () => {103 const res = await messages.handleMessage(104 { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "example.com" } },105 pageSender("https://example.com.attacker.example/login"),106 );107108 expect(res).toMatchObject({ ok: false, error: "invalid_domain" });109 expect(storage[`${DECLINE_PREFIX}example.com`]).toBeUndefined();110 });111112 it("a bare public suffix is refused", async () => {113 const res = await messages.handleMessage(114 { type: "PASSKEY_OFFER_DECLINE", payload: { domain: "com" } },115 pageSender("https://example.com/login"),116 );117118 expect(res).toMatchObject({ ok: false, error: "invalid_domain" });119 });120121 122 123 it("an over-long domain is never written", async () => {124 const long = `${"a".repeat(250)}.example`;125 expect(long.length).toBeGreaterThan(253);126127 await messages.handleMessage(128 { type: "PASSKEY_OFFER_DECLINE", payload: { domain: long } },129 pageSender(`https://${long}/login`),130 );131132 expect(storage[`${DECLINE_PREFIX}${long}`]).toBeUndefined();133 expect(Object.keys(storage)).toHaveLength(0);134 });135});