import { beforeEach, describe, expect, it, vi } from "vitest"; import { padSyncFrame } from "../shared/sync-padding"; const mocks = vi.hoisted(() => ({ openSocket: vi.fn(), getLocalIdentity: vi.fn(), getDeviceName: vi.fn(), getRootKeyHex: vi.fn(), upsert: vi.fn(), setPairSecret: vi.fn(), pushes: [] as Array<{ type: string; payload: unknown }>, triggerSync: vi.fn(), createVault: vi.fn(), activateLicense: vi.fn(), isProUser: vi.fn(), getLicenseKey: vi.fn(), localIdentity: vi.fn(), rollbackVault: vi.fn(), createSyncAdapter: vi.fn(), __applyIncomingDeviceManifest: vi.fn(), storageSet: 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: mocks.isProUser, getLicenseKey: mocks.getLicenseKey, activateLicense: mocks.activateLicense, })); vi.mock("./vault", () => ({ isUnlocked: vi.fn(() => true), getRootKeyHex: mocks.getRootKeyHex, createVaultFromRootKey: mocks.createVault, rollbackAdoptedVault: mocks.rollbackVault, exportDuressSlotForAdoption: vi.fn(async () => null), installAdoptedDuressSlot: vi.fn(async () => undefined), })); vi.mock("../shared/sync-identity", async (importOriginal) => { // Partial mock: the manifest test needs the REAL deriveSyncSigningKeypair / // pubkeyToHex to build a verifiable signed manifest (2026-08-04: this test // was orphaned inside the rendezvous mock factory and never ran; the mock // below had since lost the real exports). const actual = await importOriginal(); return { ...actual, computeVaultId: vi.fn(async () => "b".repeat(64)), }; }); vi.mock("../shared/storage", () => ({ storageSet: mocks.storageSet, })); vi.mock("./paired-devices", () => ({ upsertPairedDevice: mocks.upsert, })); vi.mock("./pair-secrets", () => ({ setPairSecret: mocks.setPairSecret, isValidPairSecret: (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{64}$/.test(value), })); vi.mock("./sync-trigger", () => ({ triggerSyncIfReachable: mocks.triggerSync, })); vi.mock("./sync-vault-adapter", () => ({ createSyncVaultAdapter: mocks.createSyncAdapter, })); import { cancelRelayAdoptionSource, startRelayAdoptionSource, } from "./relay-adoption-bg"; const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)); function socketWithFrames( frames: unknown[], options: { closeOnEmpty?: boolean } = {}, ) { const queue = frames.map((frame) => { const encoded = encode(frame); // PB-26-604: snapshot frames are bucket-padded before Noise. return (frame as { type?: string }).type === "ADOPT_SNAPSHOT" ? padSyncFrame(encoded) : encoded; }); return { send: vi.fn(), receive: vi.fn(async () => { const next = queue.shift(); if (!next) { // A real peer closes the channel once it runs out of frames; model // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast // and falls back to the sync pipeline instead of hanging on a timeout. if (options.closeOnEmpty) throw new Error("socket_closed"); 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.setPairSecret.mockReset(); mocks.triggerSync.mockReset(); mocks.triggerSync.mockResolvedValue(undefined); mocks.isProUser.mockReset(); mocks.isProUser.mockResolvedValue(true); mocks.getLicenseKey.mockReset(); mocks.getLicenseKey.mockResolvedValue("PPK-source"); 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); mocks.setPairSecret.mockResolvedValue(undefined); const applyIncomingDeviceManifest = vi.fn(async () => undefined); mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest; mocks.createSyncAdapter.mockReturnValue({ buildEntryManifest: vi.fn(async () => ({})), getEncryptedEntries: vi.fn(async () => ({})), buildTombstoneManifest: vi.fn(async () => ({})), applyEntries: vi.fn(async () => undefined), applyTombstones: vi.fn(async () => undefined), buildOutgoingDeviceManifest: vi.fn(async () => null), applyIncomingDeviceManifest, }); 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 p = message.payload as { id?: string; sas?: string; displayOnly?: boolean }; if (typeof p?.id === "string" && typeof p.sas === "string") { if (p.displayOnly === false) { const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg"); resolveReceiverSasConfirmation(p.id, p.sas, true); } else { const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg"); 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-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-v2:/); 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", })); // PB-26-213: the registry row stays secretless; the secret bypasses it // into the encrypted pair-secret record. const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record; expect(sourceRow.rendezvousSecretHex).toBeUndefined(); expect(mocks.setPairSecret).toHaveBeenCalledWith( "android-receiver", expect.stringMatching(/^[0-9a-f]{64}$/), expect.any(Uint8Array), ); expect(mocks.pushes).toContainEqual({ type: "ADOPTION_SOURCE_RESULT", payload: { 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 adoption without sending snapshot frames when duress is oversized", 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", snapshot_capable: true, }, { 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 { exportDuressSlotForAdoption } = await import("./vault"); vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce( "x".repeat(768 * 1024), ); await startRelayAdoptionSource(); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.pushes).toContainEqual({ type: "ADOPTION_SOURCE_RESULT", payload: { status: "error", error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE", }, }); expect(mocks.pushes).not.toContainEqual(expect.objectContaining({ type: "ADOPTION_SOURCE_RESULT", payload: expect.objectContaining({ status: "complete" }), })); const sentTypes = socket.send.mock.calls.map(([bytes]) => JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type, ); expect(sentTypes).not.toContain("ADOPT_SNAPSHOT"); expect(mocks.triggerSync).not.toHaveBeenCalled(); }); it("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => { // PB-26-206: an over-budget snapshot is never partially sent or silently // truncated — the source completes adoption and the sync pipeline carries // the entries instead. const socket = socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, { type: "ADOPT_REQUEST", device_id: "request-only-label", device_name: "June's phone", device_type: "android", snapshot_capable: true, }, { 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" }, // The receiver's ADOPT_SNAPSHOT_APPLIED ack never arrives (nothing is // sent); the source must not hang on it — withTimeout resolves the wait // via the sync fallback path below. { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 }, ]); mocks.openSocket.mockResolvedValue(socket); // 16 envelopes of ~674 KiB ≈ 10.8 MiB > 10 MiB aggregate budget. const envelope = "c".repeat(690_000); const entries: Record = {}; for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope; mocks.createSyncAdapter.mockReturnValue({ buildEntryManifest: vi.fn(async () => Object.fromEntries( Object.keys(entries).map((id) => [id, 1]), )), getEncryptedEntries: vi.fn(async () => entries), buildTombstoneManifest: vi.fn(async () => ({})), applyEntries: vi.fn(async () => undefined), applyTombstones: vi.fn(async () => undefined), buildOutgoingDeviceManifest: vi.fn(async () => null), applyIncomingDeviceManifest: vi.fn(async () => undefined), }); await startRelayAdoptionSource(); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.pushes).toContainEqual({ type: "ADOPTION_SOURCE_RESULT", payload: expect.objectContaining({ status: "complete" }), }); const sentTypes = socket.send.mock.calls.map(([bytes]) => JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type, ); expect(sentTypes).not.toContain("ADOPT_SNAPSHOT"); // Sync fallback ran so the entries still reach the receiver. expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true }); }); 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-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("rejects before reading the root key when neither peer has Pro", async () => { const socket = socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw", }, { type: "ADOPT_REQUEST", device_id: "free-receiver", device_name: "Free phone", device_type: "android", }, { type: "ADOPT_CONFIRM", confirmed: true }, ]); mocks.isProUser.mockResolvedValue(false); mocks.getLicenseKey.mockResolvedValue(undefined); mocks.openSocket.mockResolvedValue(socket); await startRelayAdoptionSource(); 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: "ADOPT_PRO_REQUIRED" }, }); const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => { try { return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))]; } catch { return []; } }); expect(sentFrames).toContainEqual({ type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED", }); }); 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-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.isProUser.mockReset(); mocks.isProUser.mockResolvedValue(true); mocks.getLicenseKey.mockReset(); mocks.getLicenseKey.mockResolvedValue(undefined); 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.storageSet.mockReset(); mocks.storageSet.mockResolvedValue(undefined); mocks.upsert.mockReset(); mocks.upsert.mockResolvedValue(undefined); mocks.setPairSecret.mockReset(); mocks.setPairSecret.mockResolvedValue(undefined); mocks.triggerSync.mockReset(); mocks.triggerSync.mockResolvedValue(undefined); mocks.openSocket.mockResolvedValue(socketWithFrames([])); Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { async sendMessage(message: { type: string; payload?: unknown }) { mocks.pushes.push({ type: message.type, payload: message.payload }); if (message.type === "ADOPTION_SOURCE_SAS") { const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean }; if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") { const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg"); resolveReceiverSasConfirmation(p.id, p.sas, true); } } return { 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-bg"); const started = await startRelayAdoptionReceiver("receiver-password"); expect(started.invitation).toMatch(/^peach-pair-v2:/); // 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-bg"); await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD"); }); it("surfaces the Pro-required rejection without creating a vault", async () => { mocks.openSocket.mockResolvedValue(socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" }, ])); const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg"); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.createVault).not.toHaveBeenCalled(); expect(mocks.rollbackVault).not.toHaveBeenCalled(); expect(mocks.pushes).toContainEqual({ type: "ADOPTION_SOURCE_RESULT", payload: { status: "error", error: "ADOPT_PRO_REQUIRED" }, }); }); 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, 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), }, }, { type: "ADOPT_COMPLETE" }, ], { closeOnEmpty: true }); mocks.openSocket.mockResolvedValue(socket); mocks.createVault.mockResolvedValue({ vaultId }); const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg"); Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { async sendMessage(message: { type: string; payload?: unknown }) { mocks.pushes.push({ type: message.type, payload: message.payload }); if (message.type === "ADOPTION_SOURCE_SAS") { const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean }; if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") { const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg"); resolveReceiverSasConfirmation(p.id, p.sas, true); } } return { 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, { closeOnEmpty: true })); // Replace the chrome.runtime mock for this one test and approve the displayed SAS. Object.defineProperty(globalThis, "chrome", { configurable: true, value: { runtime: { async sendMessage(message: { type: string; payload?: unknown }) { mocks.pushes.push({ type: message.type, payload: message.payload }); if (message.type === "ADOPTION_SOURCE_SAS") { const p = message.payload as { id?: string; sas?: string; displayOnly?: boolean }; if (p.displayOnly === false && typeof p.id === "string" && typeof p.sas === "string") { const { resolveReceiverSasConfirmation } = await import("./relay-adoption-bg"); resolveReceiverSasConfirmation(p.id, p.sas, true); } } return { ok: true }; }, onMessage: { addListener: () => undefined, removeListener: () => undefined, }, }, }, }); const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg"); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); // The receiver displays its locally derived SAS and requires confirmation. expect(mocks.pushes).toContainEqual({ type: "ADOPTION_SOURCE_SAS", payload: expect.objectContaining({ sas: expect.stringMatching(/^\d{8}$/), displayOnly: false, }), }); // 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.isProUser).toHaveBeenCalled(); expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array)); expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "phone-source-id", })); const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record; expect(receiverRow.rendezvousSecretHex).toBeUndefined(); expect(mocks.setPairSecret).toHaveBeenCalledWith( "phone-source-id", expect.stringMatching(/^[0-9a-f]{64}$/), expect.any(Uint8Array), ); expect(mocks.storageSet).toHaveBeenCalledWith( "peach_initial_sync_pending_device_id", "phone-source-id", ); expect(mocks.rollbackVault).not.toHaveBeenCalled(); // The terminal state is "complete" — the popup can transition to success. expect(mocks.pushes).toContainEqual({ type: "ADOPTION_SOURCE_RESULT", payload: { status: "complete" }, }); }); it("rolls back and refuses completion when a carried Pro license is not activated", async () => { const rootKeyHex = "f0".repeat(32); const vaultId = "b".repeat(64); mocks.createVault.mockResolvedValue({ vaultId }); mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" }); mocks.isProUser.mockResolvedValue(false); mocks.openSocket.mockResolvedValue(socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, license: "PPK-invalid", 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-bg"); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.rollbackVault).toHaveBeenCalledOnce(); expect(mocks.localIdentity).not.toHaveBeenCalled(); expect(mocks.upsert).not.toHaveBeenCalled(); expect(mocks.pushes).toContainEqual(expect.objectContaining({ type: "ADOPTION_SOURCE_RESULT", payload: expect.objectContaining({ status: "error", error: "license_transfer_failed", }), })); }); 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, 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), }, }, ])); const { startRelayAdoptionReceiver } = await import("./relay-adoption-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" }), })); }); it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => { // The source's ADOPT_GRANT does not echo snapshot_capable (pre-C source). // The receiver should skip the 30-second inline snapshot wait entirely // and fall through to the sync pipeline — no receiveVaultSnapshot call. const rootKeyHex = "f0".repeat(32); const vaultId = "b".repeat(64); mocks.createVault.mockResolvedValue({ vaultId }); mocks.openSocket.mockResolvedValue(socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, license: "PPK-test", source: { device_id: "old-phone-id", device_name: "Old phone", device_type: "android", long_term_public_key_hex: "99".repeat(32), }, // NO snapshot_capable: pre-C source does not echo the field }, { type: "ADOPT_COMPLETE" }, ], { closeOnEmpty: true })); const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg"); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); // Sync fallback ran immediately (no 30s wait). expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true }); // Adoption completed successfully. expect(mocks.pushes).toContainEqual(expect.objectContaining({ type: "ADOPTION_SOURCE_RESULT", payload: expect.objectContaining({ status: "complete" }), })); // The initial sync pending marker was set (not cleared — no snapshot applied). expect(mocks.storageSet).toHaveBeenCalledWith( "peach_initial_sync_pending_device_id", "old-phone-id", ); }); it("applies the source's device manifest after the snapshot, then acks", async () => { // Mesh propagation: a 3rd browser that completes an adoption with a // snapshot-capable source must also receive the source's signed device // manifest and forward it to the sync adapter before the receiver // resolves. The relay stays an opaque forwarder, so the whole path // lives in `relay-adoption-bg.ts` and `sync-vault-adapter.ts`. const rootKeyHex = "f0".repeat(32); const vaultId = "b".repeat(64); const longTermPubkeyHex = "99".repeat(32); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import( "../shared/sync-identity" ); const issuerKp = await deriveSyncSigningKeypair( new Uint8Array(32).fill(0x55), "phone-source-id", ); const manifest = await buildSignedDeviceManifest( "phone-source-id", [ { deviceId: "peer-b", deviceName: "Pool B", deviceType: "extension", longTermPublicKeyHex: "11".repeat(32), signingPublicKeyHex: "22".repeat(32), wsPort: 49152, relayUrl: "wss://relay.peachpasswords.com", pairedAt: 1, }, ], issuerKp.privateKey, ); const socket = socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, license: "PPK-test", snapshot_capable: true, source: { device_id: "phone-source-id", device_name: "June's phone", device_type: "android", long_term_public_key_hex: longTermPubkeyHex, signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw), relay_url: "wss://relay.peachpasswords.com", }, }, // Inline snapshot ack loop ends with ADOPT_COMPLETE; the manifest is // an optional follow-up frame on the same channel. { type: "ADOPT_COMPLETE" }, // Current protocol: the snapshot arrives as ADOPT_SNAPSHOT chunk frames // after the handshake; one empty chunk ends the phase. { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }, { type: "ADOPT_DEVICE_MANIFEST", manifest }, ], { closeOnEmpty: true }); mocks.createVault.mockResolvedValue({ vaultId }); mocks.openSocket.mockResolvedValue(socket); mocks.__applyIncomingDeviceManifest.mockClear(); const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg"); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1); const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0]; expect(forwarded).toEqual(manifest); expect(pubkey).toBeInstanceOf(Uint8Array); expect(pubkey.length).toBe(32); // The receiver must NOT have sent ADOPT_DEVICE_MANIFEST_APPLIED if the // adapter path was skipped (the ack only follows a real apply). 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; } }); expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED"); // And the source's ADOPT_DEVICE_MANIFEST frame must have been consumed. expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST"); }); it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => { // The source is snapshot-capable but does not send an ADOPT_DEVICE_MANIFEST // frame. The receiver should commit the snapshot, send ADOPT_SNAPSHOT_APPLIED, // and quietly time out the optional 5s wait without acking a manifest // or invoking the adapter path. const rootKeyHex = "f0".repeat(32); const vaultId = "b".repeat(64); const longTermPubkeyHex = "99".repeat(32); const socket = socketWithFrames([ { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" }, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, license: "PPK-test", snapshot_capable: true, source: { device_id: "phone-source-id", device_name: "June's phone", device_type: "android", long_term_public_key_hex: longTermPubkeyHex, signing_public_key_hex: "aa".repeat(32), relay_url: "wss://relay.peachpasswords.com", }, }, { type: "ADOPT_COMPLETE" }, // Current protocol: one empty ADOPT_SNAPSHOT chunk ends the snapshot // phase; then NO ADOPT_DEVICE_MANIFEST follow-up. { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }, ], { closeOnEmpty: true }); mocks.createVault.mockResolvedValue({ vaultId }); mocks.openSocket.mockResolvedValue(socket); mocks.__applyIncomingDeviceManifest.mockClear(); const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg"); await startRelayAdoptionReceiver("receiver-password"); await waitForPush("ADOPTION_SOURCE_RESULT"); expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled(); 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; } }); expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED"); expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED"); }); }); 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; } }