relay-adoption-source-bg.test.ts

6.1 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23const mocks = vi.hoisted(() => ({4  openSocket: vi.fn(),5  getLocalIdentity: vi.fn(),6  verifyPassword: vi.fn(),7  getRootKeyHex: vi.fn(),8  upsert: vi.fn(),9  pushes: [] as Array<{ type: string; payload: unknown }>,10  triggerSync: vi.fn(),11}));1213vi.mock("../shared/pairing-dial", () => ({14  openRelayPairingSocket: mocks.openSocket,15}));1617vi.mock("../shared/noise-js", () => ({18  noiseNKResponder: vi.fn(async () => ({19    handshakeHash: new Uint8Array(32).fill(7),20    encrypt: (bytes: Uint8Array) => bytes,21    decrypt: (bytes: Uint8Array) => bytes,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});3233vi.mock("./code-pairing-bg", () => ({34  productionCodePairingDeps: () => ({35    getLocalIdentity: mocks.getLocalIdentity,36  }),37}));3839vi.mock("./relay-settings", () => ({40  getRelaySettings: vi.fn(async () => ({41    relayUrl: "wss://relay.peachpasswords.com",42    lanOnly: false,43  })),44}));4546vi.mock("./license", () => ({47  isProUser: vi.fn(async () => false),48  getLicenseKey: vi.fn(async () => undefined),49}));5051vi.mock("./vault", () => ({52  isUnlocked: vi.fn(() => true),53  verifyMainPassword: mocks.verifyPassword,54  getRootKeyHex: mocks.getRootKeyHex,55}));5657vi.mock("../shared/sync-identity", () => ({58  computeVaultId: vi.fn(async () => "b".repeat(64)),59}));6061vi.mock("./paired-devices", () => ({62  upsertPairedDevice: mocks.upsert,63}));6465vi.mock("./sync-trigger", () => ({66  triggerSyncIfReachable: mocks.triggerSync,67}));6869import {70  cancelRelayAdoptionSource,71  confirmRelayAdoptionSource,72  startRelayAdoptionSource,73} from "./relay-adoption-source-bg";7475const encode = (value: unknown) =>76  new TextEncoder().encode(JSON.stringify(value));7778function socketWithFrames(frames: unknown[]) {79  const queue = frames.map(encode);80  return {81    send: vi.fn(),82    receive: vi.fn(async () => {83      const next = queue.shift();84      if (!next) return new Promise<Uint8Array>(() => undefined);85      return next;86    }),87    close: vi.fn(),88    closedBeforeFrame: vi.fn(() => false),89  };90}9192async function waitForPush(type: string): Promise<void> {93  await vi.waitFor(() => {94    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);95  });96}9798beforeEach(() => {99  mocks.pushes.length = 0;100  mocks.openSocket.mockReset();101  mocks.getLocalIdentity.mockReset();102  mocks.verifyPassword.mockReset();103  mocks.getRootKeyHex.mockReset();104  mocks.upsert.mockReset();105  mocks.triggerSync.mockReset();106  mocks.triggerSync.mockResolvedValue(undefined);107  mocks.getLocalIdentity.mockResolvedValue({108    identity: {109      deviceId: "browser-source",110      deviceName: "June's Browser",111      deviceType: "extension",112      longTermPublicKeyHex: "a".repeat(64),113      vaultId: "b".repeat(64),114    },115    privateKey: new Uint8Array(32).fill(1),116    publicKey: new Uint8Array(32).fill(2),117  });118  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));119  mocks.upsert.mockResolvedValue(undefined);120  Object.defineProperty(globalThis, "chrome", {121    configurable: true,122    value: {123    runtime: {124      sendMessage(message: { type: string; payload: unknown }) {125        mocks.pushes.push(message);126        return Promise.resolve();127      },128    },129    },130  });131});132133describe("relay adoption source background boundary", () => {134  it("does not access the root for a wrong password and only grants after both confirmations", async () => {135    const socket = socketWithFrames([136      {137        type: "PEACH_HELLO",138        token: "AAECAwQFBgcICQoLDA0ODw",139      },140      {141        type: "ADOPT_REQUEST",142        device_id: "request-only-label",143        device_name: "June's phone",144        device_type: "android",145      },146      { type: "ADOPT_CONFIRM", confirmed: true },147      {148        type: "ADOPT_IDENTITY",149        device_id: "android-receiver",150        device_name: "June's phone",151        device_type: "android",152        long_term_public_key_hex: "d".repeat(64),153      },154    ]);155    mocks.openSocket.mockResolvedValue(socket);156    mocks.verifyPassword157      .mockResolvedValueOnce(false)158      .mockResolvedValueOnce(true);159160    const started = await startRelayAdoptionSource();161    expect(started.invitation).toMatch(/^peach-pair-v1:/);162    await waitForPush("ADOPTION_SOURCE_SAS");163    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();164165    await expect(confirmRelayAdoptionSource("wrong")).resolves.toEqual({166      valid: false,167    });168    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();169170    await expect(confirmRelayAdoptionSource("correct")).resolves.toEqual({171      valid: true,172    });173    await waitForPush("ADOPTION_SOURCE_RESULT");174175    expect(mocks.getRootKeyHex).toHaveBeenCalled();176    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({177      deviceId: "android-receiver",178      relayUrl: "wss://relay.peachpasswords.com",179    }));180    expect(mocks.pushes).toContainEqual(expect.objectContaining({181      type: "ADOPTION_SOURCE_RESULT",182      payload: expect.objectContaining({ status: "complete" }),183    }));184    expect(mocks.triggerSync).toHaveBeenCalledWith({185      reason: "manual",186      force: true,187    });188  });189190  it("fails closed on cancellation without reading or sending the root", async () => {191    const socket = socketWithFrames([192      {193        type: "PEACH_HELLO",194        token: "AAECAwQFBgcICQoLDA0ODw",195      },196      {197        type: "ADOPT_REQUEST",198        device_id: "request-only-label",199        device_name: "New phone",200        device_type: "android",201      },202    ]);203    mocks.openSocket.mockResolvedValue(socket);204205    await startRelayAdoptionSource();206    await waitForPush("ADOPTION_SOURCE_SAS");207    cancelRelayAdoptionSource();208    await waitForPush("ADOPTION_SOURCE_RESULT");209210    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();211    expect(mocks.upsert).not.toHaveBeenCalled();212    expect(mocks.pushes).toContainEqual({213      type: "ADOPTION_SOURCE_RESULT",214      payload: { status: "error", error: "adoption_rejected" },215    });216  });217});