relay-adoption-bg.test.ts

36.7 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 { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");207          const p = message.payload as { id?: string };208          if (typeof p?.id === "string") {209            mocks.sasDecisions.push({ id: p.id, ok: true });210            resolveSourceSasConfirmation(p.id, true);211          }212        }213        return Promise.resolve();214      },215    },216    },217  });218});219220describe("relay adoption source background boundary", () => {221  it("shows the SAS and waits for the popup's single confirmation", async () => {222    const socket = socketWithFrames([223      {224        type: "PEACH_HELLO",225        token: "AAECAwQFBgcICQoLDA0ODw",226      },227      {228        type: "ADOPT_REQUEST",229        device_id: "request-only-label",230        device_name: "June's phone",231        device_type: "android",232      },233      { type: "ADOPT_CONFIRM", confirmed: true },234      {235        type: "ADOPT_IDENTITY",236        device_id: "android-receiver",237        device_name: "June's phone",238        device_type: "android",239        long_term_public_key_hex: "d".repeat(64),240      },241      { type: "ADOPT_APPLIED" },242    ]);243    mocks.openSocket.mockResolvedValue(socket);244245    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");246    const sasIds: string[] = [];247    Object.defineProperty(globalThis, "chrome", {248      configurable: true,249      value: {250        runtime: {251          async sendMessage(message: { type: string; payload: unknown }) {252            mocks.pushes.push(message);253            if (message.type === "ADOPTION_SOURCE_SAS") {254              const p = message.payload as { id?: string };255              if (typeof p?.id === "string") {256                // Hold the SAS gate open until the test explicitly approves257                // it — this proves the source flow waits for the popup.258                sasIds.push(p.id);259              }260              return Promise.resolve();261            }262            return Promise.resolve();263          },264        },265      },266    });267    void resolveSourceSasConfirmation; // imported for the post-approval call below268269    const started = await startRelayAdoptionSource();270    expect(started.invitation).toMatch(/^peach-pair-v2:/);271    await waitForPush("ADOPTION_SOURCE_SAS");272    // The adoption must NOT be complete yet — the popup's SAS decision is the273    // prerequisite for forwarding the root key.274    await vi.waitFor(() => {275      expect(mocks.sasDecisions.length).toBe(0);276    });277    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();278    expect(mocks.upsert).not.toHaveBeenCalled();279280    // Approve the SAS; the adoption should now proceed and complete.281    const id = sasIds[0];282    expect(typeof id).toBe("string");283    resolveSourceSasConfirmation(id, true);284    mocks.sasDecisions.push({ id, ok: true });285286    await waitForPush("ADOPTION_SOURCE_RESULT");287288    expect(mocks.getRootKeyHex).toHaveBeenCalled();289    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({290      deviceId: "android-receiver",291      relayUrl: "wss://relay.peachpasswords.com",292    }));293    // PB-26-213: the registry row stays secretless; the secret bypasses it294    // into the encrypted pair-secret record.295    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;296    expect(sourceRow.rendezvousSecretHex).toBeUndefined();297    expect(mocks.setPairSecret).toHaveBeenCalledWith(298      "android-receiver",299      expect.stringMatching(/^[0-9a-f]{64}$/),300      expect.any(Uint8Array),301    );302    expect(mocks.pushes).toContainEqual(expect.objectContaining({303      type: "ADOPTION_SOURCE_RESULT",304      payload: expect.objectContaining({ status: "complete" }),305    }));306    expect(mocks.triggerSync).toHaveBeenCalledWith({307      reason: "manual",308      force: true,309    });310    const sentTypes = socket.send.mock.calls311      .map(([bytes]) => {312        try {313          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;314        } catch {315          return undefined;316        }317      });318    expect(sentTypes).toContain("ADOPT_COMPLETE");319  });320321  it("fails adoption without sending snapshot frames when duress is oversized", async () => {322    const socket = socketWithFrames([323      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },324      {325        type: "ADOPT_REQUEST",326        device_id: "request-only-label",327        device_name: "June's phone",328        device_type: "android",329        snapshot_capable: true,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);342    const { exportDuressSlotForAdoption } = await import("./vault");343    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(344      "x".repeat(768 * 1024),345    );346347    await startRelayAdoptionSource();348    await waitForPush("ADOPTION_SOURCE_RESULT");349350    expect(mocks.pushes).toContainEqual({351      type: "ADOPTION_SOURCE_RESULT",352      payload: {353        status: "error",354        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",355      },356    });357    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({358      type: "ADOPTION_SOURCE_RESULT",359      payload: expect.objectContaining({ status: "complete" }),360    }));361    const sentTypes = socket.send.mock.calls.map(([bytes]) =>362      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,363    );364    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");365    expect(mocks.triggerSync).not.toHaveBeenCalled();366  });367368  it("fails closed when the popup rejects the SAS", async () => {369    const socket = socketWithFrames([370      {371        type: "PEACH_HELLO",372        token: "AAECAwQFBgcICQoLDA0ODw",373      },374      {375        type: "ADOPT_REQUEST",376        device_id: "request-only-label",377        device_name: "New phone",378        device_type: "android",379      },380      { type: "ADOPT_CONFIRM", confirmed: false },381    ]);382    mocks.openSocket.mockResolvedValue(socket);383384    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");385    let pendingId: string | undefined;386    Object.defineProperty(globalThis, "chrome", {387      configurable: true,388      value: {389        runtime: {390          sendMessage(message: { type: string; payload: unknown }) {391            mocks.pushes.push(message);392            if (message.type === "ADOPTION_SOURCE_SAS") {393              const p = message.payload as { id?: string };394              if (typeof p?.id === "string") pendingId = p.id;395            }396            return Promise.resolve();397          },398        },399      },400    });401402    await startRelayAdoptionSource();403    await waitForPush("ADOPTION_SOURCE_SAS");404    expect(typeof pendingId).toBe("string");405    resolveSourceSasConfirmation(pendingId!, false);406    mocks.sasDecisions.push({ id: pendingId!, ok: false });407408    await waitForPush("ADOPTION_SOURCE_RESULT");409410    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();411    expect(mocks.upsert).not.toHaveBeenCalled();412    expect(mocks.pushes).toContainEqual({413      type: "ADOPTION_SOURCE_RESULT",414      payload: { status: "error", error: "adoption_rejected" },415    });416  });417418  it("rejects before reading the root key when neither peer has Pro", async () => {419    const socket = socketWithFrames([420      {421        type: "PEACH_HELLO",422        token: "AAECAwQFBgcICQoLDA0ODw",423      },424      {425        type: "ADOPT_REQUEST",426        device_id: "free-receiver",427        device_name: "Free phone",428        device_type: "android",429      },430      { type: "ADOPT_CONFIRM", confirmed: true },431    ]);432    mocks.isProUser.mockResolvedValue(false);433    mocks.getLicenseKey.mockResolvedValue(undefined);434    mocks.openSocket.mockResolvedValue(socket);435436    await startRelayAdoptionSource();437    await waitForPush("ADOPTION_SOURCE_RESULT");438439    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();440    expect(mocks.upsert).not.toHaveBeenCalled();441    expect(mocks.pushes).toContainEqual({442      type: "ADOPTION_SOURCE_RESULT",443      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },444    });445    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {446      try {447        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];448      } catch {449        return [];450      }451    });452    expect(sentFrames).toContainEqual({453      type: "ADOPT_REJECT",454      reason: "ADOPT_PRO_REQUIRED",455    });456  });457458  it("fails closed when the user never answers the SAS", async () => {459    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they460    // schedule real work), so we hold the SAS gate open by never resolving461    // the deferred and then cancel the session. clearState must drop the462    // pending SAS so it doesn't pin the relay room open for the next run.463    const socket = socketWithFrames([]);464    mocks.openSocket.mockResolvedValue(socket);465466    Object.defineProperty(globalThis, "chrome", {467      configurable: true,468      value: {469        runtime: {470          sendMessage(message: { type: string; payload: unknown }) {471            mocks.pushes.push(message);472            return Promise.resolve();473          },474        },475      },476    });477478    await startRelayAdoptionSource();479    cancelRelayAdoptionSource();480481    expect(socket.close).toHaveBeenCalled();482    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();483    expect(mocks.upsert).not.toHaveBeenCalled();484  });485486  it("cancelling the source screen closes the room without reading the root", async () => {487    const socket = socketWithFrames([]);488    mocks.openSocket.mockResolvedValue(socket);489490    await startRelayAdoptionSource();491    cancelRelayAdoptionSource();492493    expect(socket.close).toHaveBeenCalled();494    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();495    expect(mocks.upsert).not.toHaveBeenCalled();496  });497});498499describe("relay adoption receiver background", () => {500  beforeEach(async () => {501    // The receiver session runs in the background via `void runReceiverSession`;502    // it doesn't get awaited by the caller and can leave `inFlight` set if the503    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test504    // starts from a clean state.505    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");506    cancelRelayAdoptionSource();507    mocks.pushes.length = 0;508    mocks.openSocket.mockReset();509    mocks.getDeviceName.mockReset();510    mocks.getDeviceName.mockResolvedValue("Receiver Browser");511    mocks.createVault.mockReset();512    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });513    mocks.activateLicense.mockReset();514    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });515    mocks.isProUser.mockReset();516    mocks.isProUser.mockResolvedValue(true);517    mocks.getLicenseKey.mockReset();518    mocks.getLicenseKey.mockResolvedValue(undefined);519    mocks.localIdentity.mockReset();520    mocks.localIdentity.mockResolvedValue({521      deviceId: "receiver-id",522      deviceName: "Receiver Browser",523      deviceType: "extension",524      longTermPublicKeyHex: "11".repeat(32),525      signingPublicKeyHex: "22".repeat(32),526    });527    mocks.rollbackVault.mockReset();528    mocks.rollbackVault.mockResolvedValue(undefined);529    mocks.storageSet.mockReset();530    mocks.storageSet.mockResolvedValue(undefined);531    mocks.upsert.mockReset();532    mocks.upsert.mockResolvedValue(undefined);533    mocks.setPairSecret.mockReset();534    mocks.setPairSecret.mockResolvedValue(undefined);535    mocks.triggerSync.mockReset();536    mocks.triggerSync.mockResolvedValue(undefined);537    mocks.openSocket.mockResolvedValue(socketWithFrames([]));538    Object.defineProperty(globalThis, "chrome", {539      configurable: true,540      value: {541        runtime: {542          sendMessage(message: { type: string; payload?: unknown }) {543            mocks.pushes.push({ type: message.type, payload: message.payload });544            return Promise.resolve({ ok: true });545          },546          onMessage: {547            addListener() { /* no-op */ },548            removeListener() { /* no-op */ },549          },550        },551      },552    });553  });554555  it("opens a relay socket and returns an empty invitation", async () => {556    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");557    const started = await startRelayAdoptionReceiver("receiver-password");558    expect(started.invitation).toMatch(/^peach-pair-v2:/);559    // The QR carries vault:"empty" so the phone knows to act as source.560    const decoded = decodeInvitation(started.invitation);561    expect(decoded?.vault).toBe("empty");562    expect(mocks.openSocket).toHaveBeenCalled();563  });564565  it("rejects an empty password", async () => {566    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");567    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");568  });569570  it("surfaces the Pro-required rejection without creating a vault", async () => {571    mocks.openSocket.mockResolvedValue(socketWithFrames([572      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },573      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },574    ]));575576    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");577    await startRelayAdoptionReceiver("receiver-password");578    await waitForPush("ADOPTION_SOURCE_RESULT");579580    expect(mocks.createVault).not.toHaveBeenCalled();581    expect(mocks.rollbackVault).not.toHaveBeenCalled();582    expect(mocks.pushes).toContainEqual({583      type: "ADOPTION_SOURCE_RESULT",584      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },585    });586  });587588  it("the receiver wires requestAdoption, not grantAdoption", async () => {589    // The receiver's protocol position is the opposite of the source side:590    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.591    // Assert this by watching what lands on socket.send: the receiver's592    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY593    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.594    const rootKeyHex = "f0".repeat(32);595    const vaultId = "b".repeat(64);596    const socket = socketWithFrames([597      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },598      {599        type: "ADOPT_GRANT",600        root_key_hex: rootKeyHex,601        vault_id: vaultId,602        license: "PPK-test",603        source: {604          device_id: "phone-source-id",605          device_name: "June's phone",606          device_type: "android",607          long_term_public_key_hex: "99".repeat(32),608        },609      },610      { type: "ADOPT_COMPLETE" },611    ], { closeOnEmpty: true });612    mocks.openSocket.mockResolvedValue(socket);613    mocks.createVault.mockResolvedValue({ vaultId });614615    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");616    Object.defineProperty(globalThis, "chrome", {617      configurable: true,618      value: {619        runtime: {620          sendMessage(message: { type: string; payload?: unknown }) {621            mocks.pushes.push({ type: message.type, payload: message.payload });622            return Promise.resolve({ ok: true });623          },624          onMessage: { addListener: () => undefined, removeListener: () => undefined },625        },626      },627    });628    await startRelayAdoptionReceiver("receiver-password");629    await waitForPush("ADOPTION_SOURCE_RESULT");630631    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })632      .mock.calls633      .map(([bytes]) => {634        try {635          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;636        } catch {637          return undefined;638        }639      });640    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;641    // ADOPT_GRANT is the source side's frame and must never appear here.642    expect(sentTypes).toContain("ADOPT_REQUEST");643    expect(sentTypes).not.toContain("ADOPT_GRANT");644  });645646  it("completes the handshake end-to-end: vault created → paired", async () => {647    // Wire the receiver's noise mock and SAS gate together so the full648    // requestAdoption protocol runs against an in-memory phone that sends649    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.650    // The mocked noise is pass-through (encrypt = decrypt = identity), so651    // frames land as ordinary JSON.652    const rootKeyHex = "f0".repeat(32);653    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);654    // the grant's vault_id is set to the same so the protocol proceeds.655    const vaultId = "b".repeat(64);656    const frames = [657      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },658      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two659      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.660      {661        type: "ADOPT_GRANT",662        root_key_hex: rootKeyHex,663        vault_id: vaultId,664        license: "PPK-test",665        source: {666          device_id: "phone-source-id",667          device_name: "June's phone",668          device_type: "android",669          long_term_public_key_hex: "99".repeat(32),670          signing_public_key_hex: "aa".repeat(32),671          relay_url: "wss://relay.peachpasswords.com",672        },673      },674      { type: "ADOPT_COMPLETE" },675    ];676    mocks.createVault.mockResolvedValue({ vaultId });677    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));678679    // Replace the chrome.runtime mock for this one test. The receiver680    // auto-accepts the SAS, so no decision needs to be simulated.681    Object.defineProperty(globalThis, "chrome", {682      configurable: true,683      value: {684        runtime: {685          sendMessage(message: { type: string; payload?: unknown }) {686            mocks.pushes.push({ type: message.type, payload: message.payload });687            return Promise.resolve({ ok: true });688          },689          onMessage: {690            addListener: () => undefined,691            removeListener: () => undefined,692          },693        },694      },695    });696697    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");698    await startRelayAdoptionReceiver("receiver-password");699700    await waitForPush("ADOPTION_SOURCE_RESULT");701702    // The receiver displays its locally derived SAS without introducing a703    // second confirmation gate.704    expect(mocks.pushes).toContainEqual({705      type: "ADOPTION_SOURCE_SAS",706      payload: expect.objectContaining({707        sas: expect.stringMatching(/^\d{8}$/),708        displayOnly: true,709      }),710    });711712    // The vault module was called directly with the popup-supplied password713    // and the root key granted by the phone.714    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);715716    // License activation, identity derivation, and pairing persistence all717    // call their modules directly from the service worker.718    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");719    expect(mocks.isProUser).toHaveBeenCalled();720    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));721    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({722      deviceId: "phone-source-id",723    }));724    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;725    expect(receiverRow.rendezvousSecretHex).toBeUndefined();726    expect(mocks.setPairSecret).toHaveBeenCalledWith(727      "phone-source-id",728      expect.stringMatching(/^[0-9a-f]{64}$/),729      expect.any(Uint8Array),730    );731    expect(mocks.storageSet).toHaveBeenCalledWith(732      "peach_initial_sync_pending_device_id",733      "phone-source-id",734    );735    expect(mocks.rollbackVault).not.toHaveBeenCalled();736737    // The terminal state is "complete" — the popup can transition to success.738    expect(mocks.pushes).toContainEqual(expect.objectContaining({739      type: "ADOPTION_SOURCE_RESULT",740      payload: expect.objectContaining({ status: "complete" }),741    }));742  });743744  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {745    const rootKeyHex = "f0".repeat(32);746    const vaultId = "b".repeat(64);747    mocks.createVault.mockResolvedValue({ vaultId });748    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });749    mocks.isProUser.mockResolvedValue(false);750    mocks.openSocket.mockResolvedValue(socketWithFrames([751      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },752      {753        type: "ADOPT_GRANT",754        root_key_hex: rootKeyHex,755        vault_id: vaultId,756        license: "PPK-invalid",757        source: {758          device_id: "phone-source-id",759          device_name: "June's phone",760          device_type: "android",761          long_term_public_key_hex: "99".repeat(32),762        },763      },764    ]));765766    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");767    await startRelayAdoptionReceiver("receiver-password");768    await waitForPush("ADOPTION_SOURCE_RESULT");769770    expect(mocks.rollbackVault).toHaveBeenCalledOnce();771    expect(mocks.localIdentity).not.toHaveBeenCalled();772    expect(mocks.upsert).not.toHaveBeenCalled();773    expect(mocks.pushes).toContainEqual(expect.objectContaining({774      type: "ADOPTION_SOURCE_RESULT",775      payload: expect.objectContaining({776        status: "error",777        error: "license_transfer_failed",778      }),779    }));780  });781782  it("rolls back a newly created vault when a later adoption step fails", async () => {783    const rootKeyHex = "f0".repeat(32);784    const vaultId = "b".repeat(64);785    mocks.createVault.mockResolvedValue({ vaultId });786    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));787    mocks.openSocket.mockResolvedValue(socketWithFrames([788      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },789      {790        type: "ADOPT_GRANT",791        root_key_hex: rootKeyHex,792        vault_id: vaultId,793        license: "PPK-test",794        source: {795          device_id: "phone-source-id",796          device_name: "June's phone",797          device_type: "android",798          long_term_public_key_hex: "99".repeat(32),799        },800      },801    ]));802803    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");804    await startRelayAdoptionReceiver("receiver-password");805    await waitForPush("ADOPTION_SOURCE_RESULT");806807    expect(mocks.rollbackVault).toHaveBeenCalledOnce();808    expect(mocks.upsert).not.toHaveBeenCalled();809    expect(mocks.pushes).toContainEqual(expect.objectContaining({810      type: "ADOPTION_SOURCE_RESULT",811      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),812    }));813  });814815  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {816    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).817    // The receiver should skip the 30-second inline snapshot wait entirely818    // and fall through to the sync pipeline — no receiveVaultSnapshot call.819    const rootKeyHex = "f0".repeat(32);820    const vaultId = "b".repeat(64);821    mocks.createVault.mockResolvedValue({ vaultId });822    mocks.openSocket.mockResolvedValue(socketWithFrames([823      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },824      {825        type: "ADOPT_GRANT",826        root_key_hex: rootKeyHex,827        vault_id: vaultId,828        license: "PPK-test",829        source: {830          device_id: "old-phone-id",831          device_name: "Old phone",832          device_type: "android",833          long_term_public_key_hex: "99".repeat(32),834        },835        // NO snapshot_capable: pre-C source does not echo the field836      },837      { type: "ADOPT_COMPLETE" },838    ], { closeOnEmpty: true }));839840    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");841    await startRelayAdoptionReceiver("receiver-password");842    await waitForPush("ADOPTION_SOURCE_RESULT");843844    // Sync fallback ran immediately (no 30s wait).845    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });846    // Adoption completed successfully.847    expect(mocks.pushes).toContainEqual(expect.objectContaining({848      type: "ADOPTION_SOURCE_RESULT",849      payload: expect.objectContaining({ status: "complete" }),850    }));851    // The initial sync pending marker was set (not cleared — no snapshot applied).852    expect(mocks.storageSet).toHaveBeenCalledWith(853      "peach_initial_sync_pending_device_id",854      "old-phone-id",855    );856  });857858  it("applies the source's device manifest after the snapshot, then acks", async () => {859    // Mesh propagation: a 3rd browser that completes an adoption with a860    // snapshot-capable source must also receive the source's signed device861    // manifest and forward it to the sync adapter before the receiver862    // resolves. The relay stays an opaque forwarder, so the whole path863    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.864    const rootKeyHex = "f0".repeat(32);865    const vaultId = "b".repeat(64);866    const longTermPubkeyHex = "99".repeat(32);867    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");868    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(869      "../shared/sync-identity"870    );871    const issuerKp = await deriveSyncSigningKeypair(872      new Uint8Array(32).fill(0x55),873      "phone-source-id",874    );875    const manifest = await buildSignedDeviceManifest(876      "phone-source-id",877      [878        {879          deviceId: "peer-b",880          deviceName: "Pool B",881          deviceType: "extension",882          longTermPublicKeyHex: "11".repeat(32),883          signingPublicKeyHex: "22".repeat(32),884          wsPort: 49152,885          relayUrl: "wss://relay.peachpasswords.com",886          pairedAt: 1,887        },888      ],889      issuerKp.privateKey,890    );891    const socket = socketWithFrames([892      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },893      {894        type: "ADOPT_GRANT",895        root_key_hex: rootKeyHex,896        vault_id: vaultId,897        license: "PPK-test",898        snapshot_capable: true,899        source: {900          device_id: "phone-source-id",901          device_name: "June's phone",902          device_type: "android",903          long_term_public_key_hex: longTermPubkeyHex,904          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),905          relay_url: "wss://relay.peachpasswords.com",906        },907      },908      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is909      // an optional follow-up frame on the same channel.910      { type: "ADOPT_COMPLETE" },911      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames912      // after the handshake; one empty chunk ends the phase.913      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },914      { type: "ADOPT_DEVICE_MANIFEST", manifest },915    ], { closeOnEmpty: true });916    mocks.createVault.mockResolvedValue({ vaultId });917    mocks.openSocket.mockResolvedValue(socket);918    mocks.__applyIncomingDeviceManifest.mockClear();919920    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");921    await startRelayAdoptionReceiver("receiver-password");922    await waitForPush("ADOPTION_SOURCE_RESULT");923924    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);925    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];926    expect(forwarded).toEqual(manifest);927    expect(pubkey).toBeInstanceOf(Uint8Array);928    expect(pubkey.length).toBe(32);929    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the930    // adapter path was skipped (the ack only follows a real apply).931    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })932      .mock.calls933      .map(([bytes]) => {934        try {935          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;936        } catch {937          return undefined;938        }939      });940    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");941    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.942    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");943  });944945  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {946    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST947    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,948    // and quietly time out the optional 5s wait without acking a manifest949    // or invoking the adapter path.950    const rootKeyHex = "f0".repeat(32);951    const vaultId = "b".repeat(64);952    const longTermPubkeyHex = "99".repeat(32);953    const socket = socketWithFrames([954      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },955      {956        type: "ADOPT_GRANT",957        root_key_hex: rootKeyHex,958        vault_id: vaultId,959        license: "PPK-test",960        snapshot_capable: true,961        source: {962          device_id: "phone-source-id",963          device_name: "June's phone",964          device_type: "android",965          long_term_public_key_hex: longTermPubkeyHex,966          signing_public_key_hex: "aa".repeat(32),967          relay_url: "wss://relay.peachpasswords.com",968        },969      },970      { type: "ADOPT_COMPLETE" },971      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot972      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.973      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },974    ], { closeOnEmpty: true });975    mocks.createVault.mockResolvedValue({ vaultId });976    mocks.openSocket.mockResolvedValue(socket);977    mocks.__applyIncomingDeviceManifest.mockClear();978979    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");980    await startRelayAdoptionReceiver("receiver-password");981    await waitForPush("ADOPTION_SOURCE_RESULT");982983    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();984    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })985      .mock.calls986      .map(([bytes]) => {987        try {988          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;989        } catch {990          return undefined;991        }992      });993    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");994    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");995  });996});997998function decodeInvitation(encoded: string): { vault?: string } | null {999  // Mirrors parsePairingInvitation's payload structure without pulling the1000  // module in: the receiver test only needs to assert the embedded status.1001  const at = encoded.indexOf(":");1002  if (at < 0) return null;1003  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1004  try {1005    const json = Buffer.from(b64, "base64").toString("utf8");1006    return JSON.parse(json) as { vault?: string };1007  } catch {1008    return null;1009  }1010}