relay-adoption-bg.test.ts

41.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  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  upsertPairedDeviceAndClearRevocation: mocks.upsert,111  clearDeviceRevocation: mocks.clearRevocation,112  hasAdoptionIdentityConflict: mocks.hasIdentityConflict,113}));114115vi.mock("./pair-secrets", () => ({116  setPairSecret: mocks.setPairSecret,117  isValidPairSecret: (value: unknown): value is string =>118    typeof value === "string" && /^[0-9a-f]{64}$/.test(value),119}));120121vi.mock("./sync-trigger", () => ({122  triggerSyncIfReachable: mocks.triggerSync,123}));124125vi.mock("./alarms", () => ({126  deferAutoLockForOperation: mocks.deferAutoLock,127}));128129vi.mock("./sync-vault-adapter", () => ({130  createSyncVaultAdapter: mocks.createSyncAdapter,131}));132133import {134  cancelRelayAdoptionSource,135  startRelayAdoptionSource,136} from "./relay-adoption-bg";137138const encode = (value: unknown) =>139  new TextEncoder().encode(JSON.stringify(value));140141function socketWithFrames(142  frames: unknown[],143  options: { closeOnEmpty?: boolean } = {},144) {145  const queue = frames.map((frame) => {146    const encoded = encode(frame);147    // PB-26-604: snapshot frames are bucket-padded before Noise.148    return (frame as { type?: string }).type === "ADOPT_SNAPSHOT"149      ? padSyncFrame(encoded)150      : encoded;151  });152  return {153    send: vi.fn(),154    receive: vi.fn(async () => {155      const next = queue.shift();156      if (!next) {157        // A real peer closes the channel once it runs out of frames; model158        // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast159        // and falls back to the sync pipeline instead of hanging on a timeout.160        if (options.closeOnEmpty) throw new Error("socket_closed");161        return new Promise<Uint8Array>(() => undefined);162      }163      return next;164    }),165    close: vi.fn(),166    closedBeforeFrame: vi.fn(() => false),167  };168}169170async function waitForPush(type: string): Promise<void> {171  await vi.waitFor(() => {172    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);173  });174}175176beforeEach(() => {177  mocks.pushes.length = 0;178  mocks.sasDecisions.length = 0;179  mocks.openSocket.mockReset();180  mocks.getLocalIdentity.mockReset();181  mocks.getRootKeyHex.mockReset();182  mocks.upsert.mockReset();183  mocks.clearRevocation.mockReset();184  mocks.clearRevocation.mockResolvedValue(undefined);185  mocks.hasIdentityConflict.mockReset();186  mocks.hasIdentityConflict.mockResolvedValue(false);187  mocks.rotateDeviceId.mockReset();188  mocks.rotateDeviceId.mockResolvedValue(undefined);189  mocks.setPairSecret.mockReset();190  mocks.triggerSync.mockReset();191  mocks.triggerSync.mockResolvedValue(undefined);192  mocks.releaseAutoLock.mockReset();193  mocks.deferAutoLock.mockReset();194  mocks.deferAutoLock.mockReturnValue(mocks.releaseAutoLock);195  mocks.isProUser.mockReset();196  mocks.isProUser.mockResolvedValue(true);197  mocks.getLicenseKey.mockReset();198  mocks.getLicenseKey.mockResolvedValue("PPK-source");199  mocks.getLocalIdentity.mockResolvedValue({200    identity: {201      deviceId: "browser-source",202      deviceName: "June's Browser",203      deviceType: "extension",204      longTermPublicKeyHex: "a".repeat(64),205      vaultId: "b".repeat(64),206    },207    privateKey: new Uint8Array(32).fill(1),208    publicKey: new Uint8Array(32).fill(2),209  });210  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));211  mocks.upsert.mockResolvedValue(undefined);212  mocks.setPairSecret.mockResolvedValue(undefined);213    const applyIncomingDeviceManifest = vi.fn(async () => undefined);214    mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;215    mocks.createSyncAdapter.mockReturnValue({216      buildEntryManifest: vi.fn(async () => ({})),217      getEncryptedEntries: vi.fn(async () => ({})),218      buildTombstoneManifest: vi.fn(async () => ({})),219      applyEntries: vi.fn(async () => undefined),220      applyTombstones: vi.fn(async () => undefined),221      buildOutgoingDeviceManifest: vi.fn(async () => null),222      applyIncomingDeviceManifest,223    });224  Object.defineProperty(globalThis, "chrome", {225    configurable: true,226    value: {227    runtime: {228      async sendMessage(message: { type: string; payload: unknown }) {229        mocks.pushes.push(message);230        // The source flow waits for the popup to send SOURCE_SAS_DECISION before231        // forwarding the root key. The receiver test overrides this mock and232        // resolves the decision itself; the source tests rely on this default233        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.234        if (message.type === "ADOPTION_SOURCE_SAS") {235          const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };236          if (typeof p?.id === "string" && typeof p.sas === "string") {237            if (p.displayOnly === false) {238              const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");239              resolveReceiverSasConfirmation(p.id, p.sas, true);240            } else {241              const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");242              mocks.sasDecisions.push({ id: p.id, ok: true });243              resolveSourceSasConfirmation(p.id, true);244            }245          }246        }247        return Promise.resolve();248      },249    },250    },251  });252});253254describe("relay adoption source background boundary", () => {255  it("shows the SAS and waits for the popup's single confirmation", async () => {256    const socket = socketWithFrames([257      {258        type: "PEACH_HELLO",259        token: "AAECAwQFBgcICQoLDA0ODw",260      },261      {262        type: "ADOPT_REQUEST",263        device_id: "request-only-label",264        device_name: "June's phone",265        device_type: "android",266      },267      { type: "ADOPT_CONFIRM", confirmed: true },268      {269        type: "ADOPT_IDENTITY",270        device_id: "android-receiver",271        device_name: "June's phone",272        device_type: "android",273        long_term_public_key_hex: "d".repeat(64),274      },275      { type: "ADOPT_APPLIED" },276    ]);277    mocks.openSocket.mockResolvedValue(socket);278279    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");280    const sasIds: string[] = [];281    Object.defineProperty(globalThis, "chrome", {282      configurable: true,283      value: {284        runtime: {285          async sendMessage(message: { type: string; payload: unknown }) {286            mocks.pushes.push(message);287            if (message.type === "ADOPTION_SOURCE_SAS") {288              const p = message.payload as { id?: string };289              if (typeof p?.id === "string") {290                // Hold the SAS gate open until the test explicitly approves291                // it — this proves the source flow waits for the popup.292                sasIds.push(p.id);293              }294              return Promise.resolve();295            }296            return Promise.resolve();297          },298        },299      },300    });301    void resolveSourceSasConfirmation; // imported for the post-approval call below302303    const started = await startRelayAdoptionSource();304    expect(started.invitation).toMatch(/^peach-pair-v2:/);305    expect(mocks.deferAutoLock).toHaveBeenCalledWith(5 * 60 * 1000);306    await waitForPush("ADOPTION_SOURCE_SAS");307    // The adoption must NOT be complete yet — the popup's SAS decision is the308    // prerequisite for forwarding the root key.309    await vi.waitFor(() => {310      expect(mocks.sasDecisions.length).toBe(0);311    });312    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();313    expect(mocks.upsert).not.toHaveBeenCalled();314315    // Approve the SAS; the adoption should now proceed and complete.316    const id = sasIds[0];317    expect(typeof id).toBe("string");318    resolveSourceSasConfirmation(id, true);319    mocks.sasDecisions.push({ id, ok: true });320321    await waitForPush("ADOPTION_SOURCE_RESULT");322323    await vi.waitFor(() => expect(mocks.releaseAutoLock).toHaveBeenCalled());324325    expect(mocks.getRootKeyHex).toHaveBeenCalled();326    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({327      deviceId: "android-receiver",328      relayUrl: "wss://relay.peachpasswords.com",329    }));330    // PB-26-213: the registry row stays secretless; the secret bypasses it331    // into the encrypted pair-secret record.332    const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;333    expect(sourceRow.rendezvousSecretHex).toBeUndefined();334    expect(mocks.setPairSecret).toHaveBeenCalledWith(335      "android-receiver",336      expect.stringMatching(/^[0-9a-f]{64}$/),337      expect.any(Uint8Array),338    );339    expect(mocks.pushes).toContainEqual({340      type: "ADOPTION_SOURCE_RESULT",341      payload: { status: "complete" },342    });343    expect(mocks.triggerSync).toHaveBeenCalledWith({344      reason: "manual",345      force: true,346    });347    const sentTypes = socket.send.mock.calls348      .map(([bytes]) => {349        try {350          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;351        } catch {352          return undefined;353        }354      });355    expect(sentTypes).toContain("ADOPT_COMPLETE");356  });357358  it("fails adoption without sending snapshot frames when duress is oversized", async () => {359    const socket = socketWithFrames([360      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },361      {362        type: "ADOPT_REQUEST",363        device_id: "request-only-label",364        device_name: "June's phone",365        device_type: "android",366        snapshot_capable: true,367      },368      { type: "ADOPT_CONFIRM", confirmed: true },369      {370        type: "ADOPT_IDENTITY",371        device_id: "android-receiver",372        device_name: "June's phone",373        device_type: "android",374        long_term_public_key_hex: "d".repeat(64),375      },376      { type: "ADOPT_APPLIED" },377    ]);378    mocks.openSocket.mockResolvedValue(socket);379    const { exportDuressSlotForAdoption } = await import("./vault");380    vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(381      "x".repeat(768 * 1024),382    );383384    await startRelayAdoptionSource();385    await waitForPush("ADOPTION_SOURCE_RESULT");386387    expect(mocks.pushes).toContainEqual({388      type: "ADOPTION_SOURCE_RESULT",389      payload: {390        status: "error",391        error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",392      },393    });394    expect(mocks.pushes).not.toContainEqual(expect.objectContaining({395      type: "ADOPTION_SOURCE_RESULT",396      payload: expect.objectContaining({ status: "complete" }),397    }));398    const sentTypes = socket.send.mock.calls.map(([bytes]) =>399      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,400    );401    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");402    expect(mocks.triggerSync).not.toHaveBeenCalled();403  });404405  it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {406    // PB-26-206: an over-budget snapshot is never partially sent or silently407    // truncated — the source completes adoption and the sync pipeline carries408    // the entries instead.409    const socket = socketWithFrames([410      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },411      {412        type: "ADOPT_REQUEST",413        device_id: "request-only-label",414        device_name: "June's phone",415        device_type: "android",416        snapshot_capable: true,417      },418      { type: "ADOPT_CONFIRM", confirmed: true },419      {420        type: "ADOPT_IDENTITY",421        device_id: "android-receiver",422        device_name: "June's phone",423        device_type: "android",424        long_term_public_key_hex: "d".repeat(64),425      },426      { type: "ADOPT_APPLIED" },427      // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is428      // sent); the source must not hang on it — withTimeout resolves the wait429      // via the sync fallback path below.430      { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },431    ]);432    mocks.openSocket.mockResolvedValue(socket);433    // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget.434    const envelope = "c".repeat(690_000);435    const entries: Record<string, string> = {};436    for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;437    mocks.createSyncAdapter.mockReturnValue({438      buildEntryManifest: vi.fn(async () => Object.fromEntries(439        Object.keys(entries).map((id) => [id, 1]),440      )),441      getEncryptedEntries: vi.fn(async () => entries),442      buildTombstoneManifest: vi.fn(async () => ({})),443      applyEntries: vi.fn(async () => undefined),444      applyTombstones: vi.fn(async () => undefined),445      buildOutgoingDeviceManifest: vi.fn(async () => null),446      applyIncomingDeviceManifest: vi.fn(async () => undefined),447    });448449    await startRelayAdoptionSource();450    await waitForPush("ADOPTION_SOURCE_RESULT");451452    expect(mocks.pushes).toContainEqual({453      type: "ADOPTION_SOURCE_RESULT",454      payload: expect.objectContaining({ status: "complete" }),455    });456    const sentTypes = socket.send.mock.calls.map(([bytes]) =>457      JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,458    );459    expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");460    // Sync fallback ran so the entries still reach the receiver.461    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });462  });463464  it("fails closed when the popup rejects the SAS", async () => {465    const socket = socketWithFrames([466      {467        type: "PEACH_HELLO",468        token: "AAECAwQFBgcICQoLDA0ODw",469      },470      {471        type: "ADOPT_REQUEST",472        device_id: "request-only-label",473        device_name: "New phone",474        device_type: "android",475      },476      { type: "ADOPT_CONFIRM", confirmed: false },477    ]);478    mocks.openSocket.mockResolvedValue(socket);479480    const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");481    let pendingId: string | undefined;482    Object.defineProperty(globalThis, "chrome", {483      configurable: true,484      value: {485        runtime: {486          sendMessage(message: { type: string; payload: unknown }) {487            mocks.pushes.push(message);488            if (message.type === "ADOPTION_SOURCE_SAS") {489              const p = message.payload as { id?: string };490              if (typeof p?.id === "string") pendingId = p.id;491            }492            return Promise.resolve();493          },494        },495      },496    });497498    await startRelayAdoptionSource();499    await waitForPush("ADOPTION_SOURCE_SAS");500    expect(typeof pendingId).toBe("string");501    resolveSourceSasConfirmation(pendingId!, false);502    mocks.sasDecisions.push({ id: pendingId!, ok: false });503504    await waitForPush("ADOPTION_SOURCE_RESULT");505506    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();507    expect(mocks.upsert).not.toHaveBeenCalled();508    expect(mocks.pushes).toContainEqual({509      type: "ADOPTION_SOURCE_RESULT",510      payload: { status: "error", error: "adoption_rejected" },511    });512  });513514  it("rejects before reading the root key when neither peer has Pro", async () => {515    const socket = socketWithFrames([516      {517        type: "PEACH_HELLO",518        token: "AAECAwQFBgcICQoLDA0ODw",519      },520      {521        type: "ADOPT_REQUEST",522        device_id: "free-receiver",523        device_name: "Free phone",524        device_type: "android",525      },526      { type: "ADOPT_CONFIRM", confirmed: true },527    ]);528    mocks.isProUser.mockResolvedValue(false);529    mocks.getLicenseKey.mockResolvedValue(undefined);530    mocks.openSocket.mockResolvedValue(socket);531532    await startRelayAdoptionSource();533    await waitForPush("ADOPTION_SOURCE_RESULT");534535    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();536    expect(mocks.upsert).not.toHaveBeenCalled();537    expect(mocks.pushes).toContainEqual({538      type: "ADOPTION_SOURCE_RESULT",539      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },540    });541    const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {542      try {543        return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];544      } catch {545        return [];546      }547    });548    expect(sentFrames).toContainEqual({549      type: "ADOPT_REJECT",550      reason: "ADOPT_PRO_REQUIRED",551    });552  });553554  it("fails closed when the user never answers the SAS", async () => {555    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they556    // schedule real work), so we hold the SAS gate open by never resolving557    // the deferred and then cancel the session. clearState must drop the558    // pending SAS so it doesn't pin the relay room open for the next run.559    const socket = socketWithFrames([]);560    mocks.openSocket.mockResolvedValue(socket);561562    Object.defineProperty(globalThis, "chrome", {563      configurable: true,564      value: {565        runtime: {566          sendMessage(message: { type: string; payload: unknown }) {567            mocks.pushes.push(message);568            return Promise.resolve();569          },570        },571      },572    });573574    await startRelayAdoptionSource();575    cancelRelayAdoptionSource();576577    expect(socket.close).toHaveBeenCalled();578    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();579    expect(mocks.upsert).not.toHaveBeenCalled();580  });581582  it("cancelling the source screen closes the room without reading the root", async () => {583    const socket = socketWithFrames([]);584    mocks.openSocket.mockResolvedValue(socket);585586    await startRelayAdoptionSource();587    cancelRelayAdoptionSource();588589    expect(socket.close).toHaveBeenCalled();590    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();591    expect(mocks.upsert).not.toHaveBeenCalled();592  });593});594595describe("relay adoption receiver background", () => {596  beforeEach(async () => {597    // The receiver session runs in the background via `void runReceiverSession`;598    // it doesn't get awaited by the caller and can leave `inFlight` set if the599    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test600    // starts from a clean state.601    const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");602    cancelRelayAdoptionSource();603    mocks.pushes.length = 0;604    mocks.openSocket.mockReset();605    mocks.getDeviceName.mockReset();606    mocks.getDeviceName.mockResolvedValue("Receiver Browser");607    mocks.createVault.mockReset();608    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });609    mocks.activateLicense.mockReset();610    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });611    mocks.isProUser.mockReset();612    mocks.isProUser.mockResolvedValue(true);613    mocks.getLicenseKey.mockReset();614    mocks.getLicenseKey.mockResolvedValue(undefined);615    mocks.localIdentity.mockReset();616    mocks.localIdentity.mockResolvedValue({617      deviceId: "receiver-id",618      deviceName: "Receiver Browser",619      deviceType: "extension",620      longTermPublicKeyHex: "11".repeat(32),621      signingPublicKeyHex: "22".repeat(32),622    });623    mocks.rollbackVault.mockReset();624    mocks.rollbackVault.mockResolvedValue(undefined);625    mocks.storageSet.mockReset();626    mocks.storageSet.mockResolvedValue(undefined);627    mocks.upsert.mockReset();628    mocks.upsert.mockResolvedValue(undefined);629    mocks.setPairSecret.mockReset();630    mocks.setPairSecret.mockResolvedValue(undefined);631    mocks.triggerSync.mockReset();632    mocks.triggerSync.mockResolvedValue(undefined);633    mocks.openSocket.mockResolvedValue(socketWithFrames([]));634    Object.defineProperty(globalThis, "chrome", {635      configurable: true,636      value: {637        runtime: {638          async sendMessage(message: { type: string; payload?: unknown }) {639            mocks.pushes.push({ type: message.type, payload: message.payload });640            if (message.type === "ADOPTION_SOURCE_SAS") {641              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };642              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {643                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");644                resolveReceiverSasConfirmation(p.id, p.sas, true);645              }646            }647            return { ok: true };648          },649          onMessage: {650            addListener() { /* no-op */ },651            removeListener() { /* no-op */ },652          },653        },654      },655    });656  });657658  it("opens a relay socket and returns an empty invitation", async () => {659    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");660    const started = await startRelayAdoptionReceiver("receiver-password");661    expect(started.invitation).toMatch(/^peach-pair-v2:/);662    // The QR carries vault:"empty" so the phone knows to act as source.663    const decoded = decodeInvitation(started.invitation);664    expect(decoded?.vault).toBe("empty");665    expect(mocks.openSocket).toHaveBeenCalled();666  });667668  it("rejects an empty password", async () => {669    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");670    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");671  });672673  it("surfaces the Pro-required rejection without creating a vault", async () => {674    mocks.openSocket.mockResolvedValue(socketWithFrames([675      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },676      { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },677    ]));678679    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");680    await startRelayAdoptionReceiver("receiver-password");681    await waitForPush("ADOPTION_SOURCE_RESULT");682683    expect(mocks.createVault).not.toHaveBeenCalled();684    expect(mocks.rollbackVault).not.toHaveBeenCalled();685    expect(mocks.pushes).toContainEqual({686      type: "ADOPTION_SOURCE_RESULT",687      payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },688    });689  });690691  it("the receiver wires requestAdoption, not grantAdoption", async () => {692    // The receiver's protocol position is the opposite of the source side:693    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.694    // Assert this by watching what lands on socket.send: the receiver's695    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY696    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.697    const rootKeyHex = "f0".repeat(32);698    const vaultId = "b".repeat(64);699    const socket = socketWithFrames([700      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },701      {702        type: "ADOPT_GRANT",703        root_key_hex: rootKeyHex,704        vault_id: vaultId,705        license: "PPK-test",706        source: {707          device_id: "phone-source-id",708          device_name: "June's phone",709          device_type: "android",710          long_term_public_key_hex: "99".repeat(32),711        },712      },713      { type: "ADOPT_COMPLETE" },714    ], { closeOnEmpty: true });715    mocks.openSocket.mockResolvedValue(socket);716    mocks.createVault.mockResolvedValue({ vaultId });717718    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");719    Object.defineProperty(globalThis, "chrome", {720      configurable: true,721      value: {722        runtime: {723          async sendMessage(message: { type: string; payload?: unknown }) {724            mocks.pushes.push({ type: message.type, payload: message.payload });725            if (message.type === "ADOPTION_SOURCE_SAS") {726              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };727              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {728                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");729                resolveReceiverSasConfirmation(p.id, p.sas, true);730              }731            }732            return { ok: true };733          },734          onMessage: { addListener: () => undefined, removeListener: () => undefined },735        },736      },737    });738    await startRelayAdoptionReceiver("receiver-password");739    await waitForPush("ADOPTION_SOURCE_RESULT");740741    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })742      .mock.calls743      .map(([bytes]) => {744        try {745          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;746        } catch {747          return undefined;748        }749      });750    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;751    // ADOPT_GRANT is the source side's frame and must never appear here.752    expect(sentTypes).toContain("ADOPT_REQUEST");753    expect(sentTypes).not.toContain("ADOPT_GRANT");754  });755756  it("completes the handshake end-to-end: vault created → paired", async () => {757    // Wire the receiver's noise mock and SAS gate together so the full758    // requestAdoption protocol runs against an in-memory phone that sends759    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.760    // The mocked noise is pass-through (encrypt = decrypt = identity), so761    // frames land as ordinary JSON.762    const rootKeyHex = "f0".repeat(32);763    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);764    // the grant's vault_id is set to the same so the protocol proceeds.765    const vaultId = "b".repeat(64);766    const frames = [767      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },768      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two769      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.770      {771        type: "ADOPT_GRANT",772        root_key_hex: rootKeyHex,773        vault_id: vaultId,774        license: "PPK-test",775        source: {776          device_id: "phone-source-id",777          device_name: "June's phone",778          device_type: "android",779          long_term_public_key_hex: "99".repeat(32),780          signing_public_key_hex: "aa".repeat(32),781          relay_url: "wss://relay.peachpasswords.com",782        },783      },784      { type: "ADOPT_COMPLETE" },785    ];786    mocks.createVault.mockResolvedValue({ vaultId });787    mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));788789    // Replace the chrome.runtime mock for this one test and approve the displayed SAS.790    Object.defineProperty(globalThis, "chrome", {791      configurable: true,792      value: {793        runtime: {794          async sendMessage(message: { type: string; payload?: unknown }) {795            mocks.pushes.push({ type: message.type, payload: message.payload });796            if (message.type === "ADOPTION_SOURCE_SAS") {797              const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean };798              if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") {799                const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg");800                resolveReceiverSasConfirmation(p.id, p.sas, true);801              }802            }803            return { ok: true };804          },805          onMessage: {806            addListener: () => undefined,807            removeListener: () => undefined,808          },809        },810      },811    });812813    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");814    await startRelayAdoptionReceiver("receiver-password");815816    await waitForPush("ADOPTION_SOURCE_RESULT");817818    // The receiver displays its locally derived SAS and requires confirmation.819    expect(mocks.pushes).toContainEqual({820      type: "ADOPTION_SOURCE_SAS",821      payload: expect.objectContaining({822        sas: expect.stringMatching(/^\d{8}$/),823        displayOnly: false,824      }),825    });826827    // The vault module was called directly with the popup-supplied password828    // and the root key granted by the phone.829    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);830831    // License activation, identity derivation, and pairing persistence all832    // call their modules directly from the service worker.833    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");834    expect(mocks.isProUser).toHaveBeenCalled();835    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));836    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({837      deviceId: "phone-source-id",838    }));839    const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;840    expect(receiverRow.rendezvousSecretHex).toBeUndefined();841    expect(mocks.setPairSecret).toHaveBeenCalledWith(842      "phone-source-id",843      expect.stringMatching(/^[0-9a-f]{64}$/),844      expect.any(Uint8Array),845    );846    expect(mocks.storageSet).toHaveBeenCalledWith(847      "peach_initial_sync_pending_device_id",848      "phone-source-id",849    );850    expect(mocks.rollbackVault).not.toHaveBeenCalled();851852    // The terminal state is "complete" — the popup can transition to success.853    expect(mocks.pushes).toContainEqual({854      type: "ADOPTION_SOURCE_RESULT",855      payload: { status: "complete" },856    });857  });858859  it("rolls back and refuses completion when a carried Pro license is not activated", async () => {860    const rootKeyHex = "f0".repeat(32);861    const vaultId = "b".repeat(64);862    mocks.createVault.mockResolvedValue({ vaultId });863    mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });864    mocks.isProUser.mockResolvedValue(false);865    mocks.openSocket.mockResolvedValue(socketWithFrames([866      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },867      {868        type: "ADOPT_GRANT",869        root_key_hex: rootKeyHex,870        vault_id: vaultId,871        license: "PPK-invalid",872        source: {873          device_id: "phone-source-id",874          device_name: "June's phone",875          device_type: "android",876          long_term_public_key_hex: "99".repeat(32),877        },878      },879    ]));880881    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");882    await startRelayAdoptionReceiver("receiver-password");883    await waitForPush("ADOPTION_SOURCE_RESULT");884885    expect(mocks.rollbackVault).toHaveBeenCalledOnce();886    expect(mocks.localIdentity).not.toHaveBeenCalled();887    expect(mocks.upsert).not.toHaveBeenCalled();888    expect(mocks.pushes).toContainEqual(expect.objectContaining({889      type: "ADOPTION_SOURCE_RESULT",890      payload: expect.objectContaining({891        status: "error",892        error: "license_transfer_failed",893      }),894    }));895  });896897  it("rolls back a newly created vault when a later adoption step fails", async () => {898    const rootKeyHex = "f0".repeat(32);899    const vaultId = "b".repeat(64);900    mocks.createVault.mockResolvedValue({ vaultId });901    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));902    mocks.openSocket.mockResolvedValue(socketWithFrames([903      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },904      {905        type: "ADOPT_GRANT",906        root_key_hex: rootKeyHex,907        vault_id: vaultId,908        license: "PPK-test",909        source: {910          device_id: "phone-source-id",911          device_name: "June's phone",912          device_type: "android",913          long_term_public_key_hex: "99".repeat(32),914        },915      },916    ]));917918    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");919    await startRelayAdoptionReceiver("receiver-password");920    await waitForPush("ADOPTION_SOURCE_RESULT");921922    expect(mocks.rollbackVault).toHaveBeenCalledOnce();923    expect(mocks.upsert).not.toHaveBeenCalled();924    expect(mocks.pushes).toContainEqual(expect.objectContaining({925      type: "ADOPTION_SOURCE_RESULT",926      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),927    }));928  });929930  it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {931    // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source).932    // The receiver should skip the 30-second inline snapshot wait entirely933    // and fall through to the sync pipeline — no receiveVaultSnapshot call.934    const rootKeyHex = "f0".repeat(32);935    const vaultId = "b".repeat(64);936    mocks.createVault.mockResolvedValue({ vaultId });937    mocks.openSocket.mockResolvedValue(socketWithFrames([938      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },939      {940        type: "ADOPT_GRANT",941        root_key_hex: rootKeyHex,942        vault_id: vaultId,943        license: "PPK-test",944        source: {945          device_id: "old-phone-id",946          device_name: "Old phone",947          device_type: "android",948          long_term_public_key_hex: "99".repeat(32),949        },950        // NO snapshot_capable: pre-C source does not echo the field951      },952      { type: "ADOPT_COMPLETE" },953    ], { closeOnEmpty: true }));954955    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");956    await startRelayAdoptionReceiver("receiver-password");957    await waitForPush("ADOPTION_SOURCE_RESULT");958959    // Sync fallback ran immediately (no 30s wait).960    expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });961    // Adoption completed successfully.962    expect(mocks.pushes).toContainEqual(expect.objectContaining({963      type: "ADOPTION_SOURCE_RESULT",964      payload: expect.objectContaining({ status: "complete" }),965    }));966    // The initial sync pending marker was set (not cleared — no snapshot applied).967    expect(mocks.storageSet).toHaveBeenCalledWith(968      "peach_initial_sync_pending_device_id",969      "old-phone-id",970    );971  });972973  it("applies the source's device manifest after the snapshot, then acks", async () => {974    // Mesh propagation: a 3rd browser that completes an adoption with a975    // snapshot-capable source must also receive the source's signed device976    // manifest and forward it to the sync adapter before the receiver977    // resolves. The relay stays an opaque forwarder, so the whole path978    // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`.979    const rootKeyHex = "f0".repeat(32);980    const vaultId = "b".repeat(64);981    const longTermPubkeyHex = "99".repeat(32);982    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");983    const { deriveSyncSigningKeypair, pubkeyToHex } = await import(984      "../shared/sync-identity"985    );986    const issuerKp = await deriveSyncSigningKeypair(987      new Uint8Array(32).fill(0x55),988      "phone-source-id",989    );990    const manifest = await buildSignedDeviceManifest(991      "phone-source-id",992      [993        {994          deviceId: "peer-b",995          deviceName: "Pool B",996          deviceType: "extension",997          longTermPublicKeyHex: "11".repeat(32),998          signingPublicKeyHex: "22".repeat(32),999          wsPort: 49152,1000          relayUrl: "wss://relay.peachpasswords.com",1001          pairedAt: 1,1002        },1003      ],1004      issuerKp.privateKey,1005    );1006    const socket = socketWithFrames([1007      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1008      {1009        type: "ADOPT_GRANT",1010        root_key_hex: rootKeyHex,1011        vault_id: vaultId,1012        license: "PPK-test",1013        snapshot_capable: true,1014        source: {1015          device_id: "phone-source-id",1016          device_name: "June's phone",1017          device_type: "android",1018          long_term_public_key_hex: longTermPubkeyHex,1019          signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),1020          relay_url: "wss://relay.peachpasswords.com",1021        },1022      },1023      // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is1024      // an optional follow-up frame on the same channel.1025      { type: "ADOPT_COMPLETE" },1026      // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames1027      // after the handshake; one empty chunk ends the phase.1028      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1029      { type: "ADOPT_DEVICE_MANIFEST", manifest },1030    ], { closeOnEmpty: true });1031    mocks.createVault.mockResolvedValue({ vaultId });1032    mocks.openSocket.mockResolvedValue(socket);1033    mocks.__applyIncomingDeviceManifest.mockClear();10341035    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1036    await startRelayAdoptionReceiver("receiver-password");1037    await waitForPush("ADOPTION_SOURCE_RESULT");10381039    expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);1040    const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];1041    expect(forwarded).toEqual(manifest);1042    expect(pubkey).toBeInstanceOf(Uint8Array);1043    expect(pubkey.length).toBe(32);1044    // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the1045    // adapter path was skipped (the ack only follows a real apply).1046    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1047      .mock.calls1048      .map(([bytes]) => {1049        try {1050          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1051        } catch {1052          return undefined;1053        }1054      });1055    expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1056    // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed.1057    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1058  });10591060  it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1061    // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST1062    // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED,1063    // and quietly time out the optional 5s wait without acking a manifest1064    // or invoking the adapter path.1065    const rootKeyHex = "f0".repeat(32);1066    const vaultId = "b".repeat(64);1067    const longTermPubkeyHex = "99".repeat(32);1068    const socket = socketWithFrames([1069      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1070      {1071        type: "ADOPT_GRANT",1072        root_key_hex: rootKeyHex,1073        vault_id: vaultId,1074        license: "PPK-test",1075        snapshot_capable: true,1076        source: {1077          device_id: "phone-source-id",1078          device_name: "June's phone",1079          device_type: "android",1080          long_term_public_key_hex: longTermPubkeyHex,1081          signing_public_key_hex: "aa".repeat(32),1082          relay_url: "wss://relay.peachpasswords.com",1083        },1084      },1085      { type: "ADOPT_COMPLETE" },1086      // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot1087      // phase; then NO ADOPT_DEVICE_MANIFEST follow-up.1088      { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1089    ], { closeOnEmpty: true });1090    mocks.createVault.mockResolvedValue({ vaultId });1091    mocks.openSocket.mockResolvedValue(socket);1092    mocks.__applyIncomingDeviceManifest.mockClear();10931094    const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1095    await startRelayAdoptionReceiver("receiver-password");1096    await waitForPush("ADOPTION_SOURCE_RESULT");10971098    expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1099    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1100      .mock.calls1101      .map(([bytes]) => {1102        try {1103          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1104        } catch {1105          return undefined;1106        }1107      });1108    expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1109    expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1110  });1111});11121113function decodeInvitation(encoded: string): { vault?: string } | null {1114  // Mirrors parsePairingInvitation's payload structure without pulling the1115  // module in: the receiver test only needs to assert the embedded status.1116  const at = encoded.indexOf(":");1117  if (at < 0) return null;1118  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1119  try {1120    const json = Buffer.from(b64, "base64").toString("utf8");1121    return JSON.parse(json) as { vault?: string };1122  } catch {1123    return null;1124  }1125}