relay-adoption-bg.test.ts

29.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  createSyncAdapter: vi.fn(),19  sasDecisions: [] as Array<{ id: string; ok: boolean }>,20}));2122vi.mock("../shared/pairing-dial", () => ({23  openRelayPairingSocket: mocks.openSocket,24}));2526vi.mock("../shared/noise-js", () => ({27  noiseNKResponder: vi.fn(async () => ({28    handshakeHash: new Uint8Array(32).fill(7),29    encrypt: (bytes: Uint8Array) => bytes,30    decrypt: (bytes: Uint8Array) => bytes,31    close: vi.fn(),32  })),33}));3435vi.mock("../shared/rendezvous", async (importOriginal) => {36  const actual = await importOriginal<typeof import("../shared/rendezvous")>();37  return {38    ...actual,39    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",40  };41});424344vi.mock("./device-identity", () => ({45  getLocalPairingIdentity: async () => ({46    identity: {47      deviceId: "test-device",48      deviceName: "Test",49      deviceType: "extension",50      longTermPublicKeyHex: "aa".repeat(32),51      vaultId: "bb".repeat(32),52    },53    privateKey: new Uint8Array(32).fill(7),54    publicKey: new Uint8Array(32).fill(8),55  }),56  getDeviceName: mocks.getDeviceName,57  buildLocalIdentity: mocks.localIdentity,58}));5960vi.mock("./sync-settings", async (importOriginal) => ({61  ...(await importOriginal<typeof import("./sync-settings")>()),62  getSyncSettings: async () => ({63    mode: "public" as const,64    relayUrl: "wss://relay.peachpasswords.com",65  }),66}));6768vi.mock("./license", () => ({69  isProUser: mocks.isProUser,70  getLicenseKey: mocks.getLicenseKey,71  activateLicense: mocks.activateLicense,72}));7374vi.mock("./vault", () => ({75  isUnlocked: vi.fn(() => true),76  getRootKeyHex: mocks.getRootKeyHex,77  createVaultFromRootKey: mocks.createVault,78  rollbackAdoptedVault: mocks.rollbackVault,79  exportDuressSlotForAdoption: vi.fn(async () => null),80  installAdoptedDuressSlot: vi.fn(async () => undefined),81}));8283vi.mock("../shared/sync-identity", () => ({84  computeVaultId: vi.fn(async () => "b".repeat(64)),85}));8687vi.mock("../shared/storage", () => ({88  storageSet: mocks.storageSet,89}));9091vi.mock("./paired-devices", () => ({92  upsertPairedDevice: mocks.upsert,93}));9495vi.mock("./sync-trigger", () => ({96  triggerSyncIfReachable: mocks.triggerSync,97}));9899vi.mock("./sync-vault-adapter", () => ({100  createSyncVaultAdapter: mocks.createSyncAdapter,101}));102103import {104  cancelRelayAdoptionSource,105  startRelayAdoptionSource,106} from "./relay-adoption-bg";107108const encode = (value: unknown) =>109  new TextEncoder().encode(JSON.stringify(value));110111function socketWithFrames(112  frames: unknown[],113  options: { closeOnEmpty?: boolean } = {},114) {115  const queue = frames.map(encode);116  return {117    send: vi.fn(),118    receive: vi.fn(async () => {119      const next = queue.shift();120      if (!next) {121        // A real peer closes the channel once it runs out of frames; model122        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast123        // and falls back to the sync pipeline instead of hanging on a timeout.124        if (options.closeOnEmpty) throw new Error("socket_closed");125        return new Promise<Uint8Array>(() => undefined);126      }127      return next;128    }),129    close: vi.fn(),130    closedBeforeFrame: vi.fn(() => false),131  };132}133134async function waitForPush(type: string): Promise<void> {135  await vi.waitFor(() => {136    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);137  });138}139140beforeEach(() => {141  mocks.pushes.length = 0;142  mocks.sasDecisions.length = 0;143  mocks.openSocket.mockReset();144  mocks.getLocalIdentity.mockReset();145  mocks.getRootKeyHex.mockReset();146  mocks.upsert.mockReset();147  mocks.triggerSync.mockReset();148  mocks.triggerSync.mockResolvedValue(undefined);149  mocks.isProUser.mockReset();150  mocks.isProUser.mockResolvedValue(true);151  mocks.getLicenseKey.mockReset();152  mocks.getLicenseKey.mockResolvedValue("PPK-source");153  mocks.getLocalIdentity.mockResolvedValue({154    identity: {155      deviceId: "browser-source",156      deviceName: "June's Browser",157      deviceType: "extension",158      longTermPublicKeyHex: "a".repeat(64),159      vaultId: "b".repeat(64),160    },161    privateKey: new Uint8Array(32).fill(1),162    publicKey: new Uint8Array(32).fill(2),163  });164  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));165  mocks.upsert.mockResolvedValue(undefined);166  mocks.createSyncAdapter.mockReturnValue({167    buildEntryManifest: vi.fn(async () => ({})),168    getEncryptedEntries: vi.fn(async () => ({})),169    buildTombstoneManifest: vi.fn(async () => ({})),170    applyEntries: vi.fn(async () => undefined),171    applyTombstones: vi.fn(async () => undefined),172    buildOutgoingDeviceManifest: vi.fn(async () => null),173  });174  Object.defineProperty(globalThis, "chrome", {175    configurable: true,176    value: {177    runtime: {178      async sendMessage(message: { type: string; payload: unknown }) {179        mocks.pushes.push(message);180        // The source flow waits for the popup to send SOURCE_SAS_DECISION before181        // forwarding the root key. The receiver test overrides this mock and182        // resolves the decision itself; the source tests rely on this default183        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.184        if (message.type === "ADOPTION_SOURCE_SAS") {185          const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");186          const p = message.payload as { id?: string };187          if (typeof p?.id === "string") {188            mocks.sasDecisions.push({ id: p.id, ok: true });189            resolveSourceSasConfirmation(p.id, true);190          }191        }192        return Promise.resolve();193      },194    },195    },196  });197});198199describe("relay adoption source background boundary", () => {200  it("shows the SAS and waits for the popup's single confirmation", async () => {201    const socket = socketWithFrames([202      {203        type: "PEACH_HELLO",204        token: "AAECAwQFBgcICQoLDA0ODw",205      },206      {207        type: "ADOPT_REQUEST",208        device_id: "request-only-label",209        device_name: "June's phone",210        device_type: "android",211      },212      { type: "ADOPT_CONFIRM", confirmed: true },213      {214        type: "ADOPT_IDENTITY",215        device_id: "android-receiver",216        device_name: "June's phone",217        device_type: "android",218        long_term_public_key_hex: "d".repeat(64),219      },220      { type: "ADOPT_APPLIED" },221    ]);222    mocks.openSocket.mockResolvedValue(socket);223224    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");225    const sasIds: string[] = [];226    Object.defineProperty(globalThis, "chrome", {227      configurable: true,228      value: {229        runtime: {230          async sendMessage(message: { type: string; payload: unknown }) {231            mocks.pushes.push(message);232            if (message.type === "ADOPTION_SOURCE_SAS") {233              const p = message.payload as { id?: string };234              if (typeof p?.id === "string") {235                // Hold the SAS gate open until the test explicitly approves236                // it — this proves the source flow waits for the popup.237                sasIds.push(p.id);238              }239              return Promise.resolve();240            }241            return Promise.resolve();242          },243        },244      },245    });246    void resolveSourceSasConfirmation; // imported for the post-approval call below247248    const started = await startRelayAdoptionSource();249    expect(started.invitation).toMatch(/^peach-pair-v1:/);250    await waitForPush("ADOPTION_SOURCE_SAS");251    // The adoption must NOT be complete yet — the popup's SAS decision is the252    // prerequisite for forwarding the root key.253    await vi.waitFor(() => {254      expect(mocks.sasDecisions.length).toBe(0);255    });256    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();257    expect(mocks.upsert).not.toHaveBeenCalled();258259    // Approve the SAS; the adoption should now proceed and complete.260    const id = sasIds[0];261    expect(typeof id).toBe("string");262    resolveSourceSasConfirmation(id, true);263    mocks.sasDecisions.push({ id, ok: true });264265    await waitForPush("ADOPTION_SOURCE_RESULT");266267    expect(mocks.getRootKeyHex).toHaveBeenCalled();268    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({269      deviceId: "android-receiver",270      relayUrl: "wss://relay.peachpasswords.com",271    }));272    expect(mocks.pushes).toContainEqual(expect.objectContaining({273      type: "ADOPTION_SOURCE_RESULT",274      payload: expect.objectContaining({ status: "complete" }),275    }));276    expect(mocks.triggerSync).toHaveBeenCalledWith({277      reason: "manual",278      force: true,279    });280    const sentTypes = socket.send.mock.calls281      .map(([bytes]) => {282        try {283          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;284        } catch {285          return undefined;286        }287      });288    expect(sentTypes).toContain("ADOPT_COMPLETE");289  });290291  it("fails adoption without sending snapshot frames when duress is oversized", async () => {292    const socket = socketWithFrames([293      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },294      {295        type: "ADOPT_REQUEST",296        device_id: "request-only-label",297        device_name: "June's phone",298        device_type: "android",299        snapshot_capable: true,300      },301      { type: "ADOPT_CONFIRM", confirmed: true },302      {303        type: "ADOPT_IDENTITY",304        device_id: "android-receiver",305        device_name: "June's phone",306        device_type: "android",307        long_term_public_key_hex: "d".repeat(64),308      },309      { type: "ADOPT_APPLIED" },310    ]);311    mocks.openSocket.mockResolvedValue(socket);312    const { exportDuressSlotForAdoption } = await import("./vault");313    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(314      "x".repeat(768 * 1024),315    );316317    await startRelayAdoptionSource();318    await waitForPush("ADOPTION_SOURCE_RESULT");319320    expect(mocks.pushes).toContainEqual({321      type: "ADOPTION_SOURCE_RESULT",322      payload: {323        status: "error",324        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",325      },326    });327    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({328      type: "ADOPTION_SOURCE_RESULT",329      payload: expect.objectContaining({ status: "complete" }),330    }));331    const sentTypes = socket.send.mock.calls.map(([bytes]) =>332      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,333    );334    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");335    expect(mocks.triggerSync).not.toHaveBeenCalled();336  });337338  it("fails closed when the popup rejects the SAS", async () => {339    const socket = socketWithFrames([340      {341        type: "PEACH_HELLO",342        token: "AAECAwQFBgcICQoLDA0ODw",343      },344      {345        type: "ADOPT_REQUEST",346        device_id: "request-only-label",347        device_name: "New phone",348        device_type: "android",349      },350      { type: "ADOPT_CONFIRM", confirmed: false },351    ]);352    mocks.openSocket.mockResolvedValue(socket);353354    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");355    let pendingId: string | undefined;356    Object.defineProperty(globalThis, "chrome", {357      configurable: true,358      value: {359        runtime: {360          sendMessage(message: { type: string; payload: unknown }) {361            mocks.pushes.push(message);362            if (message.type === "ADOPTION_SOURCE_SAS") {363              const p = message.payload as { id?: string };364              if (typeof p?.id === "string") pendingId = p.id;365            }366            return Promise.resolve();367          },368        },369      },370    });371372    await startRelayAdoptionSource();373    await waitForPush("ADOPTION_SOURCE_SAS");374    expect(typeof pendingId).toBe("string");375    resolveSourceSasConfirmation(pendingId!, false);376    mocks.sasDecisions.push({ id: pendingId!, ok: false });377378    await waitForPush("ADOPTION_SOURCE_RESULT");379380    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();381    expect(mocks.upsert).not.toHaveBeenCalled();382    expect(mocks.pushes).toContainEqual({383      type: "ADOPTION_SOURCE_RESULT",384      payload: { status: "error", error: "adoption_rejected" },385    });386  });387388  it("rejects before reading the root key when neither peer has Pro", async () => {389    const socket = socketWithFrames([390      {391        type: "PEACH_HELLO",392        token: "AAECAwQFBgcICQoLDA0ODw",393      },394      {395        type: "ADOPT_REQUEST",396        device_id: "free-receiver",397        device_name: "Free phone",398        device_type: "android",399      },400      { type: "ADOPT_CONFIRM", confirmed: true },401    ]);402    mocks.isProUser.mockResolvedValue(false);403    mocks.getLicenseKey.mockResolvedValue(undefined);404    mocks.openSocket.mockResolvedValue(socket);405406    await startRelayAdoptionSource();407    await waitForPush("ADOPTION_SOURCE_RESULT");408409    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();410    expect(mocks.upsert).not.toHaveBeenCalled();411    expect(mocks.pushes).toContainEqual({412      type: "ADOPTION_SOURCE_RESULT",413      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },414    });415    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {416      try {417        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];418      } catch {419        return [];420      }421    });422    expect(sentFrames).toContainEqual({423      type: "ADOPT_REJECT",424      reason: "ADOPT_PRO_REQUIRED",425    });426  });427428  it("fails closed when the user never answers the SAS", async () => {429    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they430    // schedule real work), so we hold the SAS gate open by never resolving431    // the deferred and then cancel the session. clearState must drop the432    // pending SAS so it doesn't pin the relay room open for the next run.433    const socket = socketWithFrames([]);434    mocks.openSocket.mockResolvedValue(socket);435436    Object.defineProperty(globalThis, "chrome", {437      configurable: true,438      value: {439        runtime: {440          sendMessage(message: { type: string; payload: unknown }) {441            mocks.pushes.push(message);442            return Promise.resolve();443          },444        },445      },446    });447448    await startRelayAdoptionSource();449    cancelRelayAdoptionSource();450451    expect(socket.close).toHaveBeenCalled();452    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();453    expect(mocks.upsert).not.toHaveBeenCalled();454  });455456  it("cancelling the source screen closes the room without reading the root", async () => {457    const socket = socketWithFrames([]);458    mocks.openSocket.mockResolvedValue(socket);459460    await startRelayAdoptionSource();461    cancelRelayAdoptionSource();462463    expect(socket.close).toHaveBeenCalled();464    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();465    expect(mocks.upsert).not.toHaveBeenCalled();466  });467});468469describe("relay adoption receiver background", () => {470  beforeEach(async () => {471    // The receiver session runs in the background via `void runReceiverSession`;472    // it doesn't get awaited by the caller and can leave `inFlight` set if the473    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test474    // starts from a clean state.475    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");476    cancelRelayAdoptionSource();477    mocks.pushes.length = 0;478    mocks.openSocket.mockReset();479    mocks.getDeviceName.mockReset();480    mocks.getDeviceName.mockResolvedValue("Receiver Browser");481    mocks.createVault.mockReset();482    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });483    mocks.activateLicense.mockReset();484    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });485    mocks.isProUser.mockReset();486    mocks.isProUser.mockResolvedValue(true);487    mocks.getLicenseKey.mockReset();488    mocks.getLicenseKey.mockResolvedValue(undefined);489    mocks.localIdentity.mockReset();490    mocks.localIdentity.mockResolvedValue({491      deviceId: "receiver-id",492      deviceName: "Receiver Browser",493      deviceType: "extension",494      longTermPublicKeyHex: "11".repeat(32),495      signingPublicKeyHex: "22".repeat(32),496    });497    mocks.rollbackVault.mockReset();498    mocks.rollbackVault.mockResolvedValue(undefined);499    mocks.storageSet.mockReset();500    mocks.storageSet.mockResolvedValue(undefined);501    mocks.upsert.mockReset();502    mocks.upsert.mockResolvedValue(undefined);503    mocks.triggerSync.mockReset();504    mocks.triggerSync.mockResolvedValue(undefined);505    mocks.openSocket.mockResolvedValue(socketWithFrames([]));506    Object.defineProperty(globalThis, "chrome", {507      configurable: true,508      value: {509        runtime: {510          sendMessage(message: { type: string; payload?: unknown }) {511            mocks.pushes.push({ type: message.type, payload: message.payload });512            return Promise.resolve({ ok: true });513          },514          onMessage: {515            addListener() { /* no-op */ },516            removeListener() { /* no-op */ },517          },518        },519      },520    });521  });522523  it("opens a relay socket and returns an empty invitation", async () => {524    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");525    const started = await startRelayAdoptionReceiver("receiver-password");526    expect(started.invitation).toMatch(/^peach-pair-v1:/);527    // The QR carries vault:"empty" so the phone knows to act as source.528    const decoded = decodeInvitation(started.invitation);529    expect(decoded?.vault).toBe("empty");530    expect(mocks.openSocket).toHaveBeenCalled();531  });532533  it("rejects an empty password", async () => {534    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");535    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");536  });537538  it("surfaces the Pro-required rejection without creating a vault", async () => {539    mocks.openSocket.mockResolvedValue(socketWithFrames([540      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },541      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },542    ]));543544    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");545    await startRelayAdoptionReceiver("receiver-password");546    await waitForPush("ADOPTION_SOURCE_RESULT");547548    expect(mocks.createVault).not.toHaveBeenCalled();549    expect(mocks.rollbackVault).not.toHaveBeenCalled();550    expect(mocks.pushes).toContainEqual({551      type: "ADOPTION_SOURCE_RESULT",552      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },553    });554  });555556  it("the receiver wires requestAdoption, not grantAdoption", async () => {557    // The receiver's protocol position is the opposite of the source side:558    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.559    // Assert this by watching what lands on socket.send: the receiver's560    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY561    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.562    const rootKeyHex = "f0".repeat(32);563    const vaultId = "b".repeat(64);564    const socket = socketWithFrames([565      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },566      {567        type: "ADOPT_GRANT",568        root_key_hex: rootKeyHex,569        vault_id: vaultId,570        license: "PPK-test",571        source: {572          device_id: "phone-source-id",573          device_name: "June's phone",574          device_type: "android",575          long_term_public_key_hex: "99".repeat(32),576        },577      },578      { type: "ADOPT_COMPLETE" },579    ], { closeOnEmpty: true });580    mocks.openSocket.mockResolvedValue(socket);581    mocks.createVault.mockResolvedValue({ vaultId });582583    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");584    Object.defineProperty(globalThis, "chrome", {585      configurable: true,586      value: {587        runtime: {588          sendMessage(message: { type: string; payload?: unknown }) {589            mocks.pushes.push({ type: message.type, payload: message.payload });590            return Promise.resolve({ ok: true });591          },592          onMessage: { addListener: () => undefined, removeListener: () => undefined },593        },594      },595    });596    await startRelayAdoptionReceiver("receiver-password");597    await waitForPush("ADOPTION_SOURCE_RESULT");598599    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })600      .mock.calls601      .map(([bytes]) => {602        try {603          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;604        } catch {605          return undefined;606        }607      });608    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;609    // ADOPT_GRANT is the source side's frame and must never appear here.610    expect(sentTypes).toContain("ADOPT_REQUEST");611    expect(sentTypes).not.toContain("ADOPT_GRANT");612  });613614  it("completes the handshake end-to-end: vault created → paired", async () => {615    // Wire the receiver's noise mock and SAS gate together so the full616    // requestAdoption protocol runs against an in-memory phone that sends617    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.618    // The mocked noise is pass-through (encrypt = decrypt = identity), so619    // frames land as ordinary JSON.620    const rootKeyHex = "f0".repeat(32);621    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);622    // the grant's vault_id is set to the same so the protocol proceeds.623    const vaultId = "b".repeat(64);624    const frames = [625      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },626      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two627      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.628      {629        type: "ADOPT_GRANT",630        root_key_hex: rootKeyHex,631        vault_id: vaultId,632        license: "PPK-test",633        source: {634          device_id: "phone-source-id",635          device_name: "June's phone",636          device_type: "android",637          long_term_public_key_hex: "99".repeat(32),638          signing_public_key_hex: "aa".repeat(32),639          relay_url: "wss://relay.peachpasswords.com",640        },641      },642      { type: "ADOPT_COMPLETE" },643    ];644    mocks.createVault.mockResolvedValue({ vaultId });645    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));646647    // Replace the chrome.runtime mock for this one test. The receiver648    // auto-accepts the SAS, so no decision needs to be simulated.649    Object.defineProperty(globalThis, "chrome", {650      configurable: true,651      value: {652        runtime: {653          sendMessage(message: { type: string; payload?: unknown }) {654            mocks.pushes.push({ type: message.type, payload: message.payload });655            return Promise.resolve({ ok: true });656          },657          onMessage: {658            addListener: () => undefined,659            removeListener: () => undefined,660          },661        },662      },663    });664665    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");666    await startRelayAdoptionReceiver("receiver-password");667668    await waitForPush("ADOPTION_SOURCE_RESULT");669670    // The receiver displays its locally derived SAS without introducing a671    // second confirmation gate.672    expect(mocks.pushes).toContainEqual({673      type: "ADOPTION_SOURCE_SAS",674      payload: expect.objectContaining({675        sas: expect.stringMatching(/^\d{8}$/),676        displayOnly: true,677      }),678    });679680    // The vault module was called directly with the popup-supplied password681    // and the root key granted by the phone.682    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);683684    // License activation, identity derivation, and pairing persistence all685    // call their modules directly from the service worker.686    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");687    expect(mocks.isProUser).toHaveBeenCalled();688    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));689    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({690      deviceId: "phone-source-id",691    }));692    expect(mocks.storageSet).toHaveBeenCalledWith(693      "peach_initial_sync_pending_device_id",694      "phone-source-id",695    );696    expect(mocks.rollbackVault).not.toHaveBeenCalled();697698    // The terminal state is "complete" — the popup can transition to success.699    expect(mocks.pushes).toContainEqual(expect.objectContaining({700      type: "ADOPTION_SOURCE_RESULT",701      payload: expect.objectContaining({ status: "complete" }),702    }));703  });704705  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {706    const rootKeyHex = "f0".repeat(32);707    const vaultId = "b".repeat(64);708    mocks.createVault.mockResolvedValue({ vaultId });709    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });710    mocks.isProUser.mockResolvedValue(false);711    mocks.openSocket.mockResolvedValue(socketWithFrames([712      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },713      {714        type: "ADOPT_GRANT",715        root_key_hex: rootKeyHex,716        vault_id: vaultId,717        license: "PPK-invalid",718        source: {719          device_id: "phone-source-id",720          device_name: "June's phone",721          device_type: "android",722          long_term_public_key_hex: "99".repeat(32),723        },724      },725    ]));726727    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");728    await startRelayAdoptionReceiver("receiver-password");729    await waitForPush("ADOPTION_SOURCE_RESULT");730731    expect(mocks.rollbackVault).toHaveBeenCalledOnce();732    expect(mocks.localIdentity).not.toHaveBeenCalled();733    expect(mocks.upsert).not.toHaveBeenCalled();734    expect(mocks.pushes).toContainEqual(expect.objectContaining({735      type: "ADOPTION_SOURCE_RESULT",736      payload: expect.objectContaining({737        status: "error",738        error: "license_transfer_failed",739      }),740    }));741  });742743  it("rolls back a newly created vault when a later adoption step fails", async () => {744    const rootKeyHex = "f0".repeat(32);745    const vaultId = "b".repeat(64);746    mocks.createVault.mockResolvedValue({ vaultId });747    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));748    mocks.openSocket.mockResolvedValue(socketWithFrames([749      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },750      {751        type: "ADOPT_GRANT",752        root_key_hex: rootKeyHex,753        vault_id: vaultId,754        license: "PPK-test",755        source: {756          device_id: "phone-source-id",757          device_name: "June's phone",758          device_type: "android",759          long_term_public_key_hex: "99".repeat(32),760        },761      },762    ]));763764    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");765    await startRelayAdoptionReceiver("receiver-password");766    await waitForPush("ADOPTION_SOURCE_RESULT");767768    expect(mocks.rollbackVault).toHaveBeenCalledOnce();769    expect(mocks.upsert).not.toHaveBeenCalled();770    expect(mocks.pushes).toContainEqual(expect.objectContaining({771      type: "ADOPTION_SOURCE_RESULT",772      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),773    }));774  });775776  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {777    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).778    // The receiver should skip the 30-second inline snapshot wait entirely779    // and fall through to the sync pipeline — no receiveVaultSnapshot call.780    const rootKeyHex = "f0".repeat(32);781    const vaultId = "b".repeat(64);782    mocks.createVault.mockResolvedValue({ vaultId });783    mocks.openSocket.mockResolvedValue(socketWithFrames([784      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },785      {786        type: "ADOPT_GRANT",787        root_key_hex: rootKeyHex,788        vault_id: vaultId,789        license: "PPK-test",790        source: {791          device_id: "old-phone-id",792          device_name: "Old phone",793          device_type: "android",794          long_term_public_key_hex: "99".repeat(32),795        },796        // NO snapshot_capable: pre-C source does not echo the field797      },798      { type: "ADOPT_COMPLETE" },799    ], { closeOnEmpty: true }));800801    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");802    await startRelayAdoptionReceiver("receiver-password");803    await waitForPush("ADOPTION_SOURCE_RESULT");804805    // Sync fallback ran immediately (no 30s wait).806    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });807    // Adoption completed successfully.808    expect(mocks.pushes).toContainEqual(expect.objectContaining({809      type: "ADOPTION_SOURCE_RESULT",810      payload: expect.objectContaining({ status: "complete" }),811    }));812    // The initial sync pending marker was set (not cleared — no snapshot applied).813    expect(mocks.storageSet).toHaveBeenCalledWith(814      "peach_initial_sync_pending_device_id",815      "old-phone-id",816    );817  });818819});820821function decodeInvitation(encoded: string): { vault?: string } | null {822  // Mirrors parsePairingInvitation's payload structure without pulling the823  // module in: the receiver test only needs to assert the embedded status.824  const at = encoded.indexOf(":");825  if (at < 0) return null;826  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");827  try {828    const json = Buffer.from(b64, "base64").toString("utf8");829    return JSON.parse(json) as { vault?: string };830  } catch {831    return null;832  }833}