import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ openSocket: vi.fn(), getLocalIdentity: vi.fn(), getDeviceName: vi.fn(), getRootKeyHex: vi.fn(), upsert: vi.fn(), pushes: [] as Array<{ type: string; payload: unknown }>, triggerSync: vi.fn(), createVault: vi.fn(), activateLicense: vi.fn(), localIdentity: vi.fn(), rollbackVault: vi.fn(), sasDecisions: [] as Array<{ id: string; ok: boolean }>, })); vi.mock("../shared/pairing-dial", () => ({ openRelayPairingSocket: mocks.openSocket, })); vi.mock("../shared/noise-js", () => ({ noiseNKResponder: vi.fn(async () => ({ handshakeHash: new Uint8Array(32).fill(7), encrypt: (bytes: Uint8Array) => bytes, decrypt: (bytes: Uint8Array) => bytes, close: vi.fn(), })), })); vi.mock("../shared/rendezvous", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw", }; }); vi.mock("./device-identity", () => ({ getLocalPairingIdentity: async () => ({ identity: { deviceId: "test-device", deviceName: "Test", deviceType: "extension", longTermPublicKeyHex: "aa".repeat(32), vaultId: "bb".repeat(32), }, privateKey: new Uint8Array(32).fill(7), publicKey: new Uint8Array(32).fill(8), }), getDeviceName: mocks.getDeviceName, buildLocalIdentity: mocks.localIdentity, })); vi.mock("./sync-settings", async (importOriginal) => ({ ...(await importOriginal()), getSyncSettings: async () => ({ mode: "public" as const, relayUrl: "wss://relay.peachpasswords.com", }), })); vi.mock("./license", () => ({ isProUser: vi.fn(async () => false), getLicenseKey: vi.fn(async () => undefined), activateLicense: mocks.activateLicense, })); vi.mock("./vault", () => ({ isUnlocked: vi.fn(() => true), getRootKeyHex: mocks.getRootKeyHex, createVaultFromRootKey: mocks.createVault, rollbackAdoptedVault: mocks.rollbackVault, })); vi.mock("../shared/sync-identity", () => ({ computeVaultId: vi.fn(async () => "b".repeat(64)), })); vi.mock("./paired-devices", () => ({ upsertPairedDevice: mocks.upsert, })); vi.mock("./sync-trigger", () => ({ triggerSyncIfReachable: mocks.triggerSync, })); import { cancelRelayAdoptionSource, startRelayAdoptionSource, } from "./relay-adoption-source-bg"; const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)); function socketWithFrames(frames: unknown[]) { const queue = frames.map(encode); return { send: vi.fn(), receive: vi.fn(async () => { const next = queue.shift(); if (!next) return new Promise(() => undefined); return next; }), close: vi.fn(), closedBeforeFrame: vi.fn(() => false), }; } async function waitForPush(type: string): Promise { await vi.waitFor(() => { expect(mocks.pushes.some((message) => message.type === type)).toBe(true); }); } beforeEach(() => { mocks.pushes.length = 0; mocks.sasDecisions.length = 0; mocks.openSocket.mockReset(); mocks.getLocalIdentity.mockReset(); mocks.getRootKeyHex.mockReset(); mocks.upsert.mockReset(); mocks.triggerSync.mockReset(); mocks.triggerSync.mockResolvedValue(undefined); mocks.getLocalIdentity.mockResolvedValue({ identity: { deviceId: "browser-source", deviceName: "June's Browser", deviceType: "extension", longTermPublicKeyHex: "a".repeat(64), vaultId: "b".repeat(64), }, privateKey: new Uint8Array(32).fill(1), publicKey: new Uint8Array(32).fill(2), }); mocks.getRootKeyHex.mockReturnValue("c".repeat(64)); mocks.upsert.mockResolvedValue(undefined); Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { async sendMessage(message: { type: string; payload: unknown }) { mocks.pushes.push(message); // The source flow waits for the popup to send SOURCE_SAS_DECISION before // forwarding the root key. The receiver test overrides this mock and // resolves the decision itself; the source tests rely on this default // auto-approver so the happy-path test reaches the ADOPT_IDENTITY frame. if (message.type === "ADOPTION_SOURCE_SAS") { const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg"); const p = message.payload as { id?: string }; if (typeof p?.id === "string") { mocks.sasDecisions.push({ id: p.id, ok: true }); resolveSourceSasConfirmation(p.id, true); } } return Promise.resolve(); }, }, }, }); }); describe("relay adoption source background boundary", () => { it("shows the SAS and waits for the popup's single confirmation", async () => { const socket = socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw", }, { type: "ADOPT_REQUEST", device_id: "request-only-label", device_name: "June's phone", device_type: "android", }, { type: "ADOPT_CONFIRM", confirmed: true }, { type: "ADOPT_IDENTITY", device_id: "android-receiver", device_name: "June's phone", device_type: "android", long_term_public_key_hex: "d".repeat(64), }, { type: "ADOPT_APPLIED" }, ]); mocks.openSocket.mockResolvedValue(socket); const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg"); const sasIds: string[] = []; Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { async sendMessage(message: { type: string; payload: unknown }) { mocks.pushes.push(message); if (message.type === "ADOPTION_SOURCE_SAS") { const p = message.payload as { id?: string }; if (typeof p?.id === "string") { // Hold the SAS gate open until the test explicitly approves // it — this proves the source flow waits for the popup. sasIds.push(p.id); } return Promise.resolve(); } return Promise.resolve(); }, }, }, }); void resolveSourceSasConfirmation; // imported for the post-approval call below const started = await startRelayAdoptionSource(); expect(started.invitation).toMatch(/^peach-pair-v1:/); await waitForPush("ADOPTION_SOURCE_SAS"); // The adoption must NOT be complete yet — the popup's SAS decision is the // prerequisite for forwarding the root key. await vi.waitFor(() => { expect(mocks.sasDecisions.length).toBe(0); }); expect(mocks.getRootKeyHex).not.toHaveBeenCalled(); expect(mocks.upsert).not.toHaveBeenCalled(); // Approve the SAS; the adoption should now proceed and complete. const id = sasIds[0]; expect(typeof id).toBe("string"); resolveSourceSasConfirmation(id, true); mocks.sasDecisions.push({ id, ok: true }); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.getRootKeyHex).toHaveBeenCalled(); expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "android-receiver", relayUrl: "wss://relay.peachpasswords.com", })); expect(mocks.pushes).toContainEqual(expect.objectContaining({ type: "ADOPTION_SOURCE_RESULT", payload: expect.objectContaining({ status: "complete" }), })); expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true, }); const sentTypes = socket.send.mock.calls .map(([bytes]) => { try { return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type; } catch { return undefined; } }); expect(sentTypes).toContain("ADOPT_COMPLETE"); }); it("fails closed when the popup rejects the SAS", async () => { const socket = socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw", }, { type: "ADOPT_REQUEST", device_id: "request-only-label", device_name: "New phone", device_type: "android", }, { type: "ADOPT_CONFIRM", confirmed: false }, ]); mocks.openSocket.mockResolvedValue(socket); const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg"); let pendingId: string | undefined; Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { sendMessage(message: { type: string; payload: unknown }) { mocks.pushes.push(message); if (message.type === "ADOPTION_SOURCE_SAS") { const p = message.payload as { id?: string }; if (typeof p?.id === "string") pendingId = p.id; } return Promise.resolve(); }, }, }, }); await startRelayAdoptionSource(); await waitForPush("ADOPTION_SOURCE_SAS"); expect(typeof pendingId).toBe("string"); resolveSourceSasConfirmation(pendingId!, false); mocks.sasDecisions.push({ id: pendingId!, ok: false }); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.getRootKeyHex).not.toHaveBeenCalled(); expect(mocks.upsert).not.toHaveBeenCalled(); expect(mocks.pushes).toContainEqual({ type: "ADOPTION_SOURCE_RESULT", payload: { status: "error", error: "adoption_rejected" }, }); }); it("fails closed when the user never answers the SAS", async () => { // vi.useFakeTimers is unsafe with the live noise/socket mocks (they // schedule real work), so we hold the SAS gate open by never resolving // the deferred and then cancel the session. clearState must drop the // pending SAS so it doesn't pin the relay room open for the next run. const socket = socketWithFrames([]); mocks.openSocket.mockResolvedValue(socket); Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { sendMessage(message: { type: string; payload: unknown }) { mocks.pushes.push(message); return Promise.resolve(); }, }, }, }); await startRelayAdoptionSource(); cancelRelayAdoptionSource(); expect(socket.close).toHaveBeenCalled(); expect(mocks.getRootKeyHex).not.toHaveBeenCalled(); expect(mocks.upsert).not.toHaveBeenCalled(); }); it("cancelling the source screen closes the room without reading the root", async () => { const socket = socketWithFrames([]); mocks.openSocket.mockResolvedValue(socket); await startRelayAdoptionSource(); cancelRelayAdoptionSource(); expect(socket.close).toHaveBeenCalled(); expect(mocks.getRootKeyHex).not.toHaveBeenCalled(); expect(mocks.upsert).not.toHaveBeenCalled(); }); }); describe("relay adoption receiver background", () => { beforeEach(async () => { // The receiver session runs in the background via `void runReceiverSession`; // it doesn't get awaited by the caller and can leave `inFlight` set if the // socket fixture didn't deliver PEACH_HELLO. Reset explicitly so each test // starts from a clean state. const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg"); cancelRelayAdoptionSource(); mocks.pushes.length = 0; mocks.openSocket.mockReset(); mocks.getDeviceName.mockReset(); mocks.getDeviceName.mockResolvedValue("Receiver Browser"); mocks.createVault.mockReset(); mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) }); mocks.activateLicense.mockReset(); mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" }); mocks.localIdentity.mockReset(); mocks.localIdentity.mockResolvedValue({ deviceId: "receiver-id", deviceName: "Receiver Browser", deviceType: "extension", longTermPublicKeyHex: "11".repeat(32), signingPublicKeyHex: "22".repeat(32), }); mocks.rollbackVault.mockReset(); mocks.rollbackVault.mockResolvedValue(undefined); mocks.upsert.mockReset(); mocks.upsert.mockResolvedValue(undefined); mocks.triggerSync.mockReset(); mocks.triggerSync.mockResolvedValue(undefined); mocks.openSocket.mockResolvedValue(socketWithFrames([])); Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { sendMessage(message: { type: string; payload?: unknown }) { mocks.pushes.push({ type: message.type, payload: message.payload }); return Promise.resolve({ ok: true }); }, onMessage: { addListener() { /* no-op */ }, removeListener() { /* no-op */ }, }, }, }, }); }); it("opens a relay socket and returns an empty invitation", async () => { const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg"); const started = await startRelayAdoptionReceiver("receiver-password"); expect(started.invitation).toMatch(/^peach-pair-v1:/); // The QR carries vault:"empty" so the phone knows to act as source. const decoded = decodeInvitation(started.invitation); expect(decoded?.vault).toBe("empty"); expect(mocks.openSocket).toHaveBeenCalled(); }); it("rejects an empty password", async () => { const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg"); await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD"); }); it("the receiver wires requestAdoption, not grantAdoption", async () => { // The receiver's protocol position is the opposite of the source side: // it sends ADOPT_REQUEST, the phone returns ADOPT_GRANT with the root key. // Assert this by watching what lands on socket.send: the receiver's // outbound ADOPT_* traffic is ADOPT_REQUEST + ADOPT_CONFIRM + ADOPT_IDENTITY // + ADOPT_APPLIED. ADOPT_GRANT only ever belongs to the source side. const rootKeyHex = "f0".repeat(32); const vaultId = "b".repeat(64); const socket = socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, source: { device_id: "phone-source-id", device_name: "June's phone", device_type: "android", long_term_public_key_hex: "99".repeat(32), }, }, { type: "ADOPT_COMPLETE" }, ]); mocks.openSocket.mockResolvedValue(socket); mocks.createVault.mockResolvedValue({ vaultId }); const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg"); Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { sendMessage(message: { type: string; payload?: unknown }) { mocks.pushes.push({ type: message.type, payload: message.payload }); return Promise.resolve({ ok: true }); }, onMessage: { addListener: () => undefined, removeListener: () => undefined }, }, }, }); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } }) .mock.calls .map(([bytes]) => { try { return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type; } catch { return undefined; } }); // ADOPT_REQUEST is the first adoption-protocol frame out of this side; // ADOPT_GRANT is the source side's frame and must never appear here. expect(sentTypes).toContain("ADOPT_REQUEST"); expect(sentTypes).not.toContain("ADOPT_GRANT"); }); it("completes the handshake end-to-end: vault created → paired", async () => { // Wire the receiver's noise mock and SAS gate together so the full // requestAdoption protocol runs against an in-memory phone that sends // PEACH_HELLO, then ADOPT_CONFIRM(true), ADOPT_GRANT, ADOPT_COMPLETE. // The mocked noise is pass-through (encrypt = decrypt = identity), so // frames land as ordinary JSON. const rootKeyHex = "f0".repeat(32); // computeVaultId and createVaultFromRootKey both return "b".repeat(64); // the grant's vault_id is set to the same so the protocol proceeds. const vaultId = "b".repeat(64); const frames = [ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, // ADOPT_CONFIRM is sent BY the receiver, not received. The next two // frames are what the phone returns: ADOPT_GRANT then ADOPT_COMPLETE. { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, license: "PPK-test", source: { device_id: "phone-source-id", device_name: "June's phone", device_type: "android", long_term_public_key_hex: "99".repeat(32), signing_public_key_hex: "aa".repeat(32), relay_url: "wss://relay.peachpasswords.com", }, }, { type: "ADOPT_COMPLETE" }, ]; mocks.createVault.mockResolvedValue({ vaultId }); mocks.openSocket.mockResolvedValue(socketWithFrames(frames)); // Replace the chrome.runtime mock for this one test. The receiver // auto-accepts the SAS now, so no decision needs to be simulated. Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { sendMessage(message: { type: string; payload?: unknown }) { mocks.pushes.push({ type: message.type, payload: message.payload }); return Promise.resolve({ ok: true }); }, onMessage: { addListener: () => undefined, removeListener: () => undefined, }, }, }, }); const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg"); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); // The receiver auto-accepted the SAS, so no ADOPTION_SOURCE_SAS push is // expected — only the terminal result matters. // The vault module was called directly with the popup-supplied password // and the root key granted by the phone. expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex); // License activation, identity derivation, and pairing persistence all // call their modules directly from the service worker. expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test"); expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array)); expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "phone-source-id", })); expect(mocks.rollbackVault).not.toHaveBeenCalled(); // The terminal state is "complete" — the popup can transition to success. expect(mocks.pushes).toContainEqual(expect.objectContaining({ type: "ADOPTION_SOURCE_RESULT", payload: expect.objectContaining({ status: "complete" }), })); }); it("rolls back a newly created vault when a later adoption step fails", async () => { const rootKeyHex = "f0".repeat(32); const vaultId = "b".repeat(64); mocks.createVault.mockResolvedValue({ vaultId }); mocks.localIdentity.mockRejectedValue(new Error("identity_failed")); mocks.openSocket.mockResolvedValue(socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, source: { device_id: "phone-source-id", device_name: "June's phone", device_type: "android", long_term_public_key_hex: "99".repeat(32), }, }, ])); const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg"); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.rollbackVault).toHaveBeenCalledOnce(); expect(mocks.upsert).not.toHaveBeenCalled(); expect(mocks.pushes).toContainEqual(expect.objectContaining({ type: "ADOPTION_SOURCE_RESULT", payload: expect.objectContaining({ status: "error", error: "identity_failed" }), })); }); }); function decodeInvitation(encoded: string): { vault?: string } | null { // Mirrors parsePairingInvitation's payload structure without pulling the // module in: the receiver test only needs to assert the embedded status. const at = encoded.indexOf(":"); if (at < 0) return null; const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/"); try { const json = Buffer.from(b64, "base64").toString("utf8"); return JSON.parse(json) as { vault?: string }; } catch { return null; } }