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    })),35  };36});3738/** `null` in the queue means "the socket closes here with no data". */39function fakeSocket(inbound: Array<Uint8Array | null>) {40  const sent: Uint8Array[] = [];41  let closed = false;42  let lastWasClose = false;43  const queue = [...inbound];44  const sock: PairingSocket = {45    send(bytes) { sent.push(bytes); },46    async receive() {47      const next = queue.shift();48      if (next === undefined || next === null) {49        closed = true;50        lastWasClose = true;51        throw new Error("socket closed");52      }53      lastWasClose = false;54      return next;55    },56    close() { closed = true; },57    closedBeforeFrame() { return lastWasClose; },58  };59  return { sock, sent, isClosed: () => closed };60}6162function ackThen(...rest: Array<Uint8Array | null>) {63  return fakeSocket([64    enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: LISTENER_EPHEMERAL_HEX }),65    ...rest,66  ]);67}6869beforeEach(() => { vi.clearAllMocks(); });7071describe("dialPairingChannel", () => {72  it("dials the coded endpoint", async () => {73    const openSocket = vi.fn(async () => ackThen().sock);7475    const result = await dialPairingChannel(CODE, { openSocket });7677    expect(result.ok).toBe(true);78    expect(openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);79  });8081  it("presents the token as hex and returns the shared SAS", async () => {82    const s = ackThen();83    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });8485    expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });86    expect(result.ok && result.dialled.sas).toBe("895245");87    expect(result.ok && result.dialled.host).toBe("192.168.4.77");88    expect(result.ok && result.dialled.rendezvousSecretHex).toBe(89      "a8977cd86e944e72e9cb569dbba00a7b0abbf7f456c745299aa8f66157859143",90    );91  });9293  it("marks LAN adoption before Noise so the source can skip device-pair auth", async () => {94    const s = ackThen();95    const result = await dialPairingChannel(96      CODE,97      { openSocket: async () => s.sock },98      { flow: "adoption" },99    );100101    expect(result.ok).toBe(true);102    expect(dec(s.sent[0])).toEqual({103      type: "PEACH_HELLO",104      token: "deadbeef",105      flow: "adoption",106    });107  });108109  it("rejects an addressless legacy code before opening a socket", async () => {110    const openSocket = vi.fn(async () => ackThen().sock);111112    const result = await dialPairingChannel("123456", { openSocket });113114    expect(result).toEqual({ ok: false, error: "invalid_code" });115    expect(openSocket).not.toHaveBeenCalled();116  });117118  it("reads a silent close as invalid_code, not a transport failure", async () => {119    // A wrong or expired token gets no error frame — the listener just closes.120    const s = fakeSocket([null]);121    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });122123    expect(result).toEqual({ ok: false, error: "invalid_code" });124    expect(s.isClosed()).toBe(true);125  });126127  it("treats a non-ACK first frame exactly like a wrong token", async () => {128    const s = fakeSocket([enc({ type: "SOMETHING_ELSE" })]);129    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });130131    expect(result).toEqual({ ok: false, error: "invalid_code" });132  });133134  it("rejects an ACK whose ephemeral key is not 32-byte hex", async () => {135    const s = fakeSocket([enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: "nope" })]);136    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });137138    expect(result).toEqual({ ok: false, error: "handshake_failed" });139    expect(s.isClosed()).toBe(true);140  });141142  it("reports no_host_found when the endpoint refuses the connection", async () => {143    const result = await dialPairingChannel(CODE, {144      openSocket: async () => { throw new Error("ECONNREFUSED"); },145    });146147    expect(result).toEqual({ ok: false, error: "no_host_found" });148  });149150  it("leaves the socket open on success so the caller owns its lifetime", async () => {151    const s = ackThen(enc({ hello: "world" }));152    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });153    if (!result.ok) throw new Error("expected a dialled channel");154155    expect(s.isClosed()).toBe(false);156    await result.dialled.channel.send(enc({ type: "ANYTHING" }));157    // sent[0] is the plaintext hello; sent[1] went through the Noise channel.158    expect(dec(s.sent[1])).toEqual({ type: "ANYTHING" });159    expect(dec(await result.dialled.channel.receive())).toEqual({ hello: "world" });160161    result.dialled.socket.close();162    expect(s.isClosed()).toBe(true);163  });164});165166describe("dialRelayPairingChannel", () => {167  it("occupies the invited relay room and runs the identical hello/NK path", async () => {168    const s = ackThen();169    const openSocket = vi.fn(async () => s.sock);170171    const result = await dialRelayPairingChannel(RELAY_INVITATION, { openSocket });172173    expect(result.ok).toBe(true);174    expect(openSocket).toHaveBeenCalledWith(175      `wss://relay.example.test/base/r/${RELAY_TOKEN}`,176    );177    expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: RELAY_TOKEN });178    expect(result.ok && result.dialled.relayUrl).toBe(179      "wss://relay.example.test/base",180    );181    expect(result.ok && result.dialled.host).toBe("relay.example.test");182  });183184  it("rejects a malformed invitation before opening a socket", async () => {185    const openSocket = vi.fn(async () => ackThen().sock);186    const result = await dialRelayPairingChannel("peach-pair-v1:not-valid", {187      openSocket,188    });189    expect(result).toEqual({ ok: false, error: "invalid_code" });190    expect(openSocket).not.toHaveBeenCalled();191  });192});