relay-adoption-source-bg.test.ts

6.4 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    close: vi.fn(),23  })),24}));2526vi.mock("../shared/rendezvous", async (importOriginal) => {27  const actual = await importOriginal<typeof import("../shared/rendezvous")>();28  return {29    ...actual,30    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",31  };32});333435vi.mock("./device-identity", () => ({36  getLocalPairingIdentity: async () => ({37    identity: {38      deviceId: "test-device",39      deviceName: "Test",40      deviceType: "extension",41      longTermPublicKeyHex: "aa".repeat(32),42      vaultId: "bb".repeat(32),43    },44    privateKey: new Uint8Array(32).fill(7),45    publicKey: new Uint8Array(32).fill(8),46  }),47}));4849vi.mock("./sync-settings", async (importOriginal) => ({50  ...(await importOriginal<typeof import("./sync-settings")>()),51  getSyncSettings: async () => ({52    mode: "public" as const,53    relayUrl: "wss://relay.peachpasswords.com",54  }),55}));5657vi.mock("./license", () => ({58  isProUser: vi.fn(async () => false),59  getLicenseKey: vi.fn(async () => undefined),60}));6162vi.mock("./vault", () => ({63  isUnlocked: vi.fn(() => true),64  verifyMainPassword: mocks.verifyPassword,65  getRootKeyHex: mocks.getRootKeyHex,66}));6768vi.mock("../shared/sync-identity", () => ({69  computeVaultId: vi.fn(async () => "b".repeat(64)),70}));7172vi.mock("./paired-devices", () => ({73  upsertPairedDevice: mocks.upsert,74}));7576vi.mock("./sync-trigger", () => ({77  triggerSyncIfReachable: mocks.triggerSync,78}));7980import {81  cancelRelayAdoptionSource,82  confirmRelayAdoptionSource,83  startRelayAdoptionSource,84} from "./relay-adoption-source-bg";8586const encode = (value: unknown) =>87  new TextEncoder().encode(JSON.stringify(value));8889function socketWithFrames(frames: unknown[]) {90  const queue = frames.map(encode);91  return {92    send: vi.fn(),93    receive: vi.fn(async () => {94      const next = queue.shift();95      if (!next) return new Promise<Uint8Array>(() => undefined);96      return next;97    }),98    close: vi.fn(),99    closedBeforeFrame: vi.fn(() => false),100  };101}102103async function waitForPush(type: string): Promise<void> {104  await vi.waitFor(() => {105    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);106  });107}108109beforeEach(() => {110  mocks.pushes.length = 0;111  mocks.openSocket.mockReset();112  mocks.getLocalIdentity.mockReset();113  mocks.verifyPassword.mockReset();114  mocks.getRootKeyHex.mockReset();115  mocks.upsert.mockReset();116  mocks.triggerSync.mockReset();117  mocks.triggerSync.mockResolvedValue(undefined);118  mocks.getLocalIdentity.mockResolvedValue({119    identity: {120      deviceId: "browser-source",121      deviceName: "June's Browser",122      deviceType: "extension",123      longTermPublicKeyHex: "a".repeat(64),124      vaultId: "b".repeat(64),125    },126    privateKey: new Uint8Array(32).fill(1),127    publicKey: new Uint8Array(32).fill(2),128  });129  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));130  mocks.upsert.mockResolvedValue(undefined);131  Object.defineProperty(globalThis, "chrome", {132    configurable: true,133    value: {134    runtime: {135      sendMessage(message: { type: string; payload: unknown }) {136        mocks.pushes.push(message);137        return Promise.resolve();138      },139    },140    },141  });142});143144describe("relay adoption source background boundary", () => {145  it("does not access the root for a wrong password and only grants after both confirmations", async () => {146    const socket = socketWithFrames([147      {148        type: "PEACH_HELLO",149        token: "AAECAwQFBgcICQoLDA0ODw",150      },151      {152        type: "ADOPT_REQUEST",153        device_id: "request-only-label",154        device_name: "June's phone",155        device_type: "android",156      },157      { type: "ADOPT_CONFIRM", confirmed: true },158      {159        type: "ADOPT_IDENTITY",160        device_id: "android-receiver",161        device_name: "June's phone",162        device_type: "android",163        long_term_public_key_hex: "d".repeat(64),164      },165    ]);166    mocks.openSocket.mockResolvedValue(socket);167    mocks.verifyPassword168      .mockResolvedValueOnce(false)169      .mockResolvedValueOnce(true);170171    const started = await startRelayAdoptionSource();172    expect(started.invitation).toMatch(/^peach-pair-v1:/);173    await waitForPush("ADOPTION_SOURCE_SAS");174    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();175176    await expect(confirmRelayAdoptionSource("wrong")).resolves.toEqual({177      valid: false,178    });179    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();180181    await expect(confirmRelayAdoptionSource("correct")).resolves.toEqual({182      valid: true,183    });184    await waitForPush("ADOPTION_SOURCE_RESULT");185186    expect(mocks.getRootKeyHex).toHaveBeenCalled();187    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({188      deviceId: "android-receiver",189      relayUrl: "wss://relay.peachpasswords.com",190    }));191    expect(mocks.pushes).toContainEqual(expect.objectContaining({192      type: "ADOPTION_SOURCE_RESULT",193      payload: expect.objectContaining({ status: "complete" }),194    }));195    expect(mocks.triggerSync).toHaveBeenCalledWith({196      reason: "manual",197      force: true,198    });199  });200201  it("fails closed on cancellation without reading or sending the root", async () => {202    const socket = socketWithFrames([203      {204        type: "PEACH_HELLO",205        token: "AAECAwQFBgcICQoLDA0ODw",206      },207      {208        type: "ADOPT_REQUEST",209        device_id: "request-only-label",210        device_name: "New phone",211        device_type: "android",212      },213    ]);214    mocks.openSocket.mockResolvedValue(socket);215216    await startRelayAdoptionSource();217    await waitForPush("ADOPTION_SOURCE_SAS");218    cancelRelayAdoptionSource();219    await waitForPush("ADOPTION_SOURCE_RESULT");220221    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();222    expect(mocks.upsert).not.toHaveBeenCalled();223    expect(mocks.pushes).toContainEqual({224      type: "ADOPTION_SOURCE_RESULT",225      payload: { status: "error", error: "adoption_rejected" },226    });227  });228});