relay-adoption-source-bg.test.ts

27.2 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23const mocks = vi.hoisted(() => ({4  openSocket: vi.fn(),5  getLocalIdentity: vi.fn(),6  getDeviceName: vi.fn(),7  getRootKeyHex: vi.fn(),8  upsert: vi.fn(),9  pushes: [] as Array<{ type: string; payload: unknown }>,10  triggerSync: vi.fn(),11  createVault: vi.fn(),12  activateLicense: vi.fn(),13  isProUser: vi.fn(),14  getLicenseKey: vi.fn(),15  localIdentity: vi.fn(),16  rollbackVault: vi.fn(),17  storageSet: vi.fn(),18  sasDecisions: [] as Array<{ id: string; ok: boolean }>,19}));2021vi.mock("../shared/pairing-dial", () => ({22  openRelayPairingSocket: mocks.openSocket,23}));2425vi.mock("../shared/noise-js", () => ({26  noiseNKResponder: vi.fn(async () => ({27    handshakeHash: new Uint8Array(32).fill(7),28    encrypt: (bytes: Uint8Array) => bytes,29    decrypt: (bytes: Uint8Array) => bytes,30    close: vi.fn(),31  })),32}));3334vi.mock("../shared/rendezvous", async (importOriginal) => {35  const actual = await importOriginal<typeof import("../shared/rendezvous")>();36  return {37    ...actual,38    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",39  };40});414243vi.mock("./device-identity", () => ({44  getLocalPairingIdentity: async () => ({45    identity: {46      deviceId: "test-device",47      deviceName: "Test",48      deviceType: "extension",49      longTermPublicKeyHex: "aa".repeat(32),50      vaultId: "bb".repeat(32),51    },52    privateKey: new Uint8Array(32).fill(7),53    publicKey: new Uint8Array(32).fill(8),54  }),55  getDeviceName: mocks.getDeviceName,56  buildLocalIdentity: mocks.localIdentity,57}));5859vi.mock("./sync-settings", async (importOriginal) => ({60  ...(await importOriginal<typeof import("./sync-settings")>()),61  getSyncSettings: async () => ({62    mode: "public" as const,63    relayUrl: "wss://relay.peachpasswords.com",64  }),65}));6667vi.mock("./license", () => ({68  isProUser: mocks.isProUser,69  getLicenseKey: mocks.getLicenseKey,70  activateLicense: mocks.activateLicense,71}));7273vi.mock("./vault", () => ({74  isUnlocked: vi.fn(() => true),75  getRootKeyHex: mocks.getRootKeyHex,76  createVaultFromRootKey: mocks.createVault,77  rollbackAdoptedVault: mocks.rollbackVault,78  exportDuressSlotForAdoption: vi.fn(async () => null),79  installAdoptedDuressSlot: vi.fn(async () => undefined),80}));8182vi.mock("../shared/sync-identity", () => ({83  computeVaultId: vi.fn(async () => "b".repeat(64)),84}));8586vi.mock("../shared/storage", () => ({87  storageSet: mocks.storageSet,88}));8990vi.mock("./paired-devices", () => ({91  upsertPairedDevice: mocks.upsert,92}));9394vi.mock("./sync-trigger", () => ({95  triggerSyncIfReachable: mocks.triggerSync,96}));9798import {99  cancelRelayAdoptionSource,100  startRelayAdoptionSource,101} from "./relay-adoption-source-bg";102103const encode = (value: unknown) =>104  new TextEncoder().encode(JSON.stringify(value));105106function socketWithFrames(107  frames: unknown[],108  options: { closeOnEmpty?: boolean } = {},109) {110  const queue = frames.map(encode);111  return {112    send: vi.fn(),113    receive: vi.fn(async () => {114      const next = queue.shift();115      if (!next) {116        // A real peer closes the channel once it runs out of frames; model117        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast118        // and falls back to the sync pipeline instead of hanging on a timeout.119        if (options.closeOnEmpty) throw new Error("socket_closed");120        return new Promise<Uint8Array>(() => undefined);121      }122      return next;123    }),124    close: vi.fn(),125    closedBeforeFrame: vi.fn(() => false),126  };127}128129async function waitForPush(type: string): Promise<void> {130  await vi.waitFor(() => {131    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);132  });133}134135beforeEach(() => {136  mocks.pushes.length = 0;137  mocks.sasDecisions.length = 0;138  mocks.openSocket.mockReset();139  mocks.getLocalIdentity.mockReset();140  mocks.getRootKeyHex.mockReset();141  mocks.upsert.mockReset();142  mocks.triggerSync.mockReset();143  mocks.triggerSync.mockResolvedValue(undefined);144  mocks.isProUser.mockReset();145  mocks.isProUser.mockResolvedValue(true);146  mocks.getLicenseKey.mockReset();147  mocks.getLicenseKey.mockResolvedValue("PPK-source");148  mocks.getLocalIdentity.mockResolvedValue({149    identity: {150      deviceId: "browser-source",151      deviceName: "June's Browser",152      deviceType: "extension",153      longTermPublicKeyHex: "a".repeat(64),154      vaultId: "b".repeat(64),155    },156    privateKey: new Uint8Array(32).fill(1),157    publicKey: new Uint8Array(32).fill(2),158  });159  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));160  mocks.upsert.mockResolvedValue(undefined);161  Object.defineProperty(globalThis, "chrome", {162    configurable: true,163    value: {164    runtime: {165      async sendMessage(message: { type: string; payload: unknown }) {166        mocks.pushes.push(message);167        // The source flow waits for the popup to send SOURCE_SAS_DECISION before168        // forwarding the root key. The receiver test overrides this mock and169        // resolves the decision itself; the source tests rely on this default170        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.171        if (message.type === "ADOPTION_SOURCE_SAS") {172          const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");173          const p = message.payload as { id?: string };174          if (typeof p?.id === "string") {175            mocks.sasDecisions.push({ id: p.id, ok: true });176            resolveSourceSasConfirmation(p.id, true);177          }178        }179        return Promise.resolve();180      },181    },182    },183  });184});185186describe("relay adoption source background boundary", () => {187  it("shows the SAS and waits for the popup's single confirmation", async () => {188    const socket = socketWithFrames([189      {190        type: "PEACH_HELLO",191        token: "AAECAwQFBgcICQoLDA0ODw",192      },193      {194        type: "ADOPT_REQUEST",195        device_id: "request-only-label",196        device_name: "June's phone",197        device_type: "android",198      },199      { type: "ADOPT_CONFIRM", confirmed: true },200      {201        type: "ADOPT_IDENTITY",202        device_id: "android-receiver",203        device_name: "June's phone",204        device_type: "android",205        long_term_public_key_hex: "d".repeat(64),206      },207      { type: "ADOPT_APPLIED" },208    ]);209    mocks.openSocket.mockResolvedValue(socket);210211    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");212    const sasIds: string[] = [];213    Object.defineProperty(globalThis, "chrome", {214      configurable: true,215      value: {216        runtime: {217          async sendMessage(message: { type: string; payload: unknown }) {218            mocks.pushes.push(message);219            if (message.type === "ADOPTION_SOURCE_SAS") {220              const p = message.payload as { id?: string };221              if (typeof p?.id === "string") {222                // Hold the SAS gate open until the test explicitly approves223                // it — this proves the source flow waits for the popup.224                sasIds.push(p.id);225              }226              return Promise.resolve();227            }228            return Promise.resolve();229          },230        },231      },232    });233    void resolveSourceSasConfirmation; // imported for the post-approval call below234235    const started = await startRelayAdoptionSource();236    expect(started.invitation).toMatch(/^peach-pair-v1:/);237    await waitForPush("ADOPTION_SOURCE_SAS");238    // The adoption must NOT be complete yet — the popup's SAS decision is the239    // prerequisite for forwarding the root key.240    await vi.waitFor(() => {241      expect(mocks.sasDecisions.length).toBe(0);242    });243    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();244    expect(mocks.upsert).not.toHaveBeenCalled();245246    // Approve the SAS; the adoption should now proceed and complete.247    const id = sasIds[0];248    expect(typeof id).toBe("string");249    resolveSourceSasConfirmation(id, true);250    mocks.sasDecisions.push({ id, ok: true });251252    await waitForPush("ADOPTION_SOURCE_RESULT");253254    expect(mocks.getRootKeyHex).toHaveBeenCalled();255    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({256      deviceId: "android-receiver",257      relayUrl: "wss://relay.peachpasswords.com",258    }));259    expect(mocks.pushes).toContainEqual(expect.objectContaining({260      type: "ADOPTION_SOURCE_RESULT",261      payload: expect.objectContaining({ status: "complete" }),262    }));263    expect(mocks.triggerSync).toHaveBeenCalledWith({264      reason: "manual",265      force: true,266    });267    const sentTypes = socket.send.mock.calls268      .map(([bytes]) => {269        try {270          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;271        } catch {272          return undefined;273        }274      });275    expect(sentTypes).toContain("ADOPT_COMPLETE");276  });277278  it("fails closed when the popup rejects the SAS", async () => {279    const socket = socketWithFrames([280      {281        type: "PEACH_HELLO",282        token: "AAECAwQFBgcICQoLDA0ODw",283      },284      {285        type: "ADOPT_REQUEST",286        device_id: "request-only-label",287        device_name: "New phone",288        device_type: "android",289      },290      { type: "ADOPT_CONFIRM", confirmed: false },291    ]);292    mocks.openSocket.mockResolvedValue(socket);293294    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");295    let pendingId: string | undefined;296    Object.defineProperty(globalThis, "chrome", {297      configurable: true,298      value: {299        runtime: {300          sendMessage(message: { type: string; payload: unknown }) {301            mocks.pushes.push(message);302            if (message.type === "ADOPTION_SOURCE_SAS") {303              const p = message.payload as { id?: string };304              if (typeof p?.id === "string") pendingId = p.id;305            }306            return Promise.resolve();307          },308        },309      },310    });311312    await startRelayAdoptionSource();313    await waitForPush("ADOPTION_SOURCE_SAS");314    expect(typeof pendingId).toBe("string");315    resolveSourceSasConfirmation(pendingId!, false);316    mocks.sasDecisions.push({ id: pendingId!, ok: false });317318    await waitForPush("ADOPTION_SOURCE_RESULT");319320    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();321    expect(mocks.upsert).not.toHaveBeenCalled();322    expect(mocks.pushes).toContainEqual({323      type: "ADOPTION_SOURCE_RESULT",324      payload: { status: "error", error: "adoption_rejected" },325    });326  });327328  it("rejects before reading the root key when neither peer has Pro", async () => {329    const socket = socketWithFrames([330      {331        type: "PEACH_HELLO",332        token: "AAECAwQFBgcICQoLDA0ODw",333      },334      {335        type: "ADOPT_REQUEST",336        device_id: "free-receiver",337        device_name: "Free phone",338        device_type: "android",339      },340      { type: "ADOPT_CONFIRM", confirmed: true },341    ]);342    mocks.isProUser.mockResolvedValue(false);343    mocks.getLicenseKey.mockResolvedValue(undefined);344    mocks.openSocket.mockResolvedValue(socket);345346    await startRelayAdoptionSource();347    await waitForPush("ADOPTION_SOURCE_RESULT");348349    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();350    expect(mocks.upsert).not.toHaveBeenCalled();351    expect(mocks.pushes).toContainEqual({352      type: "ADOPTION_SOURCE_RESULT",353      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },354    });355    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {356      try {357        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];358      } catch {359        return [];360      }361    });362    expect(sentFrames).toContainEqual({363      type: "ADOPT_REJECT",364      reason: "ADOPT_PRO_REQUIRED",365    });366  });367368  it("fails closed when the user never answers the SAS", async () => {369    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they370    // schedule real work), so we hold the SAS gate open by never resolving371    // the deferred and then cancel the session. clearState must drop the372    // pending SAS so it doesn't pin the relay room open for the next run.373    const socket = socketWithFrames([]);374    mocks.openSocket.mockResolvedValue(socket);375376    Object.defineProperty(globalThis, "chrome", {377      configurable: true,378      value: {379        runtime: {380          sendMessage(message: { type: string; payload: unknown }) {381            mocks.pushes.push(message);382            return Promise.resolve();383          },384        },385      },386    });387388    await startRelayAdoptionSource();389    cancelRelayAdoptionSource();390391    expect(socket.close).toHaveBeenCalled();392    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();393    expect(mocks.upsert).not.toHaveBeenCalled();394  });395396  it("cancelling the source screen closes the room without reading the root", async () => {397    const socket = socketWithFrames([]);398    mocks.openSocket.mockResolvedValue(socket);399400    await startRelayAdoptionSource();401    cancelRelayAdoptionSource();402403    expect(socket.close).toHaveBeenCalled();404    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();405    expect(mocks.upsert).not.toHaveBeenCalled();406  });407});408409describe("relay adoption receiver background", () => {410  beforeEach(async () => {411    // The receiver session runs in the background via `void runReceiverSession`;412    // it doesn't get awaited by the caller and can leave `inFlight` set if the413    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test414    // starts from a clean state.415    const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");416    cancelRelayAdoptionSource();417    mocks.pushes.length = 0;418    mocks.openSocket.mockReset();419    mocks.getDeviceName.mockReset();420    mocks.getDeviceName.mockResolvedValue("Receiver Browser");421    mocks.createVault.mockReset();422    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });423    mocks.activateLicense.mockReset();424    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });425    mocks.isProUser.mockReset();426    mocks.isProUser.mockResolvedValue(true);427    mocks.getLicenseKey.mockReset();428    mocks.getLicenseKey.mockResolvedValue(undefined);429    mocks.localIdentity.mockReset();430    mocks.localIdentity.mockResolvedValue({431      deviceId: "receiver-id",432      deviceName: "Receiver Browser",433      deviceType: "extension",434      longTermPublicKeyHex: "11".repeat(32),435      signingPublicKeyHex: "22".repeat(32),436    });437    mocks.rollbackVault.mockReset();438    mocks.rollbackVault.mockResolvedValue(undefined);439    mocks.storageSet.mockReset();440    mocks.storageSet.mockResolvedValue(undefined);441    mocks.upsert.mockReset();442    mocks.upsert.mockResolvedValue(undefined);443    mocks.triggerSync.mockReset();444    mocks.triggerSync.mockResolvedValue(undefined);445    mocks.openSocket.mockResolvedValue(socketWithFrames([]));446    Object.defineProperty(globalThis, "chrome", {447      configurable: true,448      value: {449        runtime: {450          sendMessage(message: { type: string; payload?: unknown }) {451            mocks.pushes.push({ type: message.type, payload: message.payload });452            return Promise.resolve({ ok: true });453          },454          onMessage: {455            addListener() { /* no-op */ },456            removeListener() { /* no-op */ },457          },458        },459      },460    });461  });462463  it("opens a relay socket and returns an empty invitation", async () => {464    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");465    const started = await startRelayAdoptionReceiver("receiver-password");466    expect(started.invitation).toMatch(/^peach-pair-v1:/);467    // The QR carries vault:"empty" so the phone knows to act as source.468    const decoded = decodeInvitation(started.invitation);469    expect(decoded?.vault).toBe("empty");470    expect(mocks.openSocket).toHaveBeenCalled();471  });472473  it("rejects an empty password", async () => {474    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");475    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");476  });477478  it("surfaces the Pro-required rejection without creating a vault", async () => {479    mocks.openSocket.mockResolvedValue(socketWithFrames([480      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },481      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },482    ]));483484    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");485    await startRelayAdoptionReceiver("receiver-password");486    await waitForPush("ADOPTION_SOURCE_RESULT");487488    expect(mocks.createVault).not.toHaveBeenCalled();489    expect(mocks.rollbackVault).not.toHaveBeenCalled();490    expect(mocks.pushes).toContainEqual({491      type: "ADOPTION_SOURCE_RESULT",492      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },493    });494  });495496  it("the receiver wires requestAdoption, not grantAdoption", async () => {497    // The receiver's protocol position is the opposite of the source side:498    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.499    // Assert this by watching what lands on socket.send: the receiver's500    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY501    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.502    const rootKeyHex = "f0".repeat(32);503    const vaultId = "b".repeat(64);504    const socket = socketWithFrames([505      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },506      {507        type: "ADOPT_GRANT",508        root_key_hex: rootKeyHex,509        vault_id: vaultId,510        license: "PPK-test",511        source: {512          device_id: "phone-source-id",513          device_name: "June's phone",514          device_type: "android",515          long_term_public_key_hex: "99".repeat(32),516        },517      },518      { type: "ADOPT_COMPLETE" },519    ], { closeOnEmpty: true });520    mocks.openSocket.mockResolvedValue(socket);521    mocks.createVault.mockResolvedValue({ vaultId });522523    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");524    Object.defineProperty(globalThis, "chrome", {525      configurable: true,526      value: {527        runtime: {528          sendMessage(message: { type: string; payload?: unknown }) {529            mocks.pushes.push({ type: message.type, payload: message.payload });530            return Promise.resolve({ ok: true });531          },532          onMessage: { addListener: () => undefined, removeListener: () => undefined },533        },534      },535    });536    await startRelayAdoptionReceiver("receiver-password");537    await waitForPush("ADOPTION_SOURCE_RESULT");538539    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })540      .mock.calls541      .map(([bytes]) => {542        try {543          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;544        } catch {545          return undefined;546        }547      });548    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;549    // ADOPT_GRANT is the source side's frame and must never appear here.550    expect(sentTypes).toContain("ADOPT_REQUEST");551    expect(sentTypes).not.toContain("ADOPT_GRANT");552  });553554  it("completes the handshake end-to-end: vault created → paired", async () => {555    // Wire the receiver's noise mock and SAS gate together so the full556    // requestAdoption protocol runs against an in-memory phone that sends557    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.558    // The mocked noise is pass-through (encrypt = decrypt = identity), so559    // frames land as ordinary JSON.560    const rootKeyHex = "f0".repeat(32);561    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);562    // the grant's vault_id is set to the same so the protocol proceeds.563    const vaultId = "b".repeat(64);564    const frames = [565      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },566      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two567      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.568      {569        type: "ADOPT_GRANT",570        root_key_hex: rootKeyHex,571        vault_id: vaultId,572        license: "PPK-test",573        source: {574          device_id: "phone-source-id",575          device_name: "June's phone",576          device_type: "android",577          long_term_public_key_hex: "99".repeat(32),578          signing_public_key_hex: "aa".repeat(32),579          relay_url: "wss://relay.peachpasswords.com",580        },581      },582      { type: "ADOPT_COMPLETE" },583    ];584    mocks.createVault.mockResolvedValue({ vaultId });585    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));586587    // Replace the chrome.runtime mock for this one test. The receiver588    // auto-accepts the SAS, so no decision needs to be simulated.589    Object.defineProperty(globalThis, "chrome", {590      configurable: true,591      value: {592        runtime: {593          sendMessage(message: { type: string; payload?: unknown }) {594            mocks.pushes.push({ type: message.type, payload: message.payload });595            return Promise.resolve({ ok: true });596          },597          onMessage: {598            addListener: () => undefined,599            removeListener: () => undefined,600          },601        },602      },603    });604605    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");606    await startRelayAdoptionReceiver("receiver-password");607608    await waitForPush("ADOPTION_SOURCE_RESULT");609610    // The receiver displays its locally derived SAS without introducing a611    // second confirmation gate.612    expect(mocks.pushes).toContainEqual({613      type: "ADOPTION_SOURCE_SAS",614      payload: expect.objectContaining({615        sas: expect.stringMatching(/^\d{8}$/),616        displayOnly: true,617      }),618    });619620    // The vault module was called directly with the popup-supplied password621    // and the root key granted by the phone.622    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);623624    // License activation, identity derivation, and pairing persistence all625    // call their modules directly from the service worker.626    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");627    expect(mocks.isProUser).toHaveBeenCalled();628    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));629    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({630      deviceId: "phone-source-id",631    }));632    expect(mocks.storageSet).toHaveBeenCalledWith(633      "peach_initial_sync_pending_device_id",634      "phone-source-id",635    );636    expect(mocks.rollbackVault).not.toHaveBeenCalled();637638    // The terminal state is "complete" — the popup can transition to success.639    expect(mocks.pushes).toContainEqual(expect.objectContaining({640      type: "ADOPTION_SOURCE_RESULT",641      payload: expect.objectContaining({ status: "complete" }),642    }));643  });644645  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {646    const rootKeyHex = "f0".repeat(32);647    const vaultId = "b".repeat(64);648    mocks.createVault.mockResolvedValue({ vaultId });649    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });650    mocks.isProUser.mockResolvedValue(false);651    mocks.openSocket.mockResolvedValue(socketWithFrames([652      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },653      {654        type: "ADOPT_GRANT",655        root_key_hex: rootKeyHex,656        vault_id: vaultId,657        license: "PPK-invalid",658        source: {659          device_id: "phone-source-id",660          device_name: "June's phone",661          device_type: "android",662          long_term_public_key_hex: "99".repeat(32),663        },664      },665    ]));666667    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");668    await startRelayAdoptionReceiver("receiver-password");669    await waitForPush("ADOPTION_SOURCE_RESULT");670671    expect(mocks.rollbackVault).toHaveBeenCalledOnce();672    expect(mocks.localIdentity).not.toHaveBeenCalled();673    expect(mocks.upsert).not.toHaveBeenCalled();674    expect(mocks.pushes).toContainEqual(expect.objectContaining({675      type: "ADOPTION_SOURCE_RESULT",676      payload: expect.objectContaining({677        status: "error",678        error: "license_transfer_failed",679      }),680    }));681  });682683  it("rolls back a newly created vault when a later adoption step fails", async () => {684    const rootKeyHex = "f0".repeat(32);685    const vaultId = "b".repeat(64);686    mocks.createVault.mockResolvedValue({ vaultId });687    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));688    mocks.openSocket.mockResolvedValue(socketWithFrames([689      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },690      {691        type: "ADOPT_GRANT",692        root_key_hex: rootKeyHex,693        vault_id: vaultId,694        license: "PPK-test",695        source: {696          device_id: "phone-source-id",697          device_name: "June's phone",698          device_type: "android",699          long_term_public_key_hex: "99".repeat(32),700        },701      },702    ]));703704    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");705    await startRelayAdoptionReceiver("receiver-password");706    await waitForPush("ADOPTION_SOURCE_RESULT");707708    expect(mocks.rollbackVault).toHaveBeenCalledOnce();709    expect(mocks.upsert).not.toHaveBeenCalled();710    expect(mocks.pushes).toContainEqual(expect.objectContaining({711      type: "ADOPTION_SOURCE_RESULT",712      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),713    }));714  });715716  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {717    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).718    // The receiver should skip the 30-second inline snapshot wait entirely719    // and fall through to the sync pipeline — no receiveVaultSnapshot call.720    const rootKeyHex = "f0".repeat(32);721    const vaultId = "b".repeat(64);722    mocks.createVault.mockResolvedValue({ vaultId });723    mocks.openSocket.mockResolvedValue(socketWithFrames([724      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },725      {726        type: "ADOPT_GRANT",727        root_key_hex: rootKeyHex,728        vault_id: vaultId,729        license: "PPK-test",730        source: {731          device_id: "old-phone-id",732          device_name: "Old phone",733          device_type: "android",734          long_term_public_key_hex: "99".repeat(32),735        },736        // NO snapshot_capable: pre-C source does not echo the field737      },738      { type: "ADOPT_COMPLETE" },739    ], { closeOnEmpty: true }));740741    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");742    await startRelayAdoptionReceiver("receiver-password");743    await waitForPush("ADOPTION_SOURCE_RESULT");744745    // Sync fallback ran immediately (no 30s wait).746    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });747    // Adoption completed successfully.748    expect(mocks.pushes).toContainEqual(expect.objectContaining({749      type: "ADOPTION_SOURCE_RESULT",750      payload: expect.objectContaining({ status: "complete" }),751    }));752    // The initial sync pending marker was set (not cleared — no snapshot applied).753    expect(mocks.storageSet).toHaveBeenCalledWith(754      "peach_initial_sync_pending_device_id",755      "old-phone-id",756    );757  });758759});760761function decodeInvitation(encoded: string): { vault?: string } | null {762  // Mirrors parsePairingInvitation's payload structure without pulling the763  // module in: the receiver test only needs to assert the embedded status.764  const at = encoded.indexOf(":");765  if (at < 0) return null;766  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");767  try {768    const json = Buffer.from(b64, "base64").toString("utf8");769    return JSON.parse(json) as { vault?: string };770  } catch {771    return null;772  }773}