relay-adoption-source-bg.test.ts

6.5 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23const mocks = vi.hoisted(() => ({4  openSocket: vi.fn(),5  getLocalIdentity: vi.fn(),6  getRootKeyHex: vi.fn(),7  upsert: vi.fn(),8  pushes: [] as Array<{ type: string; payload: unknown }>,9  triggerSync: vi.fn(),10}));1112vi.mock("../shared/pairing-dial", () => ({13  openRelayPairingSocket: mocks.openSocket,14}));1516vi.mock("../shared/noise-js", () => ({17  noiseNKResponder: vi.fn(async () => ({18    handshakeHash: new Uint8Array(32).fill(7),19    encrypt: (bytes: Uint8Array) => bytes,20    decrypt: (bytes: Uint8Array) => bytes,21    close: vi.fn(),22  })),23}));2425vi.mock("../shared/rendezvous", async (importOriginal) => {26  const actual = await importOriginal<typeof import("../shared/rendezvous")>();27  return {28    ...actual,29    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",30  };31});323334vi.mock("./device-identity", () => ({35  getLocalPairingIdentity: async () => ({36    identity: {37      deviceId: "test-device",38      deviceName: "Test",39      deviceType: "extension",40      longTermPublicKeyHex: "aa".repeat(32),41      vaultId: "bb".repeat(32),42    },43    privateKey: new Uint8Array(32).fill(7),44    publicKey: new Uint8Array(32).fill(8),45  }),46}));4748vi.mock("./sync-settings", async (importOriginal) => ({49  ...(await importOriginal<typeof import("./sync-settings")>()),50  getSyncSettings: async () => ({51    mode: "public" as const,52    relayUrl: "wss://relay.peachpasswords.com",53  }),54}));5556vi.mock("./license", () => ({57  isProUser: vi.fn(async () => false),58  getLicenseKey: vi.fn(async () => undefined),59}));6061vi.mock("./vault", () => ({62  isUnlocked: vi.fn(() => true),63  getRootKeyHex: mocks.getRootKeyHex,64}));6566vi.mock("../shared/sync-identity", () => ({67  computeVaultId: vi.fn(async () => "b".repeat(64)),68}));6970vi.mock("./paired-devices", () => ({71  upsertPairedDevice: mocks.upsert,72}));7374vi.mock("./sync-trigger", () => ({75  triggerSyncIfReachable: mocks.triggerSync,76}));7778import {79  cancelRelayAdoptionSource,80  startRelayAdoptionSource,81} from "./relay-adoption-source-bg";8283const encode = (value: unknown) =>84  new TextEncoder().encode(JSON.stringify(value));8586function socketWithFrames(frames: unknown[]) {87  const queue = frames.map(encode);88  return {89    send: vi.fn(),90    receive: vi.fn(async () => {91      const next = queue.shift();92      if (!next) return new Promise<Uint8Array>(() => undefined);93      return next;94    }),95    close: vi.fn(),96    closedBeforeFrame: vi.fn(() => false),97  };98}99100async function waitForPush(type: string): Promise<void> {101  await vi.waitFor(() => {102    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);103  });104}105106beforeEach(() => {107  mocks.pushes.length = 0;108  mocks.openSocket.mockReset();109  mocks.getLocalIdentity.mockReset();110  mocks.getRootKeyHex.mockReset();111  mocks.upsert.mockReset();112  mocks.triggerSync.mockReset();113  mocks.triggerSync.mockResolvedValue(undefined);114  mocks.getLocalIdentity.mockResolvedValue({115    identity: {116      deviceId: "browser-source",117      deviceName: "June's Browser",118      deviceType: "extension",119      longTermPublicKeyHex: "a".repeat(64),120      vaultId: "b".repeat(64),121    },122    privateKey: new Uint8Array(32).fill(1),123    publicKey: new Uint8Array(32).fill(2),124  });125  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));126  mocks.upsert.mockResolvedValue(undefined);127  Object.defineProperty(globalThis, "chrome", {128    configurable: true,129    value: {130    runtime: {131      sendMessage(message: { type: string; payload: unknown }) {132        mocks.pushes.push(message);133        return Promise.resolve();134      },135    },136    },137  });138});139140describe("relay adoption source background boundary", () => {141  it("uses the receiver SAS confirmation as the only root-key commit gate", async () => {142    const socket = socketWithFrames([143      {144        type: "PEACH_HELLO",145        token: "AAECAwQFBgcICQoLDA0ODw",146      },147      {148        type: "ADOPT_REQUEST",149        device_id: "request-only-label",150        device_name: "June's phone",151        device_type: "android",152      },153      { type: "ADOPT_CONFIRM", confirmed: true },154      {155        type: "ADOPT_IDENTITY",156        device_id: "android-receiver",157        device_name: "June's phone",158        device_type: "android",159        long_term_public_key_hex: "d".repeat(64),160      },161      { type: "ADOPT_APPLIED" },162    ]);163    mocks.openSocket.mockResolvedValue(socket);164165    const started = await startRelayAdoptionSource();166    expect(started.invitation).toMatch(/^peach-pair-v1:/);167    await waitForPush("ADOPTION_SOURCE_SAS");168    await waitForPush("ADOPTION_SOURCE_RESULT");169170    expect(mocks.getRootKeyHex).toHaveBeenCalled();171    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({172      deviceId: "android-receiver",173      relayUrl: "wss://relay.peachpasswords.com",174    }));175    expect(mocks.pushes).toContainEqual(expect.objectContaining({176      type: "ADOPTION_SOURCE_RESULT",177      payload: expect.objectContaining({ status: "complete" }),178    }));179    expect(mocks.triggerSync).toHaveBeenCalledWith({180      reason: "manual",181      force: true,182    });183    const sentTypes = socket.send.mock.calls184      .map(([bytes]) => {185        try {186          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;187        } catch {188          return undefined;189        }190      });191    expect(sentTypes).toContain("ADOPT_COMPLETE");192  });193194  it("fails closed when the scanning device rejects the SAS", async () => {195    const socket = socketWithFrames([196      {197        type: "PEACH_HELLO",198        token: "AAECAwQFBgcICQoLDA0ODw",199      },200      {201        type: "ADOPT_REQUEST",202        device_id: "request-only-label",203        device_name: "New phone",204        device_type: "android",205      },206      { type: "ADOPT_CONFIRM", confirmed: false },207    ]);208    mocks.openSocket.mockResolvedValue(socket);209210    await startRelayAdoptionSource();211    await waitForPush("ADOPTION_SOURCE_SAS");212    await waitForPush("ADOPTION_SOURCE_RESULT");213214    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();215    expect(mocks.upsert).not.toHaveBeenCalled();216    expect(mocks.pushes).toContainEqual({217      type: "ADOPTION_SOURCE_RESULT",218      payload: { status: "error", error: "adoption_rejected" },219    });220  });221222  it("cancelling the source screen closes the room without reading the root", async () => {223    const socket = socketWithFrames([]);224    mocks.openSocket.mockResolvedValue(socket);225226    await startRelayAdoptionSource();227    cancelRelayAdoptionSource();228229    expect(socket.close).toHaveBeenCalled();230    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();231    expect(mocks.upsert).not.toHaveBeenCalled();232  });233});