pairing-invitation.test.ts

4.2 KB · raw

1import { describe, expect, it } from "vitest";23import {4  PAIRING_INVITATION_PREFIX,5  createPairingInvitation,6  pairingInvitationUrl,7  parsePairingInvitation,8} from "./pairing-invitation";910const TOKEN = "AAECAwQFBgcICQoLDA0ODw";11const RELAY = "wss://relay.peachpasswords.com";12const STATIC_KEY = "01".repeat(32);13// Cross-client compact vector (pinned in the changelog). Adding a top-level key14// would change the byte length, so this is a separate vector from the v1 one.15const KAT_POPULATED =16  "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyIsInZhdWx0IjoicG9wdWxhdGVkIn0";17// A pre-vaultStatus invitation as it would have circulated before the field18// existed. Older phones parse this and fall back to "populated".19const KAT_LEGACY =20  "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2122describe("relay pairing invitation", () => {23  it("pins the cross-client compact JSON/base64url vector", () => {24    expect(createPairingInvitation(`${RELAY}/`, TOKEN, "populated")).toBe(KAT_POPULATED);25    expect(parsePairingInvitation(KAT_POPULATED)).toEqual({26      relayUrl: RELAY,27      token: TOKEN,28      vaultStatus: "populated",29    });30  });3132  it("round-trips the vaultStatus field in both directions", () => {33    for (const status of ["empty", "populated"] as const) {34      const encoded = createPairingInvitation(RELAY, TOKEN, status);35      const parsed = parsePairingInvitation(encoded);36      expect(parsed).toEqual({ relayUrl: RELAY, token: TOKEN, vaultStatus: status });37    }38  });3940  it("binds a validated Noise static key into new invitations", () => {41    const encoded = createPairingInvitation(RELAY, TOKEN, "populated", STATIC_KEY);42    expect(parsePairingInvitation(encoded)).toEqual({43      relayUrl: RELAY,44      token: TOKEN,45      vaultStatus: "populated",46      noiseStaticPublicKeyHex: STATIC_KEY,47    });48    expect(parsePairingInvitation(createRaw({49      relay: RELAY,50      token: TOKEN,51      vault: "populated",52      static: "not-a-key",53    }))).toBeNull();54  });5556  it("defaults to 'populated' for legacy invitations without the field", () => {57    // Older invitations from before vaultStatus existed still parse cleanly —58    // they default to "populated" so the phone does the right thing.59    expect(parsePairingInvitation(KAT_LEGACY)).toEqual({60      relayUrl: RELAY,61      token: TOKEN,62      vaultStatus: "populated",63    });64  });6566  it("defaults vaultStatus to 'populated' when omitted from createPairingInvitation", () => {67    const encoded = createPairingInvitation(RELAY, TOKEN);68    expect(parsePairingInvitation(encoded)).toEqual({69      relayUrl: RELAY,70      token: TOKEN,71      vaultStatus: "populated",72    });73  });7475  it("ignores an unknown vault value rather than failing the parse", () => {76    const bogus = createRaw({ relay: RELAY, token: TOKEN, vault: "full" });77    const parsed = parsePairingInvitation(bogus);78    expect(parsed).toEqual({ relayUrl: RELAY, token: TOKEN, vaultStatus: "populated" });79  });8081  it("builds a room URL without identity or vault state", () => {82    const parsed = parsePairingInvitation(KAT_POPULATED);83    expect(parsed).not.toBeNull();84    expect(pairingInvitationUrl(parsed!)).toBe(`${RELAY}/r/${TOKEN}`);85    expect(KAT_POPULATED).not.toContain("device");86    expect(KAT_POPULATED).not.toContain("identity");87    expect(KAT_POPULATED).not.toContain("encrypted");88  });8990  it("fails closed on malformed, insecure, or short-token payloads", () => {91    expect(parsePairingInvitation("not-an-invitation")).toBeNull();92    expect(parsePairingInvitation(`${PAIRING_INVITATION_PREFIX}%%%`)).toBeNull();93    const insecure = createRaw({ relay: "ws://relay.example.test", token: TOKEN });94    expect(parsePairingInvitation(insecure)).toBeNull();95    const shortToken = createRaw({ relay: RELAY, token: "too-short" });96    expect(parsePairingInvitation(shortToken)).toBeNull();97  });98});99100function createRaw(value: unknown): string {101  const bytes = new TextEncoder().encode(JSON.stringify(value));102  let binary = "";103  for (const byte of bytes) binary += String.fromCharCode(byte);104  return `${PAIRING_INVITATION_PREFIX}${btoa(binary)105    .replace(/\+/g, "-")106    .replace(/\//g, "_")107    .replace(/=+$/g, "")}`;108}