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    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});3334vi.mock("./code-pairing-bg", () => ({35  productionCodePairingDeps: () => ({36    getLocalIdentity: mocks.getLocalIdentity,37  }),38}));3940vi.mock("./relay-settings", () => ({41  getRelaySettings: vi.fn(async () => ({42    relayUrl: "wss://relay.peachpasswords.com",43    lanOnly: false,44  })),45}));4647vi.mock("./license", () => ({48  isProUser: vi.fn(async () => false),49  getLicenseKey: vi.fn(async () => undefined),50}));5152vi.mock("./vault", () => ({53  isUnlocked: vi.fn(() => true),54  verifyMainPassword: mocks.verifyPassword,55  getRootKeyHex: mocks.getRootKeyHex,56}));5758vi.mock("../shared/sync-identity", () => ({59  computeVaultId: vi.fn(async () => "b".repeat(64)),60}));6162vi.mock("./paired-devices", () => ({63  upsertPairedDevice: mocks.upsert,64}));6566vi.mock("./sync-trigger", () => ({67  triggerSyncIfReachable: mocks.triggerSync,68}));6970import {71  cancelRelayAdoptionSource,72  confirmRelayAdoptionSource,73  startRelayAdoptionSource,74} from "./relay-adoption-source-bg";7576const encode = (value: unknown) =>77  new TextEncoder().encode(JSON.stringify(value));7879function socketWithFrames(frames: unknown[]) {80  const queue = frames.map(encode);81  return {82    send: vi.fn(),83    receive: vi.fn(async () => {84      const next = queue.shift();85      if (!next) return new Promise<Uint8Array>(() => undefined);86      return next;87    }),88    close: vi.fn(),89    closedBeforeFrame: vi.fn(() => false),90  };91}9293async function waitForPush(type: string): Promise<void> {94  await vi.waitFor(() => {95    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);96  });97}9899beforeEach(() => {100  mocks.pushes.length = 0;101  mocks.openSocket.mockReset();102  mocks.getLocalIdentity.mockReset();103  mocks.verifyPassword.mockReset();104  mocks.getRootKeyHex.mockReset();105  mocks.upsert.mockReset();106  mocks.triggerSync.mockReset();107  mocks.triggerSync.mockResolvedValue(undefined);108  mocks.getLocalIdentity.mockResolvedValue({109    identity: {110      deviceId: "browser-source",111      deviceName: "June's Browser",112      deviceType: "extension",113      longTermPublicKeyHex: "a".repeat(64),114      vaultId: "b".repeat(64),115    },116    privateKey: new Uint8Array(32).fill(1),117    publicKey: new Uint8Array(32).fill(2),118  });119  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));120  mocks.upsert.mockResolvedValue(undefined);121  Object.defineProperty(globalThis, "chrome", {122    configurable: true,123    value: {124    runtime: {125      sendMessage(message: { type: string; payload: unknown }) {126        mocks.pushes.push(message);127        return Promise.resolve();128      },129    },130    },131  });132});133134describe("relay adoption source background boundary", () => {135  it("does not access the root for a wrong password and only grants after both confirmations", async () => {136    const socket = socketWithFrames([137      {138        type: "PEACH_HELLO",139        token: "AAECAwQFBgcICQoLDA0ODw",140      },141      {142        type: "ADOPT_REQUEST",143        device_id: "request-only-label",144        device_name: "June's phone",145        device_type: "android",146      },147      { type: "ADOPT_CONFIRM", confirmed: true },148      {149        type: "ADOPT_IDENTITY",150        device_id: "android-receiver",151        device_name: "June's phone",152        device_type: "android",153        long_term_public_key_hex: "d".repeat(64),154      },155    ]);156    mocks.openSocket.mockResolvedValue(socket);157    mocks.verifyPassword158      .mockResolvedValueOnce(false)159      .mockResolvedValueOnce(true);160161    const started = await startRelayAdoptionSource();162    expect(started.invitation).toMatch(/^peach-pair-v1:/);163    await waitForPush("ADOPTION_SOURCE_SAS");164    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();165166    await expect(confirmRelayAdoptionSource("wrong")).resolves.toEqual({167      valid: false,168    });169    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();170171    await expect(confirmRelayAdoptionSource("correct")).resolves.toEqual({172      valid: true,173    });174    await waitForPush("ADOPTION_SOURCE_RESULT");175176    expect(mocks.getRootKeyHex).toHaveBeenCalled();177    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({178      deviceId: "android-receiver",179      relayUrl: "wss://relay.peachpasswords.com",180    }));181    expect(mocks.pushes).toContainEqual(expect.objectContaining({182      type: "ADOPTION_SOURCE_RESULT",183      payload: expect.objectContaining({ status: "complete" }),184    }));185    expect(mocks.triggerSync).toHaveBeenCalledWith({186      reason: "manual",187      force: true,188    });189  });190191  it("fails closed on cancellation without reading or sending the root", async () => {192    const socket = socketWithFrames([193      {194        type: "PEACH_HELLO",195        token: "AAECAwQFBgcICQoLDA0ODw",196      },197      {198        type: "ADOPT_REQUEST",199        device_id: "request-only-label",200        device_name: "New phone",201        device_type: "android",202      },203    ]);204    mocks.openSocket.mockResolvedValue(socket);205206    await startRelayAdoptionSource();207    await waitForPush("ADOPTION_SOURCE_SAS");208    cancelRelayAdoptionSource();209    await waitForPush("ADOPTION_SOURCE_RESULT");210211    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();212    expect(mocks.upsert).not.toHaveBeenCalled();213    expect(mocks.pushes).toContainEqual({214      type: "ADOPTION_SOURCE_RESULT",215      payload: { status: "error", error: "adoption_rejected" },216    });217  });218});