relay-adoption-source-bg.test.ts

24.6 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  sasDecisions: [] as Array<{ id: string; ok: boolean }>,18}));1920vi.mock("../shared/pairing-dial", () => ({21  openRelayPairingSocket: mocks.openSocket,22}));2324vi.mock("../shared/noise-js", () => ({25  noiseNKResponder: vi.fn(async () => ({26    handshakeHash: new Uint8Array(32).fill(7),27    encrypt: (bytes: Uint8Array) => bytes,28    decrypt: (bytes: Uint8Array) => bytes,29    close: vi.fn(),30  })),31}));3233vi.mock("../shared/rendezvous", async (importOriginal) => {34  const actual = await importOriginal<typeof import("../shared/rendezvous")>();35  return {36    ...actual,37    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",38  };39});404142vi.mock("./device-identity", () => ({43  getLocalPairingIdentity: async () => ({44    identity: {45      deviceId: "test-device",46      deviceName: "Test",47      deviceType: "extension",48      longTermPublicKeyHex: "aa".repeat(32),49      vaultId: "bb".repeat(32),50    },51    privateKey: new Uint8Array(32).fill(7),52    publicKey: new Uint8Array(32).fill(8),53  }),54  getDeviceName: mocks.getDeviceName,55  buildLocalIdentity: mocks.localIdentity,56}));5758vi.mock("./sync-settings", async (importOriginal) => ({59  ...(await importOriginal<typeof import("./sync-settings")>()),60  getSyncSettings: async () => ({61    mode: "public" as const,62    relayUrl: "wss://relay.peachpasswords.com",63  }),64}));6566vi.mock("./license", () => ({67  isProUser: mocks.isProUser,68  getLicenseKey: mocks.getLicenseKey,69  activateLicense: mocks.activateLicense,70}));7172vi.mock("./vault", () => ({73  isUnlocked: vi.fn(() => true),74  getRootKeyHex: mocks.getRootKeyHex,75  createVaultFromRootKey: mocks.createVault,76  rollbackAdoptedVault: mocks.rollbackVault,77}));7879vi.mock("../shared/sync-identity", () => ({80  computeVaultId: vi.fn(async () => "b".repeat(64)),81}));8283vi.mock("./paired-devices", () => ({84  upsertPairedDevice: mocks.upsert,85}));8687vi.mock("./sync-trigger", () => ({88  triggerSyncIfReachable: mocks.triggerSync,89}));9091import {92  cancelRelayAdoptionSource,93  startRelayAdoptionSource,94} from "./relay-adoption-source-bg";9596const encode = (value: unknown) =>97  new TextEncoder().encode(JSON.stringify(value));9899function socketWithFrames(frames: unknown[]) {100  const queue = frames.map(encode);101  return {102    send: vi.fn(),103    receive: vi.fn(async () => {104      const next = queue.shift();105      if (!next) return new Promise<Uint8Array>(() => undefined);106      return next;107    }),108    close: vi.fn(),109    closedBeforeFrame: vi.fn(() => false),110  };111}112113async function waitForPush(type: string): Promise<void> {114  await vi.waitFor(() => {115    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);116  });117}118119beforeEach(() => {120  mocks.pushes.length = 0;121  mocks.sasDecisions.length = 0;122  mocks.openSocket.mockReset();123  mocks.getLocalIdentity.mockReset();124  mocks.getRootKeyHex.mockReset();125  mocks.upsert.mockReset();126  mocks.triggerSync.mockReset();127  mocks.triggerSync.mockResolvedValue(undefined);128  mocks.isProUser.mockReset();129  mocks.isProUser.mockResolvedValue(true);130  mocks.getLicenseKey.mockReset();131  mocks.getLicenseKey.mockResolvedValue("PPK-source");132  mocks.getLocalIdentity.mockResolvedValue({133    identity: {134      deviceId: "browser-source",135      deviceName: "June's Browser",136      deviceType: "extension",137      longTermPublicKeyHex: "a".repeat(64),138      vaultId: "b".repeat(64),139    },140    privateKey: new Uint8Array(32).fill(1),141    publicKey: new Uint8Array(32).fill(2),142  });143  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));144  mocks.upsert.mockResolvedValue(undefined);145  Object.defineProperty(globalThis, "chrome", {146    configurable: true,147    value: {148    runtime: {149      async sendMessage(message: { type: string; payload: unknown }) {150        mocks.pushes.push(message);151        // The source flow waits for the popup to send SOURCE_SAS_DECISION before152        // forwarding the root key. The receiver test overrides this mock and153        // resolves the decision itself; the source tests rely on this default154        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.155        if (message.type === "ADOPTION_SOURCE_SAS") {156          const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");157          const p = message.payload as { id?: string };158          if (typeof p?.id === "string") {159            mocks.sasDecisions.push({ id: p.id, ok: true });160            resolveSourceSasConfirmation(p.id, true);161          }162        }163        return Promise.resolve();164      },165    },166    },167  });168});169170describe("relay adoption source background boundary", () => {171  it("shows the SAS and waits for the popup's single confirmation", async () => {172    const socket = socketWithFrames([173      {174        type: "PEACH_HELLO",175        token: "AAECAwQFBgcICQoLDA0ODw",176      },177      {178        type: "ADOPT_REQUEST",179        device_id: "request-only-label",180        device_name: "June's phone",181        device_type: "android",182      },183      { type: "ADOPT_CONFIRM", confirmed: true },184      {185        type: "ADOPT_IDENTITY",186        device_id: "android-receiver",187        device_name: "June's phone",188        device_type: "android",189        long_term_public_key_hex: "d".repeat(64),190      },191      { type: "ADOPT_APPLIED" },192    ]);193    mocks.openSocket.mockResolvedValue(socket);194195    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");196    const sasIds: string[] = [];197    Object.defineProperty(globalThis, "chrome", {198      configurable: true,199      value: {200        runtime: {201          async sendMessage(message: { type: string; payload: unknown }) {202            mocks.pushes.push(message);203            if (message.type === "ADOPTION_SOURCE_SAS") {204              const p = message.payload as { id?: string };205              if (typeof p?.id === "string") {206                // Hold the SAS gate open until the test explicitly approves207                // it — this proves the source flow waits for the popup.208                sasIds.push(p.id);209              }210              return Promise.resolve();211            }212            return Promise.resolve();213          },214        },215      },216    });217    void resolveSourceSasConfirmation; // imported for the post-approval call below218219    const started = await startRelayAdoptionSource();220    expect(started.invitation).toMatch(/^peach-pair-v1:/);221    await waitForPush("ADOPTION_SOURCE_SAS");222    // The adoption must NOT be complete yet — the popup's SAS decision is the223    // prerequisite for forwarding the root key.224    await vi.waitFor(() => {225      expect(mocks.sasDecisions.length).toBe(0);226    });227    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();228    expect(mocks.upsert).not.toHaveBeenCalled();229230    // Approve the SAS; the adoption should now proceed and complete.231    const id = sasIds[0];232    expect(typeof id).toBe("string");233    resolveSourceSasConfirmation(id, true);234    mocks.sasDecisions.push({ id, ok: true });235236    await waitForPush("ADOPTION_SOURCE_RESULT");237238    expect(mocks.getRootKeyHex).toHaveBeenCalled();239    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({240      deviceId: "android-receiver",241      relayUrl: "wss://relay.peachpasswords.com",242    }));243    expect(mocks.pushes).toContainEqual(expect.objectContaining({244      type: "ADOPTION_SOURCE_RESULT",245      payload: expect.objectContaining({ status: "complete" }),246    }));247    expect(mocks.triggerSync).toHaveBeenCalledWith({248      reason: "manual",249      force: true,250    });251    const sentTypes = socket.send.mock.calls252      .map(([bytes]) => {253        try {254          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;255        } catch {256          return undefined;257        }258      });259    expect(sentTypes).toContain("ADOPT_COMPLETE");260  });261262  it("fails closed when the popup rejects the SAS", async () => {263    const socket = socketWithFrames([264      {265        type: "PEACH_HELLO",266        token: "AAECAwQFBgcICQoLDA0ODw",267      },268      {269        type: "ADOPT_REQUEST",270        device_id: "request-only-label",271        device_name: "New phone",272        device_type: "android",273      },274      { type: "ADOPT_CONFIRM", confirmed: false },275    ]);276    mocks.openSocket.mockResolvedValue(socket);277278    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");279    let pendingId: string | undefined;280    Object.defineProperty(globalThis, "chrome", {281      configurable: true,282      value: {283        runtime: {284          sendMessage(message: { type: string; payload: unknown }) {285            mocks.pushes.push(message);286            if (message.type === "ADOPTION_SOURCE_SAS") {287              const p = message.payload as { id?: string };288              if (typeof p?.id === "string") pendingId = p.id;289            }290            return Promise.resolve();291          },292        },293      },294    });295296    await startRelayAdoptionSource();297    await waitForPush("ADOPTION_SOURCE_SAS");298    expect(typeof pendingId).toBe("string");299    resolveSourceSasConfirmation(pendingId!, false);300    mocks.sasDecisions.push({ id: pendingId!, ok: false });301302    await waitForPush("ADOPTION_SOURCE_RESULT");303304    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();305    expect(mocks.upsert).not.toHaveBeenCalled();306    expect(mocks.pushes).toContainEqual({307      type: "ADOPTION_SOURCE_RESULT",308      payload: { status: "error", error: "adoption_rejected" },309    });310  });311312  it("rejects before reading the root key when neither peer has Pro", async () => {313    const socket = socketWithFrames([314      {315        type: "PEACH_HELLO",316        token: "AAECAwQFBgcICQoLDA0ODw",317      },318      {319        type: "ADOPT_REQUEST",320        device_id: "free-receiver",321        device_name: "Free phone",322        device_type: "android",323      },324      { type: "ADOPT_CONFIRM", confirmed: true },325    ]);326    mocks.isProUser.mockResolvedValue(false);327    mocks.getLicenseKey.mockResolvedValue(undefined);328    mocks.openSocket.mockResolvedValue(socket);329330    await startRelayAdoptionSource();331    await waitForPush("ADOPTION_SOURCE_RESULT");332333    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();334    expect(mocks.upsert).not.toHaveBeenCalled();335    expect(mocks.pushes).toContainEqual({336      type: "ADOPTION_SOURCE_RESULT",337      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },338    });339    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {340      try {341        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];342      } catch {343        return [];344      }345    });346    expect(sentFrames).toContainEqual({347      type: "ADOPT_REJECT",348      reason: "ADOPT_PRO_REQUIRED",349    });350  });351352  it("fails closed when the user never answers the SAS", async () => {353    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they354    // schedule real work), so we hold the SAS gate open by never resolving355    // the deferred and then cancel the session. clearState must drop the356    // pending SAS so it doesn't pin the relay room open for the next run.357    const socket = socketWithFrames([]);358    mocks.openSocket.mockResolvedValue(socket);359360    Object.defineProperty(globalThis, "chrome", {361      configurable: true,362      value: {363        runtime: {364          sendMessage(message: { type: string; payload: unknown }) {365            mocks.pushes.push(message);366            return Promise.resolve();367          },368        },369      },370    });371372    await startRelayAdoptionSource();373    cancelRelayAdoptionSource();374375    expect(socket.close).toHaveBeenCalled();376    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();377    expect(mocks.upsert).not.toHaveBeenCalled();378  });379380  it("cancelling the source screen closes the room without reading the root", async () => {381    const socket = socketWithFrames([]);382    mocks.openSocket.mockResolvedValue(socket);383384    await startRelayAdoptionSource();385    cancelRelayAdoptionSource();386387    expect(socket.close).toHaveBeenCalled();388    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();389    expect(mocks.upsert).not.toHaveBeenCalled();390  });391});392393describe("relay adoption receiver background", () => {394  beforeEach(async () => {395    // The receiver session runs in the background via `void runReceiverSession`;396    // it doesn't get awaited by the caller and can leave `inFlight` set if the397    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test398    // starts from a clean state.399    const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");400    cancelRelayAdoptionSource();401    mocks.pushes.length = 0;402    mocks.openSocket.mockReset();403    mocks.getDeviceName.mockReset();404    mocks.getDeviceName.mockResolvedValue("Receiver Browser");405    mocks.createVault.mockReset();406    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });407    mocks.activateLicense.mockReset();408    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });409    mocks.isProUser.mockReset();410    mocks.isProUser.mockResolvedValue(true);411    mocks.getLicenseKey.mockReset();412    mocks.getLicenseKey.mockResolvedValue(undefined);413    mocks.localIdentity.mockReset();414    mocks.localIdentity.mockResolvedValue({415      deviceId: "receiver-id",416      deviceName: "Receiver Browser",417      deviceType: "extension",418      longTermPublicKeyHex: "11".repeat(32),419      signingPublicKeyHex: "22".repeat(32),420    });421    mocks.rollbackVault.mockReset();422    mocks.rollbackVault.mockResolvedValue(undefined);423    mocks.upsert.mockReset();424    mocks.upsert.mockResolvedValue(undefined);425    mocks.triggerSync.mockReset();426    mocks.triggerSync.mockResolvedValue(undefined);427    mocks.openSocket.mockResolvedValue(socketWithFrames([]));428    Object.defineProperty(globalThis, "chrome", {429      configurable: true,430      value: {431        runtime: {432          sendMessage(message: { type: string; payload?: unknown }) {433            mocks.pushes.push({ type: message.type, payload: message.payload });434            return Promise.resolve({ ok: true });435          },436          onMessage: {437            addListener() { /* no-op */ },438            removeListener() { /* no-op */ },439          },440        },441      },442    });443  });444445  it("opens a relay socket and returns an empty invitation", async () => {446    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");447    const started = await startRelayAdoptionReceiver("receiver-password");448    expect(started.invitation).toMatch(/^peach-pair-v1:/);449    // The QR carries vault:"empty" so the phone knows to act as source.450    const decoded = decodeInvitation(started.invitation);451    expect(decoded?.vault).toBe("empty");452    expect(mocks.openSocket).toHaveBeenCalled();453  });454455  it("rejects an empty password", async () => {456    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");457    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");458  });459460  it("surfaces the Pro-required rejection without creating a vault", async () => {461    mocks.openSocket.mockResolvedValue(socketWithFrames([462      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },463      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },464    ]));465466    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");467    await startRelayAdoptionReceiver("receiver-password");468    await waitForPush("ADOPTION_SOURCE_RESULT");469470    expect(mocks.createVault).not.toHaveBeenCalled();471    expect(mocks.rollbackVault).not.toHaveBeenCalled();472    expect(mocks.pushes).toContainEqual({473      type: "ADOPTION_SOURCE_RESULT",474      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },475    });476  });477478  it("the receiver wires requestAdoption, not grantAdoption", async () => {479    // The receiver's protocol position is the opposite of the source side:480    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.481    // Assert this by watching what lands on socket.send: the receiver's482    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY483    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.484    const rootKeyHex = "f0".repeat(32);485    const vaultId = "b".repeat(64);486    const socket = socketWithFrames([487      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },488      {489        type: "ADOPT_GRANT",490        root_key_hex: rootKeyHex,491        vault_id: vaultId,492        license: "PPK-test",493        source: {494          device_id: "phone-source-id",495          device_name: "June's phone",496          device_type: "android",497          long_term_public_key_hex: "99".repeat(32),498        },499      },500      { type: "ADOPT_COMPLETE" },501    ]);502    mocks.openSocket.mockResolvedValue(socket);503    mocks.createVault.mockResolvedValue({ vaultId });504505    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");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: { addListener: () => undefined, removeListener: () => undefined },515        },516      },517    });518    await startRelayAdoptionReceiver("receiver-password");519    await waitForPush("ADOPTION_SOURCE_RESULT");520521    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })522      .mock.calls523      .map(([bytes]) => {524        try {525          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;526        } catch {527          return undefined;528        }529      });530    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;531    // ADOPT_GRANT is the source side's frame and must never appear here.532    expect(sentTypes).toContain("ADOPT_REQUEST");533    expect(sentTypes).not.toContain("ADOPT_GRANT");534  });535536  it("completes the handshake end-to-end: vault created → paired", async () => {537    // Wire the receiver's noise mock and SAS gate together so the full538    // requestAdoption protocol runs against an in-memory phone that sends539    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.540    // The mocked noise is pass-through (encrypt = decrypt = identity), so541    // frames land as ordinary JSON.542    const rootKeyHex = "f0".repeat(32);543    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);544    // the grant's vault_id is set to the same so the protocol proceeds.545    const vaultId = "b".repeat(64);546    const frames = [547      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },548      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two549      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.550      {551        type: "ADOPT_GRANT",552        root_key_hex: rootKeyHex,553        vault_id: vaultId,554        license: "PPK-test",555        source: {556          device_id: "phone-source-id",557          device_name: "June's phone",558          device_type: "android",559          long_term_public_key_hex: "99".repeat(32),560          signing_public_key_hex: "aa".repeat(32),561          relay_url: "wss://relay.peachpasswords.com",562        },563      },564      { type: "ADOPT_COMPLETE" },565    ];566    mocks.createVault.mockResolvedValue({ vaultId });567    mocks.openSocket.mockResolvedValue(socketWithFrames(frames));568569    // Replace the chrome.runtime mock for this one test. The receiver570    // auto-accepts the SAS, so no decision needs to be simulated.571    Object.defineProperty(globalThis, "chrome", {572      configurable: true,573      value: {574        runtime: {575          sendMessage(message: { type: string; payload?: unknown }) {576            mocks.pushes.push({ type: message.type, payload: message.payload });577            return Promise.resolve({ ok: true });578          },579          onMessage: {580            addListener: () => undefined,581            removeListener: () => undefined,582          },583        },584      },585    });586587    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");588    await startRelayAdoptionReceiver("receiver-password");589590    await waitForPush("ADOPTION_SOURCE_RESULT");591592    // The receiver displays its locally derived SAS without introducing a593    // second confirmation gate.594    expect(mocks.pushes).toContainEqual({595      type: "ADOPTION_SOURCE_SAS",596      payload: expect.objectContaining({597        sas: expect.stringMatching(/^\d{8}$/),598        displayOnly: true,599      }),600    });601602    // The vault module was called directly with the popup-supplied password603    // and the root key granted by the phone.604    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);605606    // License activation, identity derivation, and pairing persistence all607    // call their modules directly from the service worker.608    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");609    expect(mocks.isProUser).toHaveBeenCalled();610    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));611    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({612      deviceId: "phone-source-id",613    }));614    expect(mocks.rollbackVault).not.toHaveBeenCalled();615616    // The terminal state is "complete" — the popup can transition to success.617    expect(mocks.pushes).toContainEqual(expect.objectContaining({618      type: "ADOPTION_SOURCE_RESULT",619      payload: expect.objectContaining({ status: "complete" }),620    }));621  });622623  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {624    const rootKeyHex = "f0".repeat(32);625    const vaultId = "b".repeat(64);626    mocks.createVault.mockResolvedValue({ vaultId });627    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });628    mocks.isProUser.mockResolvedValue(false);629    mocks.openSocket.mockResolvedValue(socketWithFrames([630      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },631      {632        type: "ADOPT_GRANT",633        root_key_hex: rootKeyHex,634        vault_id: vaultId,635        license: "PPK-invalid",636        source: {637          device_id: "phone-source-id",638          device_name: "June's phone",639          device_type: "android",640          long_term_public_key_hex: "99".repeat(32),641        },642      },643    ]));644645    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");646    await startRelayAdoptionReceiver("receiver-password");647    await waitForPush("ADOPTION_SOURCE_RESULT");648649    expect(mocks.rollbackVault).toHaveBeenCalledOnce();650    expect(mocks.localIdentity).not.toHaveBeenCalled();651    expect(mocks.upsert).not.toHaveBeenCalled();652    expect(mocks.pushes).toContainEqual(expect.objectContaining({653      type: "ADOPTION_SOURCE_RESULT",654      payload: expect.objectContaining({655        status: "error",656        error: "license_transfer_failed",657      }),658    }));659  });660661  it("rolls back a newly created vault when a later adoption step fails", async () => {662    const rootKeyHex = "f0".repeat(32);663    const vaultId = "b".repeat(64);664    mocks.createVault.mockResolvedValue({ vaultId });665    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));666    mocks.openSocket.mockResolvedValue(socketWithFrames([667      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },668      {669        type: "ADOPT_GRANT",670        root_key_hex: rootKeyHex,671        vault_id: vaultId,672        license: "PPK-test",673        source: {674          device_id: "phone-source-id",675          device_name: "June's phone",676          device_type: "android",677          long_term_public_key_hex: "99".repeat(32),678        },679      },680    ]));681682    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");683    await startRelayAdoptionReceiver("receiver-password");684    await waitForPush("ADOPTION_SOURCE_RESULT");685686    expect(mocks.rollbackVault).toHaveBeenCalledOnce();687    expect(mocks.upsert).not.toHaveBeenCalled();688    expect(mocks.pushes).toContainEqual(expect.objectContaining({689      type: "ADOPTION_SOURCE_RESULT",690      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),691    }));692  });693});694695function decodeInvitation(encoded: string): { vault?: string } | null {696  // Mirrors parsePairingInvitation's payload structure without pulling the697  // module in: the receiver test only needs to assert the embedded status.698  const at = encoded.indexOf(":");699  if (at < 0) return null;700  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");701  try {702    const json = Buffer.from(b64, "base64").toString("utf8");703    return JSON.parse(json) as { vault?: string };704  } catch {705    return null;706  }707}