relay-adoption-source-bg.test.ts

24.9 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}));7980vi.mock("../shared/sync-identity", () => ({81  computeVaultId: vi.fn(async () => "b".repeat(64)),82}));8384vi.mock("../shared/storage", () => ({85  storageSet: mocks.storageSet,86}));8788vi.mock("./paired-devices", () => ({89  upsertPairedDevice: mocks.upsert,90}));9192vi.mock("./sync-trigger", () => ({93  triggerSyncIfReachable: mocks.triggerSync,94}));9596import {97  cancelRelayAdoptionSource,98  startRelayAdoptionSource,99} from "./relay-adoption-source-bg";100101const encode = (value: unknown) =>102  new TextEncoder().encode(JSON.stringify(value));103104function socketWithFrames(frames: unknown[]) {105  const queue = frames.map(encode);106  return {107    send: vi.fn(),108    receive: vi.fn(async () => {109      const next = queue.shift();110      if (!next) return new Promise<Uint8Array>(() => undefined);111      return next;112    }),113    close: vi.fn(),114    closedBeforeFrame: vi.fn(() => false),115  };116}117118async function waitForPush(type: string): Promise<void> {119  await vi.waitFor(() => {120    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);121  });122}123124beforeEach(() => {125  mocks.pushes.length = 0;126  mocks.sasDecisions.length = 0;127  mocks.openSocket.mockReset();128  mocks.getLocalIdentity.mockReset();129  mocks.getRootKeyHex.mockReset();130  mocks.upsert.mockReset();131  mocks.triggerSync.mockReset();132  mocks.triggerSync.mockResolvedValue(undefined);133  mocks.isProUser.mockReset();134  mocks.isProUser.mockResolvedValue(true);135  mocks.getLicenseKey.mockReset();136  mocks.getLicenseKey.mockResolvedValue("PPK-source");137  mocks.getLocalIdentity.mockResolvedValue({138    identity: {139      deviceId: "browser-source",140      deviceName: "June's Browser",141      deviceType: "extension",142      longTermPublicKeyHex: "a".repeat(64),143      vaultId: "b".repeat(64),144    },145    privateKey: new Uint8Array(32).fill(1),146    publicKey: new Uint8Array(32).fill(2),147  });148  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));149  mocks.upsert.mockResolvedValue(undefined);150  Object.defineProperty(globalThis, "chrome", {151    configurable: true,152    value: {153    runtime: {154      async sendMessage(message: { type: string; payload: unknown }) {155        mocks.pushes.push(message);156        // The source flow waits for the popup to send SOURCE_SAS_DECISION before157        // forwarding the root key. The receiver test overrides this mock and158        // resolves the decision itself; the source tests rely on this default159        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.160        if (message.type === "ADOPTION_SOURCE_SAS") {161          const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");162          const p = message.payload as { id?: string };163          if (typeof p?.id === "string") {164            mocks.sasDecisions.push({ id: p.id, ok: true });165            resolveSourceSasConfirmation(p.id, true);166          }167        }168        return Promise.resolve();169      },170    },171    },172  });173});174175describe("relay adoption source background boundary", () => {176  it("shows the SAS and waits for the popup's single confirmation", async () => {177    const socket = socketWithFrames([178      {179        type: "PEACH_HELLO",180        token: "AAECAwQFBgcICQoLDA0ODw",181      },182      {183        type: "ADOPT_REQUEST",184        device_id: "request-only-label",185        device_name: "June's phone",186        device_type: "android",187      },188      { type: "ADOPT_CONFIRM", confirmed: true },189      {190        type: "ADOPT_IDENTITY",191        device_id: "android-receiver",192        device_name: "June's phone",193        device_type: "android",194        long_term_public_key_hex: "d".repeat(64),195      },196      { type: "ADOPT_APPLIED" },197    ]);198    mocks.openSocket.mockResolvedValue(socket);199200    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");201    const sasIds: string[] = [];202    Object.defineProperty(globalThis, "chrome", {203      configurable: true,204      value: {205        runtime: {206          async sendMessage(message: { type: string; payload: unknown }) {207            mocks.pushes.push(message);208            if (message.type === "ADOPTION_SOURCE_SAS") {209              const p = message.payload as { id?: string };210              if (typeof p?.id === "string") {211                // Hold the SAS gate open until the test explicitly approves212                // it — this proves the source flow waits for the popup.213                sasIds.push(p.id);214              }215              return Promise.resolve();216            }217            return Promise.resolve();218          },219        },220      },221    });222    void resolveSourceSasConfirmation; // imported for the post-approval call below223224    const started = await startRelayAdoptionSource();225    expect(started.invitation).toMatch(/^peach-pair-v1:/);226    await waitForPush("ADOPTION_SOURCE_SAS");227    // The adoption must NOT be complete yet — the popup's SAS decision is the228    // prerequisite for forwarding the root key.229    await vi.waitFor(() => {230      expect(mocks.sasDecisions.length).toBe(0);231    });232    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();233    expect(mocks.upsert).not.toHaveBeenCalled();234235    // Approve the SAS; the adoption should now proceed and complete.236    const id = sasIds[0];237    expect(typeof id).toBe("string");238    resolveSourceSasConfirmation(id, true);239    mocks.sasDecisions.push({ id, ok: true });240241    await waitForPush("ADOPTION_SOURCE_RESULT");242243    expect(mocks.getRootKeyHex).toHaveBeenCalled();244    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({245      deviceId: "android-receiver",246      relayUrl: "wss://relay.peachpasswords.com",247    }));248    expect(mocks.pushes).toContainEqual(expect.objectContaining({249      type: "ADOPTION_SOURCE_RESULT",250      payload: expect.objectContaining({ status: "complete" }),251    }));252    expect(mocks.triggerSync).toHaveBeenCalledWith({253      reason: "manual",254      force: true,255    });256    const sentTypes = socket.send.mock.calls257      .map(([bytes]) => {258        try {259          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;260        } catch {261          return undefined;262        }263      });264    expect(sentTypes).toContain("ADOPT_COMPLETE");265  });266267  it("fails closed when the popup rejects the SAS", async () => {268    const socket = socketWithFrames([269      {270        type: "PEACH_HELLO",271        token: "AAECAwQFBgcICQoLDA0ODw",272      },273      {274        type: "ADOPT_REQUEST",275        device_id: "request-only-label",276        device_name: "New phone",277        device_type: "android",278      },279      { type: "ADOPT_CONFIRM", confirmed: false },280    ]);281    mocks.openSocket.mockResolvedValue(socket);282283    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");284    let pendingId: string | undefined;285    Object.defineProperty(globalThis, "chrome", {286      configurable: true,287      value: {288        runtime: {289          sendMessage(message: { type: string; payload: unknown }) {290            mocks.pushes.push(message);291            if (message.type === "ADOPTION_SOURCE_SAS") {292              const p = message.payload as { id?: string };293              if (typeof p?.id === "string") pendingId = p.id;294            }295            return Promise.resolve();296          },297        },298      },299    });300301    await startRelayAdoptionSource();302    await waitForPush("ADOPTION_SOURCE_SAS");303    expect(typeof pendingId).toBe("string");304    resolveSourceSasConfirmation(pendingId!, false);305    mocks.sasDecisions.push({ id: pendingId!, ok: false });306307    await waitForPush("ADOPTION_SOURCE_RESULT");308309    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();310    expect(mocks.upsert).not.toHaveBeenCalled();311    expect(mocks.pushes).toContainEqual({312      type: "ADOPTION_SOURCE_RESULT",313      payload: { status: "error", error: "adoption_rejected" },314    });315  });316317  it("rejects before reading the root key when neither peer has Pro", async () => {318    const socket = socketWithFrames([319      {320        type: "PEACH_HELLO",321        token: "AAECAwQFBgcICQoLDA0ODw",322      },323      {324        type: "ADOPT_REQUEST",325        device_id: "free-receiver",326        device_name: "Free phone",327        device_type: "android",328      },329      { type: "ADOPT_CONFIRM", confirmed: true },330    ]);331    mocks.isProUser.mockResolvedValue(false);332    mocks.getLicenseKey.mockResolvedValue(undefined);333    mocks.openSocket.mockResolvedValue(socket);334335    await startRelayAdoptionSource();336    await waitForPush("ADOPTION_SOURCE_RESULT");337338    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();339    expect(mocks.upsert).not.toHaveBeenCalled();340    expect(mocks.pushes).toContainEqual({341      type: "ADOPTION_SOURCE_RESULT",342      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },343    });344    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {345      try {346        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];347      } catch {348        return [];349      }350    });351    expect(sentFrames).toContainEqual({352      type: "ADOPT_REJECT",353      reason: "ADOPT_PRO_REQUIRED",354    });355  });356357  it("fails closed when the user never answers the SAS", async () => {358    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they359    // schedule real work), so we hold the SAS gate open by never resolving360    // the deferred and then cancel the session. clearState must drop the361    // pending SAS so it doesn't pin the relay room open for the next run.362    const socket = socketWithFrames([]);363    mocks.openSocket.mockResolvedValue(socket);364365    Object.defineProperty(globalThis, "chrome", {366      configurable: true,367      value: {368        runtime: {369          sendMessage(message: { type: string; payload: unknown }) {370            mocks.pushes.push(message);371            return Promise.resolve();372          },373        },374      },375    });376377    await startRelayAdoptionSource();378    cancelRelayAdoptionSource();379380    expect(socket.close).toHaveBeenCalled();381    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();382    expect(mocks.upsert).not.toHaveBeenCalled();383  });384385  it("cancelling the source screen closes the room without reading the root", async () => {386    const socket = socketWithFrames([]);387    mocks.openSocket.mockResolvedValue(socket);388389    await startRelayAdoptionSource();390    cancelRelayAdoptionSource();391392    expect(socket.close).toHaveBeenCalled();393    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();394    expect(mocks.upsert).not.toHaveBeenCalled();395  });396});397398describe("relay adoption receiver background", () => {399  beforeEach(async () => {400    // The receiver session runs in the background via `void runReceiverSession`;401    // it doesn't get awaited by the caller and can leave `inFlight` set if the402    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test403    // starts from a clean state.404    const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");405    cancelRelayAdoptionSource();406    mocks.pushes.length = 0;407    mocks.openSocket.mockReset();408    mocks.getDeviceName.mockReset();409    mocks.getDeviceName.mockResolvedValue("Receiver Browser");410    mocks.createVault.mockReset();411    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });412    mocks.activateLicense.mockReset();413    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });414    mocks.isProUser.mockReset();415    mocks.isProUser.mockResolvedValue(true);416    mocks.getLicenseKey.mockReset();417    mocks.getLicenseKey.mockResolvedValue(undefined);418    mocks.localIdentity.mockReset();419    mocks.localIdentity.mockResolvedValue({420      deviceId: "receiver-id",421      deviceName: "Receiver Browser",422      deviceType: "extension",423      longTermPublicKeyHex: "11".repeat(32),424      signingPublicKeyHex: "22".repeat(32),425    });426    mocks.rollbackVault.mockReset();427    mocks.rollbackVault.mockResolvedValue(undefined);428    mocks.storageSet.mockReset();429    mocks.storageSet.mockResolvedValue(undefined);430    mocks.upsert.mockReset();431    mocks.upsert.mockResolvedValue(undefined);432    mocks.triggerSync.mockReset();433    mocks.triggerSync.mockResolvedValue(undefined);434    mocks.openSocket.mockResolvedValue(socketWithFrames([]));435    Object.defineProperty(globalThis, "chrome", {436      configurable: true,437      value: {438        runtime: {439          sendMessage(message: { type: string; payload?: unknown }) {440            mocks.pushes.push({ type: message.type, payload: message.payload });441            return Promise.resolve({ ok: true });442          },443          onMessage: {444            addListener() { /* no-op */ },445            removeListener() { /* no-op */ },446          },447        },448      },449    });450  });451452  it("opens a relay socket and returns an empty invitation", async () => {453    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");454    const started = await startRelayAdoptionReceiver("receiver-password");455    expect(started.invitation).toMatch(/^peach-pair-v1:/);456    // The QR carries vault:"empty" so the phone knows to act as source.457    const decoded = decodeInvitation(started.invitation);458    expect(decoded?.vault).toBe("empty");459    expect(mocks.openSocket).toHaveBeenCalled();460  });461462  it("rejects an empty password", async () => {463    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");464    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");465  });466467  it("surfaces the Pro-required rejection without creating a vault", async () => {468    mocks.openSocket.mockResolvedValue(socketWithFrames([469      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },470      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },471    ]));472473    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");474    await startRelayAdoptionReceiver("receiver-password");475    await waitForPush("ADOPTION_SOURCE_RESULT");476477    expect(mocks.createVault).not.toHaveBeenCalled();478    expect(mocks.rollbackVault).not.toHaveBeenCalled();479    expect(mocks.pushes).toContainEqual({480      type: "ADOPTION_SOURCE_RESULT",481      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },482    });483  });484485  it("the receiver wires requestAdoption, not grantAdoption", async () => {486    // The receiver's protocol position is the opposite of the source side:487    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.488    // Assert this by watching what lands on socket.send: the receiver's489    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY490    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.491    const rootKeyHex = "f0".repeat(32);492    const vaultId = "b".repeat(64);493    const socket = socketWithFrames([494      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },495      {496        type: "ADOPT_GRANT",497        root_key_hex: rootKeyHex,498        vault_id: vaultId,499        license: "PPK-test",500        source: {501          device_id: "phone-source-id",502          device_name: "June's phone",503          device_type: "android",504          long_term_public_key_hex: "99".repeat(32),505        },506      },507      { type: "ADOPT_COMPLETE" },508    ]);509    mocks.openSocket.mockResolvedValue(socket);510    mocks.createVault.mockResolvedValue({ vaultId });511512    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");513    Object.defineProperty(globalThis, "chrome", {514      configurable: true,515      value: {516        runtime: {517          sendMessage(message: { type: string; payload?: unknown }) {518            mocks.pushes.push({ type: message.type, payload: message.payload });519            return Promise.resolve({ ok: true });520          },521          onMessage: { addListener: () => undefined, removeListener: () => undefined },522        },523      },524    });525    await startRelayAdoptionReceiver("receiver-password");526    await waitForPush("ADOPTION_SOURCE_RESULT");527528    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })529      .mock.calls530      .map(([bytes]) => {531        try {532          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;533        } catch {534          return undefined;535        }536      });537    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;538    // ADOPT_GRANT is the source side's frame and must never appear here.539    expect(sentTypes).toContain("ADOPT_REQUEST");540    expect(sentTypes).not.toContain("ADOPT_GRANT");541  });542543  it("completes the handshake end-to-end: vault created → paired", async () => {544    // Wire the receiver's noise mock and SAS gate together so the full545    // requestAdoption protocol runs against an in-memory phone that sends546    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.547    // The mocked noise is pass-through (encrypt = decrypt = identity), so548    // frames land as ordinary JSON.549    const rootKeyHex = "f0".repeat(32);550    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);551    // the grant's vault_id is set to the same so the protocol proceeds.552    const vaultId = "b".repeat(64);553    const frames = [554      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },555      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two556      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.557      {558        type: "ADOPT_GRANT",559        root_key_hex: rootKeyHex,560        vault_id: vaultId,561        license: "PPK-test",562        source: {563          device_id: "phone-source-id",564          device_name: "June's phone",565          device_type: "android",566          long_term_public_key_hex: "99".repeat(32),567          signing_public_key_hex: "aa".repeat(32),568          relay_url: "wss://relay.peachpasswords.com",569        },570      },571      { type: "ADOPT_COMPLETE" },572    ];573    mocks.createVault.mockResolvedValue({ vaultId });574    mocks.openSocket.mockResolvedValue(socketWithFrames(frames));575576    // Replace the chrome.runtime mock for this one test. The receiver577    // auto-accepts the SAS, so no decision needs to be simulated.578    Object.defineProperty(globalThis, "chrome", {579      configurable: true,580      value: {581        runtime: {582          sendMessage(message: { type: string; payload?: unknown }) {583            mocks.pushes.push({ type: message.type, payload: message.payload });584            return Promise.resolve({ ok: true });585          },586          onMessage: {587            addListener: () => undefined,588            removeListener: () => undefined,589          },590        },591      },592    });593594    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");595    await startRelayAdoptionReceiver("receiver-password");596597    await waitForPush("ADOPTION_SOURCE_RESULT");598599    // The receiver displays its locally derived SAS without introducing a600    // second confirmation gate.601    expect(mocks.pushes).toContainEqual({602      type: "ADOPTION_SOURCE_SAS",603      payload: expect.objectContaining({604        sas: expect.stringMatching(/^\d{8}$/),605        displayOnly: true,606      }),607    });608609    // The vault module was called directly with the popup-supplied password610    // and the root key granted by the phone.611    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);612613    // License activation, identity derivation, and pairing persistence all614    // call their modules directly from the service worker.615    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");616    expect(mocks.isProUser).toHaveBeenCalled();617    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));618    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({619      deviceId: "phone-source-id",620    }));621    expect(mocks.storageSet).toHaveBeenCalledWith(622      "peach_initial_sync_pending_device_id",623      "phone-source-id",624    );625    expect(mocks.rollbackVault).not.toHaveBeenCalled();626627    // The terminal state is "complete" — the popup can transition to success.628    expect(mocks.pushes).toContainEqual(expect.objectContaining({629      type: "ADOPTION_SOURCE_RESULT",630      payload: expect.objectContaining({ status: "complete" }),631    }));632  });633634  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {635    const rootKeyHex = "f0".repeat(32);636    const vaultId = "b".repeat(64);637    mocks.createVault.mockResolvedValue({ vaultId });638    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });639    mocks.isProUser.mockResolvedValue(false);640    mocks.openSocket.mockResolvedValue(socketWithFrames([641      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },642      {643        type: "ADOPT_GRANT",644        root_key_hex: rootKeyHex,645        vault_id: vaultId,646        license: "PPK-invalid",647        source: {648          device_id: "phone-source-id",649          device_name: "June's phone",650          device_type: "android",651          long_term_public_key_hex: "99".repeat(32),652        },653      },654    ]));655656    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");657    await startRelayAdoptionReceiver("receiver-password");658    await waitForPush("ADOPTION_SOURCE_RESULT");659660    expect(mocks.rollbackVault).toHaveBeenCalledOnce();661    expect(mocks.localIdentity).not.toHaveBeenCalled();662    expect(mocks.upsert).not.toHaveBeenCalled();663    expect(mocks.pushes).toContainEqual(expect.objectContaining({664      type: "ADOPTION_SOURCE_RESULT",665      payload: expect.objectContaining({666        status: "error",667        error: "license_transfer_failed",668      }),669    }));670  });671672  it("rolls back a newly created vault when a later adoption step fails", async () => {673    const rootKeyHex = "f0".repeat(32);674    const vaultId = "b".repeat(64);675    mocks.createVault.mockResolvedValue({ vaultId });676    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));677    mocks.openSocket.mockResolvedValue(socketWithFrames([678      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },679      {680        type: "ADOPT_GRANT",681        root_key_hex: rootKeyHex,682        vault_id: vaultId,683        license: "PPK-test",684        source: {685          device_id: "phone-source-id",686          device_name: "June's phone",687          device_type: "android",688          long_term_public_key_hex: "99".repeat(32),689        },690      },691    ]));692693    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");694    await startRelayAdoptionReceiver("receiver-password");695    await waitForPush("ADOPTION_SOURCE_RESULT");696697    expect(mocks.rollbackVault).toHaveBeenCalledOnce();698    expect(mocks.upsert).not.toHaveBeenCalled();699    expect(mocks.pushes).toContainEqual(expect.objectContaining({700      type: "ADOPTION_SOURCE_RESULT",701      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),702    }));703  });704});705706function decodeInvitation(encoded: string): { vault?: string } | null {707  // Mirrors parsePairingInvitation's payload structure without pulling the708  // module in: the receiver test only needs to assert the embedded status.709  const at = encoded.indexOf(":");710  if (at < 0) return null;711  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");712  try {713    const json = Buffer.from(b64, "base64").toString("utf8");714    return JSON.parse(json) as { vault?: string };715  } catch {716    return null;717  }718}