1import { beforeEach, describe, expect, it, vi } from "vitest";23const mocks = vi.hoisted(() => ({4 openSocket: vi.fn(),5 getLocalIdentity: vi.fn(),6 verifyPassword: vi.fn(),7 getRootKeyHex: vi.fn(),8 upsert: vi.fn(),9 pushes: [] as Array<{ type: string; payload: unknown }>,10 triggerSync: vi.fn(),11}));1213vi.mock("../shared/pairing-dial", () => ({14 openRelayPairingSocket: mocks.openSocket,15}));1617vi.mock("../shared/noise-js", () => ({18 noiseNKResponder: vi.fn(async () => ({19 handshakeHash: new Uint8Array(32).fill(7),20 encrypt: (bytes: Uint8Array) => bytes,21 decrypt: (bytes: Uint8Array) => bytes,22 close: vi.fn(),23 })),24}));2526vi.mock("../shared/rendezvous", async (importOriginal) => {27 const actual = await importOriginal<typeof import("../shared/rendezvous")>();28 return {29 ...actual,30 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",31 };32});333435vi.mock("./device-identity", () => ({36 getLocalPairingIdentity: async () => ({37 identity: {38 deviceId: "test-device",39 deviceName: "Test",40 deviceType: "extension",41 longTermPublicKeyHex: "aa".repeat(32),42 vaultId: "bb".repeat(32),43 },44 privateKey: new Uint8Array(32).fill(7),45 publicKey: new Uint8Array(32).fill(8),46 }),47}));4849vi.mock("./sync-settings", async (importOriginal) => ({50 ...(await importOriginal<typeof import("./sync-settings")>()),51 getSyncSettings: async () => ({52 mode: "public" as const,53 relayUrl: "wss://relay.peachpasswords.com",54 }),55}));5657vi.mock("./license", () => ({58 isProUser: vi.fn(async () => false),59 getLicenseKey: vi.fn(async () => undefined),60}));6162vi.mock("./vault", () => ({63 isUnlocked: vi.fn(() => true),64 verifyMainPassword: mocks.verifyPassword,65 getRootKeyHex: mocks.getRootKeyHex,66}));6768vi.mock("../shared/sync-identity", () => ({69 computeVaultId: vi.fn(async () => "b".repeat(64)),70}));7172vi.mock("./paired-devices", () => ({73 upsertPairedDevice: mocks.upsert,74}));7576vi.mock("./sync-trigger", () => ({77 triggerSyncIfReachable: mocks.triggerSync,78}));7980import {81 cancelRelayAdoptionSource,82 confirmRelayAdoptionSource,83 startRelayAdoptionSource,84} from "./relay-adoption-source-bg";8586const encode = (value: unknown) =>87 new TextEncoder().encode(JSON.stringify(value));8889function socketWithFrames(frames: unknown[]) {90 const queue = frames.map(encode);91 return {92 send: vi.fn(),93 receive: vi.fn(async () => {94 const next = queue.shift();95 if (!next) return new Promise<Uint8Array>(() => undefined);96 return next;97 }),98 close: vi.fn(),99 closedBeforeFrame: vi.fn(() => false),100 };101}102103async function waitForPush(type: string): Promise<void> {104 await vi.waitFor(() => {105 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);106 });107}108109beforeEach(() => {110 mocks.pushes.length = 0;111 mocks.openSocket.mockReset();112 mocks.getLocalIdentity.mockReset();113 mocks.verifyPassword.mockReset();114 mocks.getRootKeyHex.mockReset();115 mocks.upsert.mockReset();116 mocks.triggerSync.mockReset();117 mocks.triggerSync.mockResolvedValue(undefined);118 mocks.getLocalIdentity.mockResolvedValue({119 identity: {120 deviceId: "browser-source",121 deviceName: "June's Browser",122 deviceType: "extension",123 longTermPublicKeyHex: "a".repeat(64),124 vaultId: "b".repeat(64),125 },126 privateKey: new Uint8Array(32).fill(1),127 publicKey: new Uint8Array(32).fill(2),128 });129 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));130 mocks.upsert.mockResolvedValue(undefined);131 Object.defineProperty(globalThis, "chrome", {132 configurable: true,133 value: {134 runtime: {135 sendMessage(message: { type: string; payload: unknown }) {136 mocks.pushes.push(message);137 return Promise.resolve();138 },139 },140 },141 });142});143144describe("relay adoption source background boundary", () => {145 it("does not access the root for a wrong password and only grants after both confirmations", async () => {146 const socket = socketWithFrames([147 {148 type: "PEACH_HELLO",149 token: "AAECAwQFBgcICQoLDA0ODw",150 },151 {152 type: "ADOPT_REQUEST",153 device_id: "request-only-label",154 device_name: "June's phone",155 device_type: "android",156 },157 { type: "ADOPT_CONFIRM", confirmed: true },158 {159 type: "ADOPT_IDENTITY",160 device_id: "android-receiver",161 device_name: "June's phone",162 device_type: "android",163 long_term_public_key_hex: "d".repeat(64),164 },165 ]);166 mocks.openSocket.mockResolvedValue(socket);167 mocks.verifyPassword168 .mockResolvedValueOnce(false)169 .mockResolvedValueOnce(true);170171 const started = await startRelayAdoptionSource();172 expect(started.invitation).toMatch(/^peach-pair-v1:/);173 await waitForPush("ADOPTION_SOURCE_SAS");174 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();175176 await expect(confirmRelayAdoptionSource("wrong")).resolves.toEqual({177 valid: false,178 });179 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();180181 await expect(confirmRelayAdoptionSource("correct")).resolves.toEqual({182 valid: true,183 });184 await waitForPush("ADOPTION_SOURCE_RESULT");185186 expect(mocks.getRootKeyHex).toHaveBeenCalled();187 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({188 deviceId: "android-receiver",189 relayUrl: "wss://relay.peachpasswords.com",190 }));191 expect(mocks.pushes).toContainEqual(expect.objectContaining({192 type: "ADOPTION_SOURCE_RESULT",193 payload: expect.objectContaining({ status: "complete" }),194 }));195 expect(mocks.triggerSync).toHaveBeenCalledWith({196 reason: "manual",197 force: true,198 });199 });200201 it("fails closed on cancellation without reading or sending the root", async () => {202 const socket = socketWithFrames([203 {204 type: "PEACH_HELLO",205 token: "AAECAwQFBgcICQoLDA0ODw",206 },207 {208 type: "ADOPT_REQUEST",209 device_id: "request-only-label",210 device_name: "New phone",211 device_type: "android",212 },213 ]);214 mocks.openSocket.mockResolvedValue(socket);215216 await startRelayAdoptionSource();217 await waitForPush("ADOPTION_SOURCE_SAS");218 cancelRelayAdoptionSource();219 await waitForPush("ADOPTION_SOURCE_RESULT");220221 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();222 expect(mocks.upsert).not.toHaveBeenCalled();223 expect(mocks.pushes).toContainEqual({224 type: "ADOPTION_SOURCE_RESULT",225 payload: { status: "error", error: "adoption_rejected" },226 });227 });228});