pairing-dial.test.ts

7.4 KB · raw

1import { afterEach, describe, expect, it, vi } from "vitest";2import {3  MAX_PAIRING_FRAME_BYTES,4  MAX_PAIRING_INBOX_BYTES,5  MAX_PAIRING_INBOX_MESSAGES,6  PAIRING_RESOURCE_LIMIT,7  openRelayPairingSocket,8  type PairingSocket,9} from "./pairing-dial";1011// PB-26-205: the relay pairing inbox must be bounded before any12// allocate/copy of attacker-controlled frame data.1314interface FakeSocket {15  binaryType: string;16  onmessage: ((ev: { data: unknown }) => void) | null;17  onerror: (() => void) | null;18  onclose: ((ev: { code: number }) => void) | null;19  onopen: (() => void) | null;20  sent: unknown[];21  closeCalls: Array<{ code?: number; reason?: string }>;22  send(data: unknown): void;23  close(code?: number, reason?: string): void;24  simulateOpen(): void;25  simulateMessage(data: unknown): void;26  simulateClose(code?: number): void;27}2829let fake: FakeSocket;3031function createFake(): FakeSocket {32  const sock: FakeSocket = {33    binaryType: "",34    onmessage: null,35    onerror: null,36    onclose: null,37    onopen: null,38    sent: [],39    closeCalls: [],40    send(data: unknown) {41      sock.sent.push(data);42    },43    close(code?: number, reason?: string) {44      sock.closeCalls.push({ code, reason });45      // Like a real browser, firing onclose asynchronously after close().46      queueMicrotask(() => sock.simulateClose(code));47    },48    simulateOpen() {49      sock.onopen?.();50    },51    simulateMessage(data: unknown) {52      sock.onmessage?.({ data });53    },54    simulateClose(code = 1000) {55      sock.onclose?.({ code });56    },57  };58  return sock;59}6061async function openSocket(): Promise<PairingSocket> {62  const pending = openRelayPairingSocket("wss://relay.example.test/room");63  fake.simulateOpen();64  return pending;65}6667function frame(n: number): ArrayBuffer {68  return new Uint8Array(n).buffer as ArrayBuffer;69}7071afterEach(() => {72  vi.unstubAllGlobals();73});7475describe("PB-26-205 pairing relay inbox limits", () => {76  it("accepts a frame of exactly MAX_PAIRING_FRAME_BYTES", async () => {77    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {78      fake = createFake();79      return fake;80    }));81    const sock = await openSocket();82    fake.simulateMessage(frame(MAX_PAIRING_FRAME_BYTES));83    const got = await sock.receive();84    expect(got.byteLength).toBe(MAX_PAIRING_FRAME_BYTES);85    expect(fake.closeCalls).toEqual([]);86  });8788  it("rejects a max+1 frame with a 1009 close before copying", async () => {89    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {90      fake = createFake();91      return fake;92    }));93    const sock = await openSocket();94    const pending = sock.receive();95    const rejection = expect(pending).rejects.toThrow(PAIRING_RESOURCE_LIMIT);96    fake.simulateMessage(frame(MAX_PAIRING_FRAME_BYTES + 1));97    await rejection;98    expect(fake.closeCalls[0]?.code).toBe(1009);99    // Queued references are released: nothing remains deliverable.100    await expect(sock.receive()).rejects.toThrow(PAIRING_RESOURCE_LIMIT);101  });102103  it("rejects non-ArrayBuffer frames without copying", async () => {104    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {105      fake = createFake();106      return fake;107    }));108    const sock = await openSocket();109    const pending = sock.receive();110    const rejection = expect(pending).rejects.toThrow(PAIRING_RESOURCE_LIMIT);111    fake.simulateMessage("not-binary");112    await rejection;113    expect(fake.closeCalls[0]?.code).toBe(1009);114  });115116  it("closes when individually valid frames exceed the inbox byte budget", async () => {117    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {118      fake = createFake();119      return fake;120    }));121    const sock = await openSocket();122    const frames = Math.floor(MAX_PAIRING_INBOX_BYTES / MAX_PAIRING_FRAME_BYTES);123    for (let i = 0; i < frames; i++) fake.simulateMessage(frame(MAX_PAIRING_FRAME_BYTES));124    // One more byte over budget trips the limit even though the frame is valid.125    fake.simulateMessage(frame(1));126    expect(fake.closeCalls[0]?.code).toBe(1009);127    await expect(sock.receive()).rejects.toThrow(PAIRING_RESOURCE_LIMIT);128  });129130  it("enforces the message-count limit with tiny frames", async () => {131    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {132      fake = createFake();133      return fake;134    }));135    const sock = await openSocket();136    for (let i = 0; i < MAX_PAIRING_INBOX_MESSAGES; i++) fake.simulateMessage(frame(1));137    expect(fake.closeCalls).toEqual([]);138    fake.simulateMessage(frame(1));139    expect(fake.closeCalls[0]?.code).toBe(1009);140    await expect(sock.receive()).rejects.toThrow(PAIRING_RESOURCE_LIMIT);141  });142143  it("dequeue relieves the budget so later valid frames are accepted", async () => {144    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {145      fake = createFake();146      return fake;147    }));148    const sock = await openSocket();149    for (let i = 0; i < MAX_PAIRING_INBOX_MESSAGES; i++) fake.simulateMessage(frame(1));150    const first = await sock.receive();151    expect(first.byteLength).toBe(1);152    // Count and byte budget both dropped by one: this frame fits again.153    fake.simulateMessage(frame(1));154    expect(fake.closeCalls).toEqual([]);155    const rest: number[] = [];156    for (let i = 0; i < MAX_PAIRING_INBOX_MESSAGES; i++) rest.push((await sock.receive()).byteLength);157    expect(rest).toHaveLength(MAX_PAIRING_INBOX_MESSAGES);158  });159160  it("gives every pending reader the same terminal error and ignores later events", async () => {161    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {162      fake = createFake();163      return fake;164    }));165    const sock = await openSocket();166    const r1 = sock.receive();167    const r2 = sock.receive();168    fake.simulateMessage(frame(MAX_PAIRING_FRAME_BYTES + 1));169    await expect(r1).rejects.toThrow(PAIRING_RESOURCE_LIMIT);170    await expect(r2).rejects.toThrow(PAIRING_RESOURCE_LIMIT);171    // Later frames and the resulting onclose are ignored: still one close.172    fake.simulateMessage(frame(1));173    fake.simulateClose(1009);174    await new Promise((r) => setTimeout(r, 0));175    expect(fake.closeCalls).toHaveLength(1);176    await expect(sock.receive()).rejects.toThrow(PAIRING_RESOURCE_LIMIT);177    expect(sock.closedBeforeFrame()).toBe(false);178  });179180  it("refuses oversized outbound frames before sending", async () => {181    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {182      fake = createFake();183      return fake;184    }));185    const sock = await openSocket();186    expect(() => sock.send(new Uint8Array(frame(MAX_PAIRING_FRAME_BYTES + 1)))).toThrow(187      PAIRING_RESOURCE_LIMIT,188    );189    expect(fake.sent).toHaveLength(0);190    // The socket stays usable for honest frames.191    sock.send(new Uint8Array(frame(16)));192    expect(fake.sent).toHaveLength(1);193  });194195  it("keeps normal pairing and wrong-code behavior unchanged", async () => {196    vi.stubGlobal("WebSocket", vi.fn().mockImplementation(function () {197      fake = createFake();198      return fake;199    }));200    const sock = await openSocket();201    fake.simulateMessage(frame(64));202    fake.simulateMessage(frame(128));203    expect((await sock.receive()).byteLength).toBe(64);204    expect((await sock.receive()).byteLength).toBe(128);205    // Wrong/expired code: silent close with nothing queued.206    const pending = sock.receive();207    const rejection = expect(pending).rejects.toThrow("socket closed");208    fake.simulateClose(1000);209    await rejection;210    expect(sock.closedBeforeFrame()).toBe(true);211  });212});