relay-adoption-bg.test.ts

41.8 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  storageSet: vi.fn(),25  releaseAutoLock: vi.fn(),26  deferAutoLock: vi.fn(),27  sasDecisions: [] as Array<{ id: string; ok: boolean }>,28}));2930vi.mock("../shared/pairing-dial", () => ({31  openRelayPairingSocket: mocks.openSocket,32}));3334vi.mock("../shared/noise-js", () => ({35  noiseNKResponder: vi.fn(async () => ({36    handshakeHash: new Uint8Array(32).fill(7),37    encrypt: (bytes: Uint8Array) => bytes,38    decrypt: (bytes: Uint8Array) => bytes,39    close: vi.fn(),40  })),41}));4243vi.mock("../shared/rendezvous", async (importOriginal) => {44  const actual = await importOriginal<typeof import("../shared/rendezvous")>();45  return {46    ...actual,47    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",48  };49});505152vi.mock("./device-identity", () => ({53  getLocalPairingIdentity: async () => ({54    identity: {55      deviceId: "test-device",56      deviceName: "Test",57      deviceType: "extension",58      longTermPublicKeyHex: "aa".repeat(32),59      vaultId: "bb".repeat(32),60    },61    privateKey: new Uint8Array(32).fill(7),62    publicKey: new Uint8Array(32).fill(8),63  }),64  getDeviceName: mocks.getDeviceName,65  buildLocalIdentity: mocks.localIdentity,66  rotateDeviceId: mocks.rotateDeviceId,67}));6869vi.mock("./sync-settings", async (importOriginal) => ({70  ...(await importOriginal<typeof import("./sync-settings")>()),71  getSyncSettings: async () => ({72    mode: "public" as const,73    relayUrl: "wss://relay.peachpasswords.com",74  }),75}));7677vi.mock("./license", () => ({78  isProUser: mocks.isProUser,79  getLicenseKey: mocks.getLicenseKey,80  activateLicense: mocks.activateLicense,81}));8283vi.mock("./vault", () => ({84  isUnlocked: vi.fn(() => true),85  getRootKeyHex: mocks.getRootKeyHex,86  createVaultFromRootKey: mocks.createVault,87  rollbackAdoptedVault: mocks.rollbackVault,88  exportDuressSlotForAdoption: vi.fn(async () => null),89  installAdoptedDuressSlot: vi.fn(async () => undefined),90}));9192vi.mock("../shared/sync-identity", async (importOriginal) => {93  // Partial mock: the manifest test needs the REAL deriveSyncSigningKeypair /94  // pubkeyToHex to build a verifiable signed manifest (2026-08-04: this test95  // was orphaned inside the rendezvous mock factory and never ran; the mock96  // below had since lost the real exports).97  const actual = await importOriginal<typeof import("../shared/sync-identity")>();98  return {99    ...actual,100    computeVaultId: vi.fn(async () => "b".repeat(64)),101  };102});103104vi.mock("../shared/storage", () => ({105  storageSet: mocks.storageSet,106}));107108vi.mock("./paired-devices", () => ({109  upsertPairedDevice: mocks.upsert,110  clearDeviceRevocation: mocks.clearRevocation,111  hasAdoptionIdentityConflict: mocks.hasIdentityConflict,112}));113114vi.mock("./pair-secrets", () => ({115  setPairSecret: mocks.setPairSecret,116  isValidPairSecret: (value: unknown): value is string =>117    typeof value === "string" && /^[0-9a-f]{64}$/.test(value),118}));119120vi.mock("./sync-trigger", () => ({121  triggerSyncIfReachable: mocks.triggerSync,122}));123124vi.mock("./alarms", () => ({125  deferAutoLockForOperation: mocks.deferAutoLock,126}));127128vi.mock("./sync-vault-adapter", () => ({129  createSyncVaultAdapter: mocks.createSyncAdapter,130}));131132import {133  cancelRelayAdoptionSource,134  startRelayAdoptionSource,135} from "./relay-adoption-bg";136137const encode = (value: unknown) =>138  new TextEncoder().encode(JSON.stringify(value));139140function socketWithFrames(141  frames: unknown[],142  options: { closeOnEmpty?: boolean } = {},143) {144  const queue = frames.map((frame) => {145    const encoded = encode(frame);146    // PB-26-604: snapshot frames are bucket-padded before Noise.147    return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"148      ? padSyncFrame(encoded)149      : encoded;150  });151  return {152    send: vi.fn(),153    receive: vi.fn(async () => {154      const next = queue.shift();155      if (!next) {156        // A real peer closes the channel once it runs out of frames; model157        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast158        // and falls back to the sync pipeline instead of hanging on a timeout.159        if (options.closeOnEmpty) throw new Error("socket_closed");160        return new Promise<Uint8Array>(() => undefined);161      }162      return next;163    }),164    close: vi.fn(),165    closedBeforeFrame: vi.fn(() => false),166  };167}168169async function waitForPush(type: string): Promise<void> {170  await vi.waitFor(() => {171    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);172  });173}174175beforeEach(() => {176  mocks.pushes.length = 0;177  mocks.sasDecisions.length = 0;178  mocks.openSocket.mockReset();179  mocks.getLocalIdentity.mockReset();180  mocks.getRootKeyHex.mockReset();181  mocks.upsert.mockReset();182  mocks.clearRevocation.mockReset();183  mocks.clearRevocation.mockResolvedValue(undefined);184  mocks.hasIdentityConflict.mockReset();185  mocks.hasIdentityConflict.mockResolvedValue(false);186  mocks.rotateDeviceId.mockReset();187  mocks.rotateDeviceId.mockResolvedValue(undefined);188  mocks.setPairSecret.mockReset();189  mocks.triggerSync.mockReset();190  mocks.triggerSync.mockResolvedValue(undefined);191  mocks.releaseAutoLock.mockReset();192  mocks.deferAutoLock.mockReset();193  mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);194  mocks.isProUser.mockReset();195  mocks.isProUser.mockResolvedValue(true);196  mocks.getLicenseKey.mockReset();197  mocks.getLicenseKey.mockResolvedValue("PPK-source");198  mocks.getLocalIdentity.mockResolvedValue({199    identity: {200      deviceId: "browser-source",201      deviceName: "June's Browser",202      deviceType: "extension",203      longTermPublicKeyHex: "a".repeat(64),204      vaultId: "b".repeat(64),205    },206    privateKey: new Uint8Array(32).fill(1),207    publicKey: new Uint8Array(32).fill(2),208  });209  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));210  mocks.upsert.mockResolvedValue(undefined);211  mocks.setPairSecret.mockResolvedValue(undefined);212    const applyIncomingDeviceManifest = vi.fn(async () => undefined);213    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;214    mocks.createSyncAdapter.mockReturnValue({215      buildEntryManifest: vi.fn(async () => ({})),216      getEncryptedEntries: vi.fn(async () => ({})),217      buildTombstoneManifest: vi.fn(async () => ({})),218      applyEntries: vi.fn(async () => undefined),219      applyTombstones: vi.fn(async () => undefined),220      buildOutgoingDeviceManifest: vi.fn(async () => null),221      applyIncomingDeviceManifest,222    });223  Object.defineProperty(globalThis, "chrome", {224    configurable: true,225    value: {226    runtime: {227      async sendMessage(message: { type: string; payload: unknown }) {228        mocks.pushes.push(message);229        // The source flow waits for the popup to send SOURCE_SAS_DECISION before230        // forwarding the root key. The receiver test overrides this mock and231        // resolves the decision itself; the source tests rely on this default232        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.233        if (message.type === "ADOPTION_SOURCE_SAS") {234          const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };235          if (typeof p?.id === "string" && typeof p.sas === "string") {236            if (p.displayOnly === false) {237              const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");238              resolveReceiverSasConfirmation(p.id, p.sas, true);239            } else {240              const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");241              mocks.sasDecisions.push({ id: p.id, ok: true });242              resolveSourceSasConfirmation(p.id, true);243            }244          }245        }246        return Promise.resolve();247      },248    },249    },250  });251});252253describe("relay adoption source background boundary", () => {254  it("shows the SAS and waits for the popup's single confirmation", async () => {255    const socket = socketWithFrames([256      {257        type: "PEACH_HELLO",258        token: "AAECAwQFBgcICQoLDA0ODw",259      },260      {261        type: "ADOPT_REQUEST",262        device_id: "request-only-label",263        device_name: "June's phone",264        device_type: "android",265      },266      { type: "ADOPT_CONFIRM", confirmed: true },267      {268        type: "ADOPT_IDENTITY",269        device_id: "android-receiver",270        device_name: "June's phone",271        device_type: "android",272        long_term_public_key_hex: "d".repeat(64),273      },274      { type: "ADOPT_APPLIED" },275    ]);276    mocks.openSocket.mockResolvedValue(socket);277278    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");279    const sasIds: string[] = [];280    Object.defineProperty(globalThis, "chrome", {281      configurable: true,282      value: {283        runtime: {284          async sendMessage(message: { type: string; payload: unknown }) {285            mocks.pushes.push(message);286            if (message.type === "ADOPTION_SOURCE_SAS") {287              const p = message.payload as { id?: string };288              if (typeof p?.id === "string") {289                // Hold the SAS gate open until the test explicitly approves290                // it — this proves the source flow waits for the popup.291                sasIds.push(p.id);292              }293              return Promise.resolve();294            }295            return Promise.resolve();296          },297        },298      },299    });300    void resolveSourceSasConfirmation; // imported for the post-approval call below301302    const started = await startRelayAdoptionSource();303    expect(started.invitation).toMatch(/^peach-pair-v2:/);304    expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);305    await waitForPush("ADOPTION_SOURCE_SAS");306    // The adoption must NOT be complete yet — the popup's SAS decision is the307    // prerequisite for forwarding the root key.308    await vi.waitFor(() => {309      expect(mocks.sasDecisions.length).toBe(0);310    });311    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();312    expect(mocks.upsert).not.toHaveBeenCalled();313314    // Approve the SAS; the adoption should now proceed and complete.315    const id = sasIds[0];316    expect(typeof id).toBe("string");317    resolveSourceSasConfirmation(id, true);318    mocks.sasDecisions.push({ id, ok: true });319320    await waitForPush("ADOPTION_SOURCE_RESULT");321322    await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());323324    expect(mocks.getRootKeyHex).toHaveBeenCalled();325    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({326      deviceId: "android-receiver",327      relayUrl: "wss://relay.peachpasswords.com",328    }));329    // PB-26-213: the registry row stays secretless; the secret bypasses it330    // into the encrypted pair-secret record.331    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;332    expect(sourceRow.rendezvousSecretHex).toBeUndefined();333    expect(mocks.setPairSecret).toHaveBeenCalledWith(334      "android-receiver",335      expect.stringMatching(/^[0-9a-f]{64}$/),336      expect.any(Uint8Array),337    );338    expect(mocks.pushes).toContainEqual({339      type: "ADOPTION_SOURCE_RESULT",340      payload: { status: "complete" },341    });342    expect(mocks.triggerSync).toHaveBeenCalledWith({343      reason: "manual",344      force: true,345    });346    const sentTypes = socket.send.mock.calls347      .map(([bytes]) => {348        try {349          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;350        } catch {351          return undefined;352        }353      });354    expect(sentTypes).toContain("ADOPT_COMPLETE");355  });356357  it("fails adoption without sending snapshot frames when duress is oversized", async () => {358    const socket = socketWithFrames([359      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },360      {361        type: "ADOPT_REQUEST",362        device_id: "request-only-label",363        device_name: "June's phone",364        device_type: "android",365        snapshot_capable: true,366      },367      { type: "ADOPT_CONFIRM", confirmed: true },368      {369        type: "ADOPT_IDENTITY",370        device_id: "android-receiver",371        device_name: "June's phone",372        device_type: "android",373        long_term_public_key_hex: "d".repeat(64),374      },375      { type: "ADOPT_APPLIED" },376    ]);377    mocks.openSocket.mockResolvedValue(socket);378    const { exportDuressSlotForAdoption } = await import("./vault");379    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(380      "x".repeat(768 * 1024),381    );382383    await startRelayAdoptionSource();384    await waitForPush("ADOPTION_SOURCE_RESULT");385386    expect(mocks.pushes).toContainEqual({387      type: "ADOPTION_SOURCE_RESULT",388      payload: {389        status: "error",390        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",391      },392    });393    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({394      type: "ADOPTION_SOURCE_RESULT",395      payload: expect.objectContaining({ status: "complete" }),396    }));397    const sentTypes = socket.send.mock.calls.map(([bytes]) =>398      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,399    );400    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");401    expect(mocks.triggerSync).not.toHaveBeenCalled();402  });403404  it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {405    // PB-26-206: an over-budget snapshot is never partially sent or silently406    // truncated — the source completes adoption and the sync pipeline carries407    // the entries instead.408    const socket = socketWithFrames([409      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },410      {411        type: "ADOPT_REQUEST",412        device_id: "request-only-label",413        device_name: "June's phone",414        device_type: "android",415        snapshot_capable: true,416      },417      { type: "ADOPT_CONFIRM", confirmed: true },418      {419        type: "ADOPT_IDENTITY",420        device_id: "android-receiver",421        device_name: "June's phone",422        device_type: "android",423        long_term_public_key_hex: "d".repeat(64),424      },425      { type: "ADOPT_APPLIED" },426      // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is427      // sent); the source must not hang on it — withTimeout resolves the wait428      // via the sync fallback path below.429      { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },430    ]);431    mocks.openSocket.mockResolvedValue(socket);432    // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget.433    const envelope = "c".repeat(690_000);434    const entries: Record<string, string> = {};435    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;436    mocks.createSyncAdapter.mockReturnValue({437      buildEntryManifest: vi.fn(async () => Object.fromEntries(438        Object.keys(entries).map((id) => [id, 1]),439      )),440      getEncryptedEntries: vi.fn(async () => entries),441      buildTombstoneManifest: vi.fn(async () => ({})),442      applyEntries: vi.fn(async () => undefined),443      applyTombstones: vi.fn(async () => undefined),444      buildOutgoingDeviceManifest: vi.fn(async () => null),445      applyIncomingDeviceManifest: vi.fn(async () => undefined),446    });447448    await startRelayAdoptionSource();449    await waitForPush("ADOPTION_SOURCE_RESULT");450451    expect(mocks.pushes).toContainEqual({452      type: "ADOPTION_SOURCE_RESULT",453      payload: expect.objectContaining({ status: "complete" }),454    });455    const sentTypes = socket.send.mock.calls.map(([bytes]) =>456      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,457    );458    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");459    // Sync fallback ran so the entries still reach the receiver.460    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });461  });462463  it("fails closed when the popup rejects the SAS", async () => {464    const socket = socketWithFrames([465      {466        type: "PEACH_HELLO",467        token: "AAECAwQFBgcICQoLDA0ODw",468      },469      {470        type: "ADOPT_REQUEST",471        device_id: "request-only-label",472        device_name: "New phone",473        device_type: "android",474      },475      { type: "ADOPT_CONFIRM", confirmed: false },476    ]);477    mocks.openSocket.mockResolvedValue(socket);478479    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");480    let pendingId: string | undefined;481    Object.defineProperty(globalThis, "chrome", {482      configurable: true,483      value: {484        runtime: {485          sendMessage(message: { type: string; payload: unknown }) {486            mocks.pushes.push(message);487            if (message.type === "ADOPTION_SOURCE_SAS") {488              const p = message.payload as { id?: string };489              if (typeof p?.id === "string") pendingId = p.id;490            }491            return Promise.resolve();492          },493        },494      },495    });496497    await startRelayAdoptionSource();498    await waitForPush("ADOPTION_SOURCE_SAS");499    expect(typeof pendingId).toBe("string");500    resolveSourceSasConfirmation(pendingId!, false);501    mocks.sasDecisions.push({ id: pendingId!, ok: false });502503    await waitForPush("ADOPTION_SOURCE_RESULT");504505    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();506    expect(mocks.upsert).not.toHaveBeenCalled();507    expect(mocks.pushes).toContainEqual({508      type: "ADOPTION_SOURCE_RESULT",509      payload: { status: "error", error: "adoption_rejected" },510    });511  });512513  it("rejects before reading the root key when neither peer has Pro", async () => {514    const socket = socketWithFrames([515      {516        type: "PEACH_HELLO",517        token: "AAECAwQFBgcICQoLDA0ODw",518      },519      {520        type: "ADOPT_REQUEST",521        device_id: "free-receiver",522        device_name: "Free phone",523        device_type: "android",524      },525      { type: "ADOPT_CONFIRM", confirmed: true },526    ]);527    mocks.isProUser.mockResolvedValue(false);528    mocks.getLicenseKey.mockResolvedValue(undefined);529    mocks.openSocket.mockResolvedValue(socket);530531    await startRelayAdoptionSource();532    await waitForPush("ADOPTION_SOURCE_RESULT");533534    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();535    expect(mocks.upsert).not.toHaveBeenCalled();536    expect(mocks.pushes).toContainEqual({537      type: "ADOPTION_SOURCE_RESULT",538      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },539    });540    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {541      try {542        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];543      } catch {544        return [];545      }546    });547    expect(sentFrames).toContainEqual({548      type: "ADOPT_REJECT",549      reason: "ADOPT_PRO_REQUIRED",550    });551  });552553  it("fails closed when the user never answers the SAS", async () => {554    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they555    // schedule real work), so we hold the SAS gate open by never resolving556    // the deferred and then cancel the session. clearState must drop the557    // pending SAS so it doesn't pin the relay room open for the next run.558    const socket = socketWithFrames([]);559    mocks.openSocket.mockResolvedValue(socket);560561    Object.defineProperty(globalThis, "chrome", {562      configurable: true,563      value: {564        runtime: {565          sendMessage(message: { type: string; payload: unknown }) {566            mocks.pushes.push(message);567            return Promise.resolve();568          },569        },570      },571    });572573    await startRelayAdoptionSource();574    cancelRelayAdoptionSource();575576    expect(socket.close).toHaveBeenCalled();577    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();578    expect(mocks.upsert).not.toHaveBeenCalled();579  });580581  it("cancelling the source screen closes the room without reading the root", async () => {582    const socket = socketWithFrames([]);583    mocks.openSocket.mockResolvedValue(socket);584585    await startRelayAdoptionSource();586    cancelRelayAdoptionSource();587588    expect(socket.close).toHaveBeenCalled();589    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();590    expect(mocks.upsert).not.toHaveBeenCalled();591  });592});593594describe("relay adoption receiver background", () => {595  beforeEach(async () => {596    // The receiver session runs in the background via `void runReceiverSession`;597    // it doesn't get awaited by the caller and can leave `inFlight` set if the598    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test599    // starts from a clean state.600    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");601    cancelRelayAdoptionSource();602    mocks.pushes.length = 0;603    mocks.openSocket.mockReset();604    mocks.getDeviceName.mockReset();605    mocks.getDeviceName.mockResolvedValue("Receiver Browser");606    mocks.createVault.mockReset();607    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });608    mocks.activateLicense.mockReset();609    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });610    mocks.isProUser.mockReset();611    mocks.isProUser.mockResolvedValue(true);612    mocks.getLicenseKey.mockReset();613    mocks.getLicenseKey.mockResolvedValue(undefined);614    mocks.localIdentity.mockReset();615    mocks.localIdentity.mockResolvedValue({616      deviceId: "receiver-id",617      deviceName: "Receiver Browser",618      deviceType: "extension",619      longTermPublicKeyHex: "11".repeat(32),620      signingPublicKeyHex: "22".repeat(32),621    });622    mocks.rollbackVault.mockReset();623    mocks.rollbackVault.mockResolvedValue(undefined);624    mocks.storageSet.mockReset();625    mocks.storageSet.mockResolvedValue(undefined);626    mocks.upsert.mockReset();627    mocks.upsert.mockResolvedValue(undefined);628    mocks.setPairSecret.mockReset();629    mocks.setPairSecret.mockResolvedValue(undefined);630    mocks.triggerSync.mockReset();631    mocks.triggerSync.mockResolvedValue(undefined);632    mocks.openSocket.mockResolvedValue(socketWithFrames([]));633    Object.defineProperty(globalThis, "chrome", {634      configurable: true,635      value: {636        runtime: {637          async sendMessage(message: { type: string; payload?: unknown }) {638            mocks.pushes.push({ type: message.type, payload: message.payload });639            if (message.type === "ADOPTION_SOURCE_SAS") {640              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };641              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {642                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");643                resolveReceiverSasConfirmation(p.id, p.sas, true);644              }645            }646            return { ok: true };647          },648          onMessage: {649            addListener() { /* no-op */ },650            removeListener() { /* no-op */ },651          },652        },653      },654    });655  });656657  it("opens a relay socket and returns an empty invitation", async () => {658    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");659    const started = await startRelayAdoptionReceiver("receiver-password");660    expect(started.invitation).toMatch(/^peach-pair-v2:/);661    // The QR carries vault:"empty" so the phone knows to act as source.662    const decoded = decodeInvitation(started.invitation);663    expect(decoded?.vault).toBe("empty");664    expect(mocks.openSocket).toHaveBeenCalled();665  });666667  it("rejects an empty password", async () => {668    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");669    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");670  });671672  it("surfaces the Pro-required rejection without creating a vault", async () => {673    mocks.openSocket.mockResolvedValue(socketWithFrames([674      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },675      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },676    ]));677678    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");679    await startRelayAdoptionReceiver("receiver-password");680    await waitForPush("ADOPTION_SOURCE_RESULT");681682    expect(mocks.createVault).not.toHaveBeenCalled();683    expect(mocks.rollbackVault).not.toHaveBeenCalled();684    expect(mocks.pushes).toContainEqual({685      type: "ADOPTION_SOURCE_RESULT",686      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },687    });688  });689690  it("the receiver wires requestAdoption, not grantAdoption", async () => {691    // The receiver's protocol position is the opposite of the source side:692    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.693    // Assert this by watching what lands on socket.send: the receiver's694    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY695    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.696    const rootKeyHex = "f0".repeat(32);697    const vaultId = "b".repeat(64);698    const socket = socketWithFrames([699      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },700      {701        type: "ADOPT_GRANT",702        root_key_hex: rootKeyHex,703        vault_id: vaultId,704        license: "PPK-test",705        source: {706          device_id: "phone-source-id",707          device_name: "June's phone",708          device_type: "android",709          long_term_public_key_hex: "99".repeat(32),710        },711      },712      { type: "ADOPT_COMPLETE" },713    ], { closeOnEmpty: true });714    mocks.openSocket.mockResolvedValue(socket);715    mocks.createVault.mockResolvedValue({ vaultId });716717    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");718    Object.defineProperty(globalThis, "chrome", {719      configurable: true,720      value: {721        runtime: {722          async sendMessage(message: { type: string; payload?: unknown }) {723            mocks.pushes.push({ type: message.type, payload: message.payload });724            if (message.type === "ADOPTION_SOURCE_SAS") {725              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };726              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {727                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");728                resolveReceiverSasConfirmation(p.id, p.sas, true);729              }730            }731            return { ok: true };732          },733          onMessage: { addListener: () => undefined, removeListener: () => undefined },734        },735      },736    });737    await startRelayAdoptionReceiver("receiver-password");738    await waitForPush("ADOPTION_SOURCE_RESULT");739740    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })741      .mock.calls742      .map(([bytes]) => {743        try {744          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;745        } catch {746          return undefined;747        }748      });749    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;750    // ADOPT_GRANT is the source side's frame and must never appear here.751    expect(sentTypes).toContain("ADOPT_REQUEST");752    expect(sentTypes).not.toContain("ADOPT_GRANT");753  });754755  it("completes the handshake end-to-end: vault created → paired", async () => {756    // Wire the receiver's noise mock and SAS gate together so the full757    // requestAdoption protocol runs against an in-memory phone that sends758    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.759    // The mocked noise is pass-through (encrypt = decrypt = identity), so760    // frames land as ordinary JSON.761    const rootKeyHex = "f0".repeat(32);762    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);763    // the grant's vault_id is set to the same so the protocol proceeds.764    const vaultId = "b".repeat(64);765    const frames = [766      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },767      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two768      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.769      {770        type: "ADOPT_GRANT",771        root_key_hex: rootKeyHex,772        vault_id: vaultId,773        license: "PPK-test",774        source: {775          device_id: "phone-source-id",776          device_name: "June's phone",777          device_type: "android",778          long_term_public_key_hex: "99".repeat(32),779          signing_public_key_hex: "aa".repeat(32),780          relay_url: "wss://relay.peachpasswords.com",781        },782      },783      { type: "ADOPT_COMPLETE" },784    ];785    mocks.createVault.mockResolvedValue({ vaultId });786    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));787788    // Replace the chrome.runtime mock for this one test and approve the displayed SAS.789    Object.defineProperty(globalThis, "chrome", {790      configurable: true,791      value: {792        runtime: {793          async sendMessage(message: { type: string; payload?: unknown }) {794            mocks.pushes.push({ type: message.type, payload: message.payload });795            if (message.type === "ADOPTION_SOURCE_SAS") {796              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };797              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {798                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");799                resolveReceiverSasConfirmation(p.id, p.sas, true);800              }801            }802            return { ok: true };803          },804          onMessage: {805            addListener: () => undefined,806            removeListener: () => undefined,807          },808        },809      },810    });811812    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");813    await startRelayAdoptionReceiver("receiver-password");814815    await waitForPush("ADOPTION_SOURCE_RESULT");816817    // The receiver displays its locally derived SAS and requires confirmation.818    expect(mocks.pushes).toContainEqual({819      type: "ADOPTION_SOURCE_SAS",820      payload: expect.objectContaining({821        sas: expect.stringMatching(/^\d{8}$/),822        displayOnly: false,823      }),824    });825826    // The vault module was called directly with the popup-supplied password827    // and the root key granted by the phone.828    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);829830    // License activation, identity derivation, and pairing persistence all831    // call their modules directly from the service worker.832    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");833    expect(mocks.isProUser).toHaveBeenCalled();834    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));835    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({836      deviceId: "phone-source-id",837    }));838    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;839    expect(receiverRow.rendezvousSecretHex).toBeUndefined();840    expect(mocks.setPairSecret).toHaveBeenCalledWith(841      "phone-source-id",842      expect.stringMatching(/^[0-9a-f]{64}$/),843      expect.any(Uint8Array),844    );845    expect(mocks.storageSet).toHaveBeenCalledWith(846      "peach_initial_sync_pending_device_id",847      "phone-source-id",848    );849    expect(mocks.rollbackVault).not.toHaveBeenCalled();850851    // The terminal state is "complete" — the popup can transition to success.852    expect(mocks.pushes).toContainEqual({853      type: "ADOPTION_SOURCE_RESULT",854      payload: { status: "complete" },855    });856  });857858  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {859    const rootKeyHex = "f0".repeat(32);860    const vaultId = "b".repeat(64);861    mocks.createVault.mockResolvedValue({ vaultId });862    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });863    mocks.isProUser.mockResolvedValue(false);864    mocks.openSocket.mockResolvedValue(socketWithFrames([865      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },866      {867        type: "ADOPT_GRANT",868        root_key_hex: rootKeyHex,869        vault_id: vaultId,870        license: "PPK-invalid",871        source: {872          device_id: "phone-source-id",873          device_name: "June's phone",874          device_type: "android",875          long_term_public_key_hex: "99".repeat(32),876        },877      },878    ]));879880    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");881    await startRelayAdoptionReceiver("receiver-password");882    await waitForPush("ADOPTION_SOURCE_RESULT");883884    expect(mocks.rollbackVault).toHaveBeenCalledOnce();885    expect(mocks.localIdentity).not.toHaveBeenCalled();886    expect(mocks.upsert).not.toHaveBeenCalled();887    expect(mocks.pushes).toContainEqual(expect.objectContaining({888      type: "ADOPTION_SOURCE_RESULT",889      payload: expect.objectContaining({890        status: "error",891        error: "license_transfer_failed",892      }),893    }));894  });895896  it("rolls back a newly created vault when a later adoption step fails", async () => {897    const rootKeyHex = "f0".repeat(32);898    const vaultId = "b".repeat(64);899    mocks.createVault.mockResolvedValue({ vaultId });900    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));901    mocks.openSocket.mockResolvedValue(socketWithFrames([902      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },903      {904        type: "ADOPT_GRANT",905        root_key_hex: rootKeyHex,906        vault_id: vaultId,907        license: "PPK-test",908        source: {909          device_id: "phone-source-id",910          device_name: "June's phone",911          device_type: "android",912          long_term_public_key_hex: "99".repeat(32),913        },914      },915    ]));916917    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");918    await startRelayAdoptionReceiver("receiver-password");919    await waitForPush("ADOPTION_SOURCE_RESULT");920921    expect(mocks.rollbackVault).toHaveBeenCalledOnce();922    expect(mocks.upsert).not.toHaveBeenCalled();923    expect(mocks.pushes).toContainEqual(expect.objectContaining({924      type: "ADOPTION_SOURCE_RESULT",925      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),926    }));927  });928929  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {930    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).931    // The receiver should skip the 30-second inline snapshot wait entirely932    // and fall through to the sync pipeline — no receiveVaultSnapshot call.933    const rootKeyHex = "f0".repeat(32);934    const vaultId = "b".repeat(64);935    mocks.createVault.mockResolvedValue({ vaultId });936    mocks.openSocket.mockResolvedValue(socketWithFrames([937      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },938      {939        type: "ADOPT_GRANT",940        root_key_hex: rootKeyHex,941        vault_id: vaultId,942        license: "PPK-test",943        source: {944          device_id: "old-phone-id",945          device_name: "Old phone",946          device_type: "android",947          long_term_public_key_hex: "99".repeat(32),948        },949        // NO snapshot_capable: pre-C source does not echo the field950      },951      { type: "ADOPT_COMPLETE" },952    ], { closeOnEmpty: true }));953954    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");955    await startRelayAdoptionReceiver("receiver-password");956    await waitForPush("ADOPTION_SOURCE_RESULT");957958    // Sync fallback ran immediately (no 30s wait).959    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });960    // Adoption completed successfully.961    expect(mocks.pushes).toContainEqual(expect.objectContaining({962      type: "ADOPTION_SOURCE_RESULT",963      payload: expect.objectContaining({ status: "complete" }),964    }));965    // The initial sync pending marker was set (not cleared — no snapshot applied).966    expect(mocks.storageSet).toHaveBeenCalledWith(967      "peach_initial_sync_pending_device_id",968      "old-phone-id",969    );970  });971972  it("applies the source's device manifest after the snapshot, then acks", async () => {973    // Mesh propagation: a 3rd browser that completes an adoption with a974    // snapshot-capable source must also receive the source's signed device975    // manifest and forward it to the sync adapter before the receiver976    // resolves. The relay stays an opaque forwarder, so the whole path977    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.978    const rootKeyHex = "f0".repeat(32);979    const vaultId = "b".repeat(64);980    const longTermPubkeyHex = "99".repeat(32);981    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");982    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(983      "../shared/sync-identity"984    );985    const issuerKp = await deriveSyncSigningKeypair(986      new Uint8Array(32).fill(0x55),987      "phone-source-id",988    );989    const manifest = await buildSignedDeviceManifest(990      "phone-source-id",991      [992        {993          deviceId: "peer-b",994          deviceName: "Pool B",995          deviceType: "extension",996          longTermPublicKeyHex: "11".repeat(32),997          signingPublicKeyHex: "22".repeat(32),998          wsPort: 49152,999          relayUrl: "wss://relay.peachpasswords.com",1000          pairedAt: 1,1001        },1002      ],1003      issuerKp.privateKey,1004    );1005    const socket = socketWithFrames([1006      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1007      {1008        type: "ADOPT_GRANT",1009        root_key_hex: rootKeyHex,1010        vault_id: vaultId,1011        license: "PPK-test",1012        snapshot_capable: true,1013        source: {1014          device_id: "phone-source-id",1015          device_name: "June's phone",1016          device_type: "android",1017          long_term_public_key_hex: longTermPubkeyHex,1018          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),1019          relay_url: "wss://relay.peachpasswords.com",1020        },1021      },1022      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is1023      // an optional follow-up frame on the same channel.1024      { type: "ADOPT_COMPLETE" },1025      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames1026      // after the handshake; one empty chunk ends the phase.1027      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1028      { type: "ADOPT_DEVICE_MANIFEST", manifest },1029    ], { closeOnEmpty: true });1030    mocks.createVault.mockResolvedValue({ vaultId });1031    mocks.openSocket.mockResolvedValue(socket);1032    mocks.__applyIncomingDeviceManifest.mockClear();10331034    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1035    await startRelayAdoptionReceiver("receiver-password");1036    await waitForPush("ADOPTION_SOURCE_RESULT");10371038    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1039    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1040    expect(forwarded).toEqual(manifest);1041    expect(pubkey).toBeInstanceOf(Uint8Array);1042    expect(pubkey.length).toBe(32);1043    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the1044    // adapter path was skipped (the ack only follows a real apply).1045    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1046      .mock.calls1047      .map(([bytes]) => {1048        try {1049          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1050        } catch {1051          return undefined;1052        }1053      });1054    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1055    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.1056    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1057  });10581059  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1060    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST1061    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,1062    // and quietly time out the optional 5s wait without acking a manifest1063    // or invoking the adapter path.1064    const rootKeyHex = "f0".repeat(32);1065    const vaultId = "b".repeat(64);1066    const longTermPubkeyHex = "99".repeat(32);1067    const socket = socketWithFrames([1068      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1069      {1070        type: "ADOPT_GRANT",1071        root_key_hex: rootKeyHex,1072        vault_id: vaultId,1073        license: "PPK-test",1074        snapshot_capable: true,1075        source: {1076          device_id: "phone-source-id",1077          device_name: "June's phone",1078          device_type: "android",1079          long_term_public_key_hex: longTermPubkeyHex,1080          signing_public_key_hex: "aa".repeat(32),1081          relay_url: "wss://relay.peachpasswords.com",1082        },1083      },1084      { type: "ADOPT_COMPLETE" },1085      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot1086      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.1087      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1088    ], { closeOnEmpty: true });1089    mocks.createVault.mockResolvedValue({ vaultId });1090    mocks.openSocket.mockResolvedValue(socket);1091    mocks.__applyIncomingDeviceManifest.mockClear();10921093    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1094    await startRelayAdoptionReceiver("receiver-password");1095    await waitForPush("ADOPTION_SOURCE_RESULT");10961097    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1098    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1099      .mock.calls1100      .map(([bytes]) => {1101        try {1102          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1103        } catch {1104          return undefined;1105        }1106      });1107    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1108    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1109  });1110});11111112function decodeInvitation(encoded: string): { vault?: string } | null {1113  // Mirrors parsePairingInvitation's payload structure without pulling the1114  // module in: the receiver test only needs to assert the embedded status.1115  const at = encoded.indexOf(":");1116  if (at < 0) return null;1117  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1118  try {1119    const json = Buffer.from(b64, "base64").toString("utf8");1120    return JSON.parse(json) as { vault?: string };1121  } catch {1122    return null;1123  }1124}