relay-adoption-source-bg.test.ts

25.3 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(105  frames: unknown[],106  options: { closeOnEmpty?: boolean } = {},107) {108  const queue = frames.map(encode);109  return {110    send: vi.fn(),111    receive: vi.fn(async () => {112      const next = queue.shift();113      if (!next) {114        // A real peer closes the channel once it runs out of frames; model115        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast116        // and falls back to the sync pipeline instead of hanging on a timeout.117        if (options.closeOnEmpty) throw new Error("socket_closed");118        return new Promise<Uint8Array>(() => undefined);119      }120      return next;121    }),122    close: vi.fn(),123    closedBeforeFrame: vi.fn(() => false),124  };125}126127async function waitForPush(type: string): Promise<void> {128  await vi.waitFor(() => {129    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);130  });131}132133beforeEach(() => {134  mocks.pushes.length = 0;135  mocks.sasDecisions.length = 0;136  mocks.openSocket.mockReset();137  mocks.getLocalIdentity.mockReset();138  mocks.getRootKeyHex.mockReset();139  mocks.upsert.mockReset();140  mocks.triggerSync.mockReset();141  mocks.triggerSync.mockResolvedValue(undefined);142  mocks.isProUser.mockReset();143  mocks.isProUser.mockResolvedValue(true);144  mocks.getLicenseKey.mockReset();145  mocks.getLicenseKey.mockResolvedValue("PPK-source");146  mocks.getLocalIdentity.mockResolvedValue({147    identity: {148      deviceId: "browser-source",149      deviceName: "June's Browser",150      deviceType: "extension",151      longTermPublicKeyHex: "a".repeat(64),152      vaultId: "b".repeat(64),153    },154    privateKey: new Uint8Array(32).fill(1),155    publicKey: new Uint8Array(32).fill(2),156  });157  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));158  mocks.upsert.mockResolvedValue(undefined);159  Object.defineProperty(globalThis, "chrome", {160    configurable: true,161    value: {162    runtime: {163      async sendMessage(message: { type: string; payload: unknown }) {164        mocks.pushes.push(message);165        // The source flow waits for the popup to send SOURCE_SAS_DECISION before166        // forwarding the root key. The receiver test overrides this mock and167        // resolves the decision itself; the source tests rely on this default168        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.169        if (message.type === "ADOPTION_SOURCE_SAS") {170          const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");171          const p = message.payload as { id?: string };172          if (typeof p?.id === "string") {173            mocks.sasDecisions.push({ id: p.id, ok: true });174            resolveSourceSasConfirmation(p.id, true);175          }176        }177        return Promise.resolve();178      },179    },180    },181  });182});183184describe("relay adoption source background boundary", () => {185  it("shows the SAS and waits for the popup's single confirmation", async () => {186    const socket = socketWithFrames([187      {188        type: "PEACH_HELLO",189        token: "AAECAwQFBgcICQoLDA0ODw",190      },191      {192        type: "ADOPT_REQUEST",193        device_id: "request-only-label",194        device_name: "June's phone",195        device_type: "android",196      },197      { type: "ADOPT_CONFIRM", confirmed: true },198      {199        type: "ADOPT_IDENTITY",200        device_id: "android-receiver",201        device_name: "June's phone",202        device_type: "android",203        long_term_public_key_hex: "d".repeat(64),204      },205      { type: "ADOPT_APPLIED" },206    ]);207    mocks.openSocket.mockResolvedValue(socket);208209    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");210    const sasIds: string[] = [];211    Object.defineProperty(globalThis, "chrome", {212      configurable: true,213      value: {214        runtime: {215          async sendMessage(message: { type: string; payload: unknown }) {216            mocks.pushes.push(message);217            if (message.type === "ADOPTION_SOURCE_SAS") {218              const p = message.payload as { id?: string };219              if (typeof p?.id === "string") {220                // Hold the SAS gate open until the test explicitly approves221                // it — this proves the source flow waits for the popup.222                sasIds.push(p.id);223              }224              return Promise.resolve();225            }226            return Promise.resolve();227          },228        },229      },230    });231    void resolveSourceSasConfirmation; // imported for the post-approval call below232233    const started = await startRelayAdoptionSource();234    expect(started.invitation).toMatch(/^peach-pair-v1:/);235    await waitForPush("ADOPTION_SOURCE_SAS");236    // The adoption must NOT be complete yet — the popup's SAS decision is the237    // prerequisite for forwarding the root key.238    await vi.waitFor(() => {239      expect(mocks.sasDecisions.length).toBe(0);240    });241    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();242    expect(mocks.upsert).not.toHaveBeenCalled();243244    // Approve the SAS; the adoption should now proceed and complete.245    const id = sasIds[0];246    expect(typeof id).toBe("string");247    resolveSourceSasConfirmation(id, true);248    mocks.sasDecisions.push({ id, ok: true });249250    await waitForPush("ADOPTION_SOURCE_RESULT");251252    expect(mocks.getRootKeyHex).toHaveBeenCalled();253    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({254      deviceId: "android-receiver",255      relayUrl: "wss://relay.peachpasswords.com",256    }));257    expect(mocks.pushes).toContainEqual(expect.objectContaining({258      type: "ADOPTION_SOURCE_RESULT",259      payload: expect.objectContaining({ status: "complete" }),260    }));261    expect(mocks.triggerSync).toHaveBeenCalledWith({262      reason: "manual",263      force: true,264    });265    const sentTypes = socket.send.mock.calls266      .map(([bytes]) => {267        try {268          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;269        } catch {270          return undefined;271        }272      });273    expect(sentTypes).toContain("ADOPT_COMPLETE");274  });275276  it("fails closed when the popup rejects the SAS", async () => {277    const socket = socketWithFrames([278      {279        type: "PEACH_HELLO",280        token: "AAECAwQFBgcICQoLDA0ODw",281      },282      {283        type: "ADOPT_REQUEST",284        device_id: "request-only-label",285        device_name: "New phone",286        device_type: "android",287      },288      { type: "ADOPT_CONFIRM", confirmed: false },289    ]);290    mocks.openSocket.mockResolvedValue(socket);291292    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");293    let pendingId: string | undefined;294    Object.defineProperty(globalThis, "chrome", {295      configurable: true,296      value: {297        runtime: {298          sendMessage(message: { type: string; payload: unknown }) {299            mocks.pushes.push(message);300            if (message.type === "ADOPTION_SOURCE_SAS") {301              const p = message.payload as { id?: string };302              if (typeof p?.id === "string") pendingId = p.id;303            }304            return Promise.resolve();305          },306        },307      },308    });309310    await startRelayAdoptionSource();311    await waitForPush("ADOPTION_SOURCE_SAS");312    expect(typeof pendingId).toBe("string");313    resolveSourceSasConfirmation(pendingId!, false);314    mocks.sasDecisions.push({ id: pendingId!, ok: false });315316    await waitForPush("ADOPTION_SOURCE_RESULT");317318    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();319    expect(mocks.upsert).not.toHaveBeenCalled();320    expect(mocks.pushes).toContainEqual({321      type: "ADOPTION_SOURCE_RESULT",322      payload: { status: "error", error: "adoption_rejected" },323    });324  });325326  it("rejects before reading the root key when neither peer has Pro", async () => {327    const socket = socketWithFrames([328      {329        type: "PEACH_HELLO",330        token: "AAECAwQFBgcICQoLDA0ODw",331      },332      {333        type: "ADOPT_REQUEST",334        device_id: "free-receiver",335        device_name: "Free phone",336        device_type: "android",337      },338      { type: "ADOPT_CONFIRM", confirmed: true },339    ]);340    mocks.isProUser.mockResolvedValue(false);341    mocks.getLicenseKey.mockResolvedValue(undefined);342    mocks.openSocket.mockResolvedValue(socket);343344    await startRelayAdoptionSource();345    await waitForPush("ADOPTION_SOURCE_RESULT");346347    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();348    expect(mocks.upsert).not.toHaveBeenCalled();349    expect(mocks.pushes).toContainEqual({350      type: "ADOPTION_SOURCE_RESULT",351      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },352    });353    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {354      try {355        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];356      } catch {357        return [];358      }359    });360    expect(sentFrames).toContainEqual({361      type: "ADOPT_REJECT",362      reason: "ADOPT_PRO_REQUIRED",363    });364  });365366  it("fails closed when the user never answers the SAS", async () => {367    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they368    // schedule real work), so we hold the SAS gate open by never resolving369    // the deferred and then cancel the session. clearState must drop the370    // pending SAS so it doesn't pin the relay room open for the next run.371    const socket = socketWithFrames([]);372    mocks.openSocket.mockResolvedValue(socket);373374    Object.defineProperty(globalThis, "chrome", {375      configurable: true,376      value: {377        runtime: {378          sendMessage(message: { type: string; payload: unknown }) {379            mocks.pushes.push(message);380            return Promise.resolve();381          },382        },383      },384    });385386    await startRelayAdoptionSource();387    cancelRelayAdoptionSource();388389    expect(socket.close).toHaveBeenCalled();390    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();391    expect(mocks.upsert).not.toHaveBeenCalled();392  });393394  it("cancelling the source screen closes the room without reading the root", async () => {395    const socket = socketWithFrames([]);396    mocks.openSocket.mockResolvedValue(socket);397398    await startRelayAdoptionSource();399    cancelRelayAdoptionSource();400401    expect(socket.close).toHaveBeenCalled();402    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();403    expect(mocks.upsert).not.toHaveBeenCalled();404  });405});406407describe("relay adoption receiver background", () => {408  beforeEach(async () => {409    // The receiver session runs in the background via `void runReceiverSession`;410    // it doesn't get awaited by the caller and can leave `inFlight` set if the411    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test412    // starts from a clean state.413    const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");414    cancelRelayAdoptionSource();415    mocks.pushes.length = 0;416    mocks.openSocket.mockReset();417    mocks.getDeviceName.mockReset();418    mocks.getDeviceName.mockResolvedValue("Receiver Browser");419    mocks.createVault.mockReset();420    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });421    mocks.activateLicense.mockReset();422    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });423    mocks.isProUser.mockReset();424    mocks.isProUser.mockResolvedValue(true);425    mocks.getLicenseKey.mockReset();426    mocks.getLicenseKey.mockResolvedValue(undefined);427    mocks.localIdentity.mockReset();428    mocks.localIdentity.mockResolvedValue({429      deviceId: "receiver-id",430      deviceName: "Receiver Browser",431      deviceType: "extension",432      longTermPublicKeyHex: "11".repeat(32),433      signingPublicKeyHex: "22".repeat(32),434    });435    mocks.rollbackVault.mockReset();436    mocks.rollbackVault.mockResolvedValue(undefined);437    mocks.storageSet.mockReset();438    mocks.storageSet.mockResolvedValue(undefined);439    mocks.upsert.mockReset();440    mocks.upsert.mockResolvedValue(undefined);441    mocks.triggerSync.mockReset();442    mocks.triggerSync.mockResolvedValue(undefined);443    mocks.openSocket.mockResolvedValue(socketWithFrames([]));444    Object.defineProperty(globalThis, "chrome", {445      configurable: true,446      value: {447        runtime: {448          sendMessage(message: { type: string; payload?: unknown }) {449            mocks.pushes.push({ type: message.type, payload: message.payload });450            return Promise.resolve({ ok: true });451          },452          onMessage: {453            addListener() { /* no-op */ },454            removeListener() { /* no-op */ },455          },456        },457      },458    });459  });460461  it("opens a relay socket and returns an empty invitation", async () => {462    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");463    const started = await startRelayAdoptionReceiver("receiver-password");464    expect(started.invitation).toMatch(/^peach-pair-v1:/);465    // The QR carries vault:"empty" so the phone knows to act as source.466    const decoded = decodeInvitation(started.invitation);467    expect(decoded?.vault).toBe("empty");468    expect(mocks.openSocket).toHaveBeenCalled();469  });470471  it("rejects an empty password", async () => {472    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");473    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");474  });475476  it("surfaces the Pro-required rejection without creating a vault", async () => {477    mocks.openSocket.mockResolvedValue(socketWithFrames([478      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },479      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },480    ]));481482    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");483    await startRelayAdoptionReceiver("receiver-password");484    await waitForPush("ADOPTION_SOURCE_RESULT");485486    expect(mocks.createVault).not.toHaveBeenCalled();487    expect(mocks.rollbackVault).not.toHaveBeenCalled();488    expect(mocks.pushes).toContainEqual({489      type: "ADOPTION_SOURCE_RESULT",490      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },491    });492  });493494  it("the receiver wires requestAdoption, not grantAdoption", async () => {495    // The receiver's protocol position is the opposite of the source side:496    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.497    // Assert this by watching what lands on socket.send: the receiver's498    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY499    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.500    const rootKeyHex = "f0".repeat(32);501    const vaultId = "b".repeat(64);502    const socket = socketWithFrames([503      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },504      {505        type: "ADOPT_GRANT",506        root_key_hex: rootKeyHex,507        vault_id: vaultId,508        license: "PPK-test",509        source: {510          device_id: "phone-source-id",511          device_name: "June's phone",512          device_type: "android",513          long_term_public_key_hex: "99".repeat(32),514        },515      },516      { type: "ADOPT_COMPLETE" },517    ], { closeOnEmpty: true });518    mocks.openSocket.mockResolvedValue(socket);519    mocks.createVault.mockResolvedValue({ vaultId });520521    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");522    Object.defineProperty(globalThis, "chrome", {523      configurable: true,524      value: {525        runtime: {526          sendMessage(message: { type: string; payload?: unknown }) {527            mocks.pushes.push({ type: message.type, payload: message.payload });528            return Promise.resolve({ ok: true });529          },530          onMessage: { addListener: () => undefined, removeListener: () => undefined },531        },532      },533    });534    await startRelayAdoptionReceiver("receiver-password");535    await waitForPush("ADOPTION_SOURCE_RESULT");536537    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })538      .mock.calls539      .map(([bytes]) => {540        try {541          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;542        } catch {543          return undefined;544        }545      });546    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;547    // ADOPT_GRANT is the source side's frame and must never appear here.548    expect(sentTypes).toContain("ADOPT_REQUEST");549    expect(sentTypes).not.toContain("ADOPT_GRANT");550  });551552  it("completes the handshake end-to-end: vault created → paired", async () => {553    // Wire the receiver's noise mock and SAS gate together so the full554    // requestAdoption protocol runs against an in-memory phone that sends555    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.556    // The mocked noise is pass-through (encrypt = decrypt = identity), so557    // frames land as ordinary JSON.558    const rootKeyHex = "f0".repeat(32);559    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);560    // the grant's vault_id is set to the same so the protocol proceeds.561    const vaultId = "b".repeat(64);562    const frames = [563      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },564      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two565      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.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          signing_public_key_hex: "aa".repeat(32),577          relay_url: "wss://relay.peachpasswords.com",578        },579      },580      { type: "ADOPT_COMPLETE" },581    ];582    mocks.createVault.mockResolvedValue({ vaultId });583    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));584585    // Replace the chrome.runtime mock for this one test. The receiver586    // auto-accepts the SAS, so no decision needs to be simulated.587    Object.defineProperty(globalThis, "chrome", {588      configurable: true,589      value: {590        runtime: {591          sendMessage(message: { type: string; payload?: unknown }) {592            mocks.pushes.push({ type: message.type, payload: message.payload });593            return Promise.resolve({ ok: true });594          },595          onMessage: {596            addListener: () => undefined,597            removeListener: () => undefined,598          },599        },600      },601    });602603    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");604    await startRelayAdoptionReceiver("receiver-password");605606    await waitForPush("ADOPTION_SOURCE_RESULT");607608    // The receiver displays its locally derived SAS without introducing a609    // second confirmation gate.610    expect(mocks.pushes).toContainEqual({611      type: "ADOPTION_SOURCE_SAS",612      payload: expect.objectContaining({613        sas: expect.stringMatching(/^\d{8}$/),614        displayOnly: true,615      }),616    });617618    // The vault module was called directly with the popup-supplied password619    // and the root key granted by the phone.620    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);621622    // License activation, identity derivation, and pairing persistence all623    // call their modules directly from the service worker.624    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");625    expect(mocks.isProUser).toHaveBeenCalled();626    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));627    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({628      deviceId: "phone-source-id",629    }));630    expect(mocks.storageSet).toHaveBeenCalledWith(631      "peach_initial_sync_pending_device_id",632      "phone-source-id",633    );634    expect(mocks.rollbackVault).not.toHaveBeenCalled();635636    // The terminal state is "complete" — the popup can transition to success.637    expect(mocks.pushes).toContainEqual(expect.objectContaining({638      type: "ADOPTION_SOURCE_RESULT",639      payload: expect.objectContaining({ status: "complete" }),640    }));641  });642643  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {644    const rootKeyHex = "f0".repeat(32);645    const vaultId = "b".repeat(64);646    mocks.createVault.mockResolvedValue({ vaultId });647    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });648    mocks.isProUser.mockResolvedValue(false);649    mocks.openSocket.mockResolvedValue(socketWithFrames([650      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },651      {652        type: "ADOPT_GRANT",653        root_key_hex: rootKeyHex,654        vault_id: vaultId,655        license: "PPK-invalid",656        source: {657          device_id: "phone-source-id",658          device_name: "June's phone",659          device_type: "android",660          long_term_public_key_hex: "99".repeat(32),661        },662      },663    ]));664665    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");666    await startRelayAdoptionReceiver("receiver-password");667    await waitForPush("ADOPTION_SOURCE_RESULT");668669    expect(mocks.rollbackVault).toHaveBeenCalledOnce();670    expect(mocks.localIdentity).not.toHaveBeenCalled();671    expect(mocks.upsert).not.toHaveBeenCalled();672    expect(mocks.pushes).toContainEqual(expect.objectContaining({673      type: "ADOPTION_SOURCE_RESULT",674      payload: expect.objectContaining({675        status: "error",676        error: "license_transfer_failed",677      }),678    }));679  });680681  it("rolls back a newly created vault when a later adoption step fails", async () => {682    const rootKeyHex = "f0".repeat(32);683    const vaultId = "b".repeat(64);684    mocks.createVault.mockResolvedValue({ vaultId });685    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));686    mocks.openSocket.mockResolvedValue(socketWithFrames([687      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },688      {689        type: "ADOPT_GRANT",690        root_key_hex: rootKeyHex,691        vault_id: vaultId,692        license: "PPK-test",693        source: {694          device_id: "phone-source-id",695          device_name: "June's phone",696          device_type: "android",697          long_term_public_key_hex: "99".repeat(32),698        },699      },700    ]));701702    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");703    await startRelayAdoptionReceiver("receiver-password");704    await waitForPush("ADOPTION_SOURCE_RESULT");705706    expect(mocks.rollbackVault).toHaveBeenCalledOnce();707    expect(mocks.upsert).not.toHaveBeenCalled();708    expect(mocks.pushes).toContainEqual(expect.objectContaining({709      type: "ADOPTION_SOURCE_RESULT",710      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),711    }));712  });713});714715function decodeInvitation(encoded: string): { vault?: string } | null {716  // Mirrors parsePairingInvitation's payload structure without pulling the717  // module in: the receiver test only needs to assert the embedded status.718  const at = encoded.indexOf(":");719  if (at < 0) return null;720  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");721  try {722    const json = Buffer.from(b64, "base64").toString("utf8");723    return JSON.parse(json) as { vault?: string };724  } catch {725    return null;726  }727}