relay-adoption-bg.test.ts

40.9 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  sasDecisions: [] as Array<{ id: string; ok: boolean }>,23}));2425vi.mock("../shared/pairing-dial", () => ({26  openRelayPairingSocket: mocks.openSocket,27}));2829vi.mock("../shared/noise-js", () => ({30  noiseNKResponder: vi.fn(async () => ({31    handshakeHash: new Uint8Array(32).fill(7),32    encrypt: (bytes: Uint8Array) => bytes,33    decrypt: (bytes: Uint8Array) => bytes,34    close: vi.fn(),35  })),36}));3738vi.mock("../shared/rendezvous", async (importOriginal) => {39  const actual = await importOriginal<typeof import("../shared/rendezvous")>();40  return {41    ...actual,42    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",43  };44});454647vi.mock("./device-identity", () => ({48  getLocalPairingIdentity: async () => ({49    identity: {50      deviceId: "test-device",51      deviceName: "Test",52      deviceType: "extension",53      longTermPublicKeyHex: "aa".repeat(32),54      vaultId: "bb".repeat(32),55    },56    privateKey: new Uint8Array(32).fill(7),57    publicKey: new Uint8Array(32).fill(8),58  }),59  getDeviceName: mocks.getDeviceName,60  buildLocalIdentity: mocks.localIdentity,61}));6263vi.mock("./sync-settings", async (importOriginal) => ({64  ...(await importOriginal<typeof import("./sync-settings")>()),65  getSyncSettings: async () => ({66    mode: "public" as const,67    relayUrl: "wss://relay.peachpasswords.com",68  }),69}));7071vi.mock("./license", () => ({72  isProUser: mocks.isProUser,73  getLicenseKey: mocks.getLicenseKey,74  activateLicense: mocks.activateLicense,75}));7677vi.mock("./vault", () => ({78  isUnlocked: vi.fn(() => true),79  getRootKeyHex: mocks.getRootKeyHex,80  createVaultFromRootKey: mocks.createVault,81  rollbackAdoptedVault: mocks.rollbackVault,82  exportDuressSlotForAdoption: vi.fn(async () => null),83  installAdoptedDuressSlot: vi.fn(async () => undefined),84}));8586vi.mock("../shared/sync-identity", async (importOriginal) => {87  // Partial mock: the manifest test needs the REAL deriveSyncSigningKeypair /88  // pubkeyToHex to build a verifiable signed manifest (2026-08-04: this test89  // was orphaned inside the rendezvous mock factory and never ran; the mock90  // below had since lost the real exports).91  const actual = await importOriginal<typeof import("../shared/sync-identity")>();92  return {93    ...actual,94    computeVaultId: vi.fn(async () => "b".repeat(64)),95  };96});9798vi.mock("../shared/storage", () => ({99  storageSet: mocks.storageSet,100}));101102vi.mock("./paired-devices", () => ({103  upsertPairedDevice: mocks.upsert,104}));105106vi.mock("./pair-secrets", () => ({107  setPairSecret: mocks.setPairSecret,108  isValidPairSecret: (value: unknown): value is string =>109    typeof value === "string" && /^[0-9a-f]{64}$/.test(value),110}));111112vi.mock("./sync-trigger", () => ({113  triggerSyncIfReachable: mocks.triggerSync,114}));115116vi.mock("./sync-vault-adapter", () => ({117  createSyncVaultAdapter: mocks.createSyncAdapter,118}));119120import {121  cancelRelayAdoptionSource,122  startRelayAdoptionSource,123} from "./relay-adoption-bg";124125const encode = (value: unknown) =>126  new TextEncoder().encode(JSON.stringify(value));127128function socketWithFrames(129  frames: unknown[],130  options: { closeOnEmpty?: boolean } = {},131) {132  const queue = frames.map((frame) => {133    const encoded = encode(frame);134    // PB-26-604: snapshot frames are bucket-padded before Noise.135    return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"136      ? padSyncFrame(encoded)137      : encoded;138  });139  return {140    send: vi.fn(),141    receive: vi.fn(async () => {142      const next = queue.shift();143      if (!next) {144        // A real peer closes the channel once it runs out of frames; model145        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast146        // and falls back to the sync pipeline instead of hanging on a timeout.147        if (options.closeOnEmpty) throw new Error("socket_closed");148        return new Promise<Uint8Array>(() => undefined);149      }150      return next;151    }),152    close: vi.fn(),153    closedBeforeFrame: vi.fn(() => false),154  };155}156157async function waitForPush(type: string): Promise<void> {158  await vi.waitFor(() => {159    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);160  });161}162163beforeEach(() => {164  mocks.pushes.length = 0;165  mocks.sasDecisions.length = 0;166  mocks.openSocket.mockReset();167  mocks.getLocalIdentity.mockReset();168  mocks.getRootKeyHex.mockReset();169  mocks.upsert.mockReset();170  mocks.setPairSecret.mockReset();171  mocks.triggerSync.mockReset();172  mocks.triggerSync.mockResolvedValue(undefined);173  mocks.isProUser.mockReset();174  mocks.isProUser.mockResolvedValue(true);175  mocks.getLicenseKey.mockReset();176  mocks.getLicenseKey.mockResolvedValue("PPK-source");177  mocks.getLocalIdentity.mockResolvedValue({178    identity: {179      deviceId: "browser-source",180      deviceName: "June's Browser",181      deviceType: "extension",182      longTermPublicKeyHex: "a".repeat(64),183      vaultId: "b".repeat(64),184    },185    privateKey: new Uint8Array(32).fill(1),186    publicKey: new Uint8Array(32).fill(2),187  });188  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));189  mocks.upsert.mockResolvedValue(undefined);190  mocks.setPairSecret.mockResolvedValue(undefined);191    const applyIncomingDeviceManifest = vi.fn(async () => undefined);192    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;193    mocks.createSyncAdapter.mockReturnValue({194      buildEntryManifest: vi.fn(async () => ({})),195      getEncryptedEntries: vi.fn(async () => ({})),196      buildTombstoneManifest: vi.fn(async () => ({})),197      applyEntries: vi.fn(async () => undefined),198      applyTombstones: vi.fn(async () => undefined),199      buildOutgoingDeviceManifest: vi.fn(async () => null),200      applyIncomingDeviceManifest,201    });202  Object.defineProperty(globalThis, "chrome", {203    configurable: true,204    value: {205    runtime: {206      async sendMessage(message: { type: string; payload: unknown }) {207        mocks.pushes.push(message);208        // The source flow waits for the popup to send SOURCE_SAS_DECISION before209        // forwarding the root key. The receiver test overrides this mock and210        // resolves the decision itself; the source tests rely on this default211        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.212        if (message.type === "ADOPTION_SOURCE_SAS") {213          const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };214          if (typeof p?.id === "string" && typeof p.sas === "string") {215            if (p.displayOnly === false) {216              const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");217              resolveReceiverSasConfirmation(p.id, p.sas, true);218            } else {219              const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");220              mocks.sasDecisions.push({ id: p.id, ok: true });221              resolveSourceSasConfirmation(p.id, true);222            }223          }224        }225        return Promise.resolve();226      },227    },228    },229  });230});231232describe("relay adoption source background boundary", () => {233  it("shows the SAS and waits for the popup's single confirmation", async () => {234    const socket = socketWithFrames([235      {236        type: "PEACH_HELLO",237        token: "AAECAwQFBgcICQoLDA0ODw",238      },239      {240        type: "ADOPT_REQUEST",241        device_id: "request-only-label",242        device_name: "June's phone",243        device_type: "android",244      },245      { type: "ADOPT_CONFIRM", confirmed: true },246      {247        type: "ADOPT_IDENTITY",248        device_id: "android-receiver",249        device_name: "June's phone",250        device_type: "android",251        long_term_public_key_hex: "d".repeat(64),252      },253      { type: "ADOPT_APPLIED" },254    ]);255    mocks.openSocket.mockResolvedValue(socket);256257    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");258    const sasIds: string[] = [];259    Object.defineProperty(globalThis, "chrome", {260      configurable: true,261      value: {262        runtime: {263          async sendMessage(message: { type: string; payload: unknown }) {264            mocks.pushes.push(message);265            if (message.type === "ADOPTION_SOURCE_SAS") {266              const p = message.payload as { id?: string };267              if (typeof p?.id === "string") {268                // Hold the SAS gate open until the test explicitly approves269                // it — this proves the source flow waits for the popup.270                sasIds.push(p.id);271              }272              return Promise.resolve();273            }274            return Promise.resolve();275          },276        },277      },278    });279    void resolveSourceSasConfirmation; // imported for the post-approval call below280281    const started = await startRelayAdoptionSource();282    expect(started.invitation).toMatch(/^peach-pair-v2:/);283    await waitForPush("ADOPTION_SOURCE_SAS");284    // The adoption must NOT be complete yet — the popup's SAS decision is the285    // prerequisite for forwarding the root key.286    await vi.waitFor(() => {287      expect(mocks.sasDecisions.length).toBe(0);288    });289    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();290    expect(mocks.upsert).not.toHaveBeenCalled();291292    // Approve the SAS; the adoption should now proceed and complete.293    const id = sasIds[0];294    expect(typeof id).toBe("string");295    resolveSourceSasConfirmation(id, true);296    mocks.sasDecisions.push({ id, ok: true });297298    await waitForPush("ADOPTION_SOURCE_RESULT");299300    expect(mocks.getRootKeyHex).toHaveBeenCalled();301    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({302      deviceId: "android-receiver",303      relayUrl: "wss://relay.peachpasswords.com",304    }));305    // PB-26-213: the registry row stays secretless; the secret bypasses it306    // into the encrypted pair-secret record.307    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;308    expect(sourceRow.rendezvousSecretHex).toBeUndefined();309    expect(mocks.setPairSecret).toHaveBeenCalledWith(310      "android-receiver",311      expect.stringMatching(/^[0-9a-f]{64}$/),312      expect.any(Uint8Array),313    );314    expect(mocks.pushes).toContainEqual({315      type: "ADOPTION_SOURCE_RESULT",316      payload: { status: "complete" },317    });318    expect(mocks.triggerSync).toHaveBeenCalledWith({319      reason: "manual",320      force: true,321    });322    const sentTypes = socket.send.mock.calls323      .map(([bytes]) => {324        try {325          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;326        } catch {327          return undefined;328        }329      });330    expect(sentTypes).toContain("ADOPT_COMPLETE");331  });332333  it("fails adoption without sending snapshot frames when duress is oversized", async () => {334    const socket = socketWithFrames([335      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },336      {337        type: "ADOPT_REQUEST",338        device_id: "request-only-label",339        device_name: "June's phone",340        device_type: "android",341        snapshot_capable: true,342      },343      { type: "ADOPT_CONFIRM", confirmed: true },344      {345        type: "ADOPT_IDENTITY",346        device_id: "android-receiver",347        device_name: "June's phone",348        device_type: "android",349        long_term_public_key_hex: "d".repeat(64),350      },351      { type: "ADOPT_APPLIED" },352    ]);353    mocks.openSocket.mockResolvedValue(socket);354    const { exportDuressSlotForAdoption } = await import("./vault");355    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(356      "x".repeat(768 * 1024),357    );358359    await startRelayAdoptionSource();360    await waitForPush("ADOPTION_SOURCE_RESULT");361362    expect(mocks.pushes).toContainEqual({363      type: "ADOPTION_SOURCE_RESULT",364      payload: {365        status: "error",366        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",367      },368    });369    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({370      type: "ADOPTION_SOURCE_RESULT",371      payload: expect.objectContaining({ status: "complete" }),372    }));373    const sentTypes = socket.send.mock.calls.map(([bytes]) =>374      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,375    );376    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");377    expect(mocks.triggerSync).not.toHaveBeenCalled();378  });379380  it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {381    // PB-26-206: an over-budget snapshot is never partially sent or silently382    // truncated — the source completes adoption and the sync pipeline carries383    // the entries instead.384    const socket = socketWithFrames([385      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },386      {387        type: "ADOPT_REQUEST",388        device_id: "request-only-label",389        device_name: "June's phone",390        device_type: "android",391        snapshot_capable: true,392      },393      { type: "ADOPT_CONFIRM", confirmed: true },394      {395        type: "ADOPT_IDENTITY",396        device_id: "android-receiver",397        device_name: "June's phone",398        device_type: "android",399        long_term_public_key_hex: "d".repeat(64),400      },401      { type: "ADOPT_APPLIED" },402      // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is403      // sent); the source must not hang on it — withTimeout resolves the wait404      // via the sync fallback path below.405      { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },406    ]);407    mocks.openSocket.mockResolvedValue(socket);408    // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget.409    const envelope = "c".repeat(690_000);410    const entries: Record<string, string> = {};411    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;412    mocks.createSyncAdapter.mockReturnValue({413      buildEntryManifest: vi.fn(async () => Object.fromEntries(414        Object.keys(entries).map((id) => [id, 1]),415      )),416      getEncryptedEntries: vi.fn(async () => entries),417      buildTombstoneManifest: vi.fn(async () => ({})),418      applyEntries: vi.fn(async () => undefined),419      applyTombstones: vi.fn(async () => undefined),420      buildOutgoingDeviceManifest: vi.fn(async () => null),421      applyIncomingDeviceManifest: vi.fn(async () => undefined),422    });423424    await startRelayAdoptionSource();425    await waitForPush("ADOPTION_SOURCE_RESULT");426427    expect(mocks.pushes).toContainEqual({428      type: "ADOPTION_SOURCE_RESULT",429      payload: expect.objectContaining({ status: "complete" }),430    });431    const sentTypes = socket.send.mock.calls.map(([bytes]) =>432      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,433    );434    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");435    // Sync fallback ran so the entries still reach the receiver.436    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });437  });438439  it("fails closed when the popup rejects the SAS", async () => {440    const socket = socketWithFrames([441      {442        type: "PEACH_HELLO",443        token: "AAECAwQFBgcICQoLDA0ODw",444      },445      {446        type: "ADOPT_REQUEST",447        device_id: "request-only-label",448        device_name: "New phone",449        device_type: "android",450      },451      { type: "ADOPT_CONFIRM", confirmed: false },452    ]);453    mocks.openSocket.mockResolvedValue(socket);454455    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");456    let pendingId: string | undefined;457    Object.defineProperty(globalThis, "chrome", {458      configurable: true,459      value: {460        runtime: {461          sendMessage(message: { type: string; payload: unknown }) {462            mocks.pushes.push(message);463            if (message.type === "ADOPTION_SOURCE_SAS") {464              const p = message.payload as { id?: string };465              if (typeof p?.id === "string") pendingId = p.id;466            }467            return Promise.resolve();468          },469        },470      },471    });472473    await startRelayAdoptionSource();474    await waitForPush("ADOPTION_SOURCE_SAS");475    expect(typeof pendingId).toBe("string");476    resolveSourceSasConfirmation(pendingId!, false);477    mocks.sasDecisions.push({ id: pendingId!, ok: false });478479    await waitForPush("ADOPTION_SOURCE_RESULT");480481    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();482    expect(mocks.upsert).not.toHaveBeenCalled();483    expect(mocks.pushes).toContainEqual({484      type: "ADOPTION_SOURCE_RESULT",485      payload: { status: "error", error: "adoption_rejected" },486    });487  });488489  it("rejects before reading the root key when neither peer has Pro", async () => {490    const socket = socketWithFrames([491      {492        type: "PEACH_HELLO",493        token: "AAECAwQFBgcICQoLDA0ODw",494      },495      {496        type: "ADOPT_REQUEST",497        device_id: "free-receiver",498        device_name: "Free phone",499        device_type: "android",500      },501      { type: "ADOPT_CONFIRM", confirmed: true },502    ]);503    mocks.isProUser.mockResolvedValue(false);504    mocks.getLicenseKey.mockResolvedValue(undefined);505    mocks.openSocket.mockResolvedValue(socket);506507    await startRelayAdoptionSource();508    await waitForPush("ADOPTION_SOURCE_RESULT");509510    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();511    expect(mocks.upsert).not.toHaveBeenCalled();512    expect(mocks.pushes).toContainEqual({513      type: "ADOPTION_SOURCE_RESULT",514      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },515    });516    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {517      try {518        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];519      } catch {520        return [];521      }522    });523    expect(sentFrames).toContainEqual({524      type: "ADOPT_REJECT",525      reason: "ADOPT_PRO_REQUIRED",526    });527  });528529  it("fails closed when the user never answers the SAS", async () => {530    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they531    // schedule real work), so we hold the SAS gate open by never resolving532    // the deferred and then cancel the session. clearState must drop the533    // pending SAS so it doesn't pin the relay room open for the next run.534    const socket = socketWithFrames([]);535    mocks.openSocket.mockResolvedValue(socket);536537    Object.defineProperty(globalThis, "chrome", {538      configurable: true,539      value: {540        runtime: {541          sendMessage(message: { type: string; payload: unknown }) {542            mocks.pushes.push(message);543            return Promise.resolve();544          },545        },546      },547    });548549    await startRelayAdoptionSource();550    cancelRelayAdoptionSource();551552    expect(socket.close).toHaveBeenCalled();553    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();554    expect(mocks.upsert).not.toHaveBeenCalled();555  });556557  it("cancelling the source screen closes the room without reading the root", async () => {558    const socket = socketWithFrames([]);559    mocks.openSocket.mockResolvedValue(socket);560561    await startRelayAdoptionSource();562    cancelRelayAdoptionSource();563564    expect(socket.close).toHaveBeenCalled();565    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();566    expect(mocks.upsert).not.toHaveBeenCalled();567  });568});569570describe("relay adoption receiver background", () => {571  beforeEach(async () => {572    // The receiver session runs in the background via `void runReceiverSession`;573    // it doesn't get awaited by the caller and can leave `inFlight` set if the574    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test575    // starts from a clean state.576    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");577    cancelRelayAdoptionSource();578    mocks.pushes.length = 0;579    mocks.openSocket.mockReset();580    mocks.getDeviceName.mockReset();581    mocks.getDeviceName.mockResolvedValue("Receiver Browser");582    mocks.createVault.mockReset();583    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });584    mocks.activateLicense.mockReset();585    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });586    mocks.isProUser.mockReset();587    mocks.isProUser.mockResolvedValue(true);588    mocks.getLicenseKey.mockReset();589    mocks.getLicenseKey.mockResolvedValue(undefined);590    mocks.localIdentity.mockReset();591    mocks.localIdentity.mockResolvedValue({592      deviceId: "receiver-id",593      deviceName: "Receiver Browser",594      deviceType: "extension",595      longTermPublicKeyHex: "11".repeat(32),596      signingPublicKeyHex: "22".repeat(32),597    });598    mocks.rollbackVault.mockReset();599    mocks.rollbackVault.mockResolvedValue(undefined);600    mocks.storageSet.mockReset();601    mocks.storageSet.mockResolvedValue(undefined);602    mocks.upsert.mockReset();603    mocks.upsert.mockResolvedValue(undefined);604    mocks.setPairSecret.mockReset();605    mocks.setPairSecret.mockResolvedValue(undefined);606    mocks.triggerSync.mockReset();607    mocks.triggerSync.mockResolvedValue(undefined);608    mocks.openSocket.mockResolvedValue(socketWithFrames([]));609    Object.defineProperty(globalThis, "chrome", {610      configurable: true,611      value: {612        runtime: {613          async sendMessage(message: { type: string; payload?: unknown }) {614            mocks.pushes.push({ type: message.type, payload: message.payload });615            if (message.type === "ADOPTION_SOURCE_SAS") {616              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };617              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {618                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");619                resolveReceiverSasConfirmation(p.id, p.sas, true);620              }621            }622            return { ok: true };623          },624          onMessage: {625            addListener() { /* no-op */ },626            removeListener() { /* no-op */ },627          },628        },629      },630    });631  });632633  it("opens a relay socket and returns an empty invitation", async () => {634    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");635    const started = await startRelayAdoptionReceiver("receiver-password");636    expect(started.invitation).toMatch(/^peach-pair-v2:/);637    // The QR carries vault:"empty" so the phone knows to act as source.638    const decoded = decodeInvitation(started.invitation);639    expect(decoded?.vault).toBe("empty");640    expect(mocks.openSocket).toHaveBeenCalled();641  });642643  it("rejects an empty password", async () => {644    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");645    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");646  });647648  it("surfaces the Pro-required rejection without creating a vault", async () => {649    mocks.openSocket.mockResolvedValue(socketWithFrames([650      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },651      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },652    ]));653654    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");655    await startRelayAdoptionReceiver("receiver-password");656    await waitForPush("ADOPTION_SOURCE_RESULT");657658    expect(mocks.createVault).not.toHaveBeenCalled();659    expect(mocks.rollbackVault).not.toHaveBeenCalled();660    expect(mocks.pushes).toContainEqual({661      type: "ADOPTION_SOURCE_RESULT",662      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },663    });664  });665666  it("the receiver wires requestAdoption, not grantAdoption", async () => {667    // The receiver's protocol position is the opposite of the source side:668    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.669    // Assert this by watching what lands on socket.send: the receiver's670    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY671    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.672    const rootKeyHex = "f0".repeat(32);673    const vaultId = "b".repeat(64);674    const socket = socketWithFrames([675      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },676      {677        type: "ADOPT_GRANT",678        root_key_hex: rootKeyHex,679        vault_id: vaultId,680        license: "PPK-test",681        source: {682          device_id: "phone-source-id",683          device_name: "June's phone",684          device_type: "android",685          long_term_public_key_hex: "99".repeat(32),686        },687      },688      { type: "ADOPT_COMPLETE" },689    ], { closeOnEmpty: true });690    mocks.openSocket.mockResolvedValue(socket);691    mocks.createVault.mockResolvedValue({ vaultId });692693    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");694    Object.defineProperty(globalThis, "chrome", {695      configurable: true,696      value: {697        runtime: {698          async sendMessage(message: { type: string; payload?: unknown }) {699            mocks.pushes.push({ type: message.type, payload: message.payload });700            if (message.type === "ADOPTION_SOURCE_SAS") {701              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };702              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {703                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");704                resolveReceiverSasConfirmation(p.id, p.sas, true);705              }706            }707            return { ok: true };708          },709          onMessage: { addListener: () => undefined, removeListener: () => undefined },710        },711      },712    });713    await startRelayAdoptionReceiver("receiver-password");714    await waitForPush("ADOPTION_SOURCE_RESULT");715716    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })717      .mock.calls718      .map(([bytes]) => {719        try {720          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;721        } catch {722          return undefined;723        }724      });725    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;726    // ADOPT_GRANT is the source side's frame and must never appear here.727    expect(sentTypes).toContain("ADOPT_REQUEST");728    expect(sentTypes).not.toContain("ADOPT_GRANT");729  });730731  it("completes the handshake end-to-end: vault created → paired", async () => {732    // Wire the receiver's noise mock and SAS gate together so the full733    // requestAdoption protocol runs against an in-memory phone that sends734    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.735    // The mocked noise is pass-through (encrypt = decrypt = identity), so736    // frames land as ordinary JSON.737    const rootKeyHex = "f0".repeat(32);738    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);739    // the grant's vault_id is set to the same so the protocol proceeds.740    const vaultId = "b".repeat(64);741    const frames = [742      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },743      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two744      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.745      {746        type: "ADOPT_GRANT",747        root_key_hex: rootKeyHex,748        vault_id: vaultId,749        license: "PPK-test",750        source: {751          device_id: "phone-source-id",752          device_name: "June's phone",753          device_type: "android",754          long_term_public_key_hex: "99".repeat(32),755          signing_public_key_hex: "aa".repeat(32),756          relay_url: "wss://relay.peachpasswords.com",757        },758      },759      { type: "ADOPT_COMPLETE" },760    ];761    mocks.createVault.mockResolvedValue({ vaultId });762    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));763764    // Replace the chrome.runtime mock for this one test and approve the displayed SAS.765    Object.defineProperty(globalThis, "chrome", {766      configurable: true,767      value: {768        runtime: {769          async sendMessage(message: { type: string; payload?: unknown }) {770            mocks.pushes.push({ type: message.type, payload: message.payload });771            if (message.type === "ADOPTION_SOURCE_SAS") {772              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };773              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {774                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");775                resolveReceiverSasConfirmation(p.id, p.sas, true);776              }777            }778            return { ok: true };779          },780          onMessage: {781            addListener: () => undefined,782            removeListener: () => undefined,783          },784        },785      },786    });787788    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");789    await startRelayAdoptionReceiver("receiver-password");790791    await waitForPush("ADOPTION_SOURCE_RESULT");792793    // The receiver displays its locally derived SAS and requires confirmation.794    expect(mocks.pushes).toContainEqual({795      type: "ADOPTION_SOURCE_SAS",796      payload: expect.objectContaining({797        sas: expect.stringMatching(/^\d{8}$/),798        displayOnly: false,799      }),800    });801802    // The vault module was called directly with the popup-supplied password803    // and the root key granted by the phone.804    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);805806    // License activation, identity derivation, and pairing persistence all807    // call their modules directly from the service worker.808    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");809    expect(mocks.isProUser).toHaveBeenCalled();810    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));811    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({812      deviceId: "phone-source-id",813    }));814    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;815    expect(receiverRow.rendezvousSecretHex).toBeUndefined();816    expect(mocks.setPairSecret).toHaveBeenCalledWith(817      "phone-source-id",818      expect.stringMatching(/^[0-9a-f]{64}$/),819      expect.any(Uint8Array),820    );821    expect(mocks.storageSet).toHaveBeenCalledWith(822      "peach_initial_sync_pending_device_id",823      "phone-source-id",824    );825    expect(mocks.rollbackVault).not.toHaveBeenCalled();826827    // The terminal state is "complete" — the popup can transition to success.828    expect(mocks.pushes).toContainEqual({829      type: "ADOPTION_SOURCE_RESULT",830      payload: { status: "complete" },831    });832  });833834  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {835    const rootKeyHex = "f0".repeat(32);836    const vaultId = "b".repeat(64);837    mocks.createVault.mockResolvedValue({ vaultId });838    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });839    mocks.isProUser.mockResolvedValue(false);840    mocks.openSocket.mockResolvedValue(socketWithFrames([841      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },842      {843        type: "ADOPT_GRANT",844        root_key_hex: rootKeyHex,845        vault_id: vaultId,846        license: "PPK-invalid",847        source: {848          device_id: "phone-source-id",849          device_name: "June's phone",850          device_type: "android",851          long_term_public_key_hex: "99".repeat(32),852        },853      },854    ]));855856    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");857    await startRelayAdoptionReceiver("receiver-password");858    await waitForPush("ADOPTION_SOURCE_RESULT");859860    expect(mocks.rollbackVault).toHaveBeenCalledOnce();861    expect(mocks.localIdentity).not.toHaveBeenCalled();862    expect(mocks.upsert).not.toHaveBeenCalled();863    expect(mocks.pushes).toContainEqual(expect.objectContaining({864      type: "ADOPTION_SOURCE_RESULT",865      payload: expect.objectContaining({866        status: "error",867        error: "license_transfer_failed",868      }),869    }));870  });871872  it("rolls back a newly created vault when a later adoption step fails", async () => {873    const rootKeyHex = "f0".repeat(32);874    const vaultId = "b".repeat(64);875    mocks.createVault.mockResolvedValue({ vaultId });876    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));877    mocks.openSocket.mockResolvedValue(socketWithFrames([878      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },879      {880        type: "ADOPT_GRANT",881        root_key_hex: rootKeyHex,882        vault_id: vaultId,883        license: "PPK-test",884        source: {885          device_id: "phone-source-id",886          device_name: "June's phone",887          device_type: "android",888          long_term_public_key_hex: "99".repeat(32),889        },890      },891    ]));892893    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");894    await startRelayAdoptionReceiver("receiver-password");895    await waitForPush("ADOPTION_SOURCE_RESULT");896897    expect(mocks.rollbackVault).toHaveBeenCalledOnce();898    expect(mocks.upsert).not.toHaveBeenCalled();899    expect(mocks.pushes).toContainEqual(expect.objectContaining({900      type: "ADOPTION_SOURCE_RESULT",901      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),902    }));903  });904905  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {906    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).907    // The receiver should skip the 30-second inline snapshot wait entirely908    // and fall through to the sync pipeline — no receiveVaultSnapshot call.909    const rootKeyHex = "f0".repeat(32);910    const vaultId = "b".repeat(64);911    mocks.createVault.mockResolvedValue({ vaultId });912    mocks.openSocket.mockResolvedValue(socketWithFrames([913      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },914      {915        type: "ADOPT_GRANT",916        root_key_hex: rootKeyHex,917        vault_id: vaultId,918        license: "PPK-test",919        source: {920          device_id: "old-phone-id",921          device_name: "Old phone",922          device_type: "android",923          long_term_public_key_hex: "99".repeat(32),924        },925        // NO snapshot_capable: pre-C source does not echo the field926      },927      { type: "ADOPT_COMPLETE" },928    ], { closeOnEmpty: true }));929930    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");931    await startRelayAdoptionReceiver("receiver-password");932    await waitForPush("ADOPTION_SOURCE_RESULT");933934    // Sync fallback ran immediately (no 30s wait).935    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });936    // Adoption completed successfully.937    expect(mocks.pushes).toContainEqual(expect.objectContaining({938      type: "ADOPTION_SOURCE_RESULT",939      payload: expect.objectContaining({ status: "complete" }),940    }));941    // The initial sync pending marker was set (not cleared — no snapshot applied).942    expect(mocks.storageSet).toHaveBeenCalledWith(943      "peach_initial_sync_pending_device_id",944      "old-phone-id",945    );946  });947948  it("applies the source's device manifest after the snapshot, then acks", async () => {949    // Mesh propagation: a 3rd browser that completes an adoption with a950    // snapshot-capable source must also receive the source's signed device951    // manifest and forward it to the sync adapter before the receiver952    // resolves. The relay stays an opaque forwarder, so the whole path953    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.954    const rootKeyHex = "f0".repeat(32);955    const vaultId = "b".repeat(64);956    const longTermPubkeyHex = "99".repeat(32);957    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");958    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(959      "../shared/sync-identity"960    );961    const issuerKp = await deriveSyncSigningKeypair(962      new Uint8Array(32).fill(0x55),963      "phone-source-id",964    );965    const manifest = await buildSignedDeviceManifest(966      "phone-source-id",967      [968        {969          deviceId: "peer-b",970          deviceName: "Pool B",971          deviceType: "extension",972          longTermPublicKeyHex: "11".repeat(32),973          signingPublicKeyHex: "22".repeat(32),974          wsPort: 49152,975          relayUrl: "wss://relay.peachpasswords.com",976          pairedAt: 1,977        },978      ],979      issuerKp.privateKey,980    );981    const socket = socketWithFrames([982      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },983      {984        type: "ADOPT_GRANT",985        root_key_hex: rootKeyHex,986        vault_id: vaultId,987        license: "PPK-test",988        snapshot_capable: true,989        source: {990          device_id: "phone-source-id",991          device_name: "June's phone",992          device_type: "android",993          long_term_public_key_hex: longTermPubkeyHex,994          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),995          relay_url: "wss://relay.peachpasswords.com",996        },997      },998      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is999      // an optional follow-up frame on the same channel.1000      { type: "ADOPT_COMPLETE" },1001      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames1002      // after the handshake; one empty chunk ends the phase.1003      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1004      { type: "ADOPT_DEVICE_MANIFEST", manifest },1005    ], { closeOnEmpty: true });1006    mocks.createVault.mockResolvedValue({ vaultId });1007    mocks.openSocket.mockResolvedValue(socket);1008    mocks.__applyIncomingDeviceManifest.mockClear();10091010    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1011    await startRelayAdoptionReceiver("receiver-password");1012    await waitForPush("ADOPTION_SOURCE_RESULT");10131014    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1015    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1016    expect(forwarded).toEqual(manifest);1017    expect(pubkey).toBeInstanceOf(Uint8Array);1018    expect(pubkey.length).toBe(32);1019    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the1020    // adapter path was skipped (the ack only follows a real apply).1021    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1022      .mock.calls1023      .map(([bytes]) => {1024        try {1025          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1026        } catch {1027          return undefined;1028        }1029      });1030    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1031    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.1032    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1033  });10341035  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1036    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST1037    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,1038    // and quietly time out the optional 5s wait without acking a manifest1039    // or invoking the adapter path.1040    const rootKeyHex = "f0".repeat(32);1041    const vaultId = "b".repeat(64);1042    const longTermPubkeyHex = "99".repeat(32);1043    const socket = socketWithFrames([1044      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1045      {1046        type: "ADOPT_GRANT",1047        root_key_hex: rootKeyHex,1048        vault_id: vaultId,1049        license: "PPK-test",1050        snapshot_capable: true,1051        source: {1052          device_id: "phone-source-id",1053          device_name: "June's phone",1054          device_type: "android",1055          long_term_public_key_hex: longTermPubkeyHex,1056          signing_public_key_hex: "aa".repeat(32),1057          relay_url: "wss://relay.peachpasswords.com",1058        },1059      },1060      { type: "ADOPT_COMPLETE" },1061      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot1062      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.1063      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1064    ], { closeOnEmpty: true });1065    mocks.createVault.mockResolvedValue({ vaultId });1066    mocks.openSocket.mockResolvedValue(socket);1067    mocks.__applyIncomingDeviceManifest.mockClear();10681069    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1070    await startRelayAdoptionReceiver("receiver-password");1071    await waitForPush("ADOPTION_SOURCE_RESULT");10721073    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1074    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1075      .mock.calls1076      .map(([bytes]) => {1077        try {1078          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1079        } catch {1080          return undefined;1081        }1082      });1083    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1084    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1085  });1086});10871088function decodeInvitation(encoded: string): { vault?: string } | null {1089  // Mirrors parsePairingInvitation's payload structure without pulling the1090  // module in: the receiver test only needs to assert the embedded status.1091  const at = encoded.indexOf(":");1092  if (at < 0) return null;1093  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1094  try {1095    const json = Buffer.from(b64, "base64").toString("utf8");1096    return JSON.parse(json) as { vault?: string };1097  } catch {1098    return null;1099  }1100}