relay-adoption-bg.test.ts

43.0 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";2import { padSyncFrame } from "../shared/sync-padding";34const mocks = vi.hoisted(() => ({5  openSocket: vi.fn(),6  getLocalIdentity: vi.fn(),7  getDeviceName: vi.fn(),8  getRootKeyHex: vi.fn(),9  upsert: vi.fn(),10  clearRevocation: vi.fn(),11  hasIdentityConflict: vi.fn(),12  rotateDeviceId: vi.fn(),13  setPairSecret: vi.fn(),14  pushes: [] as Array<{ type: string; payload: unknown }>,15  triggerSync: vi.fn(),16  createVault: vi.fn(),17  activateLicense: vi.fn(),18  isProUser: vi.fn(),19  getLicenseKey: vi.fn(),20  localIdentity: vi.fn(),21  rollbackVault: vi.fn(),22  createSyncAdapter: vi.fn(),23  __applyIncomingDeviceManifest: vi.fn(),24  releaseAutoLock: vi.fn(),25  deferAutoLock: vi.fn(),26  storage: new Map<string, string>(),27  storageSet: vi.fn(),28  storageGet: vi.fn(),29  storageRemove: vi.fn(),30  sasDecisions: [] as Array<{ id: string; ok: boolean }>,31}));3233vi.mock("../shared/pairing-dial", () => ({34  openRelayPairingSocket: mocks.openSocket,35}));3637vi.mock("../shared/noise-js", () => ({38  noiseNKResponder: vi.fn(async () => ({39    handshakeHash: new Uint8Array(32).fill(7),40    encrypt: (bytes: Uint8Array) => bytes,41    decrypt: (bytes: Uint8Array) => bytes,42    close: vi.fn(),43  })),44}));4546vi.mock("../shared/rendezvous", async (importOriginal) => {47  const actual = await importOriginal<typeof import("../shared/rendezvous")>();48  return {49    ...actual,50    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",51  };52});535455vi.mock("./device-identity", () => ({56  getLocalPairingIdentity: async () => ({57    identity: {58      deviceId: "test-device",59      deviceName: "Test",60      deviceType: "extension",61      longTermPublicKeyHex: "aa".repeat(32),62      vaultId: "bb".repeat(32),63    },64    privateKey: new Uint8Array(32).fill(7),65    publicKey: new Uint8Array(32).fill(8),66  }),67  getDeviceName: mocks.getDeviceName,68  buildLocalIdentity: mocks.localIdentity,69  rotateDeviceId: mocks.rotateDeviceId,70}));7172vi.mock("./sync-settings", async (importOriginal) => ({73  ...(await importOriginal<typeof import("./sync-settings")>()),74  getSyncSettings: async () => ({75    mode: "public" as const,76    relayUrl: "wss://relay.peachpasswords.com",77  }),78}));7980vi.mock("./license", () => ({81  isProUser: mocks.isProUser,82  getLicenseKey: mocks.getLicenseKey,83  activateLicense: mocks.activateLicense,84}));8586vi.mock("./vault", () => ({87  isUnlocked: vi.fn(() => true),88  getRootKeyHex: mocks.getRootKeyHex,89  createVaultFromRootKey: mocks.createVault,90  rollbackAdoptedVault: mocks.rollbackVault,91  exportDuressSlotForAdoption: vi.fn(async () => null),92  installAdoptedDuressSlot: vi.fn(async () => undefined),93}));9495vi.mock("../shared/sync-identity", async (importOriginal) => {96  // Partial mock: the manifest test needs the REAL deriveSyncSigningKeypair /97  // pubkeyToHex to build a verifiable signed manifest (2026-08-04: this test98  // was orphaned inside the rendezvous mock factory and never ran; the mock99  // below had since lost the real exports).100  const actual = await importOriginal<typeof import("../shared/sync-identity")>();101  return {102    ...actual,103    computeVaultId: vi.fn(async () => "b".repeat(64)),104  };105});106107vi.mock("../shared/storage", () => ({108  storageSet: mocks.storageSet,109  storageGet: mocks.storageGet,110  storageRemove: mocks.storageRemove,111  storageClear: vi.fn(async () => mocks.storage.clear()),112}));113const chromeStorage = {114  local: {115    get: async (key: string) => ({ [key]: await mocks.storageGet(key) }),116    set: async (items: Record<string, string>) => {117      for (const [key, value] of Object.entries(items)) await mocks.storageSet(key, value);118    },119    remove: async (key: string) => mocks.storageRemove(key),120    clear: async () => mocks.storage.clear(),121  },122};123vi.mock("./paired-devices", () => ({124  upsertPairedDevice: mocks.upsert,125  upsertPairedDeviceAndClearRevocation: mocks.upsert,126  clearDeviceRevocation: mocks.clearRevocation,127  hasAdoptionIdentityConflict: mocks.hasIdentityConflict,128}));129130vi.mock("./pair-secrets", () => ({131  setPairSecret: mocks.setPairSecret,132  isValidPairSecret: (value: unknown): value is string =>133    typeof value === "string" && /^[0-9a-f]{64}$/.test(value),134}));135136vi.mock("./sync-trigger", () => ({137  triggerSyncIfReachable: mocks.triggerSync,138}));139140vi.mock("./alarms", () => ({141  deferAutoLockForOperation: mocks.deferAutoLock,142}));143144vi.mock("./sync-vault-adapter", () => ({145  createSyncVaultAdapter: mocks.createSyncAdapter,146}));147148import {149  cancelRelayAdoptionSource,150  startRelayAdoptionSource,151} from "./relay-adoption-bg";152153const encode = (value: unknown) =>154  new TextEncoder().encode(JSON.stringify(value));155156function socketWithFrames(157  frames: unknown[],158  options: { closeOnEmpty?: boolean } = {},159) {160  const queue = frames.map((frame) => {161    const encoded = encode(frame);162    // PB-26-604: snapshot frames are bucket-padded before Noise.163    return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"164      ? padSyncFrame(encoded)165      : encoded;166  });167  return {168    send: vi.fn(),169    receive: vi.fn(async () => {170      const next = queue.shift();171      if (!next) {172        // A real peer closes the channel once it runs out of frames; model173        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast174        // and falls back to the sync pipeline instead of hanging on a timeout.175        if (options.closeOnEmpty) throw new Error("socket_closed");176        return new Promise<Uint8Array>(() => undefined);177      }178      return next;179    }),180    close: vi.fn(),181    closedBeforeFrame: vi.fn(() => false),182  };183}184185async function waitForPush(type: string): Promise<void> {186  await vi.waitFor(() => {187    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);188  });189}190191beforeEach(() => {192  mocks.pushes.length = 0;193  mocks.sasDecisions.length = 0;194  mocks.openSocket.mockReset();195  mocks.getLocalIdentity.mockReset();196  mocks.getRootKeyHex.mockReset();197  mocks.upsert.mockReset();198  mocks.clearRevocation.mockReset();199  mocks.clearRevocation.mockResolvedValue(undefined);200  mocks.hasIdentityConflict.mockReset();201  mocks.hasIdentityConflict.mockResolvedValue(false);202  mocks.rotateDeviceId.mockReset();203  mocks.rotateDeviceId.mockResolvedValue(undefined);204  mocks.setPairSecret.mockReset();205  mocks.triggerSync.mockReset();206  mocks.triggerSync.mockResolvedValue(undefined);207  mocks.releaseAutoLock.mockReset();208  mocks.deferAutoLock.mockReset();209  mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);210  mocks.storage.clear();211  mocks.storageSet.mockReset();212  mocks.storageSet.mockImplementation(async (key: string, value: string) => {213    mocks.storage.set(key, value);214  });215  mocks.storageGet.mockReset();216  mocks.storageGet.mockImplementation(async (key: string) => mocks.storage.get(key) ?? null);217  mocks.storageRemove.mockReset();218  mocks.storageRemove.mockImplementation(async (key: string) => {219    mocks.storage.delete(key);220  });221  mocks.isProUser.mockReset();222  mocks.isProUser.mockResolvedValue(true);223  mocks.getLicenseKey.mockReset();224  mocks.getLicenseKey.mockResolvedValue("PPK-source");225  mocks.getLocalIdentity.mockResolvedValue({226    identity: {227      deviceId: "browser-source",228      deviceName: "June's Browser",229      deviceType: "extension",230      longTermPublicKeyHex: "a".repeat(64),231      vaultId: "b".repeat(64),232    },233    privateKey: new Uint8Array(32).fill(1),234    publicKey: new Uint8Array(32).fill(2),235  });236  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));237  mocks.upsert.mockResolvedValue(undefined);238  mocks.setPairSecret.mockResolvedValue(undefined);239  const applyIncomingDeviceManifest = vi.fn(async () => undefined);240    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;241    mocks.createSyncAdapter.mockReturnValue({242      buildEntryManifest: vi.fn(async () => ({})),243      getEncryptedEntries: vi.fn(async () => ({})),244      buildTombstoneManifest: vi.fn(async () => ({})),245      applyEntries: vi.fn(async () => undefined),246      applyTombstones: vi.fn(async () => undefined),247      buildOutgoingDeviceManifest: vi.fn(async () => null),248      applyIncomingDeviceManifest,249    });250  Object.defineProperty(globalThis, "chrome", {251    configurable: true,252    value: {253    runtime: {254      async sendMessage(message: { type: string; payload: unknown }) {255        mocks.pushes.push(message);256        // The source flow waits for the popup to send SOURCE_SAS_DECISION before257        // forwarding the root key. The receiver test overrides this mock and258        // resolves the decision itself; the source tests rely on this default259        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.260        if (message.type === "ADOPTION_SOURCE_SAS") {261          const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };262          if (typeof p?.id === "string" && typeof p.sas === "string") {263            if (p.displayOnly === false) {264              const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");265              resolveReceiverSasConfirmation(p.id, p.sas, true);266            } else {267              const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");268              mocks.sasDecisions.push({ id: p.id, ok: true });269              resolveSourceSasConfirmation(p.id, true);270            }271          }272        }273        return Promise.resolve();274      },275    },276    },277  });278});279280describe("relay adoption source background boundary", () => {281  it("shows the SAS and waits for the popup's single confirmation", async () => {282    const socket = socketWithFrames([283      {284        type: "PEACH_HELLO",285        token: "AAECAwQFBgcICQoLDA0ODw",286      },287      {288        type: "ADOPT_REQUEST",289        device_id: "request-only-label",290        device_name: "June's phone",291        device_type: "android",292      },293      { type: "ADOPT_CONFIRM", confirmed: true },294      {295        type: "ADOPT_IDENTITY",296        device_id: "android-receiver",297        device_name: "June's phone",298        device_type: "android",299        long_term_public_key_hex: "d".repeat(64),300      },301      { type: "ADOPT_APPLIED" },302    ]);303    mocks.openSocket.mockResolvedValue(socket);304305    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");306    const sasIds: string[] = [];307    Object.defineProperty(globalThis, "chrome", {308      configurable: true,309      value: {310        storage: chromeStorage,311        runtime: {312          async sendMessage(message: { type: string; payload: unknown }) {313            mocks.pushes.push(message);314            if (message.type === "ADOPTION_SOURCE_SAS") {315              const p = message.payload as { id?: string };316              if (typeof p?.id === "string") {317                // Hold the SAS gate open until the test explicitly approves318                // it — this proves the source flow waits for the popup.319                sasIds.push(p.id);320              }321              return Promise.resolve();322            }323            return Promise.resolve();324          },325        },326      },327    });328    void resolveSourceSasConfirmation; // imported for the post-approval call below329330    const started = await startRelayAdoptionSource();331    expect(started.invitation).toMatch(/^peach-pair-v2:/);332    expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);333    await waitForPush("ADOPTION_SOURCE_SAS");334    // The adoption must NOT be complete yet — the popup's SAS decision is the335    // prerequisite for forwarding the root key.336    await vi.waitFor(() => {337      expect(mocks.sasDecisions.length).toBe(0);338    });339    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();340    expect(mocks.upsert).not.toHaveBeenCalled();341342    // Approve the SAS; the adoption should now proceed and complete.343    const id = sasIds[0];344    expect(typeof id).toBe("string");345    resolveSourceSasConfirmation(id, true);346    mocks.sasDecisions.push({ id, ok: true });347348    await waitForPush("ADOPTION_SOURCE_RESULT");349350    await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());351352    expect(mocks.getRootKeyHex).toHaveBeenCalled();353    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({354      deviceId: "android-receiver",355      relayUrl: "wss://relay.peachpasswords.com",356    }));357    // PB-26-213: the registry row stays secretless; the secret bypasses it358    // into the encrypted pair-secret record.359    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;360    expect(sourceRow.rendezvousSecretHex).toBeUndefined();361    expect(mocks.setPairSecret).toHaveBeenCalledWith(362      "android-receiver",363      expect.stringMatching(/^[0-9a-f]{64}$/),364      expect.any(Uint8Array),365    );366    expect(mocks.pushes).toContainEqual({367      type: "ADOPTION_SOURCE_RESULT",368      payload: { status: "complete" },369    });370    expect(mocks.triggerSync).toHaveBeenCalledWith({371      reason: "manual",372      force: true,373    });374    const sentTypes = socket.send.mock.calls375      .map(([bytes]) => {376        try {377          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;378        } catch {379          return undefined;380        }381      });382    expect(sentTypes).toContain("ADOPT_COMPLETE");383  });384385  it("fails adoption without sending snapshot frames when duress is oversized", async () => {386    const socket = socketWithFrames([387      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },388      {389        type: "ADOPT_REQUEST",390        device_id: "request-only-label",391        device_name: "June's phone",392        device_type: "android",393        snapshot_capable: true,394      },395      { type: "ADOPT_CONFIRM", confirmed: true },396      {397        type: "ADOPT_IDENTITY",398        device_id: "android-receiver",399        device_name: "June's phone",400        device_type: "android",401        long_term_public_key_hex: "d".repeat(64),402      },403      { type: "ADOPT_APPLIED" },404    ]);405    mocks.openSocket.mockResolvedValue(socket);406    const { exportDuressSlotForAdoption } = await import("./vault");407    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(408      "x".repeat(768 * 1024),409    );410411    await startRelayAdoptionSource();412    await waitForPush("ADOPTION_SOURCE_RESULT");413414    expect(mocks.pushes).toContainEqual({415      type: "ADOPTION_SOURCE_RESULT",416      payload: {417        status: "error",418        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",419      },420    });421    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({422      type: "ADOPTION_SOURCE_RESULT",423      payload: expect.objectContaining({ status: "complete" }),424    }));425    const sentTypes = socket.send.mock.calls.map(([bytes]) =>426      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,427    );428    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");429    expect(mocks.triggerSync).not.toHaveBeenCalled();430  });431432  it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {433    // PB-26-206: an over-budget snapshot is never partially sent or silently434    // truncated — the source completes adoption and the sync pipeline carries435    // the entries instead.436    const socket = socketWithFrames([437      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },438      {439        type: "ADOPT_REQUEST",440        device_id: "request-only-label",441        device_name: "June's phone",442        device_type: "android",443        snapshot_capable: true,444      },445      { type: "ADOPT_CONFIRM", confirmed: true },446      {447        type: "ADOPT_IDENTITY",448        device_id: "android-receiver",449        device_name: "June's phone",450        device_type: "android",451        long_term_public_key_hex: "d".repeat(64),452      },453      { type: "ADOPT_APPLIED" },454      // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is455      // sent); the source must not hang on it — withTimeout resolves the wait456      // via the sync fallback path below.457      { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },458    ]);459    mocks.openSocket.mockResolvedValue(socket);460    // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget.461    const envelope = "c".repeat(690_000);462    const entries: Record<string, string> = {};463    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;464    mocks.createSyncAdapter.mockReturnValue({465      buildEntryManifest: vi.fn(async () => Object.fromEntries(466        Object.keys(entries).map((id) => [id, 1]),467      )),468      getEncryptedEntries: vi.fn(async () => entries),469      buildTombstoneManifest: vi.fn(async () => ({})),470      applyEntries: vi.fn(async () => undefined),471      applyTombstones: vi.fn(async () => undefined),472      buildOutgoingDeviceManifest: vi.fn(async () => null),473      applyIncomingDeviceManifest: vi.fn(async () => undefined),474    });475476    await startRelayAdoptionSource();477    await waitForPush("ADOPTION_SOURCE_RESULT");478479    expect(mocks.pushes).toContainEqual({480      type: "ADOPTION_SOURCE_RESULT",481      payload: expect.objectContaining({ status: "complete" }),482    });483    const sentTypes = socket.send.mock.calls.map(([bytes]) =>484      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,485    );486    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");487    // Sync fallback ran so the entries still reach the receiver.488    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });489  });490491  it("fails closed when the popup rejects the SAS", async () => {492    const socket = socketWithFrames([493      {494        type: "PEACH_HELLO",495        token: "AAECAwQFBgcICQoLDA0ODw",496      },497      {498        type: "ADOPT_REQUEST",499        device_id: "request-only-label",500        device_name: "New phone",501        device_type: "android",502      },503      { type: "ADOPT_CONFIRM", confirmed: false },504    ]);505    mocks.openSocket.mockResolvedValue(socket);506507    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");508    let pendingId: string | undefined;509    Object.defineProperty(globalThis, "chrome", {510      configurable: true,511      value: {512        runtime: {513          sendMessage(message: { type: string; payload: unknown }) {514            mocks.pushes.push(message);515            if (message.type === "ADOPTION_SOURCE_SAS") {516              const p = message.payload as { id?: string };517              if (typeof p?.id === "string") pendingId = p.id;518            }519            return Promise.resolve();520          },521        },522      },523    });524525    await startRelayAdoptionSource();526    await waitForPush("ADOPTION_SOURCE_SAS");527    expect(typeof pendingId).toBe("string");528    resolveSourceSasConfirmation(pendingId!, false);529    mocks.sasDecisions.push({ id: pendingId!, ok: false });530531    await waitForPush("ADOPTION_SOURCE_RESULT");532533    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();534    expect(mocks.upsert).not.toHaveBeenCalled();535    expect(mocks.pushes).toContainEqual({536      type: "ADOPTION_SOURCE_RESULT",537      payload: { status: "error", error: "adoption_rejected" },538    });539  });540541  it("rejects before reading the root key when neither peer has Pro", async () => {542    const socket = socketWithFrames([543      {544        type: "PEACH_HELLO",545        token: "AAECAwQFBgcICQoLDA0ODw",546      },547      {548        type: "ADOPT_REQUEST",549        device_id: "free-receiver",550        device_name: "Free phone",551        device_type: "android",552      },553      { type: "ADOPT_CONFIRM", confirmed: true },554    ]);555    mocks.isProUser.mockResolvedValue(false);556    mocks.getLicenseKey.mockResolvedValue(undefined);557    mocks.openSocket.mockResolvedValue(socket);558559    await startRelayAdoptionSource();560    await waitForPush("ADOPTION_SOURCE_RESULT");561562    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();563    expect(mocks.upsert).not.toHaveBeenCalled();564    expect(mocks.pushes).toContainEqual({565      type: "ADOPTION_SOURCE_RESULT",566      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },567    });568    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {569      try {570        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];571      } catch {572        return [];573      }574    });575    expect(sentFrames).toContainEqual({576      type: "ADOPT_REJECT",577      reason: "ADOPT_PRO_REQUIRED",578    });579  });580581  it("fails closed when the user never answers the SAS", async () => {582    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they583    // schedule real work), so we hold the SAS gate open by never resolving584    // the deferred and then cancel the session. clearState must drop the585    // pending SAS so it doesn't pin the relay room open for the next run.586    const socket = socketWithFrames([]);587    mocks.openSocket.mockResolvedValue(socket);588589    Object.defineProperty(globalThis, "chrome", {590      value: {591        storage: chromeStorage,592        runtime: {593          sendMessage(message: { type: string; payload: unknown }) {594            mocks.pushes.push(message);595            return Promise.resolve();596          },597        },598      },599    });600601    await startRelayAdoptionSource();602    cancelRelayAdoptionSource();603604    expect(socket.close).toHaveBeenCalled();605    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();606    expect(mocks.upsert).not.toHaveBeenCalled();607  });608609  it("cancelling the source screen closes the room without reading the root", async () => {610    const socket = socketWithFrames([]);611    mocks.openSocket.mockResolvedValue(socket);612613    await startRelayAdoptionSource();614    cancelRelayAdoptionSource();615616    expect(socket.close).toHaveBeenCalled();617    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();618    expect(mocks.upsert).not.toHaveBeenCalled();619  });620});621622describe("relay adoption receiver background", () => {623  beforeEach(async () => {624    // The receiver session runs in the background via `void runReceiverSession`;625    // it doesn't get awaited by the caller and can leave `inFlight` set if the626    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test627    // starts from a clean state.628    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");629    cancelRelayAdoptionSource();630    mocks.pushes.length = 0;631    mocks.openSocket.mockReset();632    mocks.getDeviceName.mockReset();633    mocks.getDeviceName.mockResolvedValue("Receiver Browser");634    mocks.createVault.mockReset();635    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });636    mocks.activateLicense.mockReset();637    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });638    mocks.isProUser.mockReset();639    mocks.isProUser.mockResolvedValue(true);640    mocks.getLicenseKey.mockReset();641    mocks.getLicenseKey.mockResolvedValue(undefined);642    mocks.localIdentity.mockReset();643    mocks.localIdentity.mockResolvedValue({644      deviceId: "receiver-id",645      deviceName: "Receiver Browser",646      deviceType: "extension",647      longTermPublicKeyHex: "11".repeat(32),648      signingPublicKeyHex: "22".repeat(32),649    });650    mocks.rollbackVault.mockReset();651    mocks.rollbackVault.mockResolvedValue(undefined);652    mocks.storageSet.mockReset();653    mocks.storageSet.mockImplementation(async (key: string, value: string) => {654      mocks.storage.set(key, value);655    });656    mocks.upsert.mockReset();657    mocks.upsert.mockResolvedValue(undefined);658    mocks.setPairSecret.mockReset();659    mocks.setPairSecret.mockResolvedValue(undefined);660    mocks.triggerSync.mockReset();661    mocks.triggerSync.mockResolvedValue(undefined);662    mocks.openSocket.mockResolvedValue(socketWithFrames([]));663    Object.defineProperty(globalThis, "chrome", {664      value: {665        storage: chromeStorage,666        runtime: {667          async sendMessage(message: { type: string; payload?: unknown }) {668            mocks.pushes.push({ type: message.type, payload: message.payload });669            if (message.type === "ADOPTION_SOURCE_SAS") {670              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };671              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {672                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");673                resolveReceiverSasConfirmation(p.id, p.sas, true);674              }675            }676            return { ok: true };677          },678          onMessage: {679            addListener() { /* no-op */ },680            removeListener() { /* no-op */ },681          },682        },683      },684    });685  });686687  it("opens a relay socket and returns an empty invitation", async () => {688    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");689    const started = await startRelayAdoptionReceiver("receiver-password");690    expect(started.invitation).toMatch(/^peach-pair-v2:/);691    // The QR carries vault:"empty" so the phone knows to act as source.692    const decoded = decodeInvitation(started.invitation);693    expect(decoded?.vault).toBe("empty");694    expect(mocks.openSocket).toHaveBeenCalled();695  });696697  it("rejects an empty password", async () => {698    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");699    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");700  });701702  it("surfaces the Pro-required rejection without creating a vault", async () => {703    mocks.openSocket.mockResolvedValue(socketWithFrames([704      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },705      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },706    ]));707708    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");709    await startRelayAdoptionReceiver("receiver-password");710    await waitForPush("ADOPTION_SOURCE_RESULT");711712    expect(mocks.createVault).not.toHaveBeenCalled();713    expect(mocks.rollbackVault).not.toHaveBeenCalled();714    expect(mocks.pushes).toContainEqual({715      type: "ADOPTION_SOURCE_RESULT",716      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },717    });718  });719720  it("the receiver wires requestAdoption, not grantAdoption", async () => {721    // The receiver's protocol position is the opposite of the source side:722    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.723    // Assert this by watching what lands on socket.send: the receiver's724    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY725    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.726    const rootKeyHex = "f0".repeat(32);727    const vaultId = "b".repeat(64);728    const socket = socketWithFrames([729      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },730      {731        type: "ADOPT_GRANT",732        root_key_hex: rootKeyHex,733        vault_id: vaultId,734        license: "PPK-test",735        source: {736          device_id: "phone-source-id",737          device_name: "June's phone",738          device_type: "android",739          long_term_public_key_hex: "99".repeat(32),740        },741      },742      { type: "ADOPT_COMPLETE" },743    ], { closeOnEmpty: true });744    mocks.openSocket.mockResolvedValue(socket);745    mocks.createVault.mockResolvedValue({ vaultId });746747    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");748    Object.defineProperty(globalThis, "chrome", {749      value: {750        storage: chromeStorage,751        runtime: {752          async sendMessage(message: { type: string; payload?: unknown }) {753            mocks.pushes.push({ type: message.type, payload: message.payload });754            if (message.type === "ADOPTION_SOURCE_SAS") {755              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };756              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {757                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");758                resolveReceiverSasConfirmation(p.id, p.sas, true);759              }760            }761            return { ok: true };762          },763          onMessage: { addListener: () => undefined, removeListener: () => undefined },764        },765      },766    });767    await startRelayAdoptionReceiver("receiver-password");768    await waitForPush("ADOPTION_SOURCE_RESULT");769770    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })771      .mock.calls772      .map(([bytes]) => {773        try {774          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;775        } catch {776          return undefined;777        }778      });779    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;780    // ADOPT_GRANT is the source side's frame and must never appear here.781    expect(sentTypes).toContain("ADOPT_REQUEST");782    expect(sentTypes).not.toContain("ADOPT_GRANT");783  });784785  it("completes the handshake end-to-end: vault created → paired", async () => {786    // Wire the receiver's noise mock and SAS gate together so the full787    // requestAdoption protocol runs against an in-memory phone that sends788    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.789    // The mocked noise is pass-through (encrypt = decrypt = identity), so790    // frames land as ordinary JSON.791    const rootKeyHex = "f0".repeat(32);792    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);793    // the grant's vault_id is set to the same so the protocol proceeds.794    const vaultId = "b".repeat(64);795    const frames = [796      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },797      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two798      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.799      {800        type: "ADOPT_GRANT",801        root_key_hex: rootKeyHex,802        vault_id: vaultId,803        license: "PPK-test",804        source: {805          device_id: "phone-source-id",806          device_name: "June's phone",807          device_type: "android",808          long_term_public_key_hex: "99".repeat(32),809          signing_public_key_hex: "aa".repeat(32),810          relay_url: "wss://relay.peachpasswords.com",811        },812      },813      { type: "ADOPT_COMPLETE" },814    ];815    mocks.createVault.mockResolvedValue({ vaultId });816    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));817818    // Replace the chrome.runtime mock for this one test and approve the displayed SAS.819    Object.defineProperty(globalThis, "chrome", {820      value: {821        storage: chromeStorage,822        runtime: {823          async sendMessage(message: { type: string; payload?: unknown }) {824            mocks.pushes.push({ type: message.type, payload: message.payload });825            if (message.type === "ADOPTION_SOURCE_SAS") {826              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };827              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {828                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");829                resolveReceiverSasConfirmation(p.id, p.sas, true);830              }831            }832            return { ok: true };833          },834          onMessage: {835            addListener: () => undefined,836            removeListener: () => undefined,837          },838        },839      },840    });841842    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");843    await startRelayAdoptionReceiver("receiver-password");844845    await waitForPush("ADOPTION_SOURCE_RESULT");846847    // The receiver displays its locally derived SAS and requires confirmation.848    expect(mocks.pushes).toContainEqual({849      type: "ADOPTION_SOURCE_SAS",850      payload: expect.objectContaining({851        sas: expect.stringMatching(/^\d{8}$/),852        displayOnly: false,853      }),854    });855856    // The vault module was called directly with the popup-supplied password857    // and the root key granted by the phone.858    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);859860    // License activation, identity derivation, and pairing persistence all861    // call their modules directly from the service worker.862    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");863    expect(mocks.isProUser).toHaveBeenCalled();864    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));865    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({866      deviceId: "phone-source-id",867    }));868    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;869    expect(receiverRow.rendezvousSecretHex).toBeUndefined();870    expect(mocks.setPairSecret).toHaveBeenCalledWith(871      "phone-source-id",872      expect.stringMatching(/^[0-9a-f]{64}$/),873      expect.any(Uint8Array),874    );875    expect(mocks.storageSet).toHaveBeenCalledWith(876      "peach_initial_sync_pending_device_id",877      "phone-source-id",878    );879    expect(mocks.rollbackVault).not.toHaveBeenCalled();880881    // The terminal state is "complete" — the popup can transition to success.882    expect(mocks.pushes).toContainEqual({883      type: "ADOPTION_SOURCE_RESULT",884      payload: { status: "complete" },885    });886  });887888  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {889    const rootKeyHex = "f0".repeat(32);890    const vaultId = "b".repeat(64);891    mocks.createVault.mockResolvedValue({ vaultId });892    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });893    mocks.isProUser.mockResolvedValue(false);894    mocks.openSocket.mockResolvedValue(socketWithFrames([895      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },896      {897        type: "ADOPT_GRANT",898        root_key_hex: rootKeyHex,899        vault_id: vaultId,900        license: "PPK-invalid",901        source: {902          device_id: "phone-source-id",903          device_name: "June's phone",904          device_type: "android",905          long_term_public_key_hex: "99".repeat(32),906        },907      },908    ]));909910    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");911    await startRelayAdoptionReceiver("receiver-password");912    await waitForPush("ADOPTION_SOURCE_RESULT");913914    expect(mocks.rollbackVault).toHaveBeenCalledOnce();915    expect(mocks.localIdentity).not.toHaveBeenCalled();916    expect(mocks.upsert).not.toHaveBeenCalled();917    expect(mocks.pushes).toContainEqual(expect.objectContaining({918      type: "ADOPTION_SOURCE_RESULT",919      payload: expect.objectContaining({920        status: "error",921        error: "license_transfer_failed",922      }),923    }));924  });925926  it("rolls back a newly created vault when a later adoption step fails", async () => {927    const rootKeyHex = "f0".repeat(32);928    const vaultId = "b".repeat(64);929    mocks.createVault.mockResolvedValue({ vaultId });930    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));931    mocks.openSocket.mockResolvedValue(socketWithFrames([932      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },933      {934        type: "ADOPT_GRANT",935        root_key_hex: rootKeyHex,936        vault_id: vaultId,937        license: "PPK-test",938        source: {939          device_id: "phone-source-id",940          device_name: "June's phone",941          device_type: "android",942          long_term_public_key_hex: "99".repeat(32),943        },944      },945    ]));946947    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");948    await startRelayAdoptionReceiver("receiver-password");949    await waitForPush("ADOPTION_SOURCE_RESULT");950951    expect(mocks.rollbackVault).toHaveBeenCalledOnce();952    expect(mocks.upsert).not.toHaveBeenCalled();953    expect(mocks.pushes).toContainEqual(expect.objectContaining({954      type: "ADOPTION_SOURCE_RESULT",955      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),956    }));957  });958959  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {960    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).961    // The receiver should skip the 30-second inline snapshot wait entirely962    // and fall through to the sync pipeline — no receiveVaultSnapshot call.963    const rootKeyHex = "f0".repeat(32);964    const vaultId = "b".repeat(64);965    mocks.createVault.mockResolvedValue({ vaultId });966    mocks.openSocket.mockResolvedValue(socketWithFrames([967      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },968      {969        type: "ADOPT_GRANT",970        root_key_hex: rootKeyHex,971        vault_id: vaultId,972        license: "PPK-test",973        source: {974          device_id: "old-phone-id",975          device_name: "Old phone",976          device_type: "android",977          long_term_public_key_hex: "99".repeat(32),978        },979        // NO snapshot_capable: pre-C source does not echo the field980      },981      { type: "ADOPT_COMPLETE" },982    ], { closeOnEmpty: true }));983984    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");985    await startRelayAdoptionReceiver("receiver-password");986    await waitForPush("ADOPTION_SOURCE_RESULT");987988    // Sync fallback ran immediately (no 30s wait).989    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });990    // Adoption completed successfully.991    expect(mocks.pushes).toContainEqual(expect.objectContaining({992      type: "ADOPTION_SOURCE_RESULT",993      payload: expect.objectContaining({ status: "complete" }),994    }));995    // The initial sync pending marker was set (not cleared — no snapshot applied).996    expect(mocks.storageSet).toHaveBeenCalledWith(997      "peach_initial_sync_pending_device_id",998      "old-phone-id",999    );1000  });10011002  it("applies the source's device manifest after the snapshot, then acks", async () => {1003    // Mesh propagation: a 3rd browser that completes an adoption with a1004    // snapshot-capable source must also receive the source's signed device1005    // manifest and forward it to the sync adapter before the receiver1006    // resolves. The relay stays an opaque forwarder, so the whole path1007    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.1008    const rootKeyHex = "f0".repeat(32);1009    const vaultId = "b".repeat(64);1010    const longTermPubkeyHex = "99".repeat(32);1011    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1012    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(1013      "../shared/sync-identity"1014    );1015    const issuerKp = await deriveSyncSigningKeypair(1016      new Uint8Array(32).fill(0x55),1017      "phone-source-id",1018    );1019    const manifest = await buildSignedDeviceManifest(1020      "phone-source-id",1021      [1022        {1023          deviceId: "peer-b",1024          deviceName: "Pool B",1025          deviceType: "extension",1026          longTermPublicKeyHex: "11".repeat(32),1027          signingPublicKeyHex: "22".repeat(32),1028          wsPort: 49152,1029          relayUrl: "wss://relay.peachpasswords.com",1030          pairedAt: 1,1031        },1032      ],1033      issuerKp.privateKey,1034    );1035    const socket = socketWithFrames([1036      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1037      {1038        type: "ADOPT_GRANT",1039        root_key_hex: rootKeyHex,1040        vault_id: vaultId,1041        license: "PPK-test",1042        snapshot_capable: true,1043        source: {1044          device_id: "phone-source-id",1045          device_name: "June's phone",1046          device_type: "android",1047          long_term_public_key_hex: longTermPubkeyHex,1048          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),1049          relay_url: "wss://relay.peachpasswords.com",1050        },1051      },1052      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is1053      // an optional follow-up frame on the same channel.1054      { type: "ADOPT_COMPLETE" },1055      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames1056      // after the handshake; one empty chunk ends the phase.1057      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1058      { type: "ADOPT_DEVICE_MANIFEST", manifest },1059    ], { closeOnEmpty: true });1060    mocks.createVault.mockResolvedValue({ vaultId });1061    mocks.openSocket.mockResolvedValue(socket);1062    mocks.__applyIncomingDeviceManifest.mockClear();10631064    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1065    await startRelayAdoptionReceiver("receiver-password");1066    await waitForPush("ADOPTION_SOURCE_RESULT");10671068    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1069    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1070    expect(forwarded).toEqual(manifest);1071    expect(pubkey).toBeInstanceOf(Uint8Array);1072    expect(pubkey.length).toBe(32);1073    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the1074    // adapter path was skipped (the ack only follows a real apply).1075    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1076      .mock.calls1077      .map(([bytes]) => {1078        try {1079          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1080        } catch {1081          return undefined;1082        }1083      });1084    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1085    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.1086    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1087  });10881089  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1090    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST1091    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,1092    // and quietly time out the optional 5s wait without acking a manifest1093    // or invoking the adapter path.1094    const rootKeyHex = "f0".repeat(32);1095    const vaultId = "b".repeat(64);1096    const longTermPubkeyHex = "99".repeat(32);1097    const socket = socketWithFrames([1098      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1099      {1100        type: "ADOPT_GRANT",1101        root_key_hex: rootKeyHex,1102        vault_id: vaultId,1103        license: "PPK-test",1104        snapshot_capable: true,1105        source: {1106          device_id: "phone-source-id",1107          device_name: "June's phone",1108          device_type: "android",1109          long_term_public_key_hex: longTermPubkeyHex,1110          signing_public_key_hex: "aa".repeat(32),1111          relay_url: "wss://relay.peachpasswords.com",1112        },1113      },1114      { type: "ADOPT_COMPLETE" },1115      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot1116      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.1117      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1118    ], { closeOnEmpty: true });1119    mocks.createVault.mockResolvedValue({ vaultId });1120    mocks.openSocket.mockResolvedValue(socket);1121    mocks.__applyIncomingDeviceManifest.mockClear();11221123    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1124    await startRelayAdoptionReceiver("receiver-password");1125    await waitForPush("ADOPTION_SOURCE_RESULT");11261127    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1128    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1129      .mock.calls1130      .map(([bytes]) => {1131        try {1132          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1133        } catch {1134          return undefined;1135        }1136      });1137    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1138    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1139  });1140});11411142function decodeInvitation(encoded: string): { vault?: string } | null {1143  // Mirrors parsePairingInvitation's payload structure without pulling the1144  // module in: the receiver test only needs to assert the embedded status.1145  const at = encoded.indexOf(":");1146  if (at < 0) return null;1147  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1148  try {1149    const json = Buffer.from(b64, "base64").toString("utf8");1150    return JSON.parse(json) as { vault?: string };1151  } catch {1152    return null;1153  }1154}