1import { beforeEach, describe, expect, it, vi } from "vitest";23const mocks = vi.hoisted(() => ({4 openSocket: vi.fn(),5 getLocalIdentity: vi.fn(),6 getRootKeyHex: vi.fn(),7 upsert: vi.fn(),8 pushes: [] as Array<{ type: string; payload: unknown }>,9 triggerSync: vi.fn(),10}));1112vi.mock("../shared/pairing-dial", () => ({13 openRelayPairingSocket: mocks.openSocket,14}));1516vi.mock("../shared/noise-js", () => ({17 noiseNKResponder: vi.fn(async () => ({18 handshakeHash: new Uint8Array(32).fill(7),19 encrypt: (bytes: Uint8Array) => bytes,20 decrypt: (bytes: Uint8Array) => bytes,21 close: vi.fn(),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});323334vi.mock("./device-identity", () => ({35 getLocalPairingIdentity: async () => ({36 identity: {37 deviceId: "test-device",38 deviceName: "Test",39 deviceType: "extension",40 longTermPublicKeyHex: "aa".repeat(32),41 vaultId: "bb".repeat(32),42 },43 privateKey: new Uint8Array(32).fill(7),44 publicKey: new Uint8Array(32).fill(8),45 }),46}));4748vi.mock("./sync-settings", async (importOriginal) => ({49 ...(await importOriginal<typeof import("./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 getRootKeyHex: mocks.getRootKeyHex,64}));6566vi.mock("../shared/sync-identity", () => ({67 computeVaultId: vi.fn(async () => "b".repeat(64)),68}));6970vi.mock("./paired-devices", () => ({71 upsertPairedDevice: mocks.upsert,72}));7374vi.mock("./sync-trigger", () => ({75 triggerSyncIfReachable: mocks.triggerSync,76}));7778import {79 cancelRelayAdoptionSource,80 startRelayAdoptionSource,81} from "./relay-adoption-source-bg";8283const encode = (value: unknown) =>84 new TextEncoder().encode(JSON.stringify(value));8586function socketWithFrames(frames: unknown[]) {87 const queue = frames.map(encode);88 return {89 send: vi.fn(),90 receive: vi.fn(async () => {91 const next = queue.shift();92 if (!next) return new Promise<Uint8Array>(() => undefined);93 return next;94 }),95 close: vi.fn(),96 closedBeforeFrame: vi.fn(() => false),97 };98}99100async function waitForPush(type: string): Promise<void> {101 await vi.waitFor(() => {102 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);103 });104}105106beforeEach(() => {107 mocks.pushes.length = 0;108 mocks.openSocket.mockReset();109 mocks.getLocalIdentity.mockReset();110 mocks.getRootKeyHex.mockReset();111 mocks.upsert.mockReset();112 mocks.triggerSync.mockReset();113 mocks.triggerSync.mockResolvedValue(undefined);114 mocks.getLocalIdentity.mockResolvedValue({115 identity: {116 deviceId: "browser-source",117 deviceName: "June's Browser",118 deviceType: "extension",119 longTermPublicKeyHex: "a".repeat(64),120 vaultId: "b".repeat(64),121 },122 privateKey: new Uint8Array(32).fill(1),123 publicKey: new Uint8Array(32).fill(2),124 });125 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));126 mocks.upsert.mockResolvedValue(undefined);127 Object.defineProperty(globalThis, "chrome", {128 configurable: true,129 value: {130 runtime: {131 sendMessage(message: { type: string; payload: unknown }) {132 mocks.pushes.push(message);133 return Promise.resolve();134 },135 },136 },137 });138});139140describe("relay adoption source background boundary", () => {141 it("uses the receiver SAS confirmation as the only root-key commit gate", async () => {142 const socket = socketWithFrames([143 {144 type: "PEACH_HELLO",145 token: "AAECAwQFBgcICQoLDA0ODw",146 },147 {148 type: "ADOPT_REQUEST",149 device_id: "request-only-label",150 device_name: "June's phone",151 device_type: "android",152 },153 { type: "ADOPT_CONFIRM", confirmed: true },154 {155 type: "ADOPT_IDENTITY",156 device_id: "android-receiver",157 device_name: "June's phone",158 device_type: "android",159 long_term_public_key_hex: "d".repeat(64),160 },161 { type: "ADOPT_APPLIED" },162 ]);163 mocks.openSocket.mockResolvedValue(socket);164165 const started = await startRelayAdoptionSource();166 expect(started.invitation).toMatch(/^peach-pair-v1:/);167 await waitForPush("ADOPTION_SOURCE_SAS");168 await waitForPush("ADOPTION_SOURCE_RESULT");169170 expect(mocks.getRootKeyHex).toHaveBeenCalled();171 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({172 deviceId: "android-receiver",173 relayUrl: "wss://relay.peachpasswords.com",174 }));175 expect(mocks.pushes).toContainEqual(expect.objectContaining({176 type: "ADOPTION_SOURCE_RESULT",177 payload: expect.objectContaining({ status: "complete" }),178 }));179 expect(mocks.triggerSync).toHaveBeenCalledWith({180 reason: "manual",181 force: true,182 });183 const sentTypes = socket.send.mock.calls184 .map(([bytes]) => {185 try {186 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;187 } catch {188 return undefined;189 }190 });191 expect(sentTypes).toContain("ADOPT_COMPLETE");192 });193194 it("fails closed when the scanning device rejects the SAS", async () => {195 const socket = socketWithFrames([196 {197 type: "PEACH_HELLO",198 token: "AAECAwQFBgcICQoLDA0ODw",199 },200 {201 type: "ADOPT_REQUEST",202 device_id: "request-only-label",203 device_name: "New phone",204 device_type: "android",205 },206 { type: "ADOPT_CONFIRM", confirmed: false },207 ]);208 mocks.openSocket.mockResolvedValue(socket);209210 await startRelayAdoptionSource();211 await waitForPush("ADOPTION_SOURCE_SAS");212 await waitForPush("ADOPTION_SOURCE_RESULT");213214 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();215 expect(mocks.upsert).not.toHaveBeenCalled();216 expect(mocks.pushes).toContainEqual({217 type: "ADOPTION_SOURCE_RESULT",218 payload: { status: "error", error: "adoption_rejected" },219 });220 });221222 it("cancelling the source screen closes the room without reading the root", async () => {223 const socket = socketWithFrames([]);224 mocks.openSocket.mockResolvedValue(socket);225226 await startRelayAdoptionSource();227 cancelRelayAdoptionSource();228229 expect(socket.close).toHaveBeenCalled();230 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();231 expect(mocks.upsert).not.toHaveBeenCalled();232 });233});