relay-adoption-bg.test.ts

40.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  setPairSecret: vi.fn(),10  pushes: [] as Array<{ type: string; payload: unknown }>,11  triggerSync: vi.fn(),12  createVault: vi.fn(),13  activateLicense: vi.fn(),14  isProUser: vi.fn(),15  getLicenseKey: vi.fn(),16  localIdentity: vi.fn(),17  rollbackVault: vi.fn(),18  createSyncAdapter: vi.fn(),19  __applyIncomingDeviceManifest: vi.fn(),20  storageSet: vi.fn(),21  sasDecisions: [] as Array<{ id: string; ok: boolean }>,22}));2324vi.mock("../shared/pairing-dial", () => ({25  openRelayPairingSocket: mocks.openSocket,26}));2728vi.mock("../shared/noise-js", () => ({29  noiseNKResponder: vi.fn(async () => ({30    handshakeHash: new Uint8Array(32).fill(7),31    encrypt: (bytes: Uint8Array) => bytes,32    decrypt: (bytes: Uint8Array) => bytes,33    close: vi.fn(),34  })),35}));3637vi.mock("../shared/rendezvous", async (importOriginal) => {38  const actual = await importOriginal<typeof import("../shared/rendezvous")>();39  return {40    ...actual,41    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",42  };43});444546vi.mock("./device-identity", () => ({47  getLocalPairingIdentity: async () => ({48    identity: {49      deviceId: "test-device",50      deviceName: "Test",51      deviceType: "extension",52      longTermPublicKeyHex: "aa".repeat(32),53      vaultId: "bb".repeat(32),54    },55    privateKey: new Uint8Array(32).fill(7),56    publicKey: new Uint8Array(32).fill(8),57  }),58  getDeviceName: mocks.getDeviceName,59  buildLocalIdentity: mocks.localIdentity,60}));6162vi.mock("./sync-settings", async (importOriginal) => ({63  ...(await importOriginal<typeof import("./sync-settings")>()),64  getSyncSettings: async () => ({65    mode: "public" as const,66    relayUrl: "wss://relay.peachpasswords.com",67  }),68}));6970vi.mock("./license", () => ({71  isProUser: mocks.isProUser,72  getLicenseKey: mocks.getLicenseKey,73  activateLicense: mocks.activateLicense,74}));7576vi.mock("./vault", () => ({77  isUnlocked: vi.fn(() => true),78  getRootKeyHex: mocks.getRootKeyHex,79  createVaultFromRootKey: mocks.createVault,80  rollbackAdoptedVault: mocks.rollbackVault,81  exportDuressSlotForAdoption: vi.fn(async () => null),82  installAdoptedDuressSlot: vi.fn(async () => undefined),83}));8485vi.mock("../shared/sync-identity", async (importOriginal) => {86  // Partial mock: the manifest test needs the REAL deriveSyncSigningKeypair /87  // pubkeyToHex to build a verifiable signed manifest (2026-08-04: this test88  // was orphaned inside the rendezvous mock factory and never ran; the mock89  // below had since lost the real exports).90  const actual = await importOriginal<typeof import("../shared/sync-identity")>();91  return {92    ...actual,93    computeVaultId: vi.fn(async () => "b".repeat(64)),94  };95});9697vi.mock("../shared/storage", () => ({98  storageSet: mocks.storageSet,99}));100101vi.mock("./paired-devices", () => ({102  upsertPairedDevice: mocks.upsert,103}));104105vi.mock("./pair-secrets", () => ({106  setPairSecret: mocks.setPairSecret,107  isValidPairSecret: (value: unknown): value is string =>108    typeof value === "string" && /^[0-9a-f]{64}$/.test(value),109}));110111vi.mock("./sync-trigger", () => ({112  triggerSyncIfReachable: mocks.triggerSync,113}));114115vi.mock("./sync-vault-adapter", () => ({116  createSyncVaultAdapter: mocks.createSyncAdapter,117}));118119import {120  cancelRelayAdoptionSource,121  startRelayAdoptionSource,122} from "./relay-adoption-bg";123124const encode = (value: unknown) =>125  new TextEncoder().encode(JSON.stringify(value));126127function socketWithFrames(128  frames: unknown[],129  options: { closeOnEmpty?: boolean } = {},130) {131  const queue = frames.map(encode);132  return {133    send: vi.fn(),134    receive: vi.fn(async () => {135      const next = queue.shift();136      if (!next) {137        // A real peer closes the channel once it runs out of frames; model138        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast139        // and falls back to the sync pipeline instead of hanging on a timeout.140        if (options.closeOnEmpty) throw new Error("socket_closed");141        return new Promise<Uint8Array>(() => undefined);142      }143      return next;144    }),145    close: vi.fn(),146    closedBeforeFrame: vi.fn(() => false),147  };148}149150async function waitForPush(type: string): Promise<void> {151  await vi.waitFor(() => {152    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);153  });154}155156beforeEach(() => {157  mocks.pushes.length = 0;158  mocks.sasDecisions.length = 0;159  mocks.openSocket.mockReset();160  mocks.getLocalIdentity.mockReset();161  mocks.getRootKeyHex.mockReset();162  mocks.upsert.mockReset();163  mocks.setPairSecret.mockReset();164  mocks.triggerSync.mockReset();165  mocks.triggerSync.mockResolvedValue(undefined);166  mocks.isProUser.mockReset();167  mocks.isProUser.mockResolvedValue(true);168  mocks.getLicenseKey.mockReset();169  mocks.getLicenseKey.mockResolvedValue("PPK-source");170  mocks.getLocalIdentity.mockResolvedValue({171    identity: {172      deviceId: "browser-source",173      deviceName: "June's Browser",174      deviceType: "extension",175      longTermPublicKeyHex: "a".repeat(64),176      vaultId: "b".repeat(64),177    },178    privateKey: new Uint8Array(32).fill(1),179    publicKey: new Uint8Array(32).fill(2),180  });181  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));182  mocks.upsert.mockResolvedValue(undefined);183  mocks.setPairSecret.mockResolvedValue(undefined);184    const applyIncomingDeviceManifest = vi.fn(async () => undefined);185    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;186    mocks.createSyncAdapter.mockReturnValue({187      buildEntryManifest: vi.fn(async () => ({})),188      getEncryptedEntries: vi.fn(async () => ({})),189      buildTombstoneManifest: vi.fn(async () => ({})),190      applyEntries: vi.fn(async () => undefined),191      applyTombstones: vi.fn(async () => undefined),192      buildOutgoingDeviceManifest: vi.fn(async () => null),193      applyIncomingDeviceManifest,194    });195  Object.defineProperty(globalThis, "chrome", {196    configurable: true,197    value: {198    runtime: {199      async sendMessage(message: { type: string; payload: unknown }) {200        mocks.pushes.push(message);201        // The source flow waits for the popup to send SOURCE_SAS_DECISION before202        // forwarding the root key. The receiver test overrides this mock and203        // resolves the decision itself; the source tests rely on this default204        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.205        if (message.type === "ADOPTION_SOURCE_SAS") {206          const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };207          if (typeof p?.id === "string" && typeof p.sas === "string") {208            if (p.displayOnly === false) {209              const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");210              resolveReceiverSasConfirmation(p.id, p.sas, true);211            } else {212              const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");213              mocks.sasDecisions.push({ id: p.id, ok: true });214              resolveSourceSasConfirmation(p.id, true);215            }216          }217        }218        return Promise.resolve();219      },220    },221    },222  });223});224225describe("relay adoption source background boundary", () => {226  it("shows the SAS and waits for the popup's single confirmation", async () => {227    const socket = socketWithFrames([228      {229        type: "PEACH_HELLO",230        token: "AAECAwQFBgcICQoLDA0ODw",231      },232      {233        type: "ADOPT_REQUEST",234        device_id: "request-only-label",235        device_name: "June's phone",236        device_type: "android",237      },238      { type: "ADOPT_CONFIRM", confirmed: true },239      {240        type: "ADOPT_IDENTITY",241        device_id: "android-receiver",242        device_name: "June's phone",243        device_type: "android",244        long_term_public_key_hex: "d".repeat(64),245      },246      { type: "ADOPT_APPLIED" },247    ]);248    mocks.openSocket.mockResolvedValue(socket);249250    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");251    const sasIds: string[] = [];252    Object.defineProperty(globalThis, "chrome", {253      configurable: true,254      value: {255        runtime: {256          async sendMessage(message: { type: string; payload: unknown }) {257            mocks.pushes.push(message);258            if (message.type === "ADOPTION_SOURCE_SAS") {259              const p = message.payload as { id?: string };260              if (typeof p?.id === "string") {261                // Hold the SAS gate open until the test explicitly approves262                // it — this proves the source flow waits for the popup.263                sasIds.push(p.id);264              }265              return Promise.resolve();266            }267            return Promise.resolve();268          },269        },270      },271    });272    void resolveSourceSasConfirmation; // imported for the post-approval call below273274    const started = await startRelayAdoptionSource();275    expect(started.invitation).toMatch(/^peach-pair-v2:/);276    await waitForPush("ADOPTION_SOURCE_SAS");277    // The adoption must NOT be complete yet — the popup's SAS decision is the278    // prerequisite for forwarding the root key.279    await vi.waitFor(() => {280      expect(mocks.sasDecisions.length).toBe(0);281    });282    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();283    expect(mocks.upsert).not.toHaveBeenCalled();284285    // Approve the SAS; the adoption should now proceed and complete.286    const id = sasIds[0];287    expect(typeof id).toBe("string");288    resolveSourceSasConfirmation(id, true);289    mocks.sasDecisions.push({ id, ok: true });290291    await waitForPush("ADOPTION_SOURCE_RESULT");292293    expect(mocks.getRootKeyHex).toHaveBeenCalled();294    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({295      deviceId: "android-receiver",296      relayUrl: "wss://relay.peachpasswords.com",297    }));298    // PB-26-213: the registry row stays secretless; the secret bypasses it299    // into the encrypted pair-secret record.300    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;301    expect(sourceRow.rendezvousSecretHex).toBeUndefined();302    expect(mocks.setPairSecret).toHaveBeenCalledWith(303      "android-receiver",304      expect.stringMatching(/^[0-9a-f]{64}$/),305      expect.any(Uint8Array),306    );307    expect(mocks.pushes).toContainEqual({308      type: "ADOPTION_SOURCE_RESULT",309      payload: { status: "complete" },310    });311    expect(mocks.triggerSync).toHaveBeenCalledWith({312      reason: "manual",313      force: true,314    });315    const sentTypes = socket.send.mock.calls316      .map(([bytes]) => {317        try {318          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;319        } catch {320          return undefined;321        }322      });323    expect(sentTypes).toContain("ADOPT_COMPLETE");324  });325326  it("fails adoption without sending snapshot frames when duress is oversized", async () => {327    const socket = socketWithFrames([328      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },329      {330        type: "ADOPT_REQUEST",331        device_id: "request-only-label",332        device_name: "June's phone",333        device_type: "android",334        snapshot_capable: true,335      },336      { type: "ADOPT_CONFIRM", confirmed: true },337      {338        type: "ADOPT_IDENTITY",339        device_id: "android-receiver",340        device_name: "June's phone",341        device_type: "android",342        long_term_public_key_hex: "d".repeat(64),343      },344      { type: "ADOPT_APPLIED" },345    ]);346    mocks.openSocket.mockResolvedValue(socket);347    const { exportDuressSlotForAdoption } = await import("./vault");348    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(349      "x".repeat(768 * 1024),350    );351352    await startRelayAdoptionSource();353    await waitForPush("ADOPTION_SOURCE_RESULT");354355    expect(mocks.pushes).toContainEqual({356      type: "ADOPTION_SOURCE_RESULT",357      payload: {358        status: "error",359        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",360      },361    });362    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({363      type: "ADOPTION_SOURCE_RESULT",364      payload: expect.objectContaining({ status: "complete" }),365    }));366    const sentTypes = socket.send.mock.calls.map(([bytes]) =>367      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,368    );369    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");370    expect(mocks.triggerSync).not.toHaveBeenCalled();371  });372373  it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {374    // PB-26-206: an over-budget snapshot is never partially sent or silently375    // truncated — the source completes adoption and the sync pipeline carries376    // the entries instead.377    const socket = socketWithFrames([378      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },379      {380        type: "ADOPT_REQUEST",381        device_id: "request-only-label",382        device_name: "June's phone",383        device_type: "android",384        snapshot_capable: true,385      },386      { type: "ADOPT_CONFIRM", confirmed: true },387      {388        type: "ADOPT_IDENTITY",389        device_id: "android-receiver",390        device_name: "June's phone",391        device_type: "android",392        long_term_public_key_hex: "d".repeat(64),393      },394      { type: "ADOPT_APPLIED" },395      // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is396      // sent); the source must not hang on it — withTimeout resolves the wait397      // via the sync fallback path below.398      { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },399    ]);400    mocks.openSocket.mockResolvedValue(socket);401    // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget.402    const envelope = "c".repeat(690_000);403    const entries: Record<string, string> = {};404    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;405    mocks.createSyncAdapter.mockReturnValue({406      buildEntryManifest: vi.fn(async () => Object.fromEntries(407        Object.keys(entries).map((id) => [id, 1]),408      )),409      getEncryptedEntries: vi.fn(async () => entries),410      buildTombstoneManifest: vi.fn(async () => ({})),411      applyEntries: vi.fn(async () => undefined),412      applyTombstones: vi.fn(async () => undefined),413      buildOutgoingDeviceManifest: vi.fn(async () => null),414      applyIncomingDeviceManifest: vi.fn(async () => undefined),415    });416417    await startRelayAdoptionSource();418    await waitForPush("ADOPTION_SOURCE_RESULT");419420    expect(mocks.pushes).toContainEqual({421      type: "ADOPTION_SOURCE_RESULT",422      payload: expect.objectContaining({ status: "complete" }),423    });424    const sentTypes = socket.send.mock.calls.map(([bytes]) =>425      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,426    );427    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");428    // Sync fallback ran so the entries still reach the receiver.429    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });430  });431432  it("fails closed when the popup rejects the SAS", async () => {433    const socket = socketWithFrames([434      {435        type: "PEACH_HELLO",436        token: "AAECAwQFBgcICQoLDA0ODw",437      },438      {439        type: "ADOPT_REQUEST",440        device_id: "request-only-label",441        device_name: "New phone",442        device_type: "android",443      },444      { type: "ADOPT_CONFIRM", confirmed: false },445    ]);446    mocks.openSocket.mockResolvedValue(socket);447448    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");449    let pendingId: string | undefined;450    Object.defineProperty(globalThis, "chrome", {451      configurable: true,452      value: {453        runtime: {454          sendMessage(message: { type: string; payload: unknown }) {455            mocks.pushes.push(message);456            if (message.type === "ADOPTION_SOURCE_SAS") {457              const p = message.payload as { id?: string };458              if (typeof p?.id === "string") pendingId = p.id;459            }460            return Promise.resolve();461          },462        },463      },464    });465466    await startRelayAdoptionSource();467    await waitForPush("ADOPTION_SOURCE_SAS");468    expect(typeof pendingId).toBe("string");469    resolveSourceSasConfirmation(pendingId!, false);470    mocks.sasDecisions.push({ id: pendingId!, ok: false });471472    await waitForPush("ADOPTION_SOURCE_RESULT");473474    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();475    expect(mocks.upsert).not.toHaveBeenCalled();476    expect(mocks.pushes).toContainEqual({477      type: "ADOPTION_SOURCE_RESULT",478      payload: { status: "error", error: "adoption_rejected" },479    });480  });481482  it("rejects before reading the root key when neither peer has Pro", async () => {483    const socket = socketWithFrames([484      {485        type: "PEACH_HELLO",486        token: "AAECAwQFBgcICQoLDA0ODw",487      },488      {489        type: "ADOPT_REQUEST",490        device_id: "free-receiver",491        device_name: "Free phone",492        device_type: "android",493      },494      { type: "ADOPT_CONFIRM", confirmed: true },495    ]);496    mocks.isProUser.mockResolvedValue(false);497    mocks.getLicenseKey.mockResolvedValue(undefined);498    mocks.openSocket.mockResolvedValue(socket);499500    await startRelayAdoptionSource();501    await waitForPush("ADOPTION_SOURCE_RESULT");502503    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();504    expect(mocks.upsert).not.toHaveBeenCalled();505    expect(mocks.pushes).toContainEqual({506      type: "ADOPTION_SOURCE_RESULT",507      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },508    });509    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {510      try {511        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];512      } catch {513        return [];514      }515    });516    expect(sentFrames).toContainEqual({517      type: "ADOPT_REJECT",518      reason: "ADOPT_PRO_REQUIRED",519    });520  });521522  it("fails closed when the user never answers the SAS", async () => {523    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they524    // schedule real work), so we hold the SAS gate open by never resolving525    // the deferred and then cancel the session. clearState must drop the526    // pending SAS so it doesn't pin the relay room open for the next run.527    const socket = socketWithFrames([]);528    mocks.openSocket.mockResolvedValue(socket);529530    Object.defineProperty(globalThis, "chrome", {531      configurable: true,532      value: {533        runtime: {534          sendMessage(message: { type: string; payload: unknown }) {535            mocks.pushes.push(message);536            return Promise.resolve();537          },538        },539      },540    });541542    await startRelayAdoptionSource();543    cancelRelayAdoptionSource();544545    expect(socket.close).toHaveBeenCalled();546    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();547    expect(mocks.upsert).not.toHaveBeenCalled();548  });549550  it("cancelling the source screen closes the room without reading the root", async () => {551    const socket = socketWithFrames([]);552    mocks.openSocket.mockResolvedValue(socket);553554    await startRelayAdoptionSource();555    cancelRelayAdoptionSource();556557    expect(socket.close).toHaveBeenCalled();558    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();559    expect(mocks.upsert).not.toHaveBeenCalled();560  });561});562563describe("relay adoption receiver background", () => {564  beforeEach(async () => {565    // The receiver session runs in the background via `void runReceiverSession`;566    // it doesn't get awaited by the caller and can leave `inFlight` set if the567    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test568    // starts from a clean state.569    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");570    cancelRelayAdoptionSource();571    mocks.pushes.length = 0;572    mocks.openSocket.mockReset();573    mocks.getDeviceName.mockReset();574    mocks.getDeviceName.mockResolvedValue("Receiver Browser");575    mocks.createVault.mockReset();576    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });577    mocks.activateLicense.mockReset();578    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });579    mocks.isProUser.mockReset();580    mocks.isProUser.mockResolvedValue(true);581    mocks.getLicenseKey.mockReset();582    mocks.getLicenseKey.mockResolvedValue(undefined);583    mocks.localIdentity.mockReset();584    mocks.localIdentity.mockResolvedValue({585      deviceId: "receiver-id",586      deviceName: "Receiver Browser",587      deviceType: "extension",588      longTermPublicKeyHex: "11".repeat(32),589      signingPublicKeyHex: "22".repeat(32),590    });591    mocks.rollbackVault.mockReset();592    mocks.rollbackVault.mockResolvedValue(undefined);593    mocks.storageSet.mockReset();594    mocks.storageSet.mockResolvedValue(undefined);595    mocks.upsert.mockReset();596    mocks.upsert.mockResolvedValue(undefined);597    mocks.setPairSecret.mockReset();598    mocks.setPairSecret.mockResolvedValue(undefined);599    mocks.triggerSync.mockReset();600    mocks.triggerSync.mockResolvedValue(undefined);601    mocks.openSocket.mockResolvedValue(socketWithFrames([]));602    Object.defineProperty(globalThis, "chrome", {603      configurable: true,604      value: {605        runtime: {606          async sendMessage(message: { type: string; payload?: unknown }) {607            mocks.pushes.push({ type: message.type, payload: message.payload });608            if (message.type === "ADOPTION_SOURCE_SAS") {609              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };610              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {611                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");612                resolveReceiverSasConfirmation(p.id, p.sas, true);613              }614            }615            return { ok: true };616          },617          onMessage: {618            addListener() { /* no-op */ },619            removeListener() { /* no-op */ },620          },621        },622      },623    });624  });625626  it("opens a relay socket and returns an empty invitation", async () => {627    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");628    const started = await startRelayAdoptionReceiver("receiver-password");629    expect(started.invitation).toMatch(/^peach-pair-v2:/);630    // The QR carries vault:"empty" so the phone knows to act as source.631    const decoded = decodeInvitation(started.invitation);632    expect(decoded?.vault).toBe("empty");633    expect(mocks.openSocket).toHaveBeenCalled();634  });635636  it("rejects an empty password", async () => {637    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");638    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");639  });640641  it("surfaces the Pro-required rejection without creating a vault", async () => {642    mocks.openSocket.mockResolvedValue(socketWithFrames([643      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },644      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },645    ]));646647    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");648    await startRelayAdoptionReceiver("receiver-password");649    await waitForPush("ADOPTION_SOURCE_RESULT");650651    expect(mocks.createVault).not.toHaveBeenCalled();652    expect(mocks.rollbackVault).not.toHaveBeenCalled();653    expect(mocks.pushes).toContainEqual({654      type: "ADOPTION_SOURCE_RESULT",655      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },656    });657  });658659  it("the receiver wires requestAdoption, not grantAdoption", async () => {660    // The receiver's protocol position is the opposite of the source side:661    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.662    // Assert this by watching what lands on socket.send: the receiver's663    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY664    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.665    const rootKeyHex = "f0".repeat(32);666    const vaultId = "b".repeat(64);667    const socket = socketWithFrames([668      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },669      {670        type: "ADOPT_GRANT",671        root_key_hex: rootKeyHex,672        vault_id: vaultId,673        license: "PPK-test",674        source: {675          device_id: "phone-source-id",676          device_name: "June's phone",677          device_type: "android",678          long_term_public_key_hex: "99".repeat(32),679        },680      },681      { type: "ADOPT_COMPLETE" },682    ], { closeOnEmpty: true });683    mocks.openSocket.mockResolvedValue(socket);684    mocks.createVault.mockResolvedValue({ vaultId });685686    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");687    Object.defineProperty(globalThis, "chrome", {688      configurable: true,689      value: {690        runtime: {691          async sendMessage(message: { type: string; payload?: unknown }) {692            mocks.pushes.push({ type: message.type, payload: message.payload });693            if (message.type === "ADOPTION_SOURCE_SAS") {694              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };695              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {696                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");697                resolveReceiverSasConfirmation(p.id, p.sas, true);698              }699            }700            return { ok: true };701          },702          onMessage: { addListener: () => undefined, removeListener: () => undefined },703        },704      },705    });706    await startRelayAdoptionReceiver("receiver-password");707    await waitForPush("ADOPTION_SOURCE_RESULT");708709    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })710      .mock.calls711      .map(([bytes]) => {712        try {713          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;714        } catch {715          return undefined;716        }717      });718    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;719    // ADOPT_GRANT is the source side's frame and must never appear here.720    expect(sentTypes).toContain("ADOPT_REQUEST");721    expect(sentTypes).not.toContain("ADOPT_GRANT");722  });723724  it("completes the handshake end-to-end: vault created → paired", async () => {725    // Wire the receiver's noise mock and SAS gate together so the full726    // requestAdoption protocol runs against an in-memory phone that sends727    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.728    // The mocked noise is pass-through (encrypt = decrypt = identity), so729    // frames land as ordinary JSON.730    const rootKeyHex = "f0".repeat(32);731    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);732    // the grant's vault_id is set to the same so the protocol proceeds.733    const vaultId = "b".repeat(64);734    const frames = [735      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },736      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two737      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.738      {739        type: "ADOPT_GRANT",740        root_key_hex: rootKeyHex,741        vault_id: vaultId,742        license: "PPK-test",743        source: {744          device_id: "phone-source-id",745          device_name: "June's phone",746          device_type: "android",747          long_term_public_key_hex: "99".repeat(32),748          signing_public_key_hex: "aa".repeat(32),749          relay_url: "wss://relay.peachpasswords.com",750        },751      },752      { type: "ADOPT_COMPLETE" },753    ];754    mocks.createVault.mockResolvedValue({ vaultId });755    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));756757    // Replace the chrome.runtime mock for this one test and approve the displayed SAS.758    Object.defineProperty(globalThis, "chrome", {759      configurable: true,760      value: {761        runtime: {762          async sendMessage(message: { type: string; payload?: unknown }) {763            mocks.pushes.push({ type: message.type, payload: message.payload });764            if (message.type === "ADOPTION_SOURCE_SAS") {765              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };766              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {767                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");768                resolveReceiverSasConfirmation(p.id, p.sas, true);769              }770            }771            return { ok: true };772          },773          onMessage: {774            addListener: () => undefined,775            removeListener: () => undefined,776          },777        },778      },779    });780781    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");782    await startRelayAdoptionReceiver("receiver-password");783784    await waitForPush("ADOPTION_SOURCE_RESULT");785786    // The receiver displays its locally derived SAS and requires confirmation.787    expect(mocks.pushes).toContainEqual({788      type: "ADOPTION_SOURCE_SAS",789      payload: expect.objectContaining({790        sas: expect.stringMatching(/^\d{8}$/),791        displayOnly: false,792      }),793    });794795    // The vault module was called directly with the popup-supplied password796    // and the root key granted by the phone.797    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);798799    // License activation, identity derivation, and pairing persistence all800    // call their modules directly from the service worker.801    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");802    expect(mocks.isProUser).toHaveBeenCalled();803    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));804    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({805      deviceId: "phone-source-id",806    }));807    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;808    expect(receiverRow.rendezvousSecretHex).toBeUndefined();809    expect(mocks.setPairSecret).toHaveBeenCalledWith(810      "phone-source-id",811      expect.stringMatching(/^[0-9a-f]{64}$/),812      expect.any(Uint8Array),813    );814    expect(mocks.storageSet).toHaveBeenCalledWith(815      "peach_initial_sync_pending_device_id",816      "phone-source-id",817    );818    expect(mocks.rollbackVault).not.toHaveBeenCalled();819820    // The terminal state is "complete" — the popup can transition to success.821    expect(mocks.pushes).toContainEqual({822      type: "ADOPTION_SOURCE_RESULT",823      payload: { status: "complete" },824    });825  });826827  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {828    const rootKeyHex = "f0".repeat(32);829    const vaultId = "b".repeat(64);830    mocks.createVault.mockResolvedValue({ vaultId });831    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });832    mocks.isProUser.mockResolvedValue(false);833    mocks.openSocket.mockResolvedValue(socketWithFrames([834      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },835      {836        type: "ADOPT_GRANT",837        root_key_hex: rootKeyHex,838        vault_id: vaultId,839        license: "PPK-invalid",840        source: {841          device_id: "phone-source-id",842          device_name: "June's phone",843          device_type: "android",844          long_term_public_key_hex: "99".repeat(32),845        },846      },847    ]));848849    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");850    await startRelayAdoptionReceiver("receiver-password");851    await waitForPush("ADOPTION_SOURCE_RESULT");852853    expect(mocks.rollbackVault).toHaveBeenCalledOnce();854    expect(mocks.localIdentity).not.toHaveBeenCalled();855    expect(mocks.upsert).not.toHaveBeenCalled();856    expect(mocks.pushes).toContainEqual(expect.objectContaining({857      type: "ADOPTION_SOURCE_RESULT",858      payload: expect.objectContaining({859        status: "error",860        error: "license_transfer_failed",861      }),862    }));863  });864865  it("rolls back a newly created vault when a later adoption step fails", async () => {866    const rootKeyHex = "f0".repeat(32);867    const vaultId = "b".repeat(64);868    mocks.createVault.mockResolvedValue({ vaultId });869    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));870    mocks.openSocket.mockResolvedValue(socketWithFrames([871      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },872      {873        type: "ADOPT_GRANT",874        root_key_hex: rootKeyHex,875        vault_id: vaultId,876        license: "PPK-test",877        source: {878          device_id: "phone-source-id",879          device_name: "June's phone",880          device_type: "android",881          long_term_public_key_hex: "99".repeat(32),882        },883      },884    ]));885886    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");887    await startRelayAdoptionReceiver("receiver-password");888    await waitForPush("ADOPTION_SOURCE_RESULT");889890    expect(mocks.rollbackVault).toHaveBeenCalledOnce();891    expect(mocks.upsert).not.toHaveBeenCalled();892    expect(mocks.pushes).toContainEqual(expect.objectContaining({893      type: "ADOPTION_SOURCE_RESULT",894      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),895    }));896  });897898  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {899    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).900    // The receiver should skip the 30-second inline snapshot wait entirely901    // and fall through to the sync pipeline — no receiveVaultSnapshot call.902    const rootKeyHex = "f0".repeat(32);903    const vaultId = "b".repeat(64);904    mocks.createVault.mockResolvedValue({ vaultId });905    mocks.openSocket.mockResolvedValue(socketWithFrames([906      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },907      {908        type: "ADOPT_GRANT",909        root_key_hex: rootKeyHex,910        vault_id: vaultId,911        license: "PPK-test",912        source: {913          device_id: "old-phone-id",914          device_name: "Old phone",915          device_type: "android",916          long_term_public_key_hex: "99".repeat(32),917        },918        // NO snapshot_capable: pre-C source does not echo the field919      },920      { type: "ADOPT_COMPLETE" },921    ], { closeOnEmpty: true }));922923    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");924    await startRelayAdoptionReceiver("receiver-password");925    await waitForPush("ADOPTION_SOURCE_RESULT");926927    // Sync fallback ran immediately (no 30s wait).928    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });929    // Adoption completed successfully.930    expect(mocks.pushes).toContainEqual(expect.objectContaining({931      type: "ADOPTION_SOURCE_RESULT",932      payload: expect.objectContaining({ status: "complete" }),933    }));934    // The initial sync pending marker was set (not cleared — no snapshot applied).935    expect(mocks.storageSet).toHaveBeenCalledWith(936      "peach_initial_sync_pending_device_id",937      "old-phone-id",938    );939  });940941  it("applies the source's device manifest after the snapshot, then acks", async () => {942    // Mesh propagation: a 3rd browser that completes an adoption with a943    // snapshot-capable source must also receive the source's signed device944    // manifest and forward it to the sync adapter before the receiver945    // resolves. The relay stays an opaque forwarder, so the whole path946    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.947    const rootKeyHex = "f0".repeat(32);948    const vaultId = "b".repeat(64);949    const longTermPubkeyHex = "99".repeat(32);950    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");951    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(952      "../shared/sync-identity"953    );954    const issuerKp = await deriveSyncSigningKeypair(955      new Uint8Array(32).fill(0x55),956      "phone-source-id",957    );958    const manifest = await buildSignedDeviceManifest(959      "phone-source-id",960      [961        {962          deviceId: "peer-b",963          deviceName: "Pool B",964          deviceType: "extension",965          longTermPublicKeyHex: "11".repeat(32),966          signingPublicKeyHex: "22".repeat(32),967          wsPort: 49152,968          relayUrl: "wss://relay.peachpasswords.com",969          pairedAt: 1,970        },971      ],972      issuerKp.privateKey,973    );974    const socket = socketWithFrames([975      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },976      {977        type: "ADOPT_GRANT",978        root_key_hex: rootKeyHex,979        vault_id: vaultId,980        license: "PPK-test",981        snapshot_capable: true,982        source: {983          device_id: "phone-source-id",984          device_name: "June's phone",985          device_type: "android",986          long_term_public_key_hex: longTermPubkeyHex,987          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),988          relay_url: "wss://relay.peachpasswords.com",989        },990      },991      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is992      // an optional follow-up frame on the same channel.993      { type: "ADOPT_COMPLETE" },994      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames995      // after the handshake; one empty chunk ends the phase.996      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },997      { type: "ADOPT_DEVICE_MANIFEST", manifest },998    ], { closeOnEmpty: true });999    mocks.createVault.mockResolvedValue({ vaultId });1000    mocks.openSocket.mockResolvedValue(socket);1001    mocks.__applyIncomingDeviceManifest.mockClear();10021003    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1004    await startRelayAdoptionReceiver("receiver-password");1005    await waitForPush("ADOPTION_SOURCE_RESULT");10061007    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1008    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1009    expect(forwarded).toEqual(manifest);1010    expect(pubkey).toBeInstanceOf(Uint8Array);1011    expect(pubkey.length).toBe(32);1012    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the1013    // adapter path was skipped (the ack only follows a real apply).1014    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1015      .mock.calls1016      .map(([bytes]) => {1017        try {1018          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1019        } catch {1020          return undefined;1021        }1022      });1023    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1024    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.1025    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1026  });10271028  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1029    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST1030    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,1031    // and quietly time out the optional 5s wait without acking a manifest1032    // or invoking the adapter path.1033    const rootKeyHex = "f0".repeat(32);1034    const vaultId = "b".repeat(64);1035    const longTermPubkeyHex = "99".repeat(32);1036    const socket = socketWithFrames([1037      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1038      {1039        type: "ADOPT_GRANT",1040        root_key_hex: rootKeyHex,1041        vault_id: vaultId,1042        license: "PPK-test",1043        snapshot_capable: true,1044        source: {1045          device_id: "phone-source-id",1046          device_name: "June's phone",1047          device_type: "android",1048          long_term_public_key_hex: longTermPubkeyHex,1049          signing_public_key_hex: "aa".repeat(32),1050          relay_url: "wss://relay.peachpasswords.com",1051        },1052      },1053      { type: "ADOPT_COMPLETE" },1054      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot1055      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.1056      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1057    ], { closeOnEmpty: true });1058    mocks.createVault.mockResolvedValue({ vaultId });1059    mocks.openSocket.mockResolvedValue(socket);1060    mocks.__applyIncomingDeviceManifest.mockClear();10611062    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1063    await startRelayAdoptionReceiver("receiver-password");1064    await waitForPush("ADOPTION_SOURCE_RESULT");10651066    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1067    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1068      .mock.calls1069      .map(([bytes]) => {1070        try {1071          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1072        } catch {1073          return undefined;1074        }1075      });1076    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1077    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1078  });1079});10801081function decodeInvitation(encoded: string): { vault?: string } | null {1082  // Mirrors parsePairingInvitation's payload structure without pulling the1083  // module in: the receiver test only needs to assert the embedded status.1084  const at = encoded.indexOf(":");1085  if (at < 0) return null;1086  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1087  try {1088    const json = Buffer.from(b64, "base64").toString("utf8");1089    return JSON.parse(json) as { vault?: string };1090  } catch {1091    return null;1092  }1093}