relay-adoption-source-bg.test.ts

20.8 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23const mocks = vi.hoisted(() => ({4  openSocket: vi.fn(),5  getLocalIdentity: vi.fn(),6  getDeviceName: vi.fn(),7  getRootKeyHex: vi.fn(),8  upsert: vi.fn(),9  pushes: [] as Array<{ type: string; payload: unknown }>,10  triggerSync: vi.fn(),11  createVault: vi.fn(),12  activateLicense: vi.fn(),13  localIdentity: vi.fn(),14  rollbackVault: vi.fn(),15  sasDecisions: [] as Array<{ id: string; ok: boolean }>,16}));1718vi.mock("../shared/pairing-dial", () => ({19  openRelayPairingSocket: mocks.openSocket,20}));2122vi.mock("../shared/noise-js", () => ({23  noiseNKResponder: vi.fn(async () => ({24    handshakeHash: new Uint8Array(32).fill(7),25    encrypt: (bytes: Uint8Array) => bytes,26    decrypt: (bytes: Uint8Array) => bytes,27    close: vi.fn(),28  })),29}));3031vi.mock("../shared/rendezvous", async (importOriginal) => {32  const actual = await importOriginal<typeof import("../shared/rendezvous")>();33  return {34    ...actual,35    generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",36  };37});383940vi.mock("./device-identity", () => ({41  getLocalPairingIdentity: async () => ({42    identity: {43      deviceId: "test-device",44      deviceName: "Test",45      deviceType: "extension",46      longTermPublicKeyHex: "aa".repeat(32),47      vaultId: "bb".repeat(32),48    },49    privateKey: new Uint8Array(32).fill(7),50    publicKey: new Uint8Array(32).fill(8),51  }),52  getDeviceName: mocks.getDeviceName,53  buildLocalIdentity: mocks.localIdentity,54}));5556vi.mock("./sync-settings", async (importOriginal) => ({57  ...(await importOriginal<typeof import("./sync-settings")>()),58  getSyncSettings: async () => ({59    mode: "public" as const,60    relayUrl: "wss://relay.peachpasswords.com",61  }),62}));6364vi.mock("./license", () => ({65  isProUser: vi.fn(async () => false),66  getLicenseKey: vi.fn(async () => undefined),67  activateLicense: mocks.activateLicense,68}));6970vi.mock("./vault", () => ({71  isUnlocked: vi.fn(() => true),72  getRootKeyHex: mocks.getRootKeyHex,73  createVaultFromRootKey: mocks.createVault,74  rollbackAdoptedVault: mocks.rollbackVault,75}));7677vi.mock("../shared/sync-identity", () => ({78  computeVaultId: vi.fn(async () => "b".repeat(64)),79}));8081vi.mock("./paired-devices", () => ({82  upsertPairedDevice: mocks.upsert,83}));8485vi.mock("./sync-trigger", () => ({86  triggerSyncIfReachable: mocks.triggerSync,87}));8889import {90  cancelRelayAdoptionSource,91  startRelayAdoptionSource,92} from "./relay-adoption-source-bg";9394const encode = (value: unknown) =>95  new TextEncoder().encode(JSON.stringify(value));9697function socketWithFrames(frames: unknown[]) {98  const queue = frames.map(encode);99  return {100    send: vi.fn(),101    receive: vi.fn(async () => {102      const next = queue.shift();103      if (!next) return new Promise<Uint8Array>(() => undefined);104      return next;105    }),106    close: vi.fn(),107    closedBeforeFrame: vi.fn(() => false),108  };109}110111async function waitForPush(type: string): Promise<void> {112  await vi.waitFor(() => {113    expect(mocks.pushes.some((message) => message.type === type)).toBe(true);114  });115}116117beforeEach(() => {118  mocks.pushes.length = 0;119  mocks.sasDecisions.length = 0;120  mocks.openSocket.mockReset();121  mocks.getLocalIdentity.mockReset();122  mocks.getRootKeyHex.mockReset();123  mocks.upsert.mockReset();124  mocks.triggerSync.mockReset();125  mocks.triggerSync.mockResolvedValue(undefined);126  mocks.getLocalIdentity.mockResolvedValue({127    identity: {128      deviceId: "browser-source",129      deviceName: "June's Browser",130      deviceType: "extension",131      longTermPublicKeyHex: "a".repeat(64),132      vaultId: "b".repeat(64),133    },134    privateKey: new Uint8Array(32).fill(1),135    publicKey: new Uint8Array(32).fill(2),136  });137  mocks.getRootKeyHex.mockReturnValue("c".repeat(64));138  mocks.upsert.mockResolvedValue(undefined);139  Object.defineProperty(globalThis, "chrome", {140    configurable: true,141    value: {142    runtime: {143      async sendMessage(message: { type: string; payload: unknown }) {144        mocks.pushes.push(message);145        // The source flow waits for the popup to send SOURCE_SAS_DECISION before146        // forwarding the root key. The receiver test overrides this mock and147        // resolves the decision itself; the source tests rely on this default148        // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame.149        if (message.type === "ADOPTION_SOURCE_SAS") {150          const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");151          const p = message.payload as { id?: string };152          if (typeof p?.id === "string") {153            mocks.sasDecisions.push({ id: p.id, ok: true });154            resolveSourceSasConfirmation(p.id, true);155          }156        }157        return Promise.resolve();158      },159    },160    },161  });162});163164describe("relay adoption source background boundary", () => {165  it("shows the SAS and waits for the popup's single confirmation", async () => {166    const socket = socketWithFrames([167      {168        type: "PEACH_HELLO",169        token: "AAECAwQFBgcICQoLDA0ODw",170      },171      {172        type: "ADOPT_REQUEST",173        device_id: "request-only-label",174        device_name: "June's phone",175        device_type: "android",176      },177      { type: "ADOPT_CONFIRM", confirmed: true },178      {179        type: "ADOPT_IDENTITY",180        device_id: "android-receiver",181        device_name: "June's phone",182        device_type: "android",183        long_term_public_key_hex: "d".repeat(64),184      },185      { type: "ADOPT_APPLIED" },186    ]);187    mocks.openSocket.mockResolvedValue(socket);188189    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");190    const sasIds: string[] = [];191    Object.defineProperty(globalThis, "chrome", {192      configurable: true,193      value: {194        runtime: {195          async sendMessage(message: { type: string; payload: unknown }) {196            mocks.pushes.push(message);197            if (message.type === "ADOPTION_SOURCE_SAS") {198              const p = message.payload as { id?: string };199              if (typeof p?.id === "string") {200                // Hold the SAS gate open until the test explicitly approves201                // it — this proves the source flow waits for the popup.202                sasIds.push(p.id);203              }204              return Promise.resolve();205            }206            return Promise.resolve();207          },208        },209      },210    });211    void resolveSourceSasConfirmation; // imported for the post-approval call below212213    const started = await startRelayAdoptionSource();214    expect(started.invitation).toMatch(/^peach-pair-v1:/);215    await waitForPush("ADOPTION_SOURCE_SAS");216    // The adoption must NOT be complete yet — the popup's SAS decision is the217    // prerequisite for forwarding the root key.218    await vi.waitFor(() => {219      expect(mocks.sasDecisions.length).toBe(0);220    });221    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();222    expect(mocks.upsert).not.toHaveBeenCalled();223224    // Approve the SAS; the adoption should now proceed and complete.225    const id = sasIds[0];226    expect(typeof id).toBe("string");227    resolveSourceSasConfirmation(id, true);228    mocks.sasDecisions.push({ id, ok: true });229230    await waitForPush("ADOPTION_SOURCE_RESULT");231232    expect(mocks.getRootKeyHex).toHaveBeenCalled();233    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({234      deviceId: "android-receiver",235      relayUrl: "wss://relay.peachpasswords.com",236    }));237    expect(mocks.pushes).toContainEqual(expect.objectContaining({238      type: "ADOPTION_SOURCE_RESULT",239      payload: expect.objectContaining({ status: "complete" }),240    }));241    expect(mocks.triggerSync).toHaveBeenCalledWith({242      reason: "manual",243      force: true,244    });245    const sentTypes = socket.send.mock.calls246      .map(([bytes]) => {247        try {248          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;249        } catch {250          return undefined;251        }252      });253    expect(sentTypes).toContain("ADOPT_COMPLETE");254  });255256  it("fails closed when the popup rejects the SAS", async () => {257    const socket = socketWithFrames([258      {259        type: "PEACH_HELLO",260        token: "AAECAwQFBgcICQoLDA0ODw",261      },262      {263        type: "ADOPT_REQUEST",264        device_id: "request-only-label",265        device_name: "New phone",266        device_type: "android",267      },268      { type: "ADOPT_CONFIRM", confirmed: false },269    ]);270    mocks.openSocket.mockResolvedValue(socket);271272    const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");273    let pendingId: string | undefined;274    Object.defineProperty(globalThis, "chrome", {275      configurable: true,276      value: {277        runtime: {278          sendMessage(message: { type: string; payload: unknown }) {279            mocks.pushes.push(message);280            if (message.type === "ADOPTION_SOURCE_SAS") {281              const p = message.payload as { id?: string };282              if (typeof p?.id === "string") pendingId = p.id;283            }284            return Promise.resolve();285          },286        },287      },288    });289290    await startRelayAdoptionSource();291    await waitForPush("ADOPTION_SOURCE_SAS");292    expect(typeof pendingId).toBe("string");293    resolveSourceSasConfirmation(pendingId!, false);294    mocks.sasDecisions.push({ id: pendingId!, ok: false });295296    await waitForPush("ADOPTION_SOURCE_RESULT");297298    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();299    expect(mocks.upsert).not.toHaveBeenCalled();300    expect(mocks.pushes).toContainEqual({301      type: "ADOPTION_SOURCE_RESULT",302      payload: { status: "error", error: "adoption_rejected" },303    });304  });305306  it("fails closed when the user never answers the SAS", async () => {307    // vi.useFakeTimers is unsafe with the live noise/socket mocks (they308    // schedule real work), so we hold the SAS gate open by never resolving309    // the deferred and then cancel the session. clearState must drop the310    // pending SAS so it doesn't pin the relay room open for the next run.311    const socket = socketWithFrames([]);312    mocks.openSocket.mockResolvedValue(socket);313314    Object.defineProperty(globalThis, "chrome", {315      configurable: true,316      value: {317        runtime: {318          sendMessage(message: { type: string; payload: unknown }) {319            mocks.pushes.push(message);320            return Promise.resolve();321          },322        },323      },324    });325326    await startRelayAdoptionSource();327    cancelRelayAdoptionSource();328329    expect(socket.close).toHaveBeenCalled();330    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();331    expect(mocks.upsert).not.toHaveBeenCalled();332  });333334  it("cancelling the source screen closes the room without reading the root", async () => {335    const socket = socketWithFrames([]);336    mocks.openSocket.mockResolvedValue(socket);337338    await startRelayAdoptionSource();339    cancelRelayAdoptionSource();340341    expect(socket.close).toHaveBeenCalled();342    expect(mocks.getRootKeyHex).not.toHaveBeenCalled();343    expect(mocks.upsert).not.toHaveBeenCalled();344  });345});346347describe("relay adoption receiver background", () => {348  beforeEach(async () => {349    // The receiver session runs in the background via `void runReceiverSession`;350    // it doesn't get awaited by the caller and can leave `inFlight` set if the351    // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test352    // starts from a clean state.353    const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");354    cancelRelayAdoptionSource();355    mocks.pushes.length = 0;356    mocks.openSocket.mockReset();357    mocks.getDeviceName.mockReset();358    mocks.getDeviceName.mockResolvedValue("Receiver Browser");359    mocks.createVault.mockReset();360    mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });361    mocks.activateLicense.mockReset();362    mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });363    mocks.localIdentity.mockReset();364    mocks.localIdentity.mockResolvedValue({365      deviceId: "receiver-id",366      deviceName: "Receiver Browser",367      deviceType: "extension",368      longTermPublicKeyHex: "11".repeat(32),369      signingPublicKeyHex: "22".repeat(32),370    });371    mocks.rollbackVault.mockReset();372    mocks.rollbackVault.mockResolvedValue(undefined);373    mocks.upsert.mockReset();374    mocks.upsert.mockResolvedValue(undefined);375    mocks.triggerSync.mockReset();376    mocks.triggerSync.mockResolvedValue(undefined);377    mocks.openSocket.mockResolvedValue(socketWithFrames([]));378    Object.defineProperty(globalThis, "chrome", {379      configurable: true,380      value: {381        runtime: {382          sendMessage(message: { type: string; payload?: unknown }) {383            mocks.pushes.push({ type: message.type, payload: message.payload });384            return Promise.resolve({ ok: true });385          },386          onMessage: {387            addListener() { /* no-op */ },388            removeListener() { /* no-op */ },389          },390        },391      },392    });393  });394395  it("opens a relay socket and returns an empty invitation", async () => {396    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");397    const started = await startRelayAdoptionReceiver("receiver-password");398    expect(started.invitation).toMatch(/^peach-pair-v1:/);399    // The QR carries vault:"empty" so the phone knows to act as source.400    const decoded = decodeInvitation(started.invitation);401    expect(decoded?.vault).toBe("empty");402    expect(mocks.openSocket).toHaveBeenCalled();403  });404405  it("rejects an empty password", async () => {406    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");407    await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");408  });409410  it("the receiver wires requestAdoption, not grantAdoption", async () => {411    // The receiver's protocol position is the opposite of the source side:412    // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key.413    // Assert this by watching what lands on socket.send: the receiver's414    // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY415    // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side.416    const rootKeyHex = "f0".repeat(32);417    const vaultId = "b".repeat(64);418    const socket = socketWithFrames([419      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },420      {421        type: "ADOPT_GRANT",422        root_key_hex: rootKeyHex,423        vault_id: vaultId,424        source: {425          device_id: "phone-source-id",426          device_name: "June's phone",427          device_type: "android",428          long_term_public_key_hex: "99".repeat(32),429        },430      },431      { type: "ADOPT_COMPLETE" },432    ]);433    mocks.openSocket.mockResolvedValue(socket);434    mocks.createVault.mockResolvedValue({ vaultId });435436    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");437    Object.defineProperty(globalThis, "chrome", {438      configurable: true,439      value: {440        runtime: {441          sendMessage(message: { type: string; payload?: unknown }) {442            mocks.pushes.push({ type: message.type, payload: message.payload });443            return Promise.resolve({ ok: true });444          },445          onMessage: { addListener: () => undefined, removeListener: () => undefined },446        },447      },448    });449    await startRelayAdoptionReceiver("receiver-password");450    await waitForPush("ADOPTION_SOURCE_RESULT");451452    const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })453      .mock.calls454      .map(([bytes]) => {455        try {456          return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;457        } catch {458          return undefined;459        }460      });461    // ADOPT_REQUEST is the first adoption-protocol frame out of this side;462    // ADOPT_GRANT is the source side's frame and must never appear here.463    expect(sentTypes).toContain("ADOPT_REQUEST");464    expect(sentTypes).not.toContain("ADOPT_GRANT");465  });466467  it("completes the handshake end-to-end: vault created → paired", async () => {468    // Wire the receiver's noise mock and SAS gate together so the full469    // requestAdoption protocol runs against an in-memory phone that sends470    // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE.471    // The mocked noise is pass-through (encrypt = decrypt = identity), so472    // frames land as ordinary JSON.473    const rootKeyHex = "f0".repeat(32);474    // computeVaultId and createVaultFromRootKey both return "b".repeat(64);475    // the grant's vault_id is set to the same so the protocol proceeds.476    const vaultId = "b".repeat(64);477    const frames = [478      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },479      // ADOPT_CONFIRM is sent BY the receiver, not received. The next two480      // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE.481      {482        type: "ADOPT_GRANT",483        root_key_hex: rootKeyHex,484        vault_id: vaultId,485        license: "PPK-test",486        source: {487          device_id: "phone-source-id",488          device_name: "June's phone",489          device_type: "android",490          long_term_public_key_hex: "99".repeat(32),491          signing_public_key_hex: "aa".repeat(32),492          relay_url: "wss://relay.peachpasswords.com",493        },494      },495      { type: "ADOPT_COMPLETE" },496    ];497    mocks.createVault.mockResolvedValue({ vaultId });498    mocks.openSocket.mockResolvedValue(socketWithFrames(frames));499500    // Replace the chrome.runtime mock for this one test. The receiver501    // auto-accepts the SAS, so no decision needs to be simulated.502    Object.defineProperty(globalThis, "chrome", {503      configurable: true,504      value: {505        runtime: {506          sendMessage(message: { type: string; payload?: unknown }) {507            mocks.pushes.push({ type: message.type, payload: message.payload });508            return Promise.resolve({ ok: true });509          },510          onMessage: {511            addListener: () => undefined,512            removeListener: () => undefined,513          },514        },515      },516    });517518    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");519    await startRelayAdoptionReceiver("receiver-password");520521    await waitForPush("ADOPTION_SOURCE_RESULT");522523    // The receiver displays its locally derived SAS without introducing a524    // second confirmation gate.525    expect(mocks.pushes).toContainEqual({526      type: "ADOPTION_SOURCE_SAS",527      payload: expect.objectContaining({528        sas: expect.stringMatching(/^\d{8}$/),529        displayOnly: true,530      }),531    });532533    // The vault module was called directly with the popup-supplied password534    // and the root key granted by the phone.535    expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);536537    // License activation, identity derivation, and pairing persistence all538    // call their modules directly from the service worker.539    expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");540    expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));541    expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({542      deviceId: "phone-source-id",543    }));544    expect(mocks.rollbackVault).not.toHaveBeenCalled();545546    // The terminal state is "complete" — the popup can transition to success.547    expect(mocks.pushes).toContainEqual(expect.objectContaining({548      type: "ADOPTION_SOURCE_RESULT",549      payload: expect.objectContaining({ status: "complete" }),550    }));551  });552553  it("rolls back a newly created vault when a later adoption step fails", async () => {554    const rootKeyHex = "f0".repeat(32);555    const vaultId = "b".repeat(64);556    mocks.createVault.mockResolvedValue({ vaultId });557    mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));558    mocks.openSocket.mockResolvedValue(socketWithFrames([559      { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },560      {561        type: "ADOPT_GRANT",562        root_key_hex: rootKeyHex,563        vault_id: vaultId,564        source: {565          device_id: "phone-source-id",566          device_name: "June's phone",567          device_type: "android",568          long_term_public_key_hex: "99".repeat(32),569        },570      },571    ]));572573    const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");574    await startRelayAdoptionReceiver("receiver-password");575    await waitForPush("ADOPTION_SOURCE_RESULT");576577    expect(mocks.rollbackVault).toHaveBeenCalledOnce();578    expect(mocks.upsert).not.toHaveBeenCalled();579    expect(mocks.pushes).toContainEqual(expect.objectContaining({580      type: "ADOPTION_SOURCE_RESULT",581      payload: expect.objectContaining({ status: "error", error: "identity_failed" }),582    }));583  });584});585586function decodeInvitation(encoded: string): { vault?: string } | null {587  // Mirrors parsePairingInvitation's payload structure without pulling the588  // module in: the receiver test only needs to assert the embedded status.589  const at = encoded.indexOf(":");590  if (at < 0) return null;591  const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");592  try {593    const json = Buffer.from(b64, "base64").toString("utf8");594    return JSON.parse(json) as { vault?: string };595  } catch {596    return null;597  }598}