relay-adoption-bg.test.ts

34.8 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  it("applies the source's device manifest after the snapshot, then acks", async () => {43    // Mesh propagation: a 3rd browser that completes an adoption with a44    // snapshot-capable source must also receive the source's signed device45    // manifest and forward it to the sync adapter before the receiver46    // resolves. The relay stays an opaque forwarder, so the whole path47    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.48    const rootKeyHex = "f0".repeat(32);49    const vaultId = "b".repeat(64);50    const longTermPubkeyHex = "99".repeat(32);51    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");52    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(53      "../shared/sync-identity"54    );55    const issuerKp = await deriveSyncSigningKeypair(56      new Uint8Array(32).fill(0x55),57      "phone-source-id",58    );59    const manifest = await buildSignedDeviceManifest(60      "phone-source-id",61      [62        {63          deviceId: "peer-b",64          deviceName: "Pool B",65          deviceType: "extension",66          longTermPublicKeyHex: "11".repeat(32),67          signingPublicKeyHex: "22".repeat(32),68          wsPort: 49152,69          relayUrl: "wss://relay.peachpasswords.com",70          pairedAt: 1,71        },72      ],73      issuerKp.privateKey,74    );75    const socket = socketWithFrames([76      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },77      {78        type: "ADOPT_GRANT",79        root_key_hex: rootKeyHex,80        vault_id: vaultId,81        license: "PPK-test",82        snapshot_capable: true,83        source: {84          device_id: "phone-source-id",85          device_name: "June's phone",86          device_type: "android",87          long_term_public_key_hex: longTermPubkeyHex,88          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),89          relay_url: "wss://relay.peachpasswords.com",90        },91      },92      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is93      // an optional follow-up frame on the same channel.94      { type: "ADOPT_COMPLETE" },95      { type: "ADOPT_DEVICE_MANIFEST", manifest },96    ], { closeOnEmpty: true });97    mocks.createVault.mockResolvedValue({ vaultId });98    mocks.openSocket.mockResolvedValue(socket);99    mocks.__applyIncomingDeviceManifest.mockClear();100101    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");102    await startRelayAdoptionReceiver("receiver-password");103    await waitForPush("ADOPTION_SOURCE_RESULT");104105    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);106    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];107    expect(forwarded).toEqual(manifest);108    expect(pubkey).toBeInstanceOf(Uint8Array);109    expect(pubkey.length).toBe(32);110    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the111    // adapter path was skipped (the ack only follows a real apply).112    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })113      .mock.calls114      .map(([bytes]) => {115        try {116          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;117        } catch {118          return undefined;119        }120      });121    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");122    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.123    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");124  });125126  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {127    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST128    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,129    // and quietly time out the optional 5s wait without acking a manifest130    // or invoking the adapter path.131    const rootKeyHex = "f0".repeat(32);132    const vaultId = "b".repeat(64);133    const longTermPubkeyHex = "99".repeat(32);134    const socket = socketWithFrames([135      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },136      {137        type: "ADOPT_GRANT",138        root_key_hex: rootKeyHex,139        vault_id: vaultId,140        license: "PPK-test",141        snapshot_capable: true,142        source: {143          device_id: "phone-source-id",144          device_name: "June's phone",145          device_type: "android",146          long_term_public_key_hex: longTermPubkeyHex,147          signing_public_key_hex: "aa".repeat(32),148          relay_url: "wss://relay.peachpasswords.com",149        },150      },151      { type: "ADOPT_COMPLETE" },152      // No ADOPT_DEVICE_MANIFEST follow-up.153    ], { closeOnEmpty: true });154    mocks.createVault.mockResolvedValue({ vaultId });155    mocks.openSocket.mockResolvedValue(socket);156    mocks.__applyIncomingDeviceManifest.mockClear();157158    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");159    await startRelayAdoptionReceiver("receiver-password");160    await waitForPush("ADOPTION_SOURCE_RESULT");161162    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();163    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })164      .mock.calls165      .map(([bytes]) => {166        try {167          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;168        } catch {169          return undefined;170        }171      });172    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");173    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");174  });175});176177178vi.mock("./device-identity", () => ({179  getLocalPairingIdentity: async () => ({180    identity: {181      deviceId: "test-device",182      deviceName: "Test",183      deviceType: "extension",184      longTermPublicKeyHex: "aa".repeat(32),185      vaultId: "bb".repeat(32),186    },187    privateKey: new Uint8Array(32).fill(7),188    publicKey: new Uint8Array(32).fill(8),189  }),190  getDeviceName: mocks.getDeviceName,191  buildLocalIdentity: mocks.localIdentity,192}));193194vi.mock("./sync-settings", async (importOriginal) => ({195  ...(await importOriginal<typeof import("./sync-settings")>()),196  getSyncSettings: async () => ({197    mode: "public" as const,198    relayUrl: "wss://relay.peachpasswords.com",199  }),200}));201202vi.mock("./license", () => ({203  isProUser: mocks.isProUser,204  getLicenseKey: mocks.getLicenseKey,205  activateLicense: mocks.activateLicense,206}));207208vi.mock("./vault", () => ({209  isUnlocked: vi.fn(() => true),210  getRootKeyHex: mocks.getRootKeyHex,211  createVaultFromRootKey: mocks.createVault,212  rollbackAdoptedVault: mocks.rollbackVault,213  exportDuressSlotForAdoption: vi.fn(async () => null),214  installAdoptedDuressSlot: vi.fn(async () => undefined),215}));216217vi.mock("../shared/sync-identity", () => ({218  computeVaultId: vi.fn(async () => "b".repeat(64)),219}));220221vi.mock("../shared/storage", () => ({222  storageSet: mocks.storageSet,223}));224225vi.mock("./paired-devices", () => ({226  upsertPairedDevice: mocks.upsert,227}));228229vi.mock("./sync-trigger", () => ({230  triggerSyncIfReachable: mocks.triggerSync,231}));232233vi.mock("./sync-vault-adapter", () => ({234  createSyncVaultAdapter: mocks.createSyncAdapter,235}));236237import {238  cancelRelayAdoptionSource,239  startRelayAdoptionSource,240} from "./relay-adoption-bg";241242const encode = (value: unknown) =>243  new TextEncoder().encode(JSON.stringify(value));244245function socketWithFrames(246  frames: unknown[],247  options: { closeOnEmpty?: boolean } = {},248) {249  const queue = frames.map(encode);250  return {251    send: vi.fn(),252    receive: vi.fn(async () => {253      const next = queue.shift();254      if (!next) {255        // A real peer closes the channel once it runs out of frames; model256        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast257        // and falls back to the sync pipeline instead of hanging on a timeout.258        if (options.closeOnEmpty) throw new Error("socket_closed");259        return new Promise<Uint8Array>(() => undefined);260      }261      return next;262    }),263    close: vi.fn(),264    closedBeforeFrame: vi.fn(() => false),265  };266}267268async function waitForPush(type: string): Promise<void> {269  await vi.waitFor(() => {270    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);271  });272}273274beforeEach(() => {275  mocks.pushes.length = 0;276  mocks.sasDecisions.length = 0;277  mocks.openSocket.mockReset();278  mocks.getLocalIdentity.mockReset();279  mocks.getRootKeyHex.mockReset();280  mocks.upsert.mockReset();281  mocks.triggerSync.mockReset();282  mocks.triggerSync.mockResolvedValue(undefined);283  mocks.isProUser.mockReset();284  mocks.isProUser.mockResolvedValue(true);285  mocks.getLicenseKey.mockReset();286  mocks.getLicenseKey.mockResolvedValue("PPK-source");287  mocks.getLocalIdentity.mockResolvedValue({288    identity: {289      deviceId: "browser-source",290      deviceName: "June's Browser",291      deviceType: "extension",292      longTermPublicKeyHex: "a".repeat(64),293      vaultId: "b".repeat(64),294    },295    privateKey: new Uint8Array(32).fill(1),296    publicKey: new Uint8Array(32).fill(2),297  });298  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));299  mocks.upsert.mockResolvedValue(undefined);300    const applyIncomingDeviceManifest = vi.fn(async () => undefined);301    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;302    mocks.createSyncAdapter.mockReturnValue({303      buildEntryManifest: vi.fn(async () => ({})),304      getEncryptedEntries: vi.fn(async () => ({})),305      buildTombstoneManifest: vi.fn(async () => ({})),306      applyEntries: vi.fn(async () => undefined),307      applyTombstones: vi.fn(async () => undefined),308      buildOutgoingDeviceManifest: vi.fn(async () => null),309      applyIncomingDeviceManifest,310    });311  Object.defineProperty(globalThis, "chrome", {312    configurable: true,313    value: {314    runtime: {315      async sendMessage(message: { type: string; payload: unknown }) {316        mocks.pushes.push(message);317        // The source flow waits for the popup to send SOURCE_SAS_DECISION before318        // forwarding the root key. The receiver test overrides this mock and319        // resolves the decision itself; the source tests rely on this default320        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.321        if (message.type === "ADOPTION_SOURCE_SAS") {322          const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");323          const p = message.payload as { id?: string };324          if (typeof p?.id === "string") {325            mocks.sasDecisions.push({ id: p.id, ok: true });326            resolveSourceSasConfirmation(p.id, true);327          }328        }329        return Promise.resolve();330      },331    },332    },333  });334});335336describe("relay adoption source background boundary", () => {337  it("shows the SAS and waits for the popup's single confirmation", async () => {338    const socket = socketWithFrames([339      {340        type: "PEACH_HELLO",341        token: "AAECAwQFBgcICQoLDA0ODw",342      },343      {344        type: "ADOPT_REQUEST",345        device_id: "request-only-label",346        device_name: "June's phone",347        device_type: "android",348      },349      { type: "ADOPT_CONFIRM", confirmed: true },350      {351        type: "ADOPT_IDENTITY",352        device_id: "android-receiver",353        device_name: "June's phone",354        device_type: "android",355        long_term_public_key_hex: "d".repeat(64),356      },357      { type: "ADOPT_APPLIED" },358    ]);359    mocks.openSocket.mockResolvedValue(socket);360361    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");362    const sasIds: string[] = [];363    Object.defineProperty(globalThis, "chrome", {364      configurable: true,365      value: {366        runtime: {367          async sendMessage(message: { type: string; payload: unknown }) {368            mocks.pushes.push(message);369            if (message.type === "ADOPTION_SOURCE_SAS") {370              const p = message.payload as { id?: string };371              if (typeof p?.id === "string") {372                // Hold the SAS gate open until the test explicitly approves373                // it — this proves the source flow waits for the popup.374                sasIds.push(p.id);375              }376              return Promise.resolve();377            }378            return Promise.resolve();379          },380        },381      },382    });383    void resolveSourceSasConfirmation; // imported for the post-approval call below384385    const started = await startRelayAdoptionSource();386    expect(started.invitation).toMatch(/^peach-pair-v1:/);387    await waitForPush("ADOPTION_SOURCE_SAS");388    // The adoption must NOT be complete yet — the popup's SAS decision is the389    // prerequisite for forwarding the root key.390    await vi.waitFor(() => {391      expect(mocks.sasDecisions.length).toBe(0);392    });393    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();394    expect(mocks.upsert).not.toHaveBeenCalled();395396    // Approve the SAS; the adoption should now proceed and complete.397    const id = sasIds[0];398    expect(typeof id).toBe("string");399    resolveSourceSasConfirmation(id, true);400    mocks.sasDecisions.push({ id, ok: true });401402    await waitForPush("ADOPTION_SOURCE_RESULT");403404    expect(mocks.getRootKeyHex).toHaveBeenCalled();405    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({406      deviceId: "android-receiver",407      relayUrl: "wss://relay.peachpasswords.com",408    }));409    expect(mocks.pushes).toContainEqual(expect.objectContaining({410      type: "ADOPTION_SOURCE_RESULT",411      payload: expect.objectContaining({ status: "complete" }),412    }));413    expect(mocks.triggerSync).toHaveBeenCalledWith({414      reason: "manual",415      force: true,416    });417    const sentTypes = socket.send.mock.calls418      .map(([bytes]) => {419        try {420          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;421        } catch {422          return undefined;423        }424      });425    expect(sentTypes).toContain("ADOPT_COMPLETE");426  });427428  it("fails adoption without sending snapshot frames when duress is oversized", async () => {429    const socket = socketWithFrames([430      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },431      {432        type: "ADOPT_REQUEST",433        device_id: "request-only-label",434        device_name: "June's phone",435        device_type: "android",436        snapshot_capable: true,437      },438      { type: "ADOPT_CONFIRM", confirmed: true },439      {440        type: "ADOPT_IDENTITY",441        device_id: "android-receiver",442        device_name: "June's phone",443        device_type: "android",444        long_term_public_key_hex: "d".repeat(64),445      },446      { type: "ADOPT_APPLIED" },447    ]);448    mocks.openSocket.mockResolvedValue(socket);449    const { exportDuressSlotForAdoption } = await import("./vault");450    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(451      "x".repeat(768 * 1024),452    );453454    await startRelayAdoptionSource();455    await waitForPush("ADOPTION_SOURCE_RESULT");456457    expect(mocks.pushes).toContainEqual({458      type: "ADOPTION_SOURCE_RESULT",459      payload: {460        status: "error",461        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",462      },463    });464    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({465      type: "ADOPTION_SOURCE_RESULT",466      payload: expect.objectContaining({ status: "complete" }),467    }));468    const sentTypes = socket.send.mock.calls.map(([bytes]) =>469      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,470    );471    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");472    expect(mocks.triggerSync).not.toHaveBeenCalled();473  });474475  it("fails closed when the popup rejects the SAS", async () => {476    const socket = socketWithFrames([477      {478        type: "PEACH_HELLO",479        token: "AAECAwQFBgcICQoLDA0ODw",480      },481      {482        type: "ADOPT_REQUEST",483        device_id: "request-only-label",484        device_name: "New phone",485        device_type: "android",486      },487      { type: "ADOPT_CONFIRM", confirmed: false },488    ]);489    mocks.openSocket.mockResolvedValue(socket);490491    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");492    let pendingId: string | undefined;493    Object.defineProperty(globalThis, "chrome", {494      configurable: true,495      value: {496        runtime: {497          sendMessage(message: { type: string; payload: unknown }) {498            mocks.pushes.push(message);499            if (message.type === "ADOPTION_SOURCE_SAS") {500              const p = message.payload as { id?: string };501              if (typeof p?.id === "string") pendingId = p.id;502            }503            return Promise.resolve();504          },505        },506      },507    });508509    await startRelayAdoptionSource();510    await waitForPush("ADOPTION_SOURCE_SAS");511    expect(typeof pendingId).toBe("string");512    resolveSourceSasConfirmation(pendingId!, false);513    mocks.sasDecisions.push({ id: pendingId!, ok: false });514515    await waitForPush("ADOPTION_SOURCE_RESULT");516517    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();518    expect(mocks.upsert).not.toHaveBeenCalled();519    expect(mocks.pushes).toContainEqual({520      type: "ADOPTION_SOURCE_RESULT",521      payload: { status: "error", error: "adoption_rejected" },522    });523  });524525  it("rejects before reading the root key when neither peer has Pro", async () => {526    const socket = socketWithFrames([527      {528        type: "PEACH_HELLO",529        token: "AAECAwQFBgcICQoLDA0ODw",530      },531      {532        type: "ADOPT_REQUEST",533        device_id: "free-receiver",534        device_name: "Free phone",535        device_type: "android",536      },537      { type: "ADOPT_CONFIRM", confirmed: true },538    ]);539    mocks.isProUser.mockResolvedValue(false);540    mocks.getLicenseKey.mockResolvedValue(undefined);541    mocks.openSocket.mockResolvedValue(socket);542543    await startRelayAdoptionSource();544    await waitForPush("ADOPTION_SOURCE_RESULT");545546    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();547    expect(mocks.upsert).not.toHaveBeenCalled();548    expect(mocks.pushes).toContainEqual({549      type: "ADOPTION_SOURCE_RESULT",550      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },551    });552    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {553      try {554        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];555      } catch {556        return [];557      }558    });559    expect(sentFrames).toContainEqual({560      type: "ADOPT_REJECT",561      reason: "ADOPT_PRO_REQUIRED",562    });563  });564565  it("fails closed when the user never answers the SAS", async () => {566    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they567    // schedule real work), so we hold the SAS gate open by never resolving568    // the deferred and then cancel the session. clearState must drop the569    // pending SAS so it doesn't pin the relay room open for the next run.570    const socket = socketWithFrames([]);571    mocks.openSocket.mockResolvedValue(socket);572573    Object.defineProperty(globalThis, "chrome", {574      configurable: true,575      value: {576        runtime: {577          sendMessage(message: { type: string; payload: unknown }) {578            mocks.pushes.push(message);579            return Promise.resolve();580          },581        },582      },583    });584585    await startRelayAdoptionSource();586    cancelRelayAdoptionSource();587588    expect(socket.close).toHaveBeenCalled();589    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();590    expect(mocks.upsert).not.toHaveBeenCalled();591  });592593  it("cancelling the source screen closes the room without reading the root", async () => {594    const socket = socketWithFrames([]);595    mocks.openSocket.mockResolvedValue(socket);596597    await startRelayAdoptionSource();598    cancelRelayAdoptionSource();599600    expect(socket.close).toHaveBeenCalled();601    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();602    expect(mocks.upsert).not.toHaveBeenCalled();603  });604});605606describe("relay adoption receiver background", () => {607  beforeEach(async () => {608    // The receiver session runs in the background via `void runReceiverSession`;609    // it doesn't get awaited by the caller and can leave `inFlight` set if the610    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test611    // starts from a clean state.612    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");613    cancelRelayAdoptionSource();614    mocks.pushes.length = 0;615    mocks.openSocket.mockReset();616    mocks.getDeviceName.mockReset();617    mocks.getDeviceName.mockResolvedValue("Receiver Browser");618    mocks.createVault.mockReset();619    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });620    mocks.activateLicense.mockReset();621    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });622    mocks.isProUser.mockReset();623    mocks.isProUser.mockResolvedValue(true);624    mocks.getLicenseKey.mockReset();625    mocks.getLicenseKey.mockResolvedValue(undefined);626    mocks.localIdentity.mockReset();627    mocks.localIdentity.mockResolvedValue({628      deviceId: "receiver-id",629      deviceName: "Receiver Browser",630      deviceType: "extension",631      longTermPublicKeyHex: "11".repeat(32),632      signingPublicKeyHex: "22".repeat(32),633    });634    mocks.rollbackVault.mockReset();635    mocks.rollbackVault.mockResolvedValue(undefined);636    mocks.storageSet.mockReset();637    mocks.storageSet.mockResolvedValue(undefined);638    mocks.upsert.mockReset();639    mocks.upsert.mockResolvedValue(undefined);640    mocks.triggerSync.mockReset();641    mocks.triggerSync.mockResolvedValue(undefined);642    mocks.openSocket.mockResolvedValue(socketWithFrames([]));643    Object.defineProperty(globalThis, "chrome", {644      configurable: true,645      value: {646        runtime: {647          sendMessage(message: { type: string; payload?: unknown }) {648            mocks.pushes.push({ type: message.type, payload: message.payload });649            return Promise.resolve({ ok: true });650          },651          onMessage: {652            addListener() { /* no-op */ },653            removeListener() { /* no-op */ },654          },655        },656      },657    });658  });659660  it("opens a relay socket and returns an empty invitation", async () => {661    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");662    const started = await startRelayAdoptionReceiver("receiver-password");663    expect(started.invitation).toMatch(/^peach-pair-v1:/);664    // The QR carries vault:"empty" so the phone knows to act as source.665    const decoded = decodeInvitation(started.invitation);666    expect(decoded?.vault).toBe("empty");667    expect(mocks.openSocket).toHaveBeenCalled();668  });669670  it("rejects an empty password", async () => {671    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");672    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");673  });674675  it("surfaces the Pro-required rejection without creating a vault", async () => {676    mocks.openSocket.mockResolvedValue(socketWithFrames([677      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },678      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },679    ]));680681    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");682    await startRelayAdoptionReceiver("receiver-password");683    await waitForPush("ADOPTION_SOURCE_RESULT");684685    expect(mocks.createVault).not.toHaveBeenCalled();686    expect(mocks.rollbackVault).not.toHaveBeenCalled();687    expect(mocks.pushes).toContainEqual({688      type: "ADOPTION_SOURCE_RESULT",689      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },690    });691  });692693  it("the receiver wires requestAdoption, not grantAdoption", async () => {694    // The receiver's protocol position is the opposite of the source side:695    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.696    // Assert this by watching what lands on socket.send: the receiver's697    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY698    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.699    const rootKeyHex = "f0".repeat(32);700    const vaultId = "b".repeat(64);701    const socket = socketWithFrames([702      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },703      {704        type: "ADOPT_GRANT",705        root_key_hex: rootKeyHex,706        vault_id: vaultId,707        license: "PPK-test",708        source: {709          device_id: "phone-source-id",710          device_name: "June's phone",711          device_type: "android",712          long_term_public_key_hex: "99".repeat(32),713        },714      },715      { type: "ADOPT_COMPLETE" },716    ], { closeOnEmpty: true });717    mocks.openSocket.mockResolvedValue(socket);718    mocks.createVault.mockResolvedValue({ vaultId });719720    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");721    Object.defineProperty(globalThis, "chrome", {722      configurable: true,723      value: {724        runtime: {725          sendMessage(message: { type: string; payload?: unknown }) {726            mocks.pushes.push({ type: message.type, payload: message.payload });727            return Promise.resolve({ ok: true });728          },729          onMessage: { addListener: () => undefined, removeListener: () => undefined },730        },731      },732    });733    await startRelayAdoptionReceiver("receiver-password");734    await waitForPush("ADOPTION_SOURCE_RESULT");735736    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })737      .mock.calls738      .map(([bytes]) => {739        try {740          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;741        } catch {742          return undefined;743        }744      });745    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;746    // ADOPT_GRANT is the source side's frame and must never appear here.747    expect(sentTypes).toContain("ADOPT_REQUEST");748    expect(sentTypes).not.toContain("ADOPT_GRANT");749  });750751  it("completes the handshake end-to-end: vault created → paired", async () => {752    // Wire the receiver's noise mock and SAS gate together so the full753    // requestAdoption protocol runs against an in-memory phone that sends754    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.755    // The mocked noise is pass-through (encrypt = decrypt = identity), so756    // frames land as ordinary JSON.757    const rootKeyHex = "f0".repeat(32);758    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);759    // the grant's vault_id is set to the same so the protocol proceeds.760    const vaultId = "b".repeat(64);761    const frames = [762      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },763      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two764      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.765      {766        type: "ADOPT_GRANT",767        root_key_hex: rootKeyHex,768        vault_id: vaultId,769        license: "PPK-test",770        source: {771          device_id: "phone-source-id",772          device_name: "June's phone",773          device_type: "android",774          long_term_public_key_hex: "99".repeat(32),775          signing_public_key_hex: "aa".repeat(32),776          relay_url: "wss://relay.peachpasswords.com",777        },778      },779      { type: "ADOPT_COMPLETE" },780    ];781    mocks.createVault.mockResolvedValue({ vaultId });782    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));783784    // Replace the chrome.runtime mock for this one test. The receiver785    // auto-accepts the SAS, so no decision needs to be simulated.786    Object.defineProperty(globalThis, "chrome", {787      configurable: true,788      value: {789        runtime: {790          sendMessage(message: { type: string; payload?: unknown }) {791            mocks.pushes.push({ type: message.type, payload: message.payload });792            return Promise.resolve({ ok: true });793          },794          onMessage: {795            addListener: () => undefined,796            removeListener: () => undefined,797          },798        },799      },800    });801802    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");803    await startRelayAdoptionReceiver("receiver-password");804805    await waitForPush("ADOPTION_SOURCE_RESULT");806807    // The receiver displays its locally derived SAS without introducing a808    // second confirmation gate.809    expect(mocks.pushes).toContainEqual({810      type: "ADOPTION_SOURCE_SAS",811      payload: expect.objectContaining({812        sas: expect.stringMatching(/^\d{8}$/),813        displayOnly: true,814      }),815    });816817    // The vault module was called directly with the popup-supplied password818    // and the root key granted by the phone.819    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);820821    // License activation, identity derivation, and pairing persistence all822    // call their modules directly from the service worker.823    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");824    expect(mocks.isProUser).toHaveBeenCalled();825    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));826    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({827      deviceId: "phone-source-id",828    }));829    expect(mocks.storageSet).toHaveBeenCalledWith(830      "peach_initial_sync_pending_device_id",831      "phone-source-id",832    );833    expect(mocks.rollbackVault).not.toHaveBeenCalled();834835    // The terminal state is "complete" — the popup can transition to success.836    expect(mocks.pushes).toContainEqual(expect.objectContaining({837      type: "ADOPTION_SOURCE_RESULT",838      payload: expect.objectContaining({ status: "complete" }),839    }));840  });841842  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {843    const rootKeyHex = "f0".repeat(32);844    const vaultId = "b".repeat(64);845    mocks.createVault.mockResolvedValue({ vaultId });846    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });847    mocks.isProUser.mockResolvedValue(false);848    mocks.openSocket.mockResolvedValue(socketWithFrames([849      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },850      {851        type: "ADOPT_GRANT",852        root_key_hex: rootKeyHex,853        vault_id: vaultId,854        license: "PPK-invalid",855        source: {856          device_id: "phone-source-id",857          device_name: "June's phone",858          device_type: "android",859          long_term_public_key_hex: "99".repeat(32),860        },861      },862    ]));863864    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");865    await startRelayAdoptionReceiver("receiver-password");866    await waitForPush("ADOPTION_SOURCE_RESULT");867868    expect(mocks.rollbackVault).toHaveBeenCalledOnce();869    expect(mocks.localIdentity).not.toHaveBeenCalled();870    expect(mocks.upsert).not.toHaveBeenCalled();871    expect(mocks.pushes).toContainEqual(expect.objectContaining({872      type: "ADOPTION_SOURCE_RESULT",873      payload: expect.objectContaining({874        status: "error",875        error: "license_transfer_failed",876      }),877    }));878  });879880  it("rolls back a newly created vault when a later adoption step fails", async () => {881    const rootKeyHex = "f0".repeat(32);882    const vaultId = "b".repeat(64);883    mocks.createVault.mockResolvedValue({ vaultId });884    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));885    mocks.openSocket.mockResolvedValue(socketWithFrames([886      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },887      {888        type: "ADOPT_GRANT",889        root_key_hex: rootKeyHex,890        vault_id: vaultId,891        license: "PPK-test",892        source: {893          device_id: "phone-source-id",894          device_name: "June's phone",895          device_type: "android",896          long_term_public_key_hex: "99".repeat(32),897        },898      },899    ]));900901    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");902    await startRelayAdoptionReceiver("receiver-password");903    await waitForPush("ADOPTION_SOURCE_RESULT");904905    expect(mocks.rollbackVault).toHaveBeenCalledOnce();906    expect(mocks.upsert).not.toHaveBeenCalled();907    expect(mocks.pushes).toContainEqual(expect.objectContaining({908      type: "ADOPTION_SOURCE_RESULT",909      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),910    }));911  });912913  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {914    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).915    // The receiver should skip the 30-second inline snapshot wait entirely916    // and fall through to the sync pipeline — no receiveVaultSnapshot call.917    const rootKeyHex = "f0".repeat(32);918    const vaultId = "b".repeat(64);919    mocks.createVault.mockResolvedValue({ vaultId });920    mocks.openSocket.mockResolvedValue(socketWithFrames([921      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },922      {923        type: "ADOPT_GRANT",924        root_key_hex: rootKeyHex,925        vault_id: vaultId,926        license: "PPK-test",927        source: {928          device_id: "old-phone-id",929          device_name: "Old phone",930          device_type: "android",931          long_term_public_key_hex: "99".repeat(32),932        },933        // NO snapshot_capable: pre-C source does not echo the field934      },935      { type: "ADOPT_COMPLETE" },936    ], { closeOnEmpty: true }));937938    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");939    await startRelayAdoptionReceiver("receiver-password");940    await waitForPush("ADOPTION_SOURCE_RESULT");941942    // Sync fallback ran immediately (no 30s wait).943    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });944    // Adoption completed successfully.945    expect(mocks.pushes).toContainEqual(expect.objectContaining({946      type: "ADOPTION_SOURCE_RESULT",947      payload: expect.objectContaining({ status: "complete" }),948    }));949    // The initial sync pending marker was set (not cleared — no snapshot applied).950    expect(mocks.storageSet).toHaveBeenCalledWith(951      "peach_initial_sync_pending_device_id",952      "old-phone-id",953    );954  });955956});957958function decodeInvitation(encoded: string): { vault?: string } | null {959  // Mirrors parsePairingInvitation's payload structure without pulling the960  // module in: the receiver test only needs to assert the embedded status.961  const at = encoded.indexOf(":");962  if (at < 0) return null;963  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");964  try {965    const json = Buffer.from(b64, "base64").toString("utf8");966    return JSON.parse(json) as { vault?: string };967  } catch {968    return null;969  }970}