relay-adoption-bg.test.ts

39.2 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  setPairSecret: vi.fn(),10  pushes: [] as Array<{ type: string; payload: unknown }>,11  triggerSync: vi.fn(),12  createVault: vi.fn(),13  activateLicense: vi.fn(),14  isProUser: vi.fn(),15  getLicenseKey: vi.fn(),16  localIdentity: vi.fn(),17  rollbackVault: vi.fn(),18  createSyncAdapter: vi.fn(),19  __applyIncomingDeviceManifest: vi.fn(),20  storageSet: vi.fn(),21  sasDecisions: [] as Array<{ id: string; ok: boolean }>,22}));2324vi.mock("../shared/pairing-dial", () => ({25  openRelayPairingSocket: mocks.openSocket,26}));2728vi.mock("../shared/noise-js", () => ({29  noiseNKResponder: vi.fn(async () => ({30    handshakeHash: new Uint8Array(32).fill(7),31    encrypt: (bytes: Uint8Array) => bytes,32    decrypt: (bytes: Uint8Array) => bytes,33    close: vi.fn(),34  })),35}));3637vi.mock("../shared/rendezvous", async (importOriginal) => {38  const actual = await importOriginal<typeof import("../shared/rendezvous")>();39  return {40    ...actual,41    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",42  };43});444546vi.mock("./device-identity", () => ({47  getLocalPairingIdentity: async () => ({48    identity: {49      deviceId: "test-device",50      deviceName: "Test",51      deviceType: "extension",52      longTermPublicKeyHex: "aa".repeat(32),53      vaultId: "bb".repeat(32),54    },55    privateKey: new Uint8Array(32).fill(7),56    publicKey: new Uint8Array(32).fill(8),57  }),58  getDeviceName: mocks.getDeviceName,59  buildLocalIdentity: mocks.localIdentity,60}));6162vi.mock("./sync-settings", async (importOriginal) => ({63  ...(await importOriginal<typeof import("./sync-settings")>()),64  getSyncSettings: async () => ({65    mode: "public" as const,66    relayUrl: "wss://relay.peachpasswords.com",67  }),68}));6970vi.mock("./license", () => ({71  isProUser: mocks.isProUser,72  getLicenseKey: mocks.getLicenseKey,73  activateLicense: mocks.activateLicense,74}));7576vi.mock("./vault", () => ({77  isUnlocked: vi.fn(() => true),78  getRootKeyHex: mocks.getRootKeyHex,79  createVaultFromRootKey: mocks.createVault,80  rollbackAdoptedVault: mocks.rollbackVault,81  exportDuressSlotForAdoption: vi.fn(async () => null),82  installAdoptedDuressSlot: vi.fn(async () => undefined),83}));8485vi.mock("../shared/sync-identity", async (importOriginal) => {86  // Partial mock: the manifest test needs the REAL deriveSyncSigningKeypair /87  // pubkeyToHex to build a verifiable signed manifest (2026-08-04: this test88  // was orphaned inside the rendezvous mock factory and never ran; the mock89  // below had since lost the real exports).90  const actual = await importOriginal<typeof import("../shared/sync-identity")>();91  return {92    ...actual,93    computeVaultId: vi.fn(async () => "b".repeat(64)),94  };95});9697vi.mock("../shared/storage", () => ({98  storageSet: mocks.storageSet,99}));100101vi.mock("./paired-devices", () => ({102  upsertPairedDevice: mocks.upsert,103}));104105vi.mock("./pair-secrets", () => ({106  setPairSecret: mocks.setPairSecret,107  isValidPairSecret: (value: unknown): value is string =>108    typeof value === "string" && /^[0-9a-f]{64}$/.test(value),109}));110111vi.mock("./sync-trigger", () => ({112  triggerSyncIfReachable: mocks.triggerSync,113}));114115vi.mock("./sync-vault-adapter", () => ({116  createSyncVaultAdapter: mocks.createSyncAdapter,117}));118119import {120  cancelRelayAdoptionSource,121  startRelayAdoptionSource,122} from "./relay-adoption-bg";123124const encode = (value: unknown) =>125  new TextEncoder().encode(JSON.stringify(value));126127function socketWithFrames(128  frames: unknown[],129  options: { closeOnEmpty?: boolean } = {},130) {131  const queue = frames.map(encode);132  return {133    send: vi.fn(),134    receive: vi.fn(async () => {135      const next = queue.shift();136      if (!next) {137        // A real peer closes the channel once it runs out of frames; model138        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast139        // and falls back to the sync pipeline instead of hanging on a timeout.140        if (options.closeOnEmpty) throw new Error("socket_closed");141        return new Promise<Uint8Array>(() => undefined);142      }143      return next;144    }),145    close: vi.fn(),146    closedBeforeFrame: vi.fn(() => false),147  };148}149150async function waitForPush(type: string): Promise<void> {151  await vi.waitFor(() => {152    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);153  });154}155156beforeEach(() => {157  mocks.pushes.length = 0;158  mocks.sasDecisions.length = 0;159  mocks.openSocket.mockReset();160  mocks.getLocalIdentity.mockReset();161  mocks.getRootKeyHex.mockReset();162  mocks.upsert.mockReset();163  mocks.setPairSecret.mockReset();164  mocks.triggerSync.mockReset();165  mocks.triggerSync.mockResolvedValue(undefined);166  mocks.isProUser.mockReset();167  mocks.isProUser.mockResolvedValue(true);168  mocks.getLicenseKey.mockReset();169  mocks.getLicenseKey.mockResolvedValue("PPK-source");170  mocks.getLocalIdentity.mockResolvedValue({171    identity: {172      deviceId: "browser-source",173      deviceName: "June's Browser",174      deviceType: "extension",175      longTermPublicKeyHex: "a".repeat(64),176      vaultId: "b".repeat(64),177    },178    privateKey: new Uint8Array(32).fill(1),179    publicKey: new Uint8Array(32).fill(2),180  });181  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));182  mocks.upsert.mockResolvedValue(undefined);183  mocks.setPairSecret.mockResolvedValue(undefined);184    const applyIncomingDeviceManifest = vi.fn(async () => undefined);185    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;186    mocks.createSyncAdapter.mockReturnValue({187      buildEntryManifest: vi.fn(async () => ({})),188      getEncryptedEntries: vi.fn(async () => ({})),189      buildTombstoneManifest: vi.fn(async () => ({})),190      applyEntries: vi.fn(async () => undefined),191      applyTombstones: vi.fn(async () => undefined),192      buildOutgoingDeviceManifest: vi.fn(async () => null),193      applyIncomingDeviceManifest,194    });195  Object.defineProperty(globalThis, "chrome", {196    configurable: true,197    value: {198    runtime: {199      async sendMessage(message: { type: string; payload: unknown }) {200        mocks.pushes.push(message);201        // The source flow waits for the popup to send SOURCE_SAS_DECISION before202        // forwarding the root key. The receiver test overrides this mock and203        // resolves the decision itself; the source tests rely on this default204        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.205        if (message.type === "ADOPTION_SOURCE_SAS") {206          const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");207          const p = message.payload as { id?: string };208          if (typeof p?.id === "string") {209            mocks.sasDecisions.push({ id: p.id, ok: true });210            resolveSourceSasConfirmation(p.id, true);211          }212        }213        return Promise.resolve();214      },215    },216    },217  });218});219220describe("relay adoption source background boundary", () => {221  it("shows the SAS and waits for the popup's single confirmation", async () => {222    const socket = socketWithFrames([223      {224        type: "PEACH_HELLO",225        token: "AAECAwQFBgcICQoLDA0ODw",226      },227      {228        type: "ADOPT_REQUEST",229        device_id: "request-only-label",230        device_name: "June's phone",231        device_type: "android",232      },233      { type: "ADOPT_CONFIRM", confirmed: true },234      {235        type: "ADOPT_IDENTITY",236        device_id: "android-receiver",237        device_name: "June's phone",238        device_type: "android",239        long_term_public_key_hex: "d".repeat(64),240      },241      { type: "ADOPT_APPLIED" },242    ]);243    mocks.openSocket.mockResolvedValue(socket);244245    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");246    const sasIds: string[] = [];247    Object.defineProperty(globalThis, "chrome", {248      configurable: true,249      value: {250        runtime: {251          async sendMessage(message: { type: string; payload: unknown }) {252            mocks.pushes.push(message);253            if (message.type === "ADOPTION_SOURCE_SAS") {254              const p = message.payload as { id?: string };255              if (typeof p?.id === "string") {256                // Hold the SAS gate open until the test explicitly approves257                // it — this proves the source flow waits for the popup.258                sasIds.push(p.id);259              }260              return Promise.resolve();261            }262            return Promise.resolve();263          },264        },265      },266    });267    void resolveSourceSasConfirmation; // imported for the post-approval call below268269    const started = await startRelayAdoptionSource();270    expect(started.invitation).toMatch(/^peach-pair-v2:/);271    await waitForPush("ADOPTION_SOURCE_SAS");272    // The adoption must NOT be complete yet — the popup's SAS decision is the273    // prerequisite for forwarding the root key.274    await vi.waitFor(() => {275      expect(mocks.sasDecisions.length).toBe(0);276    });277    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();278    expect(mocks.upsert).not.toHaveBeenCalled();279280    // Approve the SAS; the adoption should now proceed and complete.281    const id = sasIds[0];282    expect(typeof id).toBe("string");283    resolveSourceSasConfirmation(id, true);284    mocks.sasDecisions.push({ id, ok: true });285286    await waitForPush("ADOPTION_SOURCE_RESULT");287288    expect(mocks.getRootKeyHex).toHaveBeenCalled();289    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({290      deviceId: "android-receiver",291      relayUrl: "wss://relay.peachpasswords.com",292    }));293    // PB-26-213: the registry row stays secretless; the secret bypasses it294    // into the encrypted pair-secret record.295    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;296    expect(sourceRow.rendezvousSecretHex).toBeUndefined();297    expect(mocks.setPairSecret).toHaveBeenCalledWith(298      "android-receiver",299      expect.stringMatching(/^[0-9a-f]{64}$/),300      expect.any(Uint8Array),301    );302    expect(mocks.pushes).toContainEqual(expect.objectContaining({303      type: "ADOPTION_SOURCE_RESULT",304      payload: expect.objectContaining({ status: "complete" }),305    }));306    expect(mocks.triggerSync).toHaveBeenCalledWith({307      reason: "manual",308      force: true,309    });310    const sentTypes = socket.send.mock.calls311      .map(([bytes]) => {312        try {313          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;314        } catch {315          return undefined;316        }317      });318    expect(sentTypes).toContain("ADOPT_COMPLETE");319  });320321  it("fails adoption without sending snapshot frames when duress is oversized", async () => {322    const socket = socketWithFrames([323      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },324      {325        type: "ADOPT_REQUEST",326        device_id: "request-only-label",327        device_name: "June's phone",328        device_type: "android",329        snapshot_capable: true,330      },331      { type: "ADOPT_CONFIRM", confirmed: true },332      {333        type: "ADOPT_IDENTITY",334        device_id: "android-receiver",335        device_name: "June's phone",336        device_type: "android",337        long_term_public_key_hex: "d".repeat(64),338      },339      { type: "ADOPT_APPLIED" },340    ]);341    mocks.openSocket.mockResolvedValue(socket);342    const { exportDuressSlotForAdoption } = await import("./vault");343    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(344      "x".repeat(768 * 1024),345    );346347    await startRelayAdoptionSource();348    await waitForPush("ADOPTION_SOURCE_RESULT");349350    expect(mocks.pushes).toContainEqual({351      type: "ADOPTION_SOURCE_RESULT",352      payload: {353        status: "error",354        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",355      },356    });357    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({358      type: "ADOPTION_SOURCE_RESULT",359      payload: expect.objectContaining({ status: "complete" }),360    }));361    const sentTypes = socket.send.mock.calls.map(([bytes]) =>362      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,363    );364    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");365    expect(mocks.triggerSync).not.toHaveBeenCalled();366  });367368  it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {369    // PB-26-206: an over-budget snapshot is never partially sent or silently370    // truncated — the source completes adoption and the sync pipeline carries371    // the entries instead.372    const socket = socketWithFrames([373      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },374      {375        type: "ADOPT_REQUEST",376        device_id: "request-only-label",377        device_name: "June's phone",378        device_type: "android",379        snapshot_capable: true,380      },381      { type: "ADOPT_CONFIRM", confirmed: true },382      {383        type: "ADOPT_IDENTITY",384        device_id: "android-receiver",385        device_name: "June's phone",386        device_type: "android",387        long_term_public_key_hex: "d".repeat(64),388      },389      { type: "ADOPT_APPLIED" },390      // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is391      // sent); the source must not hang on it — withTimeout resolves the wait392      // via the sync fallback path below.393      { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },394    ]);395    mocks.openSocket.mockResolvedValue(socket);396    // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget.397    const envelope = "c".repeat(690_000);398    const entries: Record<string, string> = {};399    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;400    mocks.createSyncAdapter.mockReturnValue({401      buildEntryManifest: vi.fn(async () => Object.fromEntries(402        Object.keys(entries).map((id) => [id, 1]),403      )),404      getEncryptedEntries: vi.fn(async () => entries),405      buildTombstoneManifest: vi.fn(async () => ({})),406      applyEntries: vi.fn(async () => undefined),407      applyTombstones: vi.fn(async () => undefined),408      buildOutgoingDeviceManifest: vi.fn(async () => null),409      applyIncomingDeviceManifest: vi.fn(async () => undefined),410    });411412    await startRelayAdoptionSource();413    await waitForPush("ADOPTION_SOURCE_RESULT");414415    expect(mocks.pushes).toContainEqual({416      type: "ADOPTION_SOURCE_RESULT",417      payload: expect.objectContaining({ status: "complete" }),418    });419    const sentTypes = socket.send.mock.calls.map(([bytes]) =>420      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,421    );422    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");423    // Sync fallback ran so the entries still reach the receiver.424    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });425  });426427  it("fails closed when the popup rejects the SAS", async () => {428    const socket = socketWithFrames([429      {430        type: "PEACH_HELLO",431        token: "AAECAwQFBgcICQoLDA0ODw",432      },433      {434        type: "ADOPT_REQUEST",435        device_id: "request-only-label",436        device_name: "New phone",437        device_type: "android",438      },439      { type: "ADOPT_CONFIRM", confirmed: false },440    ]);441    mocks.openSocket.mockResolvedValue(socket);442443    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");444    let pendingId: string | undefined;445    Object.defineProperty(globalThis, "chrome", {446      configurable: true,447      value: {448        runtime: {449          sendMessage(message: { type: string; payload: unknown }) {450            mocks.pushes.push(message);451            if (message.type === "ADOPTION_SOURCE_SAS") {452              const p = message.payload as { id?: string };453              if (typeof p?.id === "string") pendingId = p.id;454            }455            return Promise.resolve();456          },457        },458      },459    });460461    await startRelayAdoptionSource();462    await waitForPush("ADOPTION_SOURCE_SAS");463    expect(typeof pendingId).toBe("string");464    resolveSourceSasConfirmation(pendingId!, false);465    mocks.sasDecisions.push({ id: pendingId!, ok: false });466467    await waitForPush("ADOPTION_SOURCE_RESULT");468469    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();470    expect(mocks.upsert).not.toHaveBeenCalled();471    expect(mocks.pushes).toContainEqual({472      type: "ADOPTION_SOURCE_RESULT",473      payload: { status: "error", error: "adoption_rejected" },474    });475  });476477  it("rejects before reading the root key when neither peer has Pro", async () => {478    const socket = socketWithFrames([479      {480        type: "PEACH_HELLO",481        token: "AAECAwQFBgcICQoLDA0ODw",482      },483      {484        type: "ADOPT_REQUEST",485        device_id: "free-receiver",486        device_name: "Free phone",487        device_type: "android",488      },489      { type: "ADOPT_CONFIRM", confirmed: true },490    ]);491    mocks.isProUser.mockResolvedValue(false);492    mocks.getLicenseKey.mockResolvedValue(undefined);493    mocks.openSocket.mockResolvedValue(socket);494495    await startRelayAdoptionSource();496    await waitForPush("ADOPTION_SOURCE_RESULT");497498    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();499    expect(mocks.upsert).not.toHaveBeenCalled();500    expect(mocks.pushes).toContainEqual({501      type: "ADOPTION_SOURCE_RESULT",502      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },503    });504    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {505      try {506        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];507      } catch {508        return [];509      }510    });511    expect(sentFrames).toContainEqual({512      type: "ADOPT_REJECT",513      reason: "ADOPT_PRO_REQUIRED",514    });515  });516517  it("fails closed when the user never answers the SAS", async () => {518    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they519    // schedule real work), so we hold the SAS gate open by never resolving520    // the deferred and then cancel the session. clearState must drop the521    // pending SAS so it doesn't pin the relay room open for the next run.522    const socket = socketWithFrames([]);523    mocks.openSocket.mockResolvedValue(socket);524525    Object.defineProperty(globalThis, "chrome", {526      configurable: true,527      value: {528        runtime: {529          sendMessage(message: { type: string; payload: unknown }) {530            mocks.pushes.push(message);531            return Promise.resolve();532          },533        },534      },535    });536537    await startRelayAdoptionSource();538    cancelRelayAdoptionSource();539540    expect(socket.close).toHaveBeenCalled();541    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();542    expect(mocks.upsert).not.toHaveBeenCalled();543  });544545  it("cancelling the source screen closes the room without reading the root", async () => {546    const socket = socketWithFrames([]);547    mocks.openSocket.mockResolvedValue(socket);548549    await startRelayAdoptionSource();550    cancelRelayAdoptionSource();551552    expect(socket.close).toHaveBeenCalled();553    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();554    expect(mocks.upsert).not.toHaveBeenCalled();555  });556});557558describe("relay adoption receiver background", () => {559  beforeEach(async () => {560    // The receiver session runs in the background via `void runReceiverSession`;561    // it doesn't get awaited by the caller and can leave `inFlight` set if the562    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test563    // starts from a clean state.564    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");565    cancelRelayAdoptionSource();566    mocks.pushes.length = 0;567    mocks.openSocket.mockReset();568    mocks.getDeviceName.mockReset();569    mocks.getDeviceName.mockResolvedValue("Receiver Browser");570    mocks.createVault.mockReset();571    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });572    mocks.activateLicense.mockReset();573    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });574    mocks.isProUser.mockReset();575    mocks.isProUser.mockResolvedValue(true);576    mocks.getLicenseKey.mockReset();577    mocks.getLicenseKey.mockResolvedValue(undefined);578    mocks.localIdentity.mockReset();579    mocks.localIdentity.mockResolvedValue({580      deviceId: "receiver-id",581      deviceName: "Receiver Browser",582      deviceType: "extension",583      longTermPublicKeyHex: "11".repeat(32),584      signingPublicKeyHex: "22".repeat(32),585    });586    mocks.rollbackVault.mockReset();587    mocks.rollbackVault.mockResolvedValue(undefined);588    mocks.storageSet.mockReset();589    mocks.storageSet.mockResolvedValue(undefined);590    mocks.upsert.mockReset();591    mocks.upsert.mockResolvedValue(undefined);592    mocks.setPairSecret.mockReset();593    mocks.setPairSecret.mockResolvedValue(undefined);594    mocks.triggerSync.mockReset();595    mocks.triggerSync.mockResolvedValue(undefined);596    mocks.openSocket.mockResolvedValue(socketWithFrames([]));597    Object.defineProperty(globalThis, "chrome", {598      configurable: true,599      value: {600        runtime: {601          sendMessage(message: { type: string; payload?: unknown }) {602            mocks.pushes.push({ type: message.type, payload: message.payload });603            return Promise.resolve({ ok: true });604          },605          onMessage: {606            addListener() { /* no-op */ },607            removeListener() { /* no-op */ },608          },609        },610      },611    });612  });613614  it("opens a relay socket and returns an empty invitation", async () => {615    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");616    const started = await startRelayAdoptionReceiver("receiver-password");617    expect(started.invitation).toMatch(/^peach-pair-v2:/);618    // The QR carries vault:"empty" so the phone knows to act as source.619    const decoded = decodeInvitation(started.invitation);620    expect(decoded?.vault).toBe("empty");621    expect(mocks.openSocket).toHaveBeenCalled();622  });623624  it("rejects an empty password", async () => {625    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");626    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");627  });628629  it("surfaces the Pro-required rejection without creating a vault", async () => {630    mocks.openSocket.mockResolvedValue(socketWithFrames([631      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },632      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },633    ]));634635    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");636    await startRelayAdoptionReceiver("receiver-password");637    await waitForPush("ADOPTION_SOURCE_RESULT");638639    expect(mocks.createVault).not.toHaveBeenCalled();640    expect(mocks.rollbackVault).not.toHaveBeenCalled();641    expect(mocks.pushes).toContainEqual({642      type: "ADOPTION_SOURCE_RESULT",643      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },644    });645  });646647  it("the receiver wires requestAdoption, not grantAdoption", async () => {648    // The receiver's protocol position is the opposite of the source side:649    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.650    // Assert this by watching what lands on socket.send: the receiver's651    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY652    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.653    const rootKeyHex = "f0".repeat(32);654    const vaultId = "b".repeat(64);655    const socket = socketWithFrames([656      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },657      {658        type: "ADOPT_GRANT",659        root_key_hex: rootKeyHex,660        vault_id: vaultId,661        license: "PPK-test",662        source: {663          device_id: "phone-source-id",664          device_name: "June's phone",665          device_type: "android",666          long_term_public_key_hex: "99".repeat(32),667        },668      },669      { type: "ADOPT_COMPLETE" },670    ], { closeOnEmpty: true });671    mocks.openSocket.mockResolvedValue(socket);672    mocks.createVault.mockResolvedValue({ vaultId });673674    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");675    Object.defineProperty(globalThis, "chrome", {676      configurable: true,677      value: {678        runtime: {679          sendMessage(message: { type: string; payload?: unknown }) {680            mocks.pushes.push({ type: message.type, payload: message.payload });681            return Promise.resolve({ ok: true });682          },683          onMessage: { addListener: () => undefined, removeListener: () => undefined },684        },685      },686    });687    await startRelayAdoptionReceiver("receiver-password");688    await waitForPush("ADOPTION_SOURCE_RESULT");689690    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })691      .mock.calls692      .map(([bytes]) => {693        try {694          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;695        } catch {696          return undefined;697        }698      });699    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;700    // ADOPT_GRANT is the source side's frame and must never appear here.701    expect(sentTypes).toContain("ADOPT_REQUEST");702    expect(sentTypes).not.toContain("ADOPT_GRANT");703  });704705  it("completes the handshake end-to-end: vault created → paired", async () => {706    // Wire the receiver's noise mock and SAS gate together so the full707    // requestAdoption protocol runs against an in-memory phone that sends708    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.709    // The mocked noise is pass-through (encrypt = decrypt = identity), so710    // frames land as ordinary JSON.711    const rootKeyHex = "f0".repeat(32);712    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);713    // the grant's vault_id is set to the same so the protocol proceeds.714    const vaultId = "b".repeat(64);715    const frames = [716      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },717      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two718      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.719      {720        type: "ADOPT_GRANT",721        root_key_hex: rootKeyHex,722        vault_id: vaultId,723        license: "PPK-test",724        source: {725          device_id: "phone-source-id",726          device_name: "June's phone",727          device_type: "android",728          long_term_public_key_hex: "99".repeat(32),729          signing_public_key_hex: "aa".repeat(32),730          relay_url: "wss://relay.peachpasswords.com",731        },732      },733      { type: "ADOPT_COMPLETE" },734    ];735    mocks.createVault.mockResolvedValue({ vaultId });736    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));737738    // Replace the chrome.runtime mock for this one test. The receiver739    // auto-accepts the SAS, so no decision needs to be simulated.740    Object.defineProperty(globalThis, "chrome", {741      configurable: true,742      value: {743        runtime: {744          sendMessage(message: { type: string; payload?: unknown }) {745            mocks.pushes.push({ type: message.type, payload: message.payload });746            return Promise.resolve({ ok: true });747          },748          onMessage: {749            addListener: () => undefined,750            removeListener: () => undefined,751          },752        },753      },754    });755756    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");757    await startRelayAdoptionReceiver("receiver-password");758759    await waitForPush("ADOPTION_SOURCE_RESULT");760761    // The receiver displays its locally derived SAS without introducing a762    // second confirmation gate.763    expect(mocks.pushes).toContainEqual({764      type: "ADOPTION_SOURCE_SAS",765      payload: expect.objectContaining({766        sas: expect.stringMatching(/^\d{8}$/),767        displayOnly: true,768      }),769    });770771    // The vault module was called directly with the popup-supplied password772    // and the root key granted by the phone.773    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);774775    // License activation, identity derivation, and pairing persistence all776    // call their modules directly from the service worker.777    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");778    expect(mocks.isProUser).toHaveBeenCalled();779    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));780    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({781      deviceId: "phone-source-id",782    }));783    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;784    expect(receiverRow.rendezvousSecretHex).toBeUndefined();785    expect(mocks.setPairSecret).toHaveBeenCalledWith(786      "phone-source-id",787      expect.stringMatching(/^[0-9a-f]{64}$/),788      expect.any(Uint8Array),789    );790    expect(mocks.storageSet).toHaveBeenCalledWith(791      "peach_initial_sync_pending_device_id",792      "phone-source-id",793    );794    expect(mocks.rollbackVault).not.toHaveBeenCalled();795796    // The terminal state is "complete" — the popup can transition to success.797    expect(mocks.pushes).toContainEqual(expect.objectContaining({798      type: "ADOPTION_SOURCE_RESULT",799      payload: expect.objectContaining({ status: "complete" }),800    }));801  });802803  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {804    const rootKeyHex = "f0".repeat(32);805    const vaultId = "b".repeat(64);806    mocks.createVault.mockResolvedValue({ vaultId });807    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });808    mocks.isProUser.mockResolvedValue(false);809    mocks.openSocket.mockResolvedValue(socketWithFrames([810      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },811      {812        type: "ADOPT_GRANT",813        root_key_hex: rootKeyHex,814        vault_id: vaultId,815        license: "PPK-invalid",816        source: {817          device_id: "phone-source-id",818          device_name: "June's phone",819          device_type: "android",820          long_term_public_key_hex: "99".repeat(32),821        },822      },823    ]));824825    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");826    await startRelayAdoptionReceiver("receiver-password");827    await waitForPush("ADOPTION_SOURCE_RESULT");828829    expect(mocks.rollbackVault).toHaveBeenCalledOnce();830    expect(mocks.localIdentity).not.toHaveBeenCalled();831    expect(mocks.upsert).not.toHaveBeenCalled();832    expect(mocks.pushes).toContainEqual(expect.objectContaining({833      type: "ADOPTION_SOURCE_RESULT",834      payload: expect.objectContaining({835        status: "error",836        error: "license_transfer_failed",837      }),838    }));839  });840841  it("rolls back a newly created vault when a later adoption step fails", async () => {842    const rootKeyHex = "f0".repeat(32);843    const vaultId = "b".repeat(64);844    mocks.createVault.mockResolvedValue({ vaultId });845    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));846    mocks.openSocket.mockResolvedValue(socketWithFrames([847      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },848      {849        type: "ADOPT_GRANT",850        root_key_hex: rootKeyHex,851        vault_id: vaultId,852        license: "PPK-test",853        source: {854          device_id: "phone-source-id",855          device_name: "June's phone",856          device_type: "android",857          long_term_public_key_hex: "99".repeat(32),858        },859      },860    ]));861862    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");863    await startRelayAdoptionReceiver("receiver-password");864    await waitForPush("ADOPTION_SOURCE_RESULT");865866    expect(mocks.rollbackVault).toHaveBeenCalledOnce();867    expect(mocks.upsert).not.toHaveBeenCalled();868    expect(mocks.pushes).toContainEqual(expect.objectContaining({869      type: "ADOPTION_SOURCE_RESULT",870      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),871    }));872  });873874  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {875    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).876    // The receiver should skip the 30-second inline snapshot wait entirely877    // and fall through to the sync pipeline — no receiveVaultSnapshot call.878    const rootKeyHex = "f0".repeat(32);879    const vaultId = "b".repeat(64);880    mocks.createVault.mockResolvedValue({ vaultId });881    mocks.openSocket.mockResolvedValue(socketWithFrames([882      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },883      {884        type: "ADOPT_GRANT",885        root_key_hex: rootKeyHex,886        vault_id: vaultId,887        license: "PPK-test",888        source: {889          device_id: "old-phone-id",890          device_name: "Old phone",891          device_type: "android",892          long_term_public_key_hex: "99".repeat(32),893        },894        // NO snapshot_capable: pre-C source does not echo the field895      },896      { type: "ADOPT_COMPLETE" },897    ], { closeOnEmpty: true }));898899    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");900    await startRelayAdoptionReceiver("receiver-password");901    await waitForPush("ADOPTION_SOURCE_RESULT");902903    // Sync fallback ran immediately (no 30s wait).904    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });905    // Adoption completed successfully.906    expect(mocks.pushes).toContainEqual(expect.objectContaining({907      type: "ADOPTION_SOURCE_RESULT",908      payload: expect.objectContaining({ status: "complete" }),909    }));910    // The initial sync pending marker was set (not cleared — no snapshot applied).911    expect(mocks.storageSet).toHaveBeenCalledWith(912      "peach_initial_sync_pending_device_id",913      "old-phone-id",914    );915  });916917  it("applies the source's device manifest after the snapshot, then acks", async () => {918    // Mesh propagation: a 3rd browser that completes an adoption with a919    // snapshot-capable source must also receive the source's signed device920    // manifest and forward it to the sync adapter before the receiver921    // resolves. The relay stays an opaque forwarder, so the whole path922    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.923    const rootKeyHex = "f0".repeat(32);924    const vaultId = "b".repeat(64);925    const longTermPubkeyHex = "99".repeat(32);926    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");927    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(928      "../shared/sync-identity"929    );930    const issuerKp = await deriveSyncSigningKeypair(931      new Uint8Array(32).fill(0x55),932      "phone-source-id",933    );934    const manifest = await buildSignedDeviceManifest(935      "phone-source-id",936      [937        {938          deviceId: "peer-b",939          deviceName: "Pool B",940          deviceType: "extension",941          longTermPublicKeyHex: "11".repeat(32),942          signingPublicKeyHex: "22".repeat(32),943          wsPort: 49152,944          relayUrl: "wss://relay.peachpasswords.com",945          pairedAt: 1,946        },947      ],948      issuerKp.privateKey,949    );950    const socket = socketWithFrames([951      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },952      {953        type: "ADOPT_GRANT",954        root_key_hex: rootKeyHex,955        vault_id: vaultId,956        license: "PPK-test",957        snapshot_capable: true,958        source: {959          device_id: "phone-source-id",960          device_name: "June's phone",961          device_type: "android",962          long_term_public_key_hex: longTermPubkeyHex,963          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),964          relay_url: "wss://relay.peachpasswords.com",965        },966      },967      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is968      // an optional follow-up frame on the same channel.969      { type: "ADOPT_COMPLETE" },970      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames971      // after the handshake; one empty chunk ends the phase.972      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },973      { type: "ADOPT_DEVICE_MANIFEST", manifest },974    ], { closeOnEmpty: true });975    mocks.createVault.mockResolvedValue({ vaultId });976    mocks.openSocket.mockResolvedValue(socket);977    mocks.__applyIncomingDeviceManifest.mockClear();978979    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");980    await startRelayAdoptionReceiver("receiver-password");981    await waitForPush("ADOPTION_SOURCE_RESULT");982983    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);984    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];985    expect(forwarded).toEqual(manifest);986    expect(pubkey).toBeInstanceOf(Uint8Array);987    expect(pubkey.length).toBe(32);988    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the989    // adapter path was skipped (the ack only follows a real apply).990    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })991      .mock.calls992      .map(([bytes]) => {993        try {994          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;995        } catch {996          return undefined;997        }998      });999    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1000    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.1001    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1002  });10031004  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1005    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST1006    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,1007    // and quietly time out the optional 5s wait without acking a manifest1008    // or invoking the adapter path.1009    const rootKeyHex = "f0".repeat(32);1010    const vaultId = "b".repeat(64);1011    const longTermPubkeyHex = "99".repeat(32);1012    const socket = socketWithFrames([1013      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1014      {1015        type: "ADOPT_GRANT",1016        root_key_hex: rootKeyHex,1017        vault_id: vaultId,1018        license: "PPK-test",1019        snapshot_capable: true,1020        source: {1021          device_id: "phone-source-id",1022          device_name: "June's phone",1023          device_type: "android",1024          long_term_public_key_hex: longTermPubkeyHex,1025          signing_public_key_hex: "aa".repeat(32),1026          relay_url: "wss://relay.peachpasswords.com",1027        },1028      },1029      { type: "ADOPT_COMPLETE" },1030      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot1031      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.1032      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1033    ], { closeOnEmpty: true });1034    mocks.createVault.mockResolvedValue({ vaultId });1035    mocks.openSocket.mockResolvedValue(socket);1036    mocks.__applyIncomingDeviceManifest.mockClear();10371038    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1039    await startRelayAdoptionReceiver("receiver-password");1040    await waitForPush("ADOPTION_SOURCE_RESULT");10411042    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1043    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1044      .mock.calls1045      .map(([bytes]) => {1046        try {1047          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1048        } catch {1049          return undefined;1050        }1051      });1052    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1053    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1054  });1055});10561057function decodeInvitation(encoded: string): { vault?: string } | null {1058  // Mirrors parsePairingInvitation's payload structure without pulling the1059  // module in: the receiver test only needs to assert the embedded status.1060  const at = encoded.indexOf(":");1061  if (at < 0) return null;1062  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1063  try {1064    const json = Buffer.from(b64, "base64").toString("utf8");1065    return JSON.parse(json) as { vault?: string };1066  } catch {1067    return null;1068  }1069}