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});3334vi.mock("./code-pairing-bg", () => ({35 productionCodePairingDeps: () => ({36 getLocalIdentity: mocks.getLocalIdentity,37 }),38}));3940vi.mock("./relay-settings", () => ({41 getRelaySettings: vi.fn(async () => ({42 relayUrl: "wss://relay.peachpasswords.com",43 lanOnly: false,44 })),45}));4647vi.mock("./license", () => ({48 isProUser: vi.fn(async () => false),49 getLicenseKey: vi.fn(async () => undefined),50}));5152vi.mock("./vault", () => ({53 isUnlocked: vi.fn(() => true),54 verifyMainPassword: mocks.verifyPassword,55 getRootKeyHex: mocks.getRootKeyHex,56}));5758vi.mock("../shared/sync-identity", () => ({59 computeVaultId: vi.fn(async () => "b".repeat(64)),60}));6162vi.mock("./paired-devices", () => ({63 upsertPairedDevice: mocks.upsert,64}));6566vi.mock("./sync-trigger", () => ({67 triggerSyncIfReachable: mocks.triggerSync,68}));6970import {71 cancelRelayAdoptionSource,72 confirmRelayAdoptionSource,73 startRelayAdoptionSource,74} from "./relay-adoption-source-bg";7576const encode = (value: unknown) =>77 new TextEncoder().encode(JSON.stringify(value));7879function socketWithFrames(frames: unknown[]) {80 const queue = frames.map(encode);81 return {82 send: vi.fn(),83 receive: vi.fn(async () => {84 const next = queue.shift();85 if (!next) return new Promise<Uint8Array>(() => undefined);86 return next;87 }),88 close: vi.fn(),89 closedBeforeFrame: vi.fn(() => false),90 };91}9293async function waitForPush(type: string): Promise<void> {94 await vi.waitFor(() => {95 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);96 });97}9899beforeEach(() => {100 mocks.pushes.length = 0;101 mocks.openSocket.mockReset();102 mocks.getLocalIdentity.mockReset();103 mocks.verifyPassword.mockReset();104 mocks.getRootKeyHex.mockReset();105 mocks.upsert.mockReset();106 mocks.triggerSync.mockReset();107 mocks.triggerSync.mockResolvedValue(undefined);108 mocks.getLocalIdentity.mockResolvedValue({109 identity: {110 deviceId: "browser-source",111 deviceName: "June's Browser",112 deviceType: "extension",113 longTermPublicKeyHex: "a".repeat(64),114 vaultId: "b".repeat(64),115 },116 privateKey: new Uint8Array(32).fill(1),117 publicKey: new Uint8Array(32).fill(2),118 });119 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));120 mocks.upsert.mockResolvedValue(undefined);121 Object.defineProperty(globalThis, "chrome", {122 configurable: true,123 value: {124 runtime: {125 sendMessage(message: { type: string; payload: unknown }) {126 mocks.pushes.push(message);127 return Promise.resolve();128 },129 },130 },131 });132});133134describe("relay adoption source background boundary", () => {135 it("does not access the root for a wrong password and only grants after both confirmations", async () => {136 const socket = socketWithFrames([137 {138 type: "PEACH_HELLO",139 token: "AAECAwQFBgcICQoLDA0ODw",140 },141 {142 type: "ADOPT_REQUEST",143 device_id: "request-only-label",144 device_name: "June's phone",145 device_type: "android",146 },147 { type: "ADOPT_CONFIRM", confirmed: true },148 {149 type: "ADOPT_IDENTITY",150 device_id: "android-receiver",151 device_name: "June's phone",152 device_type: "android",153 long_term_public_key_hex: "d".repeat(64),154 },155 ]);156 mocks.openSocket.mockResolvedValue(socket);157 mocks.verifyPassword158 .mockResolvedValueOnce(false)159 .mockResolvedValueOnce(true);160161 const started = await startRelayAdoptionSource();162 expect(started.invitation).toMatch(/^peach-pair-v1:/);163 await waitForPush("ADOPTION_SOURCE_SAS");164 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();165166 await expect(confirmRelayAdoptionSource("wrong")).resolves.toEqual({167 valid: false,168 });169 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();170171 await expect(confirmRelayAdoptionSource("correct")).resolves.toEqual({172 valid: true,173 });174 await waitForPush("ADOPTION_SOURCE_RESULT");175176 expect(mocks.getRootKeyHex).toHaveBeenCalled();177 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({178 deviceId: "android-receiver",179 relayUrl: "wss://relay.peachpasswords.com",180 }));181 expect(mocks.pushes).toContainEqual(expect.objectContaining({182 type: "ADOPTION_SOURCE_RESULT",183 payload: expect.objectContaining({ status: "complete" }),184 }));185 expect(mocks.triggerSync).toHaveBeenCalledWith({186 reason: "manual",187 force: true,188 });189 });190191 it("fails closed on cancellation without reading or sending the root", async () => {192 const socket = socketWithFrames([193 {194 type: "PEACH_HELLO",195 token: "AAECAwQFBgcICQoLDA0ODw",196 },197 {198 type: "ADOPT_REQUEST",199 device_id: "request-only-label",200 device_name: "New phone",201 device_type: "android",202 },203 ]);204 mocks.openSocket.mockResolvedValue(socket);205206 await startRelayAdoptionSource();207 await waitForPush("ADOPTION_SOURCE_SAS");208 cancelRelayAdoptionSource();209 await waitForPush("ADOPTION_SOURCE_RESULT");210211 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();212 expect(mocks.upsert).not.toHaveBeenCalled();213 expect(mocks.pushes).toContainEqual({214 type: "ADOPTION_SOURCE_RESULT",215 payload: { status: "error", error: "adoption_rejected" },216 });217 });218});