relay-adoption-bg.test.ts

41.8 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import { padSyncFrame } from "../shared/sync-padding";34const mocks = vi.hoisted(() => ({5  openSocket: vi.fn(),6  getLocalIdentity: vi.fn(),7  getDeviceName: vi.fn(),8  getRootKeyHex: vi.fn(),9  upsert: vi.fn(),10  clearRevocation: vi.fn(),11  hasIdentityConflict: vi.fn(),12  rotateDeviceId: vi.fn(),13  setPairSecret: vi.fn(),14  pushes: [] as Array<{ type: string; payload: unknown }>,15  triggerSync: vi.fn(),16  createVault: vi.fn(),17  activateLicense: vi.fn(),18  isProUser: vi.fn(),19  getLicenseKey: vi.fn(),20  localIdentity: vi.fn(),21  rollbackVault: vi.fn(),22  createSyncAdapter: vi.fn(),23  __applyIncomingDeviceManifest: vi.fn(),24  releaseAutoLock: vi.fn(),25  deferAutoLock: vi.fn(),26  storage: new Map<string, string>(),27  storageSet: vi.fn(),28  storageGet: vi.fn(),29  storageRemove: vi.fn(),30  sasDecisions: [] as Array<{ id: string; ok: boolean }>,31}));3233vi.mock("../shared/pairing-dial", () => ({34  openRelayPairingSocket: mocks.openSocket,35}));3637vi.mock("../shared/noise-js", () => ({38  noiseNKResponder: vi.fn(async () => ({39    handshakeHash: new Uint8Array(32).fill(7),40    encrypt: (bytes: Uint8Array) => bytes,41    decrypt: (bytes: Uint8Array) => bytes,42    close: vi.fn(),43  })),44}));4546vi.mock("../shared/rendezvous", async (importOriginal) => {47  const actual = await importOriginal<typeof import("../shared/rendezvous")>();48  return {49    ...actual,50    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",51  };52});535455vi.mock("./device-identity", () => ({56  getLocalPairingIdentity: async () => ({57    identity: {58      deviceId: "test-device",59      deviceName: "Test",60      deviceType: "extension",61      longTermPublicKeyHex: "aa".repeat(32),62      vaultId: "bb".repeat(32),63    },64    privateKey: new Uint8Array(32).fill(7),65    publicKey: new Uint8Array(32).fill(8),66  }),67  getDeviceName: mocks.getDeviceName,68  buildLocalIdentity: mocks.localIdentity,69  preparePreVaultIdentity: vi.fn(async () => undefined),70  readPreVaultIdentity: mocks.localIdentity,71  rotatePreVaultIdentity: mocks.localIdentity,72  rotateDeviceId: mocks.rotateDeviceId,73}));7475vi.mock("./sync-settings", async (importOriginal) => ({76  ...(await importOriginal<typeof import("./sync-settings")>()),77  getSyncSettings: async () => ({78    mode: "public" as const,79    relayUrl: "wss://relay.peachpasswords.com",80  }),81}));8283vi.mock("./license", () => ({84  isProUser: mocks.isProUser,85  getLicenseKey: mocks.getLicenseKey,86  activateLicense: mocks.activateLicense,87}));8889vi.mock("./vault", () => ({90  isUnlocked: vi.fn(() => true),91  getRootKeyHex: mocks.getRootKeyHex,92  createVaultFromRootKey: mocks.createVault,93  rollbackAdoptedVault: mocks.rollbackVault,94  exportDuressSlotForAdoption: vi.fn(async () => null),95  installAdoptedDuressSlot: vi.fn(async () => undefined),96}));9798vi.mock("../shared/sync-identity", async (importOriginal) => {99  // Partial mock: the manifest test needs the REAL deriveSyncSigningKeypair /100  // pubkeyToHex to build a verifiable signed manifest (2026-08-04: this test101  // was orphaned inside the rendezvous mock factory and never ran; the mock102  // below had since lost the real exports).103  const actual = await importOriginal<typeof import("../shared/sync-identity")>();104  return {105    ...actual,106    computeVaultId: vi.fn(async () => "b".repeat(64)),107  };108});109110vi.mock("../shared/storage", () => ({111  storageSet: mocks.storageSet,112  storageGet: mocks.storageGet,113  storageRemove: mocks.storageRemove,114  storageClear: vi.fn(async () => mocks.storage.clear()),115}));116const chromeStorage = {117  local: {118    get: async (key: string) => ({ [key]: await mocks.storageGet(key) }),119    set: async (items: Record<string, string>) => {120      for (const [key, value] of Object.entries(items)) await mocks.storageSet(key, value);121    },122    remove: async (key: string) => mocks.storageRemove(key),123    clear: async () => mocks.storage.clear(),124  },125};126vi.mock("./paired-devices", () => ({127  upsertPairedDevice: mocks.upsert,128  upsertPairedDeviceAndClearRevocation: mocks.upsert,129  clearDeviceRevocation: mocks.clearRevocation,130  hasAdoptionIdentityConflict: mocks.hasIdentityConflict,131}));132133vi.mock("./pair-secrets", () => ({134  setPairSecret: mocks.setPairSecret,135  isValidPairSecret: (value: unknown): value is string =>136    typeof value === "string" && /^[0-9a-f]{64}$/.test(value),137}));138139vi.mock("./sync-trigger", () => ({140  triggerSyncIfReachable: mocks.triggerSync,141}));142143vi.mock("./alarms", () => ({144  deferAutoLockForOperation: mocks.deferAutoLock,145}));146147vi.mock("./sync-vault-adapter", () => ({148  createSyncVaultAdapter: mocks.createSyncAdapter,149}));150151import {152  cancelRelayAdoptionSource,153  startRelayAdoptionSource,154} from "./relay-adoption-bg";155156const encode = (value: unknown) =>157  new TextEncoder().encode(JSON.stringify(value));158159function adoptionV2Frames(options: {160  rootKeyHex: string;161  vaultId: string;162  deviceId?: string;163  deviceName?: string;164  longTermPublicKeyHex?: string;165  signingPublicKeyHex?: string;166  license?: string;167  snapshotCapable?: boolean;168}): unknown[] {169  const license = options.license ?? "PPK-test";170  return [171    { type: "ADOPT_CONFIRM_REQUIRED" },172    {173      type: "ADOPT_PREPARE",174      device_id: options.deviceId ?? "phone-source-id",175      device_name: options.deviceName ?? "June's phone",176      device_type: "android",177      long_term_public_key_hex: options.longTermPublicKeyHex ?? "99".repeat(32),178      signing_public_key_hex: options.signingPublicKeyHex ?? "aa".repeat(32),179      vault_id: options.vaultId,180      relay_url: "wss://relay.peachpasswords.com",181      license,182      snapshot_capable: options.snapshotCapable,183    },184    {185      type: "ADOPT_GRANT",186      root_key_hex: options.rootKeyHex,187      vault_id: options.vaultId,188      license,189    },190    { type: "ADOPT_COMPLETE" },191  ];192}193194function socketWithFrames(195  frames: unknown[],196  options: { closeOnEmpty?: boolean } = {},197) {198  const queue = frames.map((frame) => {199    const encoded = encode(frame);200    // PB-26-604: snapshot frames are bucket-padded before Noise.201    return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"202      ? padSyncFrame(encoded)203      : encoded;204  });205  return {206    send: vi.fn(),207    receive: vi.fn(async () => {208      const next = queue.shift();209      if (!next) {210        // A real peer closes the channel once it runs out of frames; model211        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast212        // and falls back to the sync pipeline instead of hanging on a timeout.213        if (options.closeOnEmpty) throw new Error("socket_closed");214        return new Promise<Uint8Array>(() => undefined);215      }216      return next;217    }),218    close: vi.fn(),219    closedBeforeFrame: vi.fn(() => false),220  };221}222223async function waitForPush(type: string): Promise<void> {224  await vi.waitFor(() => {225    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);226  });227}228229beforeEach(() => {230  mocks.pushes.length = 0;231  mocks.sasDecisions.length = 0;232  mocks.openSocket.mockReset();233  mocks.getLocalIdentity.mockReset();234  mocks.getRootKeyHex.mockReset();235  mocks.upsert.mockReset();236  mocks.clearRevocation.mockReset();237  mocks.clearRevocation.mockResolvedValue(undefined);238  mocks.hasIdentityConflict.mockReset();239  mocks.hasIdentityConflict.mockResolvedValue(false);240  mocks.rotateDeviceId.mockReset();241  mocks.rotateDeviceId.mockResolvedValue(undefined);242  mocks.setPairSecret.mockReset();243  mocks.triggerSync.mockReset();244  mocks.triggerSync.mockResolvedValue(undefined);245  mocks.releaseAutoLock.mockReset();246  mocks.deferAutoLock.mockReset();247  mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);248  mocks.storage.clear();249  mocks.storageSet.mockReset();250  mocks.storageSet.mockImplementation(async (key: string, value: string) => {251    mocks.storage.set(key, value);252  });253  mocks.storageGet.mockReset();254  mocks.storageGet.mockImplementation(async (key: string) => mocks.storage.get(key) ?? null);255  mocks.storageRemove.mockReset();256  mocks.storageRemove.mockImplementation(async (key: string) => {257    mocks.storage.delete(key);258  });259  mocks.isProUser.mockReset();260  mocks.isProUser.mockResolvedValue(true);261  mocks.getLicenseKey.mockReset();262  mocks.getLicenseKey.mockResolvedValue("PPK-source");263  mocks.getLocalIdentity.mockResolvedValue({264    identity: {265      deviceId: "browser-source",266      deviceName: "June's Browser",267      deviceType: "extension",268      longTermPublicKeyHex: "a".repeat(64),269      vaultId: "b".repeat(64),270    },271    privateKey: new Uint8Array(32).fill(1),272    publicKey: new Uint8Array(32).fill(2),273  });274  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));275  mocks.upsert.mockResolvedValue(undefined);276  mocks.setPairSecret.mockResolvedValue(undefined);277  const applyIncomingDeviceManifest = vi.fn(async () => undefined);278    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;279    mocks.createSyncAdapter.mockReturnValue({280      buildEntryManifest: vi.fn(async () => ({})),281      getEncryptedEntries: vi.fn(async () => ({})),282      buildTombstoneManifest: vi.fn(async () => ({})),283      applyEntries: vi.fn(async () => undefined),284      applyTombstones: vi.fn(async () => undefined),285      buildOutgoingDeviceManifest: vi.fn(async () => null),286      applyIncomingDeviceManifest,287    });288  Object.defineProperty(globalThis, "chrome", {289    configurable: true,290    value: {291    runtime: {292      async sendMessage(message: { type: string; payload: unknown }) {293        mocks.pushes.push(message);294        // The source flow waits for the popup to send SOURCE_SAS_DECISION before295        // forwarding the root key. The receiver test overrides this mock and296        // resolves the decision itself; the source tests rely on this default297        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.298        if (message.type === "ADOPTION_SOURCE_SAS") {299          const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };300          if (typeof p?.id === "string" && typeof p.sas === "string") {301            if (p.displayOnly === false) {302              const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");303              resolveReceiverSasConfirmation(p.id, p.sas, true);304            } else {305              const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");306              mocks.sasDecisions.push({ id: p.id, ok: true });307              resolveSourceSasConfirmation(p.id, true);308            }309          }310        }311        return Promise.resolve();312      },313    },314    },315  });316});317318describe("relay adoption source background boundary", () => {319  it("shows the SAS and waits for the popup's single confirmation", async () => {320    const socket = socketWithFrames([321      {322        type: "PEACH_HELLO",323        token: "AAECAwQFBgcICQoLDA0ODw",324      },325      {326        type: "ADOPT_REQUEST",327        device_id: "request-only-label",328        device_name: "June's phone",329        device_type: "android",330      },331      { type: "ADOPT_CONFIRM", confirmed: true },332      {333        type: "ADOPT_IDENTITY",334        device_id: "android-receiver",335        device_name: "June's phone",336        device_type: "android",337        long_term_public_key_hex: "d".repeat(64),338      },339      { type: "ADOPT_APPLIED" },340    ]);341    mocks.openSocket.mockResolvedValue(socket);342343    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");344    const sasIds: string[] = [];345    Object.defineProperty(globalThis, "chrome", {346      configurable: true,347      value: {348        storage: chromeStorage,349        runtime: {350          async sendMessage(message: { type: string; payload: unknown }) {351            mocks.pushes.push(message);352            if (message.type === "ADOPTION_SOURCE_SAS") {353              const p = message.payload as { id?: string };354              if (typeof p?.id === "string") {355                // Hold the SAS gate open until the test explicitly approves356                // it — this proves the source flow waits for the popup.357                sasIds.push(p.id);358              }359              return Promise.resolve();360            }361            return Promise.resolve();362          },363        },364      },365    });366    void resolveSourceSasConfirmation; // imported for the post-approval call below367368    const started = await startRelayAdoptionSource();369    expect(started.invitation).toMatch(/^peach-pair-v2:/);370    expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);371    await waitForPush("ADOPTION_SOURCE_SAS");372    // The adoption must NOT be complete yet — the popup's SAS decision is the373    // prerequisite for forwarding the root key.374    await vi.waitFor(() => {375      expect(mocks.sasDecisions.length).toBe(0);376    });377    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();378    expect(mocks.upsert).not.toHaveBeenCalled();379380    // Approve the SAS; the adoption should now proceed and complete.381    const id = sasIds[0];382    expect(typeof id).toBe("string");383    resolveSourceSasConfirmation(id, true);384    mocks.sasDecisions.push({ id, ok: true });385386    await waitForPush("ADOPTION_SOURCE_RESULT");387388    await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());389390    expect(mocks.getRootKeyHex).toHaveBeenCalled();391    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({392      deviceId: "android-receiver",393      relayUrl: "wss://relay.peachpasswords.com",394    }));395    // PB-26-213: the registry row stays secretless; the secret bypasses it396    // into the encrypted pair-secret record.397    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;398    expect(sourceRow.rendezvousSecretHex).toBeUndefined();399    expect(mocks.setPairSecret).toHaveBeenCalledWith(400      "android-receiver",401      expect.stringMatching(/^[0-9a-f]{64}$/),402      expect.any(Uint8Array),403    );404    expect(mocks.pushes).toContainEqual({405      type: "ADOPTION_SOURCE_RESULT",406      payload: { status: "complete" },407    });408    expect(mocks.triggerSync).toHaveBeenCalledWith({409      reason: "manual",410      force: true,411    });412    const sentTypes = socket.send.mock.calls413      .map(([bytes]) => {414        try {415          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;416        } catch {417          return undefined;418        }419      });420    expect(sentTypes).toContain("ADOPT_COMPLETE");421  });422423  it("fails adoption without sending snapshot frames when duress is oversized", async () => {424    const socket = socketWithFrames([425      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },426      {427        type: "ADOPT_REQUEST",428        device_id: "request-only-label",429        device_name: "June's phone",430        device_type: "android",431        snapshot_capable: true,432      },433      { type: "ADOPT_CONFIRM", confirmed: true },434      {435        type: "ADOPT_IDENTITY",436        device_id: "android-receiver",437        device_name: "June's phone",438        device_type: "android",439        long_term_public_key_hex: "d".repeat(64),440      },441      { type: "ADOPT_APPLIED" },442    ]);443    mocks.openSocket.mockResolvedValue(socket);444    const { exportDuressSlotForAdoption } = await import("./vault");445    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(446      "x".repeat(768 * 1024),447    );448449    await startRelayAdoptionSource();450    await waitForPush("ADOPTION_SOURCE_RESULT");451452    expect(mocks.pushes).toContainEqual({453      type: "ADOPTION_SOURCE_RESULT",454      payload: {455        status: "error",456        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",457      },458    });459    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({460      type: "ADOPTION_SOURCE_RESULT",461      payload: expect.objectContaining({ status: "complete" }),462    }));463    const sentTypes = socket.send.mock.calls.map(([bytes]) =>464      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,465    );466    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");467    expect(mocks.triggerSync).not.toHaveBeenCalled();468  });469470  it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {471    // PB-26-206: an over-budget snapshot is never partially sent or silently472    // truncated — the source completes adoption and the sync pipeline carries473    // the entries instead.474    const socket = socketWithFrames([475      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },476      {477        type: "ADOPT_REQUEST",478        device_id: "request-only-label",479        device_name: "June's phone",480        device_type: "android",481        snapshot_capable: true,482      },483      { type: "ADOPT_CONFIRM", confirmed: true },484      {485        type: "ADOPT_IDENTITY",486        device_id: "android-receiver",487        device_name: "June's phone",488        device_type: "android",489        long_term_public_key_hex: "d".repeat(64),490      },491      { type: "ADOPT_APPLIED" },492      // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is493      // sent); the source must not hang on it — withTimeout resolves the wait494      // via the sync fallback path below.495      { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },496    ]);497    mocks.openSocket.mockResolvedValue(socket);498    // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget.499    const envelope = "c".repeat(690_000);500    const entries: Record<string, string> = {};501    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;502    mocks.createSyncAdapter.mockReturnValue({503      buildEntryManifest: vi.fn(async () => Object.fromEntries(504        Object.keys(entries).map((id) => [id, 1]),505      )),506      getEncryptedEntries: vi.fn(async () => entries),507      buildTombstoneManifest: vi.fn(async () => ({})),508      applyEntries: vi.fn(async () => undefined),509      applyTombstones: vi.fn(async () => undefined),510      buildOutgoingDeviceManifest: vi.fn(async () => null),511      applyIncomingDeviceManifest: vi.fn(async () => undefined),512    });513514    await startRelayAdoptionSource();515    await waitForPush("ADOPTION_SOURCE_RESULT");516517    expect(mocks.pushes).toContainEqual({518      type: "ADOPTION_SOURCE_RESULT",519      payload: expect.objectContaining({ status: "complete" }),520    });521    const sentTypes = socket.send.mock.calls.map(([bytes]) =>522      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,523    );524    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");525    // Sync fallback ran so the entries still reach the receiver.526    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });527  });528529  it("fails closed when the popup rejects the SAS", async () => {530    const socket = socketWithFrames([531      {532        type: "PEACH_HELLO",533        token: "AAECAwQFBgcICQoLDA0ODw",534      },535      {536        type: "ADOPT_REQUEST",537        device_id: "request-only-label",538        device_name: "New phone",539        device_type: "android",540      },541      { type: "ADOPT_CONFIRM", confirmed: false },542    ]);543    mocks.openSocket.mockResolvedValue(socket);544545    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");546    let pendingId: string | undefined;547    Object.defineProperty(globalThis, "chrome", {548      configurable: true,549      value: {550        runtime: {551          sendMessage(message: { type: string; payload: unknown }) {552            mocks.pushes.push(message);553            if (message.type === "ADOPTION_SOURCE_SAS") {554              const p = message.payload as { id?: string };555              if (typeof p?.id === "string") pendingId = p.id;556            }557            return Promise.resolve();558          },559        },560      },561    });562563    await startRelayAdoptionSource();564    await waitForPush("ADOPTION_SOURCE_SAS");565    expect(typeof pendingId).toBe("string");566    resolveSourceSasConfirmation(pendingId!, false);567    mocks.sasDecisions.push({ id: pendingId!, ok: false });568569    await waitForPush("ADOPTION_SOURCE_RESULT");570571    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();572    expect(mocks.upsert).not.toHaveBeenCalled();573    expect(mocks.pushes).toContainEqual({574      type: "ADOPTION_SOURCE_RESULT",575      payload: { status: "error", error: "adoption_rejected" },576    });577  });578579  it("rejects before reading the root key when neither peer has Pro", async () => {580    const socket = socketWithFrames([581      {582        type: "PEACH_HELLO",583        token: "AAECAwQFBgcICQoLDA0ODw",584      },585      {586        type: "ADOPT_REQUEST",587        device_id: "free-receiver",588        device_name: "Free phone",589        device_type: "android",590      },591      { type: "ADOPT_CONFIRM", confirmed: true },592    ]);593    mocks.isProUser.mockResolvedValue(false);594    mocks.getLicenseKey.mockResolvedValue(undefined);595    mocks.openSocket.mockResolvedValue(socket);596597    await startRelayAdoptionSource();598    await waitForPush("ADOPTION_SOURCE_RESULT");599600    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();601    expect(mocks.upsert).not.toHaveBeenCalled();602    expect(mocks.pushes).toContainEqual({603      type: "ADOPTION_SOURCE_RESULT",604      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },605    });606    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {607      try {608        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];609      } catch {610        return [];611      }612    });613    expect(sentFrames).toContainEqual({614      type: "ADOPT_REJECT",615      reason: "ADOPT_PRO_REQUIRED",616    });617  });618619  it("fails closed when the user never answers the SAS", async () => {620    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they621    // schedule real work), so we hold the SAS gate open by never resolving622    // the deferred and then cancel the session. clearState must drop the623    // pending SAS so it doesn't pin the relay room open for the next run.624    const socket = socketWithFrames([]);625    mocks.openSocket.mockResolvedValue(socket);626627    Object.defineProperty(globalThis, "chrome", {628      value: {629        storage: chromeStorage,630        runtime: {631          sendMessage(message: { type: string; payload: unknown }) {632            mocks.pushes.push(message);633            return Promise.resolve();634          },635        },636      },637    });638639    await startRelayAdoptionSource();640    cancelRelayAdoptionSource();641642    expect(socket.close).toHaveBeenCalled();643    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();644    expect(mocks.upsert).not.toHaveBeenCalled();645  });646647  it("cancelling the source screen closes the room without reading the root", async () => {648    const socket = socketWithFrames([]);649    mocks.openSocket.mockResolvedValue(socket);650651    await startRelayAdoptionSource();652    cancelRelayAdoptionSource();653654    expect(socket.close).toHaveBeenCalled();655    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();656    expect(mocks.upsert).not.toHaveBeenCalled();657  });658});659660describe("relay adoption receiver background", () => {661  beforeEach(async () => {662    // The receiver session runs in the background via `void runReceiverSession`;663    // it doesn't get awaited by the caller and can leave `inFlight` set if the664    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test665    // starts from a clean state.666    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");667    cancelRelayAdoptionSource();668    mocks.pushes.length = 0;669    mocks.openSocket.mockReset();670    mocks.getDeviceName.mockReset();671    mocks.getDeviceName.mockResolvedValue("Receiver Browser");672    mocks.createVault.mockReset();673    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });674    mocks.activateLicense.mockReset();675    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });676    mocks.isProUser.mockReset();677    mocks.isProUser.mockResolvedValue(true);678    mocks.getLicenseKey.mockReset();679    mocks.getLicenseKey.mockResolvedValue(undefined);680    mocks.localIdentity.mockReset();681    mocks.localIdentity.mockResolvedValue({682      deviceId: "receiver-id",683      deviceName: "Receiver Browser",684      deviceType: "extension",685      longTermPublicKeyHex: "11".repeat(32),686      signingPublicKeyHex: "22".repeat(32),687    });688    mocks.rollbackVault.mockReset();689    mocks.rollbackVault.mockResolvedValue(undefined);690    mocks.storageSet.mockReset();691    mocks.storageSet.mockImplementation(async (key: string, value: string) => {692      mocks.storage.set(key, value);693    });694    mocks.upsert.mockReset();695    mocks.upsert.mockResolvedValue(undefined);696    mocks.setPairSecret.mockReset();697    mocks.setPairSecret.mockResolvedValue(undefined);698    mocks.triggerSync.mockReset();699    mocks.triggerSync.mockResolvedValue(undefined);700    mocks.openSocket.mockResolvedValue(socketWithFrames([]));701    Object.defineProperty(globalThis, "chrome", {702      value: {703        storage: chromeStorage,704        runtime: {705          async sendMessage(message: { type: string; payload?: unknown }) {706            mocks.pushes.push({ type: message.type, payload: message.payload });707            if (message.type === "ADOPTION_SOURCE_SAS") {708              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };709              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {710                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");711                resolveReceiverSasConfirmation(p.id, p.sas, true);712              }713            }714            return { ok: true };715          },716          onMessage: {717            addListener() { /* no-op */ },718            removeListener() { /* no-op */ },719          },720        },721      },722    });723  });724725  it("opens a relay socket and returns an empty invitation", async () => {726    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");727    const started = await startRelayAdoptionReceiver("receiver-password");728    expect(started.invitation).toMatch(/^peach-pair-v2:/);729    // The QR carries vault:"empty" so the phone knows to act as source.730    const decoded = decodeInvitation(started.invitation);731    expect(decoded?.vault).toBe("empty");732    expect(mocks.openSocket).toHaveBeenCalled();733  });734735  it("rejects an empty password", async () => {736    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");737    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");738  });739740  it("surfaces the Pro-required rejection without creating a vault", async () => {741    mocks.openSocket.mockResolvedValue(socketWithFrames([742      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },743      { type: "ADOPT_CONFIRM_REQUIRED" },744      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },745    ]));746747    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");748    await startRelayAdoptionReceiver("receiver-password");749    await waitForPush("ADOPTION_SOURCE_RESULT");750751    expect(mocks.createVault).not.toHaveBeenCalled();752    expect(mocks.rollbackVault).not.toHaveBeenCalled();753    expect(mocks.pushes).toContainEqual({754      type: "ADOPTION_SOURCE_RESULT",755      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },756    });757  });758759  it("the receiver wires requestAdoption, not grantAdoption", async () => {760    // The receiver's protocol position is the opposite of the source side:761    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.762    // Assert this by watching what lands on socket.send: the receiver's763    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY764    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.765    const rootKeyHex = "f0".repeat(32);766    const vaultId = "b".repeat(64);767    const socket = socketWithFrames([768      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },769      ...adoptionV2Frames({ rootKeyHex, vaultId }),770    ], { closeOnEmpty: true });771    mocks.openSocket.mockResolvedValue(socket);772    mocks.createVault.mockResolvedValue({ vaultId });773774    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");775    Object.defineProperty(globalThis, "chrome", {776      value: {777        storage: chromeStorage,778        runtime: {779          async sendMessage(message: { type: string; payload?: unknown }) {780            mocks.pushes.push({ type: message.type, payload: message.payload });781            if (message.type === "ADOPTION_SOURCE_SAS") {782              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };783              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {784                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");785                resolveReceiverSasConfirmation(p.id, p.sas, true);786              }787            }788            return { ok: true };789          },790          onMessage: { addListener: () => undefined, removeListener: () => undefined },791        },792      },793    });794    await startRelayAdoptionReceiver("receiver-password");795    await waitForPush("ADOPTION_SOURCE_RESULT");796797    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })798      .mock.calls799      .map(([bytes]) => {800        try {801          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;802        } catch {803          return undefined;804        }805      });806    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;807    // ADOPT_GRANT is the source side's frame and must never appear here.808    expect(sentTypes).toContain("ADOPT_REQUEST_V2");809    expect(sentTypes).not.toContain("ADOPT_GRANT");810  });811812  it("completes the handshake end-to-end: vault created → paired", async () => {813    // Wire the receiver's noise mock and SAS gate together so the full814    // requestAdoption protocol runs against an in-memory phone that sends815    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.816    // The mocked noise is pass-through (encrypt = decrypt = identity), so817    // frames land as ordinary JSON.818    const rootKeyHex = "f0".repeat(32);819    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);820    // the grant's vault_id is set to the same so the protocol proceeds.821    const vaultId = "b".repeat(64);822    const frames = [823      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },824      ...adoptionV2Frames({ rootKeyHex, vaultId }),825    ];826    mocks.createVault.mockResolvedValue({ vaultId });827    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));828829    // Replace the chrome.runtime mock for this one test and approve the displayed SAS.830    Object.defineProperty(globalThis, "chrome", {831      value: {832        storage: chromeStorage,833        runtime: {834          async sendMessage(message: { type: string; payload?: unknown }) {835            mocks.pushes.push({ type: message.type, payload: message.payload });836            if (message.type === "ADOPTION_SOURCE_SAS") {837              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };838              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {839                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");840                resolveReceiverSasConfirmation(p.id, p.sas, true);841              }842            }843            return { ok: true };844          },845          onMessage: {846            addListener: () => undefined,847            removeListener: () => undefined,848          },849        },850      },851    });852853    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");854    await startRelayAdoptionReceiver("receiver-password");855856    await waitForPush("ADOPTION_SOURCE_RESULT");857858    // The receiver displays its locally derived SAS and requires confirmation.859    expect(mocks.pushes).toContainEqual({860      type: "ADOPTION_SOURCE_SAS",861      payload: expect.objectContaining({862        sas: expect.stringMatching(/^\d{8}$/),863        displayOnly: false,864      }),865    });866867    // The vault module was called directly with the popup-supplied password868    // and the root key granted by the phone.869    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);870871    // License activation, identity derivation, and pairing persistence all872    // call their modules directly from the service worker.873    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");874    expect(mocks.isProUser).toHaveBeenCalled();875    expect(mocks.localIdentity).toHaveBeenCalled();876    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({877      deviceId: "phone-source-id",878    }));879    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;880    expect(receiverRow.rendezvousSecretHex).toBeUndefined();881    expect(mocks.setPairSecret).toHaveBeenCalledWith(882      "phone-source-id",883      expect.stringMatching(/^[0-9a-f]{64}$/),884      expect.any(Uint8Array),885    );886    expect(mocks.storageSet).toHaveBeenCalledWith(887      "peach_initial_sync_pending_device_id",888      "phone-source-id",889    );890    expect(mocks.rollbackVault).not.toHaveBeenCalled();891892    // The terminal state is "complete" — the popup can transition to success.893    expect(mocks.pushes).toContainEqual({894      type: "ADOPTION_SOURCE_RESULT",895      payload: { status: "complete" },896    });897  });898899  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {900    const rootKeyHex = "f0".repeat(32);901    const vaultId = "b".repeat(64);902    mocks.createVault.mockResolvedValue({ vaultId });903    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });904    mocks.isProUser.mockResolvedValue(false);905    mocks.openSocket.mockResolvedValue(socketWithFrames([906      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },907      ...adoptionV2Frames({ rootKeyHex, vaultId, license: "PPK-invalid" }),908    ]));909910    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");911    await startRelayAdoptionReceiver("receiver-password");912    await waitForPush("ADOPTION_SOURCE_RESULT");913914    expect(mocks.rollbackVault).toHaveBeenCalledOnce();915    expect(mocks.localIdentity).toHaveBeenCalled();916    expect(mocks.upsert).not.toHaveBeenCalled();917    expect(mocks.pushes).toContainEqual(expect.objectContaining({918      type: "ADOPTION_SOURCE_RESULT",919      payload: expect.objectContaining({920        status: "error",921        error: "license_transfer_failed",922      }),923    }));924  });925926  it("rolls back a newly created vault when a later adoption step fails", async () => {927    const rootKeyHex = "f0".repeat(32);928    const vaultId = "b".repeat(64);929    mocks.createVault.mockResolvedValue({ vaultId });930    mocks.upsert.mockRejectedValue(new Error("pair_persist_failed"));931    mocks.openSocket.mockResolvedValue(socketWithFrames([932      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },933      ...adoptionV2Frames({ rootKeyHex, vaultId }),934    ]));935936    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");937    await startRelayAdoptionReceiver("receiver-password");938    await waitForPush("ADOPTION_SOURCE_RESULT");939940    expect(mocks.rollbackVault).toHaveBeenCalledOnce();941    expect(mocks.upsert).toHaveBeenCalledOnce();942    expect(mocks.pushes).toContainEqual(expect.objectContaining({943      type: "ADOPTION_SOURCE_RESULT",944      payload: expect.objectContaining({ status: "error", error: "pair_persist_failed" }),945    }));946  });947948  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {949    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).950    // The receiver should skip the 30-second inline snapshot wait entirely951    // and fall through to the sync pipeline — no receiveVaultSnapshot call.952    const rootKeyHex = "f0".repeat(32);953    const vaultId = "b".repeat(64);954    mocks.createVault.mockResolvedValue({ vaultId });955    mocks.openSocket.mockResolvedValue(socketWithFrames([956      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },957      ...adoptionV2Frames({958        rootKeyHex,959        vaultId,960        deviceId: "old-phone-id",961        deviceName: "Old phone",962      }),963    ], { closeOnEmpty: true }));964965    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");966    await startRelayAdoptionReceiver("receiver-password");967    await waitForPush("ADOPTION_SOURCE_RESULT");968969    // Sync fallback ran immediately (no 30s wait).970    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });971    // Adoption completed successfully.972    expect(mocks.pushes).toContainEqual(expect.objectContaining({973      type: "ADOPTION_SOURCE_RESULT",974      payload: expect.objectContaining({ status: "complete" }),975    }));976    // The initial sync pending marker was set (not cleared — no snapshot applied).977    expect(mocks.storageSet).toHaveBeenCalledWith(978      "peach_initial_sync_pending_device_id",979      "old-phone-id",980    );981  });982983  it("applies the source's device manifest after the snapshot, then acks", async () => {984    // Mesh propagation: a 3rd browser that completes an adoption with a985    // snapshot-capable source must also receive the source's signed device986    // manifest and forward it to the sync adapter before the receiver987    // resolves. The relay stays an opaque forwarder, so the whole path988    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.989    const rootKeyHex = "f0".repeat(32);990    const vaultId = "b".repeat(64);991    const longTermPubkeyHex = "99".repeat(32);992    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");993    const { deriveSyncSigningKeypair } = await import(994      "../shared/legacy-identity-derivation.test-support"995    );996    const { pubkeyToHex } = await import("../shared/sync-identity");997    const issuerKp = await deriveSyncSigningKeypair(998      new Uint8Array(32).fill(0x55),999      "phone-source-id",1000    );1001    const manifest = await buildSignedDeviceManifest(1002      "phone-source-id",1003      [1004        {1005          deviceId: "peer-b",1006          deviceName: "Pool B",1007          deviceType: "extension",1008          longTermPublicKeyHex: "11".repeat(32),1009          signingPublicKeyHex: "22".repeat(32),1010          wsPort: 49152,1011          relayUrl: "wss://relay.peachpasswords.com",1012          pairedAt: 1,1013        },1014      ],1015      issuerKp.privateKey,1016    );1017    const socket = socketWithFrames([1018      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1019      ...adoptionV2Frames({1020        rootKeyHex,1021        vaultId,1022        longTermPublicKeyHex: longTermPubkeyHex,1023        signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1024        snapshotCapable: true,1025      }),1026      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames1027      // after the handshake; one empty chunk ends the phase.1028      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1029      { type: "ADOPT_DEVICE_MANIFEST", manifest },1030    ], { closeOnEmpty: true });1031    mocks.createVault.mockResolvedValue({ vaultId });1032    mocks.openSocket.mockResolvedValue(socket);1033    mocks.__applyIncomingDeviceManifest.mockClear();10341035    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1036    await startRelayAdoptionReceiver("receiver-password");1037    await waitForPush("ADOPTION_SOURCE_RESULT");10381039    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1040    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1041    expect(forwarded).toEqual(manifest);1042    expect(pubkey).toBeInstanceOf(Uint8Array);1043    expect(pubkey.length).toBe(32);1044    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the1045    // adapter path was skipped (the ack only follows a real apply).1046    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1047      .mock.calls1048      .map(([bytes]) => {1049        try {1050          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1051        } catch {1052          return undefined;1053        }1054      });1055    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1056    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.1057    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1058  });10591060  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1061    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST1062    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,1063    // and quietly time out the optional 5s wait without acking a manifest1064    // or invoking the adapter path.1065    const rootKeyHex = "f0".repeat(32);1066    const vaultId = "b".repeat(64);1067    const longTermPubkeyHex = "99".repeat(32);1068    const socket = socketWithFrames([1069      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1070      ...adoptionV2Frames({1071        rootKeyHex,1072        vaultId,1073        longTermPublicKeyHex: longTermPubkeyHex,1074        snapshotCapable: true,1075      }),1076      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot1077      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.1078      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1079    ], { closeOnEmpty: true });1080    mocks.createVault.mockResolvedValue({ vaultId });1081    mocks.openSocket.mockResolvedValue(socket);1082    mocks.__applyIncomingDeviceManifest.mockClear();10831084    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1085    await startRelayAdoptionReceiver("receiver-password");1086    await waitForPush("ADOPTION_SOURCE_RESULT");10871088    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1089    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1090      .mock.calls1091      .map(([bytes]) => {1092        try {1093          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1094        } catch {1095          return undefined;1096        }1097      });1098    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1099    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1100  });1101});11021103function decodeInvitation(encoded: string): { vault?: string } | null {1104  // Mirrors parsePairingInvitation's payload structure without pulling the1105  // module in: the receiver test only needs to assert the embedded status.1106  const at = encoded.indexOf(":");1107  if (at < 0) return null;1108  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1109  try {1110    const json = Buffer.from(b64, "base64").toString("utf8");1111    return JSON.parse(json) as { vault?: string };1112  } catch {1113    return null;1114  }1115}