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", () => ({50 getSyncSettings: async () => ({51 mode: "public" as const,52 relayUrl: "wss://relay.peachpasswords.com",53 }),54}));5556vi.mock("./license", () => ({57 isProUser: vi.fn(async () => false),58 getLicenseKey: vi.fn(async () => undefined),59}));6061vi.mock("./vault", () => ({62 isUnlocked: vi.fn(() => true),63 verifyMainPassword: mocks.verifyPassword,64 getRootKeyHex: mocks.getRootKeyHex,65}));6667vi.mock("../shared/sync-identity", () => ({68 computeVaultId: vi.fn(async () => "b".repeat(64)),69}));7071vi.mock("./paired-devices", () => ({72 upsertPairedDevice: mocks.upsert,73}));7475vi.mock("./sync-trigger", () => ({76 triggerSyncIfReachable: mocks.triggerSync,77}));7879import {80 cancelRelayAdoptionSource,81 confirmRelayAdoptionSource,82 startRelayAdoptionSource,83} from "./relay-adoption-source-bg";8485const encode = (value: unknown) =>86 new TextEncoder().encode(JSON.stringify(value));8788function socketWithFrames(frames: unknown[]) {89 const queue = frames.map(encode);90 return {91 send: vi.fn(),92 receive: vi.fn(async () => {93 const next = queue.shift();94 if (!next) return new Promise<Uint8Array>(() => undefined);95 return next;96 }),97 close: vi.fn(),98 closedBeforeFrame: vi.fn(() => false),99 };100}101102async function waitForPush(type: string): Promise<void> {103 await vi.waitFor(() => {104 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);105 });106}107108beforeEach(() => {109 mocks.pushes.length = 0;110 mocks.openSocket.mockReset();111 mocks.getLocalIdentity.mockReset();112 mocks.verifyPassword.mockReset();113 mocks.getRootKeyHex.mockReset();114 mocks.upsert.mockReset();115 mocks.triggerSync.mockReset();116 mocks.triggerSync.mockResolvedValue(undefined);117 mocks.getLocalIdentity.mockResolvedValue({118 identity: {119 deviceId: "browser-source",120 deviceName: "June's Browser",121 deviceType: "extension",122 longTermPublicKeyHex: "a".repeat(64),123 vaultId: "b".repeat(64),124 },125 privateKey: new Uint8Array(32).fill(1),126 publicKey: new Uint8Array(32).fill(2),127 });128 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));129 mocks.upsert.mockResolvedValue(undefined);130 Object.defineProperty(globalThis, "chrome", {131 configurable: true,132 value: {133 runtime: {134 sendMessage(message: { type: string; payload: unknown }) {135 mocks.pushes.push(message);136 return Promise.resolve();137 },138 },139 },140 });141});142143describe("relay adoption source background boundary", () => {144 it("does not access the root for a wrong password and only grants after both confirmations", async () => {145 const socket = socketWithFrames([146 {147 type: "PEACH_HELLO",148 token: "AAECAwQFBgcICQoLDA0ODw",149 },150 {151 type: "ADOPT_REQUEST",152 device_id: "request-only-label",153 device_name: "June's phone",154 device_type: "android",155 },156 { type: "ADOPT_CONFIRM", confirmed: true },157 {158 type: "ADOPT_IDENTITY",159 device_id: "android-receiver",160 device_name: "June's phone",161 device_type: "android",162 long_term_public_key_hex: "d".repeat(64),163 },164 ]);165 mocks.openSocket.mockResolvedValue(socket);166 mocks.verifyPassword167 .mockResolvedValueOnce(false)168 .mockResolvedValueOnce(true);169170 const started = await startRelayAdoptionSource();171 expect(started.invitation).toMatch(/^peach-pair-v1:/);172 await waitForPush("ADOPTION_SOURCE_SAS");173 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();174175 await expect(confirmRelayAdoptionSource("wrong")).resolves.toEqual({176 valid: false,177 });178 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();179180 await expect(confirmRelayAdoptionSource("correct")).resolves.toEqual({181 valid: true,182 });183 await waitForPush("ADOPTION_SOURCE_RESULT");184185 expect(mocks.getRootKeyHex).toHaveBeenCalled();186 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({187 deviceId: "android-receiver",188 relayUrl: "wss://relay.peachpasswords.com",189 }));190 expect(mocks.pushes).toContainEqual(expect.objectContaining({191 type: "ADOPTION_SOURCE_RESULT",192 payload: expect.objectContaining({ status: "complete" }),193 }));194 expect(mocks.triggerSync).toHaveBeenCalledWith({195 reason: "manual",196 force: true,197 });198 });199200 it("fails closed on cancellation without reading or sending the root", async () => {201 const socket = socketWithFrames([202 {203 type: "PEACH_HELLO",204 token: "AAECAwQFBgcICQoLDA0ODw",205 },206 {207 type: "ADOPT_REQUEST",208 device_id: "request-only-label",209 device_name: "New phone",210 device_type: "android",211 },212 ]);213 mocks.openSocket.mockResolvedValue(socket);214215 await startRelayAdoptionSource();216 await waitForPush("ADOPTION_SOURCE_SAS");217 cancelRelayAdoptionSource();218 await waitForPush("ADOPTION_SOURCE_RESULT");219220 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();221 expect(mocks.upsert).not.toHaveBeenCalled();222 expect(mocks.pushes).toContainEqual({223 type: "ADOPTION_SOURCE_RESULT",224 payload: { status: "error", error: "adoption_rejected" },225 });226 });227});