relay-adoption-bg.test.ts

35.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  createSyncAdapter: vi.fn(),18  __applyIncomingDeviceManifest: vi.fn(),19  storageSet: vi.fn(),20  sasDecisions: [] as Array<{ id: string; ok: boolean }>,21}));2223vi.mock("../shared/pairing-dial", () => ({24  openRelayPairingSocket: mocks.openSocket,25}));2627vi.mock("../shared/noise-js", () => ({28  noiseNKResponder: vi.fn(async () => ({29    handshakeHash: new Uint8Array(32).fill(7),30    encrypt: (bytes: Uint8Array) => bytes,31    decrypt: (bytes: Uint8Array) => bytes,32    close: vi.fn(),33  })),34}));3536vi.mock("../shared/rendezvous", async (importOriginal) => {37  const actual = await importOriginal<typeof import("../shared/rendezvous")>();38  return {39    ...actual,40    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",41  };42});434445vi.mock("./device-identity", () => ({46  getLocalPairingIdentity: async () => ({47    identity: {48      deviceId: "test-device",49      deviceName: "Test",50      deviceType: "extension",51      longTermPublicKeyHex: "aa".repeat(32),52      vaultId: "bb".repeat(32),53    },54    privateKey: new Uint8Array(32).fill(7),55    publicKey: new Uint8Array(32).fill(8),56  }),57  getDeviceName: mocks.getDeviceName,58  buildLocalIdentity: mocks.localIdentity,59}));6061vi.mock("./sync-settings", async (importOriginal) => ({62  ...(await importOriginal<typeof import("./sync-settings")>()),63  getSyncSettings: async () => ({64    mode: "public" as const,65    relayUrl: "wss://relay.peachpasswords.com",66  }),67}));6869vi.mock("./license", () => ({70  isProUser: mocks.isProUser,71  getLicenseKey: mocks.getLicenseKey,72  activateLicense: mocks.activateLicense,73}));7475vi.mock("./vault", () => ({76  isUnlocked: vi.fn(() => true),77  getRootKeyHex: mocks.getRootKeyHex,78  createVaultFromRootKey: mocks.createVault,79  rollbackAdoptedVault: mocks.rollbackVault,80  exportDuressSlotForAdoption: vi.fn(async () => null),81  installAdoptedDuressSlot: vi.fn(async () => undefined),82}));8384vi.mock("../shared/sync-identity", async (importOriginal) => {85  // Partial mock: the manifest test needs the REAL deriveSyncSigningKeypair /86  // pubkeyToHex to build a verifiable signed manifest (2026-08-04: this test87  // was orphaned inside the rendezvous mock factory and never ran; the mock88  // below had since lost the real exports).89  const actual = await importOriginal<typeof import("../shared/sync-identity")>();90  return {91    ...actual,92    computeVaultId: vi.fn(async () => "b".repeat(64)),93  };94});9596vi.mock("../shared/storage", () => ({97  storageSet: mocks.storageSet,98}));99100vi.mock("./paired-devices", () => ({101  upsertPairedDevice: mocks.upsert,102}));103104vi.mock("./sync-trigger", () => ({105  triggerSyncIfReachable: mocks.triggerSync,106}));107108vi.mock("./sync-vault-adapter", () => ({109  createSyncVaultAdapter: mocks.createSyncAdapter,110}));111112import {113  cancelRelayAdoptionSource,114  startRelayAdoptionSource,115} from "./relay-adoption-bg";116117const encode = (value: unknown) =>118  new TextEncoder().encode(JSON.stringify(value));119120function socketWithFrames(121  frames: unknown[],122  options: { closeOnEmpty?: boolean } = {},123) {124  const queue = frames.map(encode);125  return {126    send: vi.fn(),127    receive: vi.fn(async () => {128      const next = queue.shift();129      if (!next) {130        // A real peer closes the channel once it runs out of frames; model131        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast132        // and falls back to the sync pipeline instead of hanging on a timeout.133        if (options.closeOnEmpty) throw new Error("socket_closed");134        return new Promise<Uint8Array>(() => undefined);135      }136      return next;137    }),138    close: vi.fn(),139    closedBeforeFrame: vi.fn(() => false),140  };141}142143async function waitForPush(type: string): Promise<void> {144  await vi.waitFor(() => {145    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);146  });147}148149beforeEach(() => {150  mocks.pushes.length = 0;151  mocks.sasDecisions.length = 0;152  mocks.openSocket.mockReset();153  mocks.getLocalIdentity.mockReset();154  mocks.getRootKeyHex.mockReset();155  mocks.upsert.mockReset();156  mocks.triggerSync.mockReset();157  mocks.triggerSync.mockResolvedValue(undefined);158  mocks.isProUser.mockReset();159  mocks.isProUser.mockResolvedValue(true);160  mocks.getLicenseKey.mockReset();161  mocks.getLicenseKey.mockResolvedValue("PPK-source");162  mocks.getLocalIdentity.mockResolvedValue({163    identity: {164      deviceId: "browser-source",165      deviceName: "June's Browser",166      deviceType: "extension",167      longTermPublicKeyHex: "a".repeat(64),168      vaultId: "b".repeat(64),169    },170    privateKey: new Uint8Array(32).fill(1),171    publicKey: new Uint8Array(32).fill(2),172  });173  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));174  mocks.upsert.mockResolvedValue(undefined);175    const applyIncomingDeviceManifest = vi.fn(async () => undefined);176    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;177    mocks.createSyncAdapter.mockReturnValue({178      buildEntryManifest: vi.fn(async () => ({})),179      getEncryptedEntries: vi.fn(async () => ({})),180      buildTombstoneManifest: vi.fn(async () => ({})),181      applyEntries: vi.fn(async () => undefined),182      applyTombstones: vi.fn(async () => undefined),183      buildOutgoingDeviceManifest: vi.fn(async () => null),184      applyIncomingDeviceManifest,185    });186  Object.defineProperty(globalThis, "chrome", {187    configurable: true,188    value: {189    runtime: {190      async sendMessage(message: { type: string; payload: unknown }) {191        mocks.pushes.push(message);192        // The source flow waits for the popup to send SOURCE_SAS_DECISION before193        // forwarding the root key. The receiver test overrides this mock and194        // resolves the decision itself; the source tests rely on this default195        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.196        if (message.type === "ADOPTION_SOURCE_SAS") {197          const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");198          const p = message.payload as { id?: string };199          if (typeof p?.id === "string") {200            mocks.sasDecisions.push({ id: p.id, ok: true });201            resolveSourceSasConfirmation(p.id, true);202          }203        }204        return Promise.resolve();205      },206    },207    },208  });209});210211describe("relay adoption source background boundary", () => {212  it("shows the SAS and waits for the popup's single confirmation", async () => {213    const socket = socketWithFrames([214      {215        type: "PEACH_HELLO",216        token: "AAECAwQFBgcICQoLDA0ODw",217      },218      {219        type: "ADOPT_REQUEST",220        device_id: "request-only-label",221        device_name: "June's phone",222        device_type: "android",223      },224      { type: "ADOPT_CONFIRM", confirmed: true },225      {226        type: "ADOPT_IDENTITY",227        device_id: "android-receiver",228        device_name: "June's phone",229        device_type: "android",230        long_term_public_key_hex: "d".repeat(64),231      },232      { type: "ADOPT_APPLIED" },233    ]);234    mocks.openSocket.mockResolvedValue(socket);235236    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");237    const sasIds: string[] = [];238    Object.defineProperty(globalThis, "chrome", {239      configurable: true,240      value: {241        runtime: {242          async sendMessage(message: { type: string; payload: unknown }) {243            mocks.pushes.push(message);244            if (message.type === "ADOPTION_SOURCE_SAS") {245              const p = message.payload as { id?: string };246              if (typeof p?.id === "string") {247                // Hold the SAS gate open until the test explicitly approves248                // it — this proves the source flow waits for the popup.249                sasIds.push(p.id);250              }251              return Promise.resolve();252            }253            return Promise.resolve();254          },255        },256      },257    });258    void resolveSourceSasConfirmation; // imported for the post-approval call below259260    const started = await startRelayAdoptionSource();261    expect(started.invitation).toMatch(/^peach-pair-v2:/);262    await waitForPush("ADOPTION_SOURCE_SAS");263    // The adoption must NOT be complete yet — the popup's SAS decision is the264    // prerequisite for forwarding the root key.265    await vi.waitFor(() => {266      expect(mocks.sasDecisions.length).toBe(0);267    });268    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();269    expect(mocks.upsert).not.toHaveBeenCalled();270271    // Approve the SAS; the adoption should now proceed and complete.272    const id = sasIds[0];273    expect(typeof id).toBe("string");274    resolveSourceSasConfirmation(id, true);275    mocks.sasDecisions.push({ id, ok: true });276277    await waitForPush("ADOPTION_SOURCE_RESULT");278279    expect(mocks.getRootKeyHex).toHaveBeenCalled();280    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({281      deviceId: "android-receiver",282      relayUrl: "wss://relay.peachpasswords.com",283    }));284    expect(mocks.pushes).toContainEqual(expect.objectContaining({285      type: "ADOPTION_SOURCE_RESULT",286      payload: expect.objectContaining({ status: "complete" }),287    }));288    expect(mocks.triggerSync).toHaveBeenCalledWith({289      reason: "manual",290      force: true,291    });292    const sentTypes = socket.send.mock.calls293      .map(([bytes]) => {294        try {295          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;296        } catch {297          return undefined;298        }299      });300    expect(sentTypes).toContain("ADOPT_COMPLETE");301  });302303  it("fails adoption without sending snapshot frames when duress is oversized", async () => {304    const socket = socketWithFrames([305      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },306      {307        type: "ADOPT_REQUEST",308        device_id: "request-only-label",309        device_name: "June's phone",310        device_type: "android",311        snapshot_capable: true,312      },313      { type: "ADOPT_CONFIRM", confirmed: true },314      {315        type: "ADOPT_IDENTITY",316        device_id: "android-receiver",317        device_name: "June's phone",318        device_type: "android",319        long_term_public_key_hex: "d".repeat(64),320      },321      { type: "ADOPT_APPLIED" },322    ]);323    mocks.openSocket.mockResolvedValue(socket);324    const { exportDuressSlotForAdoption } = await import("./vault");325    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(326      "x".repeat(768 * 1024),327    );328329    await startRelayAdoptionSource();330    await waitForPush("ADOPTION_SOURCE_RESULT");331332    expect(mocks.pushes).toContainEqual({333      type: "ADOPTION_SOURCE_RESULT",334      payload: {335        status: "error",336        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",337      },338    });339    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({340      type: "ADOPTION_SOURCE_RESULT",341      payload: expect.objectContaining({ status: "complete" }),342    }));343    const sentTypes = socket.send.mock.calls.map(([bytes]) =>344      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,345    );346    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");347    expect(mocks.triggerSync).not.toHaveBeenCalled();348  });349350  it("fails closed when the popup rejects the SAS", async () => {351    const socket = socketWithFrames([352      {353        type: "PEACH_HELLO",354        token: "AAECAwQFBgcICQoLDA0ODw",355      },356      {357        type: "ADOPT_REQUEST",358        device_id: "request-only-label",359        device_name: "New phone",360        device_type: "android",361      },362      { type: "ADOPT_CONFIRM", confirmed: false },363    ]);364    mocks.openSocket.mockResolvedValue(socket);365366    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");367    let pendingId: string | undefined;368    Object.defineProperty(globalThis, "chrome", {369      configurable: true,370      value: {371        runtime: {372          sendMessage(message: { type: string; payload: unknown }) {373            mocks.pushes.push(message);374            if (message.type === "ADOPTION_SOURCE_SAS") {375              const p = message.payload as { id?: string };376              if (typeof p?.id === "string") pendingId = p.id;377            }378            return Promise.resolve();379          },380        },381      },382    });383384    await startRelayAdoptionSource();385    await waitForPush("ADOPTION_SOURCE_SAS");386    expect(typeof pendingId).toBe("string");387    resolveSourceSasConfirmation(pendingId!, false);388    mocks.sasDecisions.push({ id: pendingId!, ok: false });389390    await waitForPush("ADOPTION_SOURCE_RESULT");391392    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();393    expect(mocks.upsert).not.toHaveBeenCalled();394    expect(mocks.pushes).toContainEqual({395      type: "ADOPTION_SOURCE_RESULT",396      payload: { status: "error", error: "adoption_rejected" },397    });398  });399400  it("rejects before reading the root key when neither peer has Pro", async () => {401    const socket = socketWithFrames([402      {403        type: "PEACH_HELLO",404        token: "AAECAwQFBgcICQoLDA0ODw",405      },406      {407        type: "ADOPT_REQUEST",408        device_id: "free-receiver",409        device_name: "Free phone",410        device_type: "android",411      },412      { type: "ADOPT_CONFIRM", confirmed: true },413    ]);414    mocks.isProUser.mockResolvedValue(false);415    mocks.getLicenseKey.mockResolvedValue(undefined);416    mocks.openSocket.mockResolvedValue(socket);417418    await startRelayAdoptionSource();419    await waitForPush("ADOPTION_SOURCE_RESULT");420421    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();422    expect(mocks.upsert).not.toHaveBeenCalled();423    expect(mocks.pushes).toContainEqual({424      type: "ADOPTION_SOURCE_RESULT",425      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },426    });427    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {428      try {429        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];430      } catch {431        return [];432      }433    });434    expect(sentFrames).toContainEqual({435      type: "ADOPT_REJECT",436      reason: "ADOPT_PRO_REQUIRED",437    });438  });439440  it("fails closed when the user never answers the SAS", async () => {441    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they442    // schedule real work), so we hold the SAS gate open by never resolving443    // the deferred and then cancel the session. clearState must drop the444    // pending SAS so it doesn't pin the relay room open for the next run.445    const socket = socketWithFrames([]);446    mocks.openSocket.mockResolvedValue(socket);447448    Object.defineProperty(globalThis, "chrome", {449      configurable: true,450      value: {451        runtime: {452          sendMessage(message: { type: string; payload: unknown }) {453            mocks.pushes.push(message);454            return Promise.resolve();455          },456        },457      },458    });459460    await startRelayAdoptionSource();461    cancelRelayAdoptionSource();462463    expect(socket.close).toHaveBeenCalled();464    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();465    expect(mocks.upsert).not.toHaveBeenCalled();466  });467468  it("cancelling the source screen closes the room without reading the root", async () => {469    const socket = socketWithFrames([]);470    mocks.openSocket.mockResolvedValue(socket);471472    await startRelayAdoptionSource();473    cancelRelayAdoptionSource();474475    expect(socket.close).toHaveBeenCalled();476    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();477    expect(mocks.upsert).not.toHaveBeenCalled();478  });479});480481describe("relay adoption receiver background", () => {482  beforeEach(async () => {483    // The receiver session runs in the background via `void runReceiverSession`;484    // it doesn't get awaited by the caller and can leave `inFlight` set if the485    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test486    // starts from a clean state.487    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");488    cancelRelayAdoptionSource();489    mocks.pushes.length = 0;490    mocks.openSocket.mockReset();491    mocks.getDeviceName.mockReset();492    mocks.getDeviceName.mockResolvedValue("Receiver Browser");493    mocks.createVault.mockReset();494    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });495    mocks.activateLicense.mockReset();496    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });497    mocks.isProUser.mockReset();498    mocks.isProUser.mockResolvedValue(true);499    mocks.getLicenseKey.mockReset();500    mocks.getLicenseKey.mockResolvedValue(undefined);501    mocks.localIdentity.mockReset();502    mocks.localIdentity.mockResolvedValue({503      deviceId: "receiver-id",504      deviceName: "Receiver Browser",505      deviceType: "extension",506      longTermPublicKeyHex: "11".repeat(32),507      signingPublicKeyHex: "22".repeat(32),508    });509    mocks.rollbackVault.mockReset();510    mocks.rollbackVault.mockResolvedValue(undefined);511    mocks.storageSet.mockReset();512    mocks.storageSet.mockResolvedValue(undefined);513    mocks.upsert.mockReset();514    mocks.upsert.mockResolvedValue(undefined);515    mocks.triggerSync.mockReset();516    mocks.triggerSync.mockResolvedValue(undefined);517    mocks.openSocket.mockResolvedValue(socketWithFrames([]));518    Object.defineProperty(globalThis, "chrome", {519      configurable: true,520      value: {521        runtime: {522          sendMessage(message: { type: string; payload?: unknown }) {523            mocks.pushes.push({ type: message.type, payload: message.payload });524            return Promise.resolve({ ok: true });525          },526          onMessage: {527            addListener() { /* no-op */ },528            removeListener() { /* no-op */ },529          },530        },531      },532    });533  });534535  it("opens a relay socket and returns an empty invitation", async () => {536    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");537    const started = await startRelayAdoptionReceiver("receiver-password");538    expect(started.invitation).toMatch(/^peach-pair-v2:/);539    // The QR carries vault:"empty" so the phone knows to act as source.540    const decoded = decodeInvitation(started.invitation);541    expect(decoded?.vault).toBe("empty");542    expect(mocks.openSocket).toHaveBeenCalled();543  });544545  it("rejects an empty password", async () => {546    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");547    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");548  });549550  it("surfaces the Pro-required rejection without creating a vault", async () => {551    mocks.openSocket.mockResolvedValue(socketWithFrames([552      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },553      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },554    ]));555556    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");557    await startRelayAdoptionReceiver("receiver-password");558    await waitForPush("ADOPTION_SOURCE_RESULT");559560    expect(mocks.createVault).not.toHaveBeenCalled();561    expect(mocks.rollbackVault).not.toHaveBeenCalled();562    expect(mocks.pushes).toContainEqual({563      type: "ADOPTION_SOURCE_RESULT",564      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },565    });566  });567568  it("the receiver wires requestAdoption, not grantAdoption", async () => {569    // The receiver's protocol position is the opposite of the source side:570    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.571    // Assert this by watching what lands on socket.send: the receiver's572    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY573    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.574    const rootKeyHex = "f0".repeat(32);575    const vaultId = "b".repeat(64);576    const socket = socketWithFrames([577      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },578      {579        type: "ADOPT_GRANT",580        root_key_hex: rootKeyHex,581        vault_id: vaultId,582        license: "PPK-test",583        source: {584          device_id: "phone-source-id",585          device_name: "June's phone",586          device_type: "android",587          long_term_public_key_hex: "99".repeat(32),588        },589      },590      { type: "ADOPT_COMPLETE" },591    ], { closeOnEmpty: true });592    mocks.openSocket.mockResolvedValue(socket);593    mocks.createVault.mockResolvedValue({ vaultId });594595    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");596    Object.defineProperty(globalThis, "chrome", {597      configurable: true,598      value: {599        runtime: {600          sendMessage(message: { type: string; payload?: unknown }) {601            mocks.pushes.push({ type: message.type, payload: message.payload });602            return Promise.resolve({ ok: true });603          },604          onMessage: { addListener: () => undefined, removeListener: () => undefined },605        },606      },607    });608    await startRelayAdoptionReceiver("receiver-password");609    await waitForPush("ADOPTION_SOURCE_RESULT");610611    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })612      .mock.calls613      .map(([bytes]) => {614        try {615          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;616        } catch {617          return undefined;618        }619      });620    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;621    // ADOPT_GRANT is the source side's frame and must never appear here.622    expect(sentTypes).toContain("ADOPT_REQUEST");623    expect(sentTypes).not.toContain("ADOPT_GRANT");624  });625626  it("completes the handshake end-to-end: vault created → paired", async () => {627    // Wire the receiver's noise mock and SAS gate together so the full628    // requestAdoption protocol runs against an in-memory phone that sends629    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.630    // The mocked noise is pass-through (encrypt = decrypt = identity), so631    // frames land as ordinary JSON.632    const rootKeyHex = "f0".repeat(32);633    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);634    // the grant's vault_id is set to the same so the protocol proceeds.635    const vaultId = "b".repeat(64);636    const frames = [637      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },638      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two639      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.640      {641        type: "ADOPT_GRANT",642        root_key_hex: rootKeyHex,643        vault_id: vaultId,644        license: "PPK-test",645        source: {646          device_id: "phone-source-id",647          device_name: "June's phone",648          device_type: "android",649          long_term_public_key_hex: "99".repeat(32),650          signing_public_key_hex: "aa".repeat(32),651          relay_url: "wss://relay.peachpasswords.com",652        },653      },654      { type: "ADOPT_COMPLETE" },655    ];656    mocks.createVault.mockResolvedValue({ vaultId });657    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));658659    // Replace the chrome.runtime mock for this one test. The receiver660    // auto-accepts the SAS, so no decision needs to be simulated.661    Object.defineProperty(globalThis, "chrome", {662      configurable: true,663      value: {664        runtime: {665          sendMessage(message: { type: string; payload?: unknown }) {666            mocks.pushes.push({ type: message.type, payload: message.payload });667            return Promise.resolve({ ok: true });668          },669          onMessage: {670            addListener: () => undefined,671            removeListener: () => undefined,672          },673        },674      },675    });676677    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");678    await startRelayAdoptionReceiver("receiver-password");679680    await waitForPush("ADOPTION_SOURCE_RESULT");681682    // The receiver displays its locally derived SAS without introducing a683    // second confirmation gate.684    expect(mocks.pushes).toContainEqual({685      type: "ADOPTION_SOURCE_SAS",686      payload: expect.objectContaining({687        sas: expect.stringMatching(/^\d{8}$/),688        displayOnly: true,689      }),690    });691692    // The vault module was called directly with the popup-supplied password693    // and the root key granted by the phone.694    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);695696    // License activation, identity derivation, and pairing persistence all697    // call their modules directly from the service worker.698    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");699    expect(mocks.isProUser).toHaveBeenCalled();700    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));701    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({702      deviceId: "phone-source-id",703    }));704    expect(mocks.storageSet).toHaveBeenCalledWith(705      "peach_initial_sync_pending_device_id",706      "phone-source-id",707    );708    expect(mocks.rollbackVault).not.toHaveBeenCalled();709710    // The terminal state is "complete" — the popup can transition to success.711    expect(mocks.pushes).toContainEqual(expect.objectContaining({712      type: "ADOPTION_SOURCE_RESULT",713      payload: expect.objectContaining({ status: "complete" }),714    }));715  });716717  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {718    const rootKeyHex = "f0".repeat(32);719    const vaultId = "b".repeat(64);720    mocks.createVault.mockResolvedValue({ vaultId });721    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });722    mocks.isProUser.mockResolvedValue(false);723    mocks.openSocket.mockResolvedValue(socketWithFrames([724      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },725      {726        type: "ADOPT_GRANT",727        root_key_hex: rootKeyHex,728        vault_id: vaultId,729        license: "PPK-invalid",730        source: {731          device_id: "phone-source-id",732          device_name: "June's phone",733          device_type: "android",734          long_term_public_key_hex: "99".repeat(32),735        },736      },737    ]));738739    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");740    await startRelayAdoptionReceiver("receiver-password");741    await waitForPush("ADOPTION_SOURCE_RESULT");742743    expect(mocks.rollbackVault).toHaveBeenCalledOnce();744    expect(mocks.localIdentity).not.toHaveBeenCalled();745    expect(mocks.upsert).not.toHaveBeenCalled();746    expect(mocks.pushes).toContainEqual(expect.objectContaining({747      type: "ADOPTION_SOURCE_RESULT",748      payload: expect.objectContaining({749        status: "error",750        error: "license_transfer_failed",751      }),752    }));753  });754755  it("rolls back a newly created vault when a later adoption step fails", async () => {756    const rootKeyHex = "f0".repeat(32);757    const vaultId = "b".repeat(64);758    mocks.createVault.mockResolvedValue({ vaultId });759    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));760    mocks.openSocket.mockResolvedValue(socketWithFrames([761      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },762      {763        type: "ADOPT_GRANT",764        root_key_hex: rootKeyHex,765        vault_id: vaultId,766        license: "PPK-test",767        source: {768          device_id: "phone-source-id",769          device_name: "June's phone",770          device_type: "android",771          long_term_public_key_hex: "99".repeat(32),772        },773      },774    ]));775776    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");777    await startRelayAdoptionReceiver("receiver-password");778    await waitForPush("ADOPTION_SOURCE_RESULT");779780    expect(mocks.rollbackVault).toHaveBeenCalledOnce();781    expect(mocks.upsert).not.toHaveBeenCalled();782    expect(mocks.pushes).toContainEqual(expect.objectContaining({783      type: "ADOPTION_SOURCE_RESULT",784      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),785    }));786  });787788  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {789    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).790    // The receiver should skip the 30-second inline snapshot wait entirely791    // and fall through to the sync pipeline — no receiveVaultSnapshot call.792    const rootKeyHex = "f0".repeat(32);793    const vaultId = "b".repeat(64);794    mocks.createVault.mockResolvedValue({ vaultId });795    mocks.openSocket.mockResolvedValue(socketWithFrames([796      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },797      {798        type: "ADOPT_GRANT",799        root_key_hex: rootKeyHex,800        vault_id: vaultId,801        license: "PPK-test",802        source: {803          device_id: "old-phone-id",804          device_name: "Old phone",805          device_type: "android",806          long_term_public_key_hex: "99".repeat(32),807        },808        // NO snapshot_capable: pre-C source does not echo the field809      },810      { type: "ADOPT_COMPLETE" },811    ], { closeOnEmpty: true }));812813    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");814    await startRelayAdoptionReceiver("receiver-password");815    await waitForPush("ADOPTION_SOURCE_RESULT");816817    // Sync fallback ran immediately (no 30s wait).818    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });819    // Adoption completed successfully.820    expect(mocks.pushes).toContainEqual(expect.objectContaining({821      type: "ADOPTION_SOURCE_RESULT",822      payload: expect.objectContaining({ status: "complete" }),823    }));824    // The initial sync pending marker was set (not cleared — no snapshot applied).825    expect(mocks.storageSet).toHaveBeenCalledWith(826      "peach_initial_sync_pending_device_id",827      "old-phone-id",828    );829  });830831  it("applies the source's device manifest after the snapshot, then acks", async () => {832    // Mesh propagation: a 3rd browser that completes an adoption with a833    // snapshot-capable source must also receive the source's signed device834    // manifest and forward it to the sync adapter before the receiver835    // resolves. The relay stays an opaque forwarder, so the whole path836    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.837    const rootKeyHex = "f0".repeat(32);838    const vaultId = "b".repeat(64);839    const longTermPubkeyHex = "99".repeat(32);840    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");841    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(842      "../shared/sync-identity"843    );844    const issuerKp = await deriveSyncSigningKeypair(845      new Uint8Array(32).fill(0x55),846      "phone-source-id",847    );848    const manifest = await buildSignedDeviceManifest(849      "phone-source-id",850      [851        {852          deviceId: "peer-b",853          deviceName: "Pool B",854          deviceType: "extension",855          longTermPublicKeyHex: "11".repeat(32),856          signingPublicKeyHex: "22".repeat(32),857          wsPort: 49152,858          relayUrl: "wss://relay.peachpasswords.com",859          pairedAt: 1,860        },861      ],862      issuerKp.privateKey,863    );864    const socket = socketWithFrames([865      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },866      {867        type: "ADOPT_GRANT",868        root_key_hex: rootKeyHex,869        vault_id: vaultId,870        license: "PPK-test",871        snapshot_capable: true,872        source: {873          device_id: "phone-source-id",874          device_name: "June's phone",875          device_type: "android",876          long_term_public_key_hex: longTermPubkeyHex,877          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),878          relay_url: "wss://relay.peachpasswords.com",879        },880      },881      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is882      // an optional follow-up frame on the same channel.883      { type: "ADOPT_COMPLETE" },884      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames885      // after the handshake; one empty chunk ends the phase.886      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },887      { type: "ADOPT_DEVICE_MANIFEST", manifest },888    ], { closeOnEmpty: true });889    mocks.createVault.mockResolvedValue({ vaultId });890    mocks.openSocket.mockResolvedValue(socket);891    mocks.__applyIncomingDeviceManifest.mockClear();892893    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");894    await startRelayAdoptionReceiver("receiver-password");895    await waitForPush("ADOPTION_SOURCE_RESULT");896897    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);898    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];899    expect(forwarded).toEqual(manifest);900    expect(pubkey).toBeInstanceOf(Uint8Array);901    expect(pubkey.length).toBe(32);902    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the903    // adapter path was skipped (the ack only follows a real apply).904    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })905      .mock.calls906      .map(([bytes]) => {907        try {908          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;909        } catch {910          return undefined;911        }912      });913    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");914    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.915    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");916  });917918  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {919    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST920    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,921    // and quietly time out the optional 5s wait without acking a manifest922    // or invoking the adapter path.923    const rootKeyHex = "f0".repeat(32);924    const vaultId = "b".repeat(64);925    const longTermPubkeyHex = "99".repeat(32);926    const socket = socketWithFrames([927      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },928      {929        type: "ADOPT_GRANT",930        root_key_hex: rootKeyHex,931        vault_id: vaultId,932        license: "PPK-test",933        snapshot_capable: true,934        source: {935          device_id: "phone-source-id",936          device_name: "June's phone",937          device_type: "android",938          long_term_public_key_hex: longTermPubkeyHex,939          signing_public_key_hex: "aa".repeat(32),940          relay_url: "wss://relay.peachpasswords.com",941        },942      },943      { type: "ADOPT_COMPLETE" },944      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot945      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.946      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },947    ], { closeOnEmpty: true });948    mocks.createVault.mockResolvedValue({ vaultId });949    mocks.openSocket.mockResolvedValue(socket);950    mocks.__applyIncomingDeviceManifest.mockClear();951952    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");953    await startRelayAdoptionReceiver("receiver-password");954    await waitForPush("ADOPTION_SOURCE_RESULT");955956    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();957    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })958      .mock.calls959      .map(([bytes]) => {960        try {961          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;962        } catch {963          return undefined;964        }965      });966    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");967    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");968  });969});970971function decodeInvitation(encoded: string): { vault?: string } | null {972  // Mirrors parsePairingInvitation's payload structure without pulling the973  // module in: the receiver test only needs to assert the embedded status.974  const at = encoded.indexOf(":");975  if (at < 0) return null;976  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");977  try {978    const json = Buffer.from(b64, "base64").toString("utf8");979    return JSON.parse(json) as { vault?: string };980  } catch {981    return null;982  }983}