pairing-dial.test.ts

6.8 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23import {4  dialPairingChannel,5  dialRelayPairingChannel,6  type PairingSocket,7} from "./pairing-dial";8import { encodePairingCode } from "./pairing-code";9import { createPairingInvitation } from "./pairing-invitation";1011const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));12const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));1314const LISTENER_EPHEMERAL_HEX = "a".repeat(64);15const CODE = encodePairingCode("192.168.4.77", 51555, Uint8Array.of(0xde, 0xad, 0xbe, 0xef));16const RELAY_TOKEN = "AAECAwQFBgcICQoLDA0ODw";17const RELAY_INVITATION = createPairingInvitation(18  "wss://relay.example.test/base",19  RELAY_TOKEN,20);2122const FAKE_HANDSHAKE_HASH = Uint8Array.from([0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0)]);2324// Skip the real handshake messages; encrypt/decrypt are identity transforms so25// the channel's framing can be asserted directly.26vi.mock("./noise-js", async (orig) => {27  const actual = await orig<typeof import("./noise-js")>();28  return {29    ...actual,30    noiseNKInitiator: vi.fn(async () => ({31      encrypt: (pt: Uint8Array) => pt,32      decrypt: (ct: Uint8Array) => ct,33      handshakeHash: FAKE_HANDSHAKE_HASH,34      close: vi.fn(),35    })),36  };37});3839/** `null` in the queue means "the socket closes here with no data". */40function fakeSocket(inbound: Array<Uint8Array | null>) {41  const sent: Uint8Array[] = [];42  let closed = false;43  let lastWasClose = false;44  const queue = [...inbound];45  const sock: PairingSocket = {46    send(bytes) { sent.push(bytes); },47    async receive() {48      const next = queue.shift();49      if (next === undefined || next === null) {50        closed = true;51        lastWasClose = true;52        throw new Error("socket closed");53      }54      lastWasClose = false;55      return next;56    },57    close() { closed = true; },58    closedBeforeFrame() { return lastWasClose; },59  };60  return { sock, sent, isClosed: () => closed };61}6263function ackThen(...rest: Array<Uint8Array | null>) {64  return fakeSocket([65    enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: LISTENER_EPHEMERAL_HEX }),66    ...rest,67  ]);68}6970beforeEach(() => { vi.clearAllMocks(); });7172describe("dialPairingChannel", () => {73  it("dials the coded endpoint", async () => {74    const openSocket = vi.fn(async () => ackThen().sock);7576    const result = await dialPairingChannel(CODE, { openSocket });7778    expect(result.ok).toBe(true);79    expect(openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);80  });8182  it("presents the token as hex and returns the shared SAS", async () => {83    const s = ackThen();84    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });8586    expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });87    expect(result.ok && result.dialled.sas).toBe("895245");88    expect(result.ok && result.dialled.host).toBe("192.168.4.77");89    expect(result.ok && result.dialled.rendezvousSecretHex).toBe(90      "a8977cd86e944e72e9cb569dbba00a7b0abbf7f456c745299aa8f66157859143",91    );92  });9394  it("marks LAN adoption before Noise so the source can skip device-pair auth", async () => {95    const s = ackThen();96    const result = await dialPairingChannel(97      CODE,98      { openSocket: async () => s.sock },99      { flow: "adoption" },100    );101102    expect(result.ok).toBe(true);103    expect(dec(s.sent[0])).toEqual({104      type: "PEACH_HELLO",105      token: "deadbeef",106      flow: "adoption",107    });108  });109110  it("rejects an addressless legacy code before opening a socket", async () => {111    const openSocket = vi.fn(async () => ackThen().sock);112113    const result = await dialPairingChannel("123456", { openSocket });114115    expect(result).toEqual({ ok: false, error: "invalid_code" });116    expect(openSocket).not.toHaveBeenCalled();117  });118119  it("reads a silent close as invalid_code, not a transport failure", async () => {120    // A wrong or expired token gets no error frame — the listener just closes.121    const s = fakeSocket([null]);122    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });123124    expect(result).toEqual({ ok: false, error: "invalid_code" });125    expect(s.isClosed()).toBe(true);126  });127128  it("treats a non-ACK first frame exactly like a wrong token", async () => {129    const s = fakeSocket([enc({ type: "SOMETHING_ELSE" })]);130    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });131132    expect(result).toEqual({ ok: false, error: "invalid_code" });133  });134135  it("rejects an ACK whose ephemeral key is not 32-byte hex", async () => {136    const s = fakeSocket([enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: "nope" })]);137    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });138139    expect(result).toEqual({ ok: false, error: "handshake_failed" });140    expect(s.isClosed()).toBe(true);141  });142143  it("reports no_host_found when the endpoint refuses the connection", async () => {144    const result = await dialPairingChannel(CODE, {145      openSocket: async () => { throw new Error("ECONNREFUSED"); },146    });147148    expect(result).toEqual({ ok: false, error: "no_host_found" });149  });150151  it("leaves the socket open on success so the caller owns its lifetime", async () => {152    const s = ackThen(enc({ hello: "world" }));153    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });154    if (!result.ok) throw new Error("expected a dialled channel");155156    expect(s.isClosed()).toBe(false);157    await result.dialled.channel.send(enc({ type: "ANYTHING" }));158    // sent[0] is the plaintext hello; sent[1] went through the Noise channel.159    expect(dec(s.sent[1])).toEqual({ type: "ANYTHING" });160    expect(dec(await result.dialled.channel.receive())).toEqual({ hello: "world" });161162    result.dialled.socket.close();163    expect(s.isClosed()).toBe(true);164  });165});166167describe("dialRelayPairingChannel", () => {168  it("occupies the invited relay room and runs the identical hello/NK path", async () => {169    const s = ackThen();170    const openSocket = vi.fn(async () => s.sock);171172    const result = await dialRelayPairingChannel(RELAY_INVITATION, { openSocket });173174    expect(result.ok).toBe(true);175    expect(openSocket).toHaveBeenCalledWith(176      `wss://relay.example.test/base/r/${RELAY_TOKEN}`,177    );178    expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: RELAY_TOKEN });179    expect(result.ok && result.dialled.relayUrl).toBe(180      "wss://relay.example.test/base",181    );182    expect(result.ok && result.dialled.host).toBe("relay.example.test");183  });184185  it("rejects a malformed invitation before opening a socket", async () => {186    const openSocket = vi.fn(async () => ackThen().sock);187    const result = await dialRelayPairingChannel("peach-pair-v1:not-valid", {188      openSocket,189    });190    expect(result).toEqual({ ok: false, error: "invalid_code" });191    expect(openSocket).not.toHaveBeenCalled();192  });193});