relay-adoption-bg.test.ts

41.3 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  setPairSecret: vi.fn(),11  pushes: [] as Array<{ type: string; payload: unknown }>,12  triggerSync: vi.fn(),13  createVault: vi.fn(),14  activateLicense: vi.fn(),15  isProUser: vi.fn(),16  getLicenseKey: vi.fn(),17  localIdentity: vi.fn(),18  rollbackVault: vi.fn(),19  createSyncAdapter: vi.fn(),20  __applyIncomingDeviceManifest: vi.fn(),21  storageSet: vi.fn(),22  releaseAutoLock: vi.fn(),23  deferAutoLock: vi.fn(),24  sasDecisions: [] as Array<{ id: string; ok: boolean }>,25}));2627vi.mock("../shared/pairing-dial", () => ({28  openRelayPairingSocket: mocks.openSocket,29}));3031vi.mock("../shared/noise-js", () => ({32  noiseNKResponder: vi.fn(async () => ({33    handshakeHash: new Uint8Array(32).fill(7),34    encrypt: (bytes: Uint8Array) => bytes,35    decrypt: (bytes: Uint8Array) => bytes,36    close: vi.fn(),37  })),38}));3940vi.mock("../shared/rendezvous", async (importOriginal) => {41  const actual = await importOriginal<typeof import("../shared/rendezvous")>();42  return {43    ...actual,44    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",45  };46});474849vi.mock("./device-identity", () => ({50  getLocalPairingIdentity: async () => ({51    identity: {52      deviceId: "test-device",53      deviceName: "Test",54      deviceType: "extension",55      longTermPublicKeyHex: "aa".repeat(32),56      vaultId: "bb".repeat(32),57    },58    privateKey: new Uint8Array(32).fill(7),59    publicKey: new Uint8Array(32).fill(8),60  }),61  getDeviceName: mocks.getDeviceName,62  buildLocalIdentity: mocks.localIdentity,63}));6465vi.mock("./sync-settings", async (importOriginal) => ({66  ...(await importOriginal<typeof import("./sync-settings")>()),67  getSyncSettings: async () => ({68    mode: "public" as const,69    relayUrl: "wss://relay.peachpasswords.com",70  }),71}));7273vi.mock("./license", () => ({74  isProUser: mocks.isProUser,75  getLicenseKey: mocks.getLicenseKey,76  activateLicense: mocks.activateLicense,77}));7879vi.mock("./vault", () => ({80  isUnlocked: vi.fn(() => true),81  getRootKeyHex: mocks.getRootKeyHex,82  createVaultFromRootKey: mocks.createVault,83  rollbackAdoptedVault: mocks.rollbackVault,84  exportDuressSlotForAdoption: vi.fn(async () => null),85  installAdoptedDuressSlot: vi.fn(async () => undefined),86}));8788vi.mock("../shared/sync-identity", async (importOriginal) => {89  // Partial mock: the manifest test needs the REAL deriveSyncSigningKeypair /90  // pubkeyToHex to build a verifiable signed manifest (2026-08-04: this test91  // was orphaned inside the rendezvous mock factory and never ran; the mock92  // below had since lost the real exports).93  const actual = await importOriginal<typeof import("../shared/sync-identity")>();94  return {95    ...actual,96    computeVaultId: vi.fn(async () => "b".repeat(64)),97  };98});99100vi.mock("../shared/storage", () => ({101  storageSet: mocks.storageSet,102}));103104vi.mock("./paired-devices", () => ({105  upsertPairedDevice: mocks.upsert,106}));107108vi.mock("./pair-secrets", () => ({109  setPairSecret: mocks.setPairSecret,110  isValidPairSecret: (value: unknown): value is string =>111    typeof value === "string" && /^[0-9a-f]{64}$/.test(value),112}));113114vi.mock("./sync-trigger", () => ({115  triggerSyncIfReachable: mocks.triggerSync,116}));117118vi.mock("./alarms", () => ({119  deferAutoLockForOperation: mocks.deferAutoLock,120}));121122vi.mock("./sync-vault-adapter", () => ({123  createSyncVaultAdapter: mocks.createSyncAdapter,124}));125126import {127  cancelRelayAdoptionSource,128  startRelayAdoptionSource,129} from "./relay-adoption-bg";130131const encode = (value: unknown) =>132  new TextEncoder().encode(JSON.stringify(value));133134function socketWithFrames(135  frames: unknown[],136  options: { closeOnEmpty?: boolean } = {},137) {138  const queue = frames.map((frame) => {139    const encoded = encode(frame);140    // PB-26-604: snapshot frames are bucket-padded before Noise.141    return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"142      ? padSyncFrame(encoded)143      : encoded;144  });145  return {146    send: vi.fn(),147    receive: vi.fn(async () => {148      const next = queue.shift();149      if (!next) {150        // A real peer closes the channel once it runs out of frames; model151        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast152        // and falls back to the sync pipeline instead of hanging on a timeout.153        if (options.closeOnEmpty) throw new Error("socket_closed");154        return new Promise<Uint8Array>(() => undefined);155      }156      return next;157    }),158    close: vi.fn(),159    closedBeforeFrame: vi.fn(() => false),160  };161}162163async function waitForPush(type: string): Promise<void> {164  await vi.waitFor(() => {165    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);166  });167}168169beforeEach(() => {170  mocks.pushes.length = 0;171  mocks.sasDecisions.length = 0;172  mocks.openSocket.mockReset();173  mocks.getLocalIdentity.mockReset();174  mocks.getRootKeyHex.mockReset();175  mocks.upsert.mockReset();176  mocks.setPairSecret.mockReset();177  mocks.triggerSync.mockReset();178  mocks.triggerSync.mockResolvedValue(undefined);179  mocks.releaseAutoLock.mockReset();180  mocks.deferAutoLock.mockReset();181  mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);182  mocks.isProUser.mockReset();183  mocks.isProUser.mockResolvedValue(true);184  mocks.getLicenseKey.mockReset();185  mocks.getLicenseKey.mockResolvedValue("PPK-source");186  mocks.getLocalIdentity.mockResolvedValue({187    identity: {188      deviceId: "browser-source",189      deviceName: "June's Browser",190      deviceType: "extension",191      longTermPublicKeyHex: "a".repeat(64),192      vaultId: "b".repeat(64),193    },194    privateKey: new Uint8Array(32).fill(1),195    publicKey: new Uint8Array(32).fill(2),196  });197  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));198  mocks.upsert.mockResolvedValue(undefined);199  mocks.setPairSecret.mockResolvedValue(undefined);200    const applyIncomingDeviceManifest = vi.fn(async () => undefined);201    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;202    mocks.createSyncAdapter.mockReturnValue({203      buildEntryManifest: vi.fn(async () => ({})),204      getEncryptedEntries: vi.fn(async () => ({})),205      buildTombstoneManifest: vi.fn(async () => ({})),206      applyEntries: vi.fn(async () => undefined),207      applyTombstones: vi.fn(async () => undefined),208      buildOutgoingDeviceManifest: vi.fn(async () => null),209      applyIncomingDeviceManifest,210    });211  Object.defineProperty(globalThis, "chrome", {212    configurable: true,213    value: {214    runtime: {215      async sendMessage(message: { type: string; payload: unknown }) {216        mocks.pushes.push(message);217        // The source flow waits for the popup to send SOURCE_SAS_DECISION before218        // forwarding the root key. The receiver test overrides this mock and219        // resolves the decision itself; the source tests rely on this default220        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.221        if (message.type === "ADOPTION_SOURCE_SAS") {222          const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };223          if (typeof p?.id === "string" && typeof p.sas === "string") {224            if (p.displayOnly === false) {225              const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");226              resolveReceiverSasConfirmation(p.id, p.sas, true);227            } else {228              const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");229              mocks.sasDecisions.push({ id: p.id, ok: true });230              resolveSourceSasConfirmation(p.id, true);231            }232          }233        }234        return Promise.resolve();235      },236    },237    },238  });239});240241describe("relay adoption source background boundary", () => {242  it("shows the SAS and waits for the popup's single confirmation", async () => {243    const socket = socketWithFrames([244      {245        type: "PEACH_HELLO",246        token: "AAECAwQFBgcICQoLDA0ODw",247      },248      {249        type: "ADOPT_REQUEST",250        device_id: "request-only-label",251        device_name: "June's phone",252        device_type: "android",253      },254      { type: "ADOPT_CONFIRM", confirmed: true },255      {256        type: "ADOPT_IDENTITY",257        device_id: "android-receiver",258        device_name: "June's phone",259        device_type: "android",260        long_term_public_key_hex: "d".repeat(64),261      },262      { type: "ADOPT_APPLIED" },263    ]);264    mocks.openSocket.mockResolvedValue(socket);265266    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");267    const sasIds: string[] = [];268    Object.defineProperty(globalThis, "chrome", {269      configurable: true,270      value: {271        runtime: {272          async sendMessage(message: { type: string; payload: unknown }) {273            mocks.pushes.push(message);274            if (message.type === "ADOPTION_SOURCE_SAS") {275              const p = message.payload as { id?: string };276              if (typeof p?.id === "string") {277                // Hold the SAS gate open until the test explicitly approves278                // it — this proves the source flow waits for the popup.279                sasIds.push(p.id);280              }281              return Promise.resolve();282            }283            return Promise.resolve();284          },285        },286      },287    });288    void resolveSourceSasConfirmation; // imported for the post-approval call below289290    const started = await startRelayAdoptionSource();291    expect(started.invitation).toMatch(/^peach-pair-v2:/);292    expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);293    await waitForPush("ADOPTION_SOURCE_SAS");294    // The adoption must NOT be complete yet — the popup's SAS decision is the295    // prerequisite for forwarding the root key.296    await vi.waitFor(() => {297      expect(mocks.sasDecisions.length).toBe(0);298    });299    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();300    expect(mocks.upsert).not.toHaveBeenCalled();301302    // Approve the SAS; the adoption should now proceed and complete.303    const id = sasIds[0];304    expect(typeof id).toBe("string");305    resolveSourceSasConfirmation(id, true);306    mocks.sasDecisions.push({ id, ok: true });307308    await waitForPush("ADOPTION_SOURCE_RESULT");309310    await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());311312    expect(mocks.getRootKeyHex).toHaveBeenCalled();313    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({314      deviceId: "android-receiver",315      relayUrl: "wss://relay.peachpasswords.com",316    }));317    // PB-26-213: the registry row stays secretless; the secret bypasses it318    // into the encrypted pair-secret record.319    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;320    expect(sourceRow.rendezvousSecretHex).toBeUndefined();321    expect(mocks.setPairSecret).toHaveBeenCalledWith(322      "android-receiver",323      expect.stringMatching(/^[0-9a-f]{64}$/),324      expect.any(Uint8Array),325    );326    expect(mocks.pushes).toContainEqual({327      type: "ADOPTION_SOURCE_RESULT",328      payload: { status: "complete" },329    });330    expect(mocks.triggerSync).toHaveBeenCalledWith({331      reason: "manual",332      force: true,333    });334    const sentTypes = socket.send.mock.calls335      .map(([bytes]) => {336        try {337          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;338        } catch {339          return undefined;340        }341      });342    expect(sentTypes).toContain("ADOPT_COMPLETE");343  });344345  it("fails adoption without sending snapshot frames when duress is oversized", async () => {346    const socket = socketWithFrames([347      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },348      {349        type: "ADOPT_REQUEST",350        device_id: "request-only-label",351        device_name: "June's phone",352        device_type: "android",353        snapshot_capable: true,354      },355      { type: "ADOPT_CONFIRM", confirmed: true },356      {357        type: "ADOPT_IDENTITY",358        device_id: "android-receiver",359        device_name: "June's phone",360        device_type: "android",361        long_term_public_key_hex: "d".repeat(64),362      },363      { type: "ADOPT_APPLIED" },364    ]);365    mocks.openSocket.mockResolvedValue(socket);366    const { exportDuressSlotForAdoption } = await import("./vault");367    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(368      "x".repeat(768 * 1024),369    );370371    await startRelayAdoptionSource();372    await waitForPush("ADOPTION_SOURCE_RESULT");373374    expect(mocks.pushes).toContainEqual({375      type: "ADOPTION_SOURCE_RESULT",376      payload: {377        status: "error",378        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",379      },380    });381    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({382      type: "ADOPTION_SOURCE_RESULT",383      payload: expect.objectContaining({ status: "complete" }),384    }));385    const sentTypes = socket.send.mock.calls.map(([bytes]) =>386      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,387    );388    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");389    expect(mocks.triggerSync).not.toHaveBeenCalled();390  });391392  it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {393    // PB-26-206: an over-budget snapshot is never partially sent or silently394    // truncated — the source completes adoption and the sync pipeline carries395    // the entries instead.396    const socket = socketWithFrames([397      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },398      {399        type: "ADOPT_REQUEST",400        device_id: "request-only-label",401        device_name: "June's phone",402        device_type: "android",403        snapshot_capable: true,404      },405      { type: "ADOPT_CONFIRM", confirmed: true },406      {407        type: "ADOPT_IDENTITY",408        device_id: "android-receiver",409        device_name: "June's phone",410        device_type: "android",411        long_term_public_key_hex: "d".repeat(64),412      },413      { type: "ADOPT_APPLIED" },414      // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is415      // sent); the source must not hang on it — withTimeout resolves the wait416      // via the sync fallback path below.417      { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },418    ]);419    mocks.openSocket.mockResolvedValue(socket);420    // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget.421    const envelope = "c".repeat(690_000);422    const entries: Record<string, string> = {};423    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;424    mocks.createSyncAdapter.mockReturnValue({425      buildEntryManifest: vi.fn(async () => Object.fromEntries(426        Object.keys(entries).map((id) => [id, 1]),427      )),428      getEncryptedEntries: vi.fn(async () => entries),429      buildTombstoneManifest: vi.fn(async () => ({})),430      applyEntries: vi.fn(async () => undefined),431      applyTombstones: vi.fn(async () => undefined),432      buildOutgoingDeviceManifest: vi.fn(async () => null),433      applyIncomingDeviceManifest: vi.fn(async () => undefined),434    });435436    await startRelayAdoptionSource();437    await waitForPush("ADOPTION_SOURCE_RESULT");438439    expect(mocks.pushes).toContainEqual({440      type: "ADOPTION_SOURCE_RESULT",441      payload: expect.objectContaining({ status: "complete" }),442    });443    const sentTypes = socket.send.mock.calls.map(([bytes]) =>444      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,445    );446    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");447    // Sync fallback ran so the entries still reach the receiver.448    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });449  });450451  it("fails closed when the popup rejects the SAS", async () => {452    const socket = socketWithFrames([453      {454        type: "PEACH_HELLO",455        token: "AAECAwQFBgcICQoLDA0ODw",456      },457      {458        type: "ADOPT_REQUEST",459        device_id: "request-only-label",460        device_name: "New phone",461        device_type: "android",462      },463      { type: "ADOPT_CONFIRM", confirmed: false },464    ]);465    mocks.openSocket.mockResolvedValue(socket);466467    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");468    let pendingId: string | undefined;469    Object.defineProperty(globalThis, "chrome", {470      configurable: true,471      value: {472        runtime: {473          sendMessage(message: { type: string; payload: unknown }) {474            mocks.pushes.push(message);475            if (message.type === "ADOPTION_SOURCE_SAS") {476              const p = message.payload as { id?: string };477              if (typeof p?.id === "string") pendingId = p.id;478            }479            return Promise.resolve();480          },481        },482      },483    });484485    await startRelayAdoptionSource();486    await waitForPush("ADOPTION_SOURCE_SAS");487    expect(typeof pendingId).toBe("string");488    resolveSourceSasConfirmation(pendingId!, false);489    mocks.sasDecisions.push({ id: pendingId!, ok: false });490491    await waitForPush("ADOPTION_SOURCE_RESULT");492493    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();494    expect(mocks.upsert).not.toHaveBeenCalled();495    expect(mocks.pushes).toContainEqual({496      type: "ADOPTION_SOURCE_RESULT",497      payload: { status: "error", error: "adoption_rejected" },498    });499  });500501  it("rejects before reading the root key when neither peer has Pro", async () => {502    const socket = socketWithFrames([503      {504        type: "PEACH_HELLO",505        token: "AAECAwQFBgcICQoLDA0ODw",506      },507      {508        type: "ADOPT_REQUEST",509        device_id: "free-receiver",510        device_name: "Free phone",511        device_type: "android",512      },513      { type: "ADOPT_CONFIRM", confirmed: true },514    ]);515    mocks.isProUser.mockResolvedValue(false);516    mocks.getLicenseKey.mockResolvedValue(undefined);517    mocks.openSocket.mockResolvedValue(socket);518519    await startRelayAdoptionSource();520    await waitForPush("ADOPTION_SOURCE_RESULT");521522    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();523    expect(mocks.upsert).not.toHaveBeenCalled();524    expect(mocks.pushes).toContainEqual({525      type: "ADOPTION_SOURCE_RESULT",526      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },527    });528    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {529      try {530        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];531      } catch {532        return [];533      }534    });535    expect(sentFrames).toContainEqual({536      type: "ADOPT_REJECT",537      reason: "ADOPT_PRO_REQUIRED",538    });539  });540541  it("fails closed when the user never answers the SAS", async () => {542    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they543    // schedule real work), so we hold the SAS gate open by never resolving544    // the deferred and then cancel the session. clearState must drop the545    // pending SAS so it doesn't pin the relay room open for the next run.546    const socket = socketWithFrames([]);547    mocks.openSocket.mockResolvedValue(socket);548549    Object.defineProperty(globalThis, "chrome", {550      configurable: true,551      value: {552        runtime: {553          sendMessage(message: { type: string; payload: unknown }) {554            mocks.pushes.push(message);555            return Promise.resolve();556          },557        },558      },559    });560561    await startRelayAdoptionSource();562    cancelRelayAdoptionSource();563564    expect(socket.close).toHaveBeenCalled();565    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();566    expect(mocks.upsert).not.toHaveBeenCalled();567  });568569  it("cancelling the source screen closes the room without reading the root", async () => {570    const socket = socketWithFrames([]);571    mocks.openSocket.mockResolvedValue(socket);572573    await startRelayAdoptionSource();574    cancelRelayAdoptionSource();575576    expect(socket.close).toHaveBeenCalled();577    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();578    expect(mocks.upsert).not.toHaveBeenCalled();579  });580});581582describe("relay adoption receiver background", () => {583  beforeEach(async () => {584    // The receiver session runs in the background via `void runReceiverSession`;585    // it doesn't get awaited by the caller and can leave `inFlight` set if the586    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test587    // starts from a clean state.588    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");589    cancelRelayAdoptionSource();590    mocks.pushes.length = 0;591    mocks.openSocket.mockReset();592    mocks.getDeviceName.mockReset();593    mocks.getDeviceName.mockResolvedValue("Receiver Browser");594    mocks.createVault.mockReset();595    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });596    mocks.activateLicense.mockReset();597    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });598    mocks.isProUser.mockReset();599    mocks.isProUser.mockResolvedValue(true);600    mocks.getLicenseKey.mockReset();601    mocks.getLicenseKey.mockResolvedValue(undefined);602    mocks.localIdentity.mockReset();603    mocks.localIdentity.mockResolvedValue({604      deviceId: "receiver-id",605      deviceName: "Receiver Browser",606      deviceType: "extension",607      longTermPublicKeyHex: "11".repeat(32),608      signingPublicKeyHex: "22".repeat(32),609    });610    mocks.rollbackVault.mockReset();611    mocks.rollbackVault.mockResolvedValue(undefined);612    mocks.storageSet.mockReset();613    mocks.storageSet.mockResolvedValue(undefined);614    mocks.upsert.mockReset();615    mocks.upsert.mockResolvedValue(undefined);616    mocks.setPairSecret.mockReset();617    mocks.setPairSecret.mockResolvedValue(undefined);618    mocks.triggerSync.mockReset();619    mocks.triggerSync.mockResolvedValue(undefined);620    mocks.openSocket.mockResolvedValue(socketWithFrames([]));621    Object.defineProperty(globalThis, "chrome", {622      configurable: true,623      value: {624        runtime: {625          async sendMessage(message: { type: string; payload?: unknown }) {626            mocks.pushes.push({ type: message.type, payload: message.payload });627            if (message.type === "ADOPTION_SOURCE_SAS") {628              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };629              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {630                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");631                resolveReceiverSasConfirmation(p.id, p.sas, true);632              }633            }634            return { ok: true };635          },636          onMessage: {637            addListener() { /* no-op */ },638            removeListener() { /* no-op */ },639          },640        },641      },642    });643  });644645  it("opens a relay socket and returns an empty invitation", async () => {646    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");647    const started = await startRelayAdoptionReceiver("receiver-password");648    expect(started.invitation).toMatch(/^peach-pair-v2:/);649    // The QR carries vault:"empty" so the phone knows to act as source.650    const decoded = decodeInvitation(started.invitation);651    expect(decoded?.vault).toBe("empty");652    expect(mocks.openSocket).toHaveBeenCalled();653  });654655  it("rejects an empty password", async () => {656    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");657    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");658  });659660  it("surfaces the Pro-required rejection without creating a vault", async () => {661    mocks.openSocket.mockResolvedValue(socketWithFrames([662      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },663      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },664    ]));665666    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");667    await startRelayAdoptionReceiver("receiver-password");668    await waitForPush("ADOPTION_SOURCE_RESULT");669670    expect(mocks.createVault).not.toHaveBeenCalled();671    expect(mocks.rollbackVault).not.toHaveBeenCalled();672    expect(mocks.pushes).toContainEqual({673      type: "ADOPTION_SOURCE_RESULT",674      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },675    });676  });677678  it("the receiver wires requestAdoption, not grantAdoption", async () => {679    // The receiver's protocol position is the opposite of the source side:680    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.681    // Assert this by watching what lands on socket.send: the receiver's682    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY683    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.684    const rootKeyHex = "f0".repeat(32);685    const vaultId = "b".repeat(64);686    const socket = socketWithFrames([687      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },688      {689        type: "ADOPT_GRANT",690        root_key_hex: rootKeyHex,691        vault_id: vaultId,692        license: "PPK-test",693        source: {694          device_id: "phone-source-id",695          device_name: "June's phone",696          device_type: "android",697          long_term_public_key_hex: "99".repeat(32),698        },699      },700      { type: "ADOPT_COMPLETE" },701    ], { closeOnEmpty: true });702    mocks.openSocket.mockResolvedValue(socket);703    mocks.createVault.mockResolvedValue({ vaultId });704705    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");706    Object.defineProperty(globalThis, "chrome", {707      configurable: true,708      value: {709        runtime: {710          async sendMessage(message: { type: string; payload?: unknown }) {711            mocks.pushes.push({ type: message.type, payload: message.payload });712            if (message.type === "ADOPTION_SOURCE_SAS") {713              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };714              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {715                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");716                resolveReceiverSasConfirmation(p.id, p.sas, true);717              }718            }719            return { ok: true };720          },721          onMessage: { addListener: () => undefined, removeListener: () => undefined },722        },723      },724    });725    await startRelayAdoptionReceiver("receiver-password");726    await waitForPush("ADOPTION_SOURCE_RESULT");727728    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })729      .mock.calls730      .map(([bytes]) => {731        try {732          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;733        } catch {734          return undefined;735        }736      });737    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;738    // ADOPT_GRANT is the source side's frame and must never appear here.739    expect(sentTypes).toContain("ADOPT_REQUEST");740    expect(sentTypes).not.toContain("ADOPT_GRANT");741  });742743  it("completes the handshake end-to-end: vault created → paired", async () => {744    // Wire the receiver's noise mock and SAS gate together so the full745    // requestAdoption protocol runs against an in-memory phone that sends746    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.747    // The mocked noise is pass-through (encrypt = decrypt = identity), so748    // frames land as ordinary JSON.749    const rootKeyHex = "f0".repeat(32);750    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);751    // the grant's vault_id is set to the same so the protocol proceeds.752    const vaultId = "b".repeat(64);753    const frames = [754      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },755      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two756      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.757      {758        type: "ADOPT_GRANT",759        root_key_hex: rootKeyHex,760        vault_id: vaultId,761        license: "PPK-test",762        source: {763          device_id: "phone-source-id",764          device_name: "June's phone",765          device_type: "android",766          long_term_public_key_hex: "99".repeat(32),767          signing_public_key_hex: "aa".repeat(32),768          relay_url: "wss://relay.peachpasswords.com",769        },770      },771      { type: "ADOPT_COMPLETE" },772    ];773    mocks.createVault.mockResolvedValue({ vaultId });774    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));775776    // Replace the chrome.runtime mock for this one test and approve the displayed SAS.777    Object.defineProperty(globalThis, "chrome", {778      configurable: true,779      value: {780        runtime: {781          async sendMessage(message: { type: string; payload?: unknown }) {782            mocks.pushes.push({ type: message.type, payload: message.payload });783            if (message.type === "ADOPTION_SOURCE_SAS") {784              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };785              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {786                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");787                resolveReceiverSasConfirmation(p.id, p.sas, true);788              }789            }790            return { ok: true };791          },792          onMessage: {793            addListener: () => undefined,794            removeListener: () => undefined,795          },796        },797      },798    });799800    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");801    await startRelayAdoptionReceiver("receiver-password");802803    await waitForPush("ADOPTION_SOURCE_RESULT");804805    // The receiver displays its locally derived SAS and requires confirmation.806    expect(mocks.pushes).toContainEqual({807      type: "ADOPTION_SOURCE_SAS",808      payload: expect.objectContaining({809        sas: expect.stringMatching(/^\d{8}$/),810        displayOnly: false,811      }),812    });813814    // The vault module was called directly with the popup-supplied password815    // and the root key granted by the phone.816    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);817818    // License activation, identity derivation, and pairing persistence all819    // call their modules directly from the service worker.820    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");821    expect(mocks.isProUser).toHaveBeenCalled();822    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));823    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({824      deviceId: "phone-source-id",825    }));826    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;827    expect(receiverRow.rendezvousSecretHex).toBeUndefined();828    expect(mocks.setPairSecret).toHaveBeenCalledWith(829      "phone-source-id",830      expect.stringMatching(/^[0-9a-f]{64}$/),831      expect.any(Uint8Array),832    );833    expect(mocks.storageSet).toHaveBeenCalledWith(834      "peach_initial_sync_pending_device_id",835      "phone-source-id",836    );837    expect(mocks.rollbackVault).not.toHaveBeenCalled();838839    // The terminal state is "complete" — the popup can transition to success.840    expect(mocks.pushes).toContainEqual({841      type: "ADOPTION_SOURCE_RESULT",842      payload: { status: "complete" },843    });844  });845846  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {847    const rootKeyHex = "f0".repeat(32);848    const vaultId = "b".repeat(64);849    mocks.createVault.mockResolvedValue({ vaultId });850    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });851    mocks.isProUser.mockResolvedValue(false);852    mocks.openSocket.mockResolvedValue(socketWithFrames([853      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },854      {855        type: "ADOPT_GRANT",856        root_key_hex: rootKeyHex,857        vault_id: vaultId,858        license: "PPK-invalid",859        source: {860          device_id: "phone-source-id",861          device_name: "June's phone",862          device_type: "android",863          long_term_public_key_hex: "99".repeat(32),864        },865      },866    ]));867868    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");869    await startRelayAdoptionReceiver("receiver-password");870    await waitForPush("ADOPTION_SOURCE_RESULT");871872    expect(mocks.rollbackVault).toHaveBeenCalledOnce();873    expect(mocks.localIdentity).not.toHaveBeenCalled();874    expect(mocks.upsert).not.toHaveBeenCalled();875    expect(mocks.pushes).toContainEqual(expect.objectContaining({876      type: "ADOPTION_SOURCE_RESULT",877      payload: expect.objectContaining({878        status: "error",879        error: "license_transfer_failed",880      }),881    }));882  });883884  it("rolls back a newly created vault when a later adoption step fails", async () => {885    const rootKeyHex = "f0".repeat(32);886    const vaultId = "b".repeat(64);887    mocks.createVault.mockResolvedValue({ vaultId });888    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));889    mocks.openSocket.mockResolvedValue(socketWithFrames([890      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },891      {892        type: "ADOPT_GRANT",893        root_key_hex: rootKeyHex,894        vault_id: vaultId,895        license: "PPK-test",896        source: {897          device_id: "phone-source-id",898          device_name: "June's phone",899          device_type: "android",900          long_term_public_key_hex: "99".repeat(32),901        },902      },903    ]));904905    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");906    await startRelayAdoptionReceiver("receiver-password");907    await waitForPush("ADOPTION_SOURCE_RESULT");908909    expect(mocks.rollbackVault).toHaveBeenCalledOnce();910    expect(mocks.upsert).not.toHaveBeenCalled();911    expect(mocks.pushes).toContainEqual(expect.objectContaining({912      type: "ADOPTION_SOURCE_RESULT",913      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),914    }));915  });916917  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {918    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).919    // The receiver should skip the 30-second inline snapshot wait entirely920    // and fall through to the sync pipeline — no receiveVaultSnapshot call.921    const rootKeyHex = "f0".repeat(32);922    const vaultId = "b".repeat(64);923    mocks.createVault.mockResolvedValue({ vaultId });924    mocks.openSocket.mockResolvedValue(socketWithFrames([925      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },926      {927        type: "ADOPT_GRANT",928        root_key_hex: rootKeyHex,929        vault_id: vaultId,930        license: "PPK-test",931        source: {932          device_id: "old-phone-id",933          device_name: "Old phone",934          device_type: "android",935          long_term_public_key_hex: "99".repeat(32),936        },937        // NO snapshot_capable: pre-C source does not echo the field938      },939      { type: "ADOPT_COMPLETE" },940    ], { closeOnEmpty: true }));941942    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");943    await startRelayAdoptionReceiver("receiver-password");944    await waitForPush("ADOPTION_SOURCE_RESULT");945946    // Sync fallback ran immediately (no 30s wait).947    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });948    // Adoption completed successfully.949    expect(mocks.pushes).toContainEqual(expect.objectContaining({950      type: "ADOPTION_SOURCE_RESULT",951      payload: expect.objectContaining({ status: "complete" }),952    }));953    // The initial sync pending marker was set (not cleared — no snapshot applied).954    expect(mocks.storageSet).toHaveBeenCalledWith(955      "peach_initial_sync_pending_device_id",956      "old-phone-id",957    );958  });959960  it("applies the source's device manifest after the snapshot, then acks", async () => {961    // Mesh propagation: a 3rd browser that completes an adoption with a962    // snapshot-capable source must also receive the source's signed device963    // manifest and forward it to the sync adapter before the receiver964    // resolves. The relay stays an opaque forwarder, so the whole path965    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.966    const rootKeyHex = "f0".repeat(32);967    const vaultId = "b".repeat(64);968    const longTermPubkeyHex = "99".repeat(32);969    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");970    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(971      "../shared/sync-identity"972    );973    const issuerKp = await deriveSyncSigningKeypair(974      new Uint8Array(32).fill(0x55),975      "phone-source-id",976    );977    const manifest = await buildSignedDeviceManifest(978      "phone-source-id",979      [980        {981          deviceId: "peer-b",982          deviceName: "Pool B",983          deviceType: "extension",984          longTermPublicKeyHex: "11".repeat(32),985          signingPublicKeyHex: "22".repeat(32),986          wsPort: 49152,987          relayUrl: "wss://relay.peachpasswords.com",988          pairedAt: 1,989        },990      ],991      issuerKp.privateKey,992    );993    const socket = socketWithFrames([994      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },995      {996        type: "ADOPT_GRANT",997        root_key_hex: rootKeyHex,998        vault_id: vaultId,999        license: "PPK-test",1000        snapshot_capable: true,1001        source: {1002          device_id: "phone-source-id",1003          device_name: "June's phone",1004          device_type: "android",1005          long_term_public_key_hex: longTermPubkeyHex,1006          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),1007          relay_url: "wss://relay.peachpasswords.com",1008        },1009      },1010      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is1011      // an optional follow-up frame on the same channel.1012      { type: "ADOPT_COMPLETE" },1013      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames1014      // after the handshake; one empty chunk ends the phase.1015      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1016      { type: "ADOPT_DEVICE_MANIFEST", manifest },1017    ], { closeOnEmpty: true });1018    mocks.createVault.mockResolvedValue({ vaultId });1019    mocks.openSocket.mockResolvedValue(socket);1020    mocks.__applyIncomingDeviceManifest.mockClear();10211022    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1023    await startRelayAdoptionReceiver("receiver-password");1024    await waitForPush("ADOPTION_SOURCE_RESULT");10251026    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1027    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1028    expect(forwarded).toEqual(manifest);1029    expect(pubkey).toBeInstanceOf(Uint8Array);1030    expect(pubkey.length).toBe(32);1031    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the1032    // adapter path was skipped (the ack only follows a real apply).1033    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1034      .mock.calls1035      .map(([bytes]) => {1036        try {1037          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1038        } catch {1039          return undefined;1040        }1041      });1042    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1043    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.1044    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1045  });10461047  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1048    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST1049    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,1050    // and quietly time out the optional 5s wait without acking a manifest1051    // or invoking the adapter path.1052    const rootKeyHex = "f0".repeat(32);1053    const vaultId = "b".repeat(64);1054    const longTermPubkeyHex = "99".repeat(32);1055    const socket = socketWithFrames([1056      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1057      {1058        type: "ADOPT_GRANT",1059        root_key_hex: rootKeyHex,1060        vault_id: vaultId,1061        license: "PPK-test",1062        snapshot_capable: true,1063        source: {1064          device_id: "phone-source-id",1065          device_name: "June's phone",1066          device_type: "android",1067          long_term_public_key_hex: longTermPubkeyHex,1068          signing_public_key_hex: "aa".repeat(32),1069          relay_url: "wss://relay.peachpasswords.com",1070        },1071      },1072      { type: "ADOPT_COMPLETE" },1073      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot1074      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.1075      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1076    ], { closeOnEmpty: true });1077    mocks.createVault.mockResolvedValue({ vaultId });1078    mocks.openSocket.mockResolvedValue(socket);1079    mocks.__applyIncomingDeviceManifest.mockClear();10801081    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1082    await startRelayAdoptionReceiver("receiver-password");1083    await waitForPush("ADOPTION_SOURCE_RESULT");10841085    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1086    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1087      .mock.calls1088      .map(([bytes]) => {1089        try {1090          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1091        } catch {1092          return undefined;1093        }1094      });1095    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1096    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1097  });1098});10991100function decodeInvitation(encoded: string): { vault?: string } | null {1101  // Mirrors parsePairingInvitation's payload structure without pulling the1102  // module in: the receiver test only needs to assert the embedded status.1103  const at = encoded.indexOf(":");1104  if (at < 0) return null;1105  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1106  try {1107    const json = Buffer.from(b64, "base64").toString("utf8");1108    return JSON.parse(json) as { vault?: string };1109  } catch {1110    return null;1111  }1112}