relay-adoption-source-bg.test.ts

21.6 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23const mocks = vi.hoisted(() => ({4  openSocket: vi.fn(),5  getLocalIdentity: vi.fn(),6  getDeviceName: vi.fn(),7  getRootKeyHex: vi.fn(),8  upsert: vi.fn(),9  pushes: [] as Array<{ type: string; payload: unknown }>,10  triggerSync: vi.fn(),11  createVault: vi.fn(),12  activateLicense: vi.fn(),13  localIdentity: vi.fn(),14  completePairing: vi.fn(),15  sasDecisions: [] as Array<{ id: string; ok: boolean }>,16}));1718vi.mock("../shared/pairing-dial", () => ({19  openRelayPairingSocket: mocks.openSocket,20}));2122vi.mock("../shared/noise-js", () => ({23  noiseNKResponder: vi.fn(async () => ({24    handshakeHash: new Uint8Array(32).fill(7),25    encrypt: (bytes: Uint8Array) => bytes,26    decrypt: (bytes: Uint8Array) => bytes,27    close: vi.fn(),28  })),29}));3031vi.mock("../shared/rendezvous", async (importOriginal) => {32  const actual = await importOriginal<typeof import("../shared/rendezvous")>();33  return {34    ...actual,35    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",36  };37});383940vi.mock("./device-identity", () => ({41  getLocalPairingIdentity: async () => ({42    identity: {43      deviceId: "test-device",44      deviceName: "Test",45      deviceType: "extension",46      longTermPublicKeyHex: "aa".repeat(32),47      vaultId: "bb".repeat(32),48    },49    privateKey: new Uint8Array(32).fill(7),50    publicKey: new Uint8Array(32).fill(8),51  }),52  getDeviceName: mocks.getDeviceName,53}));5455vi.mock("./sync-settings", async (importOriginal) => ({56  ...(await importOriginal<typeof import("./sync-settings")>()),57  getSyncSettings: async () => ({58    mode: "public" as const,59    relayUrl: "wss://relay.peachpasswords.com",60  }),61}));6263vi.mock("./license", () => ({64  isProUser: vi.fn(async () => false),65  getLicenseKey: vi.fn(async () => undefined),66}));6768vi.mock("./vault", () => ({69  isUnlocked: vi.fn(() => true),70  getRootKeyHex: mocks.getRootKeyHex,71}));7273vi.mock("../shared/sync-identity", () => ({74  computeVaultId: vi.fn(async () => "b".repeat(64)),75}));7677vi.mock("./paired-devices", () => ({78  upsertPairedDevice: mocks.upsert,79}));8081vi.mock("./sync-trigger", () => ({82  triggerSyncIfReachable: mocks.triggerSync,83}));8485import {86  cancelRelayAdoptionSource,87  startRelayAdoptionSource,88} from "./relay-adoption-source-bg";8990const encode = (value: unknown) =>91  new TextEncoder().encode(JSON.stringify(value));9293function socketWithFrames(frames: unknown[]) {94  const queue = frames.map(encode);95  return {96    send: vi.fn(),97    receive: vi.fn(async () => {98      const next = queue.shift();99      if (!next) return new Promise<Uint8Array>(() => undefined);100      return next;101    }),102    close: vi.fn(),103    closedBeforeFrame: vi.fn(() => false),104  };105}106107async function waitForPush(type: string): Promise<void> {108  await vi.waitFor(() => {109    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);110  });111}112113beforeEach(() => {114  mocks.pushes.length = 0;115  mocks.sasDecisions.length = 0;116  mocks.openSocket.mockReset();117  mocks.getLocalIdentity.mockReset();118  mocks.getRootKeyHex.mockReset();119  mocks.upsert.mockReset();120  mocks.triggerSync.mockReset();121  mocks.triggerSync.mockResolvedValue(undefined);122  mocks.getLocalIdentity.mockResolvedValue({123    identity: {124      deviceId: "browser-source",125      deviceName: "June's Browser",126      deviceType: "extension",127      longTermPublicKeyHex: "a".repeat(64),128      vaultId: "b".repeat(64),129    },130    privateKey: new Uint8Array(32).fill(1),131    publicKey: new Uint8Array(32).fill(2),132  });133  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));134  mocks.upsert.mockResolvedValue(undefined);135  Object.defineProperty(globalThis, "chrome", {136    configurable: true,137    value: {138    runtime: {139      async sendMessage(message: { type: string; payload: unknown }) {140        mocks.pushes.push(message);141        // The source flow waits for the popup to send SOURCE_SAS_DECISION before142        // forwarding the root key. The receiver test overrides this mock and143        // resolves the decision itself; the source tests rely on this default144        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.145        if (message.type === "ADOPTION_SOURCE_SAS") {146          const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");147          const p = message.payload as { id?: string };148          if (typeof p?.id === "string") {149            mocks.sasDecisions.push({ id: p.id, ok: true });150            resolveSourceSasConfirmation(p.id, true);151          }152        }153        return Promise.resolve();154      },155    },156    },157  });158});159160describe("relay adoption source background boundary", () => {161  it("shows the SAS and waits for the popup's single confirmation", async () => {162    const socket = socketWithFrames([163      {164        type: "PEACH_HELLO",165        token: "AAECAwQFBgcICQoLDA0ODw",166      },167      {168        type: "ADOPT_REQUEST",169        device_id: "request-only-label",170        device_name: "June's phone",171        device_type: "android",172      },173      { type: "ADOPT_CONFIRM", confirmed: true },174      {175        type: "ADOPT_IDENTITY",176        device_id: "android-receiver",177        device_name: "June's phone",178        device_type: "android",179        long_term_public_key_hex: "d".repeat(64),180      },181      { type: "ADOPT_APPLIED" },182    ]);183    mocks.openSocket.mockResolvedValue(socket);184185    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");186    const sasIds: string[] = [];187    Object.defineProperty(globalThis, "chrome", {188      configurable: true,189      value: {190        runtime: {191          async sendMessage(message: { type: string; payload: unknown }) {192            mocks.pushes.push(message);193            if (message.type === "ADOPTION_SOURCE_SAS") {194              const p = message.payload as { id?: string };195              if (typeof p?.id === "string") {196                // Hold the SAS gate open until the test explicitly approves197                // it — this proves the source flow waits for the popup.198                sasIds.push(p.id);199              }200              return Promise.resolve();201            }202            return Promise.resolve();203          },204        },205      },206    });207    void resolveSourceSasConfirmation; // imported for the post-approval call below208209    const started = await startRelayAdoptionSource();210    expect(started.invitation).toMatch(/^peach-pair-v1:/);211    await waitForPush("ADOPTION_SOURCE_SAS");212    // The adoption must NOT be complete yet — the popup's SAS decision is the213    // prerequisite for forwarding the root key.214    await vi.waitFor(() => {215      expect(mocks.sasDecisions.length).toBe(0);216    });217    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();218    expect(mocks.upsert).not.toHaveBeenCalled();219220    // Approve the SAS; the adoption should now proceed and complete.221    const id = sasIds[0];222    expect(typeof id).toBe("string");223    resolveSourceSasConfirmation(id, true);224    mocks.sasDecisions.push({ id, ok: true });225226    await waitForPush("ADOPTION_SOURCE_RESULT");227228    expect(mocks.getRootKeyHex).toHaveBeenCalled();229    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({230      deviceId: "android-receiver",231      relayUrl: "wss://relay.peachpasswords.com",232    }));233    expect(mocks.pushes).toContainEqual(expect.objectContaining({234      type: "ADOPTION_SOURCE_RESULT",235      payload: expect.objectContaining({ status: "complete" }),236    }));237    expect(mocks.triggerSync).toHaveBeenCalledWith({238      reason: "manual",239      force: true,240    });241    const sentTypes = socket.send.mock.calls242      .map(([bytes]) => {243        try {244          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;245        } catch {246          return undefined;247        }248      });249    expect(sentTypes).toContain("ADOPT_COMPLETE");250  });251252  it("fails closed when the popup rejects the SAS", async () => {253    const socket = socketWithFrames([254      {255        type: "PEACH_HELLO",256        token: "AAECAwQFBgcICQoLDA0ODw",257      },258      {259        type: "ADOPT_REQUEST",260        device_id: "request-only-label",261        device_name: "New phone",262        device_type: "android",263      },264      { type: "ADOPT_CONFIRM", confirmed: false },265    ]);266    mocks.openSocket.mockResolvedValue(socket);267268    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");269    let pendingId: string | undefined;270    Object.defineProperty(globalThis, "chrome", {271      configurable: true,272      value: {273        runtime: {274          sendMessage(message: { type: string; payload: unknown }) {275            mocks.pushes.push(message);276            if (message.type === "ADOPTION_SOURCE_SAS") {277              const p = message.payload as { id?: string };278              if (typeof p?.id === "string") pendingId = p.id;279            }280            return Promise.resolve();281          },282        },283      },284    });285286    await startRelayAdoptionSource();287    await waitForPush("ADOPTION_SOURCE_SAS");288    expect(typeof pendingId).toBe("string");289    resolveSourceSasConfirmation(pendingId!, false);290    mocks.sasDecisions.push({ id: pendingId!, ok: false });291292    await waitForPush("ADOPTION_SOURCE_RESULT");293294    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();295    expect(mocks.upsert).not.toHaveBeenCalled();296    expect(mocks.pushes).toContainEqual({297      type: "ADOPTION_SOURCE_RESULT",298      payload: { status: "error", error: "adoption_rejected" },299    });300  });301302  it("fails closed when the user never answers the SAS", async () => {303    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they304    // schedule real work), so we hold the SAS gate open by never resolving305    // the deferred and then cancel the session. clearState must drop the306    // pending SAS so it doesn't pin the relay room open for the next run.307    const socket = socketWithFrames([]);308    mocks.openSocket.mockResolvedValue(socket);309310    Object.defineProperty(globalThis, "chrome", {311      configurable: true,312      value: {313        runtime: {314          sendMessage(message: { type: string; payload: unknown }) {315            mocks.pushes.push(message);316            return Promise.resolve();317          },318        },319      },320    });321322    await startRelayAdoptionSource();323    cancelRelayAdoptionSource();324325    expect(socket.close).toHaveBeenCalled();326    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();327    expect(mocks.upsert).not.toHaveBeenCalled();328  });329330  it("cancelling the source screen closes the room without reading the root", async () => {331    const socket = socketWithFrames([]);332    mocks.openSocket.mockResolvedValue(socket);333334    await startRelayAdoptionSource();335    cancelRelayAdoptionSource();336337    expect(socket.close).toHaveBeenCalled();338    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();339    expect(mocks.upsert).not.toHaveBeenCalled();340  });341});342343describe("relay adoption receiver background", () => {344  beforeEach(async () => {345    // The receiver session runs in the background via `void runReceiverSession`;346    // it doesn't get awaited by the caller and can leave `inFlight` set if the347    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test348    // starts from a clean state.349    const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");350    cancelRelayAdoptionSource();351    mocks.pushes.length = 0;352    mocks.openSocket.mockReset();353    mocks.getDeviceName.mockReset();354    mocks.getDeviceName.mockResolvedValue("Receiver Browser");355    mocks.createVault.mockReset();356    mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId: "ee".repeat(32) } });357    mocks.activateLicense.mockReset();358    mocks.activateLicense.mockResolvedValue({ ok: true });359    mocks.localIdentity.mockReset();360    mocks.localIdentity.mockResolvedValue({361      ok: true,362      data: {363        deviceId: "receiver-id",364        deviceName: "Receiver Browser",365        deviceType: "extension",366        longTermPublicKeyHex: "11".repeat(32),367        signingPublicKeyHex: "22".repeat(32),368      },369    });370    mocks.completePairing.mockReset();371    mocks.completePairing.mockResolvedValue({ ok: true });372    mocks.upsert.mockReset();373    mocks.upsert.mockResolvedValue(undefined);374    mocks.triggerSync.mockReset();375    mocks.triggerSync.mockResolvedValue(undefined);376    mocks.openSocket.mockResolvedValue(socketWithFrames([]));377    Object.defineProperty(globalThis, "chrome", {378      configurable: true,379      value: {380        runtime: {381          sendMessage(message: { type: string; payload?: unknown }) {382            // The receiver auto-accepts the SAS — no popup decision to mock.383            if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {384              return Promise.resolve(mocks.createVault(message.payload));385            }386            if (message.type === "ACTIVATE_LICENSE") {387              return Promise.resolve(mocks.activateLicense(message.payload));388            }389            if (message.type === "GET_LOCAL_IDENTITY") {390              return Promise.resolve(mocks.localIdentity());391            }392            if (message.type === "COMPLETE_PAIRING") {393              return Promise.resolve(mocks.completePairing(message.payload));394            }395            if (message.type === "TRIGGER_SYNC") {396              return Promise.resolve({ ok: true });397            }398            mocks.pushes.push({ type: message.type, payload: message.payload });399            return Promise.resolve({ ok: true });400          },401          onMessage: {402            addListener() { /* no-op */ },403            removeListener() { /* no-op */ },404          },405        },406      },407    });408  });409410  it("opens a relay socket and returns an empty invitation", async () => {411    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");412    const started = await startRelayAdoptionReceiver("receiver-password");413    expect(started.invitation).toMatch(/^peach-pair-v1:/);414    // The QR carries vault:"empty" so the phone knows to act as source.415    const decoded = decodeInvitation(started.invitation);416    expect(decoded?.vault).toBe("empty");417    expect(mocks.openSocket).toHaveBeenCalled();418  });419420  it("rejects an empty password", async () => {421    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");422    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");423  });424425  it("the receiver wires requestAdoption, not grantAdoption", async () => {426    // The receiver's protocol position is the opposite of the source side:427    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.428    // Assert this by watching what lands on socket.send: the receiver's429    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY430    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.431    const rootKeyHex = "f0".repeat(32);432    const vaultId = "b".repeat(64);433    const socket = socketWithFrames([434      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },435      {436        type: "ADOPT_GRANT",437        root_key_hex: rootKeyHex,438        vault_id: vaultId,439        source: {440          device_id: "phone-source-id",441          device_name: "June's phone",442          device_type: "android",443          long_term_public_key_hex: "99".repeat(32),444        },445      },446      { type: "ADOPT_COMPLETE" },447    ]);448    mocks.openSocket.mockResolvedValue(socket);449450    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");451    Object.defineProperty(globalThis, "chrome", {452      configurable: true,453      value: {454        runtime: {455          sendMessage(message: { type: string; payload?: unknown }) {456            if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {457              return Promise.resolve({ ok: true, data: { vaultId } });458            }459            if (message.type === "GET_LOCAL_IDENTITY") {460              return Promise.resolve({461                ok: true,462                data: {463                  deviceId: "receiver-id",464                  deviceName: "Receiver Browser",465                  deviceType: "extension",466                  longTermPublicKeyHex: "11".repeat(32),467                },468              });469            }470            if (message.type === "COMPLETE_PAIRING") {471              return Promise.resolve({ ok: true });472            }473            if (message.type === "TRIGGER_SYNC") {474              return Promise.resolve({ ok: true });475            }476            mocks.pushes.push({ type: message.type, payload: message.payload });477            return Promise.resolve({ ok: true });478          },479          onMessage: { addListener: () => undefined, removeListener: () => undefined },480        },481      },482    });483    await startRelayAdoptionReceiver("receiver-password");484    await waitForPush("ADOPTION_SOURCE_RESULT");485486    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })487      .mock.calls488      .map(([bytes]) => {489        try {490          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;491        } catch {492          return undefined;493        }494      });495    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;496    // ADOPT_GRANT is the source side's frame and must never appear here.497    expect(sentTypes).toContain("ADOPT_REQUEST");498    expect(sentTypes).not.toContain("ADOPT_GRANT");499  });500501  it("completes the handshake end-to-end: vault created → paired", async () => {502    // Wire the receiver's noise mock and SAS gate together so the full503    // requestAdoption protocol runs against an in-memory phone that sends504    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.505    // The mocked noise is pass-through (encrypt = decrypt = identity), so506    // frames land as ordinary JSON.507    const rootKeyHex = "f0".repeat(32);508    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);509    // the grant's vault_id is set to the same so the protocol proceeds.510    const vaultId = "b".repeat(64);511    const frames = [512      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },513      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two514      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.515      {516        type: "ADOPT_GRANT",517        root_key_hex: rootKeyHex,518        vault_id: vaultId,519        license: "PPK-test",520        source: {521          device_id: "phone-source-id",522          device_name: "June's phone",523          device_type: "android",524          long_term_public_key_hex: "99".repeat(32),525          signing_public_key_hex: "aa".repeat(32),526          relay_url: "wss://relay.peachpasswords.com",527        },528      },529      { type: "ADOPT_COMPLETE" },530    ];531    mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId } });532    mocks.openSocket.mockResolvedValue(socketWithFrames(frames));533534    // Replace the chrome.runtime mock for this one test. The receiver535    // auto-accepts the SAS now, so no decision needs to be simulated.536    Object.defineProperty(globalThis, "chrome", {537      configurable: true,538      value: {539        runtime: {540          sendMessage(message: { type: string; payload?: unknown }) {541            if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {542              return Promise.resolve(mocks.createVault(message.payload));543            }544            if (message.type === "ACTIVATE_LICENSE") {545              return Promise.resolve(mocks.activateLicense(message.payload));546            }547            if (message.type === "GET_LOCAL_IDENTITY") {548              return Promise.resolve(mocks.localIdentity());549            }550            if (message.type === "COMPLETE_PAIRING") {551              return Promise.resolve(mocks.completePairing(message.payload));552            }553            if (message.type === "TRIGGER_SYNC") {554              return Promise.resolve({ ok: true });555            }556            mocks.pushes.push({ type: message.type, payload: message.payload });557            return Promise.resolve({ ok: true });558          },559          onMessage: {560            addListener: () => undefined,561            removeListener: () => undefined,562          },563        },564      },565    });566567    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");568    await startRelayAdoptionReceiver("receiver-password");569570    await waitForPush("ADOPTION_SOURCE_RESULT");571572    // The receiver auto-accepted the SAS, so no ADOPTION_SOURCE_SAS push is573    // expected — only the terminal result matters.574575    // CREATE_VAULT_FROM_ROOT_KEY was issued with the password the popup576    // supplied and the root key the phone granted.577    expect(mocks.createVault).toHaveBeenCalledWith(expect.objectContaining({578      password: "receiver-password",579      rootKeyHex,580    }));581582    // License activation, identity derivation, and pairing completion all583    // ran through the standard background handlers.584    expect(mocks.activateLicense).toHaveBeenCalledWith({ key: "PPK-test" });585    expect(mocks.localIdentity).toHaveBeenCalled();586    expect(mocks.completePairing).toHaveBeenCalled();587    const pairedCall = mocks.completePairing.mock.calls[0]?.[0] as588      { paired?: { deviceId?: string; deviceName?: string } };589    expect(pairedCall.paired?.deviceId).toBe("phone-source-id");590591    // The terminal state is "complete" — the popup can transition to success.592    expect(mocks.pushes).toContainEqual(expect.objectContaining({593      type: "ADOPTION_SOURCE_RESULT",594      payload: expect.objectContaining({ status: "complete" }),595    }));596  });597});598599function decodeInvitation(encoded: string): { vault?: string } | null {600  // Mirrors parsePairingInvitation's payload structure without pulling the601  // module in: the receiver test only needs to assert the embedded status.602  const at = encoded.indexOf(":");603  if (at < 0) return null;604  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");605  try {606    const json = Buffer.from(b64, "base64").toString("utf8");607    return JSON.parse(json) as { vault?: string };608  } catch {609    return null;610  }611}