relay-adoption-source-bg.test.ts

6.3 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", () => ({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  verifyMainPassword: mocks.verifyPassword,64  getRootKeyHex: mocks.getRootKeyHex,65}));6667vi.mock("../shared/sync-identity", () => ({68  computeVaultId: vi.fn(async () => "b".repeat(64)),69}));7071vi.mock("./paired-devices", () => ({72  upsertPairedDevice: mocks.upsert,73}));7475vi.mock("./sync-trigger", () => ({76  triggerSyncIfReachable: mocks.triggerSync,77}));7879import {80  cancelRelayAdoptionSource,81  confirmRelayAdoptionSource,82  startRelayAdoptionSource,83} from "./relay-adoption-source-bg";8485const encode = (value: unknown) =>86  new TextEncoder().encode(JSON.stringify(value));8788function socketWithFrames(frames: unknown[]) {89  const queue = frames.map(encode);90  return {91    send: vi.fn(),92    receive: vi.fn(async () => {93      const next = queue.shift();94      if (!next) return new Promise<Uint8Array>(() => undefined);95      return next;96    }),97    close: vi.fn(),98    closedBeforeFrame: vi.fn(() => false),99  };100}101102async function waitForPush(type: string): Promise<void> {103  await vi.waitFor(() => {104    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);105  });106}107108beforeEach(() => {109  mocks.pushes.length = 0;110  mocks.openSocket.mockReset();111  mocks.getLocalIdentity.mockReset();112  mocks.verifyPassword.mockReset();113  mocks.getRootKeyHex.mockReset();114  mocks.upsert.mockReset();115  mocks.triggerSync.mockReset();116  mocks.triggerSync.mockResolvedValue(undefined);117  mocks.getLocalIdentity.mockResolvedValue({118    identity: {119      deviceId: "browser-source",120      deviceName: "June's Browser",121      deviceType: "extension",122      longTermPublicKeyHex: "a".repeat(64),123      vaultId: "b".repeat(64),124    },125    privateKey: new Uint8Array(32).fill(1),126    publicKey: new Uint8Array(32).fill(2),127  });128  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));129  mocks.upsert.mockResolvedValue(undefined);130  Object.defineProperty(globalThis, "chrome", {131    configurable: true,132    value: {133    runtime: {134      sendMessage(message: { type: string; payload: unknown }) {135        mocks.pushes.push(message);136        return Promise.resolve();137      },138    },139    },140  });141});142143describe("relay adoption source background boundary", () => {144  it("does not access the root for a wrong password and only grants after both confirmations", async () => {145    const socket = socketWithFrames([146      {147        type: "PEACH_HELLO",148        token: "AAECAwQFBgcICQoLDA0ODw",149      },150      {151        type: "ADOPT_REQUEST",152        device_id: "request-only-label",153        device_name: "June's phone",154        device_type: "android",155      },156      { type: "ADOPT_CONFIRM", confirmed: true },157      {158        type: "ADOPT_IDENTITY",159        device_id: "android-receiver",160        device_name: "June's phone",161        device_type: "android",162        long_term_public_key_hex: "d".repeat(64),163      },164    ]);165    mocks.openSocket.mockResolvedValue(socket);166    mocks.verifyPassword167      .mockResolvedValueOnce(false)168      .mockResolvedValueOnce(true);169170    const started = await startRelayAdoptionSource();171    expect(started.invitation).toMatch(/^peach-pair-v1:/);172    await waitForPush("ADOPTION_SOURCE_SAS");173    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();174175    await expect(confirmRelayAdoptionSource("wrong")).resolves.toEqual({176      valid: false,177    });178    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();179180    await expect(confirmRelayAdoptionSource("correct")).resolves.toEqual({181      valid: true,182    });183    await waitForPush("ADOPTION_SOURCE_RESULT");184185    expect(mocks.getRootKeyHex).toHaveBeenCalled();186    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({187      deviceId: "android-receiver",188      relayUrl: "wss://relay.peachpasswords.com",189    }));190    expect(mocks.pushes).toContainEqual(expect.objectContaining({191      type: "ADOPTION_SOURCE_RESULT",192      payload: expect.objectContaining({ status: "complete" }),193    }));194    expect(mocks.triggerSync).toHaveBeenCalledWith({195      reason: "manual",196      force: true,197    });198  });199200  it("fails closed on cancellation without reading or sending the root", async () => {201    const socket = socketWithFrames([202      {203        type: "PEACH_HELLO",204        token: "AAECAwQFBgcICQoLDA0ODw",205      },206      {207        type: "ADOPT_REQUEST",208        device_id: "request-only-label",209        device_name: "New phone",210        device_type: "android",211      },212    ]);213    mocks.openSocket.mockResolvedValue(socket);214215    await startRelayAdoptionSource();216    await waitForPush("ADOPTION_SOURCE_SAS");217    cancelRelayAdoptionSource();218    await waitForPush("ADOPTION_SOURCE_RESULT");219220    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();221    expect(mocks.upsert).not.toHaveBeenCalled();222    expect(mocks.pushes).toContainEqual({223      type: "ADOPTION_SOURCE_RESULT",224      payload: { status: "error", error: "adoption_rejected" },225    });226  });227});