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 })),23}));2425vi.mock("../shared/rendezvous", async (importOriginal) => {26 const actual = await importOriginal<typeof import("../shared/rendezvous")>();27 return {28 ...actual,29 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",30 };31});3233vi.mock("./code-pairing-bg", () => ({34 productionCodePairingDeps: () => ({35 getLocalIdentity: mocks.getLocalIdentity,36 }),37}));3839vi.mock("./relay-settings", () => ({40 getRelaySettings: vi.fn(async () => ({41 relayUrl: "wss://relay.peachpasswords.com",42 lanOnly: false,43 })),44}));4546vi.mock("./license", () => ({47 isProUser: vi.fn(async () => false),48 getLicenseKey: vi.fn(async () => undefined),49}));5051vi.mock("./vault", () => ({52 isUnlocked: vi.fn(() => true),53 verifyMainPassword: mocks.verifyPassword,54 getRootKeyHex: mocks.getRootKeyHex,55}));5657vi.mock("../shared/sync-identity", () => ({58 computeVaultId: vi.fn(async () => "b".repeat(64)),59}));6061vi.mock("./paired-devices", () => ({62 upsertPairedDevice: mocks.upsert,63}));6465vi.mock("./sync-trigger", () => ({66 triggerSyncIfReachable: mocks.triggerSync,67}));6869import {70 cancelRelayAdoptionSource,71 confirmRelayAdoptionSource,72 startRelayAdoptionSource,73} from "./relay-adoption-source-bg";7475const encode = (value: unknown) =>76 new TextEncoder().encode(JSON.stringify(value));7778function socketWithFrames(frames: unknown[]) {79 const queue = frames.map(encode);80 return {81 send: vi.fn(),82 receive: vi.fn(async () => {83 const next = queue.shift();84 if (!next) return new Promise<Uint8Array>(() => undefined);85 return next;86 }),87 close: vi.fn(),88 closedBeforeFrame: vi.fn(() => false),89 };90}9192async function waitForPush(type: string): Promise<void> {93 await vi.waitFor(() => {94 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);95 });96}9798beforeEach(() => {99 mocks.pushes.length = 0;100 mocks.openSocket.mockReset();101 mocks.getLocalIdentity.mockReset();102 mocks.verifyPassword.mockReset();103 mocks.getRootKeyHex.mockReset();104 mocks.upsert.mockReset();105 mocks.triggerSync.mockReset();106 mocks.triggerSync.mockResolvedValue(undefined);107 mocks.getLocalIdentity.mockResolvedValue({108 identity: {109 deviceId: "browser-source",110 deviceName: "June's Browser",111 deviceType: "extension",112 longTermPublicKeyHex: "a".repeat(64),113 vaultId: "b".repeat(64),114 },115 privateKey: new Uint8Array(32).fill(1),116 publicKey: new Uint8Array(32).fill(2),117 });118 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));119 mocks.upsert.mockResolvedValue(undefined);120 Object.defineProperty(globalThis, "chrome", {121 configurable: true,122 value: {123 runtime: {124 sendMessage(message: { type: string; payload: unknown }) {125 mocks.pushes.push(message);126 return Promise.resolve();127 },128 },129 },130 });131});132133describe("relay adoption source background boundary", () => {134 it("does not access the root for a wrong password and only grants after both confirmations", async () => {135 const socket = socketWithFrames([136 {137 type: "PEACH_HELLO",138 token: "AAECAwQFBgcICQoLDA0ODw",139 },140 {141 type: "ADOPT_REQUEST",142 device_id: "request-only-label",143 device_name: "June's phone",144 device_type: "android",145 },146 { type: "ADOPT_CONFIRM", confirmed: true },147 {148 type: "ADOPT_IDENTITY",149 device_id: "android-receiver",150 device_name: "June's phone",151 device_type: "android",152 long_term_public_key_hex: "d".repeat(64),153 },154 ]);155 mocks.openSocket.mockResolvedValue(socket);156 mocks.verifyPassword157 .mockResolvedValueOnce(false)158 .mockResolvedValueOnce(true);159160 const started = await startRelayAdoptionSource();161 expect(started.invitation).toMatch(/^peach-pair-v1:/);162 await waitForPush("ADOPTION_SOURCE_SAS");163 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();164165 await expect(confirmRelayAdoptionSource("wrong")).resolves.toEqual({166 valid: false,167 });168 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();169170 await expect(confirmRelayAdoptionSource("correct")).resolves.toEqual({171 valid: true,172 });173 await waitForPush("ADOPTION_SOURCE_RESULT");174175 expect(mocks.getRootKeyHex).toHaveBeenCalled();176 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({177 deviceId: "android-receiver",178 relayUrl: "wss://relay.peachpasswords.com",179 }));180 expect(mocks.pushes).toContainEqual(expect.objectContaining({181 type: "ADOPTION_SOURCE_RESULT",182 payload: expect.objectContaining({ status: "complete" }),183 }));184 expect(mocks.triggerSync).toHaveBeenCalledWith({185 reason: "manual",186 force: true,187 });188 });189190 it("fails closed on cancellation without reading or sending the root", async () => {191 const socket = socketWithFrames([192 {193 type: "PEACH_HELLO",194 token: "AAECAwQFBgcICQoLDA0ODw",195 },196 {197 type: "ADOPT_REQUEST",198 device_id: "request-only-label",199 device_name: "New phone",200 device_type: "android",201 },202 ]);203 mocks.openSocket.mockResolvedValue(socket);204205 await startRelayAdoptionSource();206 await waitForPush("ADOPTION_SOURCE_SAS");207 cancelRelayAdoptionSource();208 await waitForPush("ADOPTION_SOURCE_RESULT");209210 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();211 expect(mocks.upsert).not.toHaveBeenCalled();212 expect(mocks.pushes).toContainEqual({213 type: "ADOPTION_SOURCE_RESULT",214 payload: { status: "error", error: "adoption_rejected" },215 });216 });217});