1import { beforeEach, describe, expect, it, vi } from "vitest";23const mocks = vi.hoisted(() => ({4 openSocket: vi.fn(),5 getLocalIdentity: vi.fn(),6 getDeviceName: vi.fn(),7 getRootKeyHex: vi.fn(),8 upsert: vi.fn(),9 pushes: [] as Array<{ type: string; payload: unknown }>,10 triggerSync: vi.fn(),11 createVault: vi.fn(),12 activateLicense: vi.fn(),13 localIdentity: vi.fn(),14 rollbackVault: vi.fn(),15 sasDecisions: [] as Array<{ id: string; ok: boolean }>,16}));1718vi.mock("../shared/pairing-dial", () => ({19 openRelayPairingSocket: mocks.openSocket,20}));2122vi.mock("../shared/noise-js", () => ({23 noiseNKResponder: vi.fn(async () => ({24 handshakeHash: new Uint8Array(32).fill(7),25 encrypt: (bytes: Uint8Array) => bytes,26 decrypt: (bytes: Uint8Array) => bytes,27 close: vi.fn(),28 })),29}));3031vi.mock("../shared/rendezvous", async (importOriginal) => {32 const actual = await importOriginal<typeof import("../shared/rendezvous")>();33 return {34 ...actual,35 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",36 };37});383940vi.mock("./device-identity", () => ({41 getLocalPairingIdentity: async () => ({42 identity: {43 deviceId: "test-device",44 deviceName: "Test",45 deviceType: "extension",46 longTermPublicKeyHex: "aa".repeat(32),47 vaultId: "bb".repeat(32),48 },49 privateKey: new Uint8Array(32).fill(7),50 publicKey: new Uint8Array(32).fill(8),51 }),52 getDeviceName: mocks.getDeviceName,53 buildLocalIdentity: mocks.localIdentity,54}));5556vi.mock("./sync-settings", async (importOriginal) => ({57 ...(await importOriginal<typeof import("./sync-settings")>()),58 getSyncSettings: async () => ({59 mode: "public" as const,60 relayUrl: "wss://relay.peachpasswords.com",61 }),62}));6364vi.mock("./license", () => ({65 isProUser: vi.fn(async () => false),66 getLicenseKey: vi.fn(async () => undefined),67 activateLicense: mocks.activateLicense,68}));6970vi.mock("./vault", () => ({71 isUnlocked: vi.fn(() => true),72 getRootKeyHex: mocks.getRootKeyHex,73 createVaultFromRootKey: mocks.createVault,74 rollbackAdoptedVault: mocks.rollbackVault,75}));7677vi.mock("../shared/sync-identity", () => ({78 computeVaultId: vi.fn(async () => "b".repeat(64)),79}));8081vi.mock("./paired-devices", () => ({82 upsertPairedDevice: mocks.upsert,83}));8485vi.mock("./sync-trigger", () => ({86 triggerSyncIfReachable: mocks.triggerSync,87}));8889import {90 cancelRelayAdoptionSource,91 startRelayAdoptionSource,92} from "./relay-adoption-source-bg";9394const encode = (value: unknown) =>95 new TextEncoder().encode(JSON.stringify(value));9697function socketWithFrames(frames: unknown[]) {98 const queue = frames.map(encode);99 return {100 send: vi.fn(),101 receive: vi.fn(async () => {102 const next = queue.shift();103 if (!next) return new Promise<Uint8Array>(() => undefined);104 return next;105 }),106 close: vi.fn(),107 closedBeforeFrame: vi.fn(() => false),108 };109}110111async function waitForPush(type: string): Promise<void> {112 await vi.waitFor(() => {113 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);114 });115}116117beforeEach(() => {118 mocks.pushes.length = 0;119 mocks.sasDecisions.length = 0;120 mocks.openSocket.mockReset();121 mocks.getLocalIdentity.mockReset();122 mocks.getRootKeyHex.mockReset();123 mocks.upsert.mockReset();124 mocks.triggerSync.mockReset();125 mocks.triggerSync.mockResolvedValue(undefined);126 mocks.getLocalIdentity.mockResolvedValue({127 identity: {128 deviceId: "browser-source",129 deviceName: "June's Browser",130 deviceType: "extension",131 longTermPublicKeyHex: "a".repeat(64),132 vaultId: "b".repeat(64),133 },134 privateKey: new Uint8Array(32).fill(1),135 publicKey: new Uint8Array(32).fill(2),136 });137 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));138 mocks.upsert.mockResolvedValue(undefined);139 Object.defineProperty(globalThis, "chrome", {140 configurable: true,141 value: {142 runtime: {143 async sendMessage(message: { type: string; payload: unknown }) {144 mocks.pushes.push(message);145 146 147 148 149 if (message.type === "ADOPTION_SOURCE_SAS") {150 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");151 const p = message.payload as { id?: string };152 if (typeof p?.id === "string") {153 mocks.sasDecisions.push({ id: p.id, ok: true });154 resolveSourceSasConfirmation(p.id, true);155 }156 }157 return Promise.resolve();158 },159 },160 },161 });162});163164describe("relay adoption source background boundary", () => {165 it("shows the SAS and waits for the popup's single confirmation", async () => {166 const socket = socketWithFrames([167 {168 type: "PEACH_HELLO",169 token: "AAECAwQFBgcICQoLDA0ODw",170 },171 {172 type: "ADOPT_REQUEST",173 device_id: "request-only-label",174 device_name: "June's phone",175 device_type: "android",176 },177 { type: "ADOPT_CONFIRM", confirmed: true },178 {179 type: "ADOPT_IDENTITY",180 device_id: "android-receiver",181 device_name: "June's phone",182 device_type: "android",183 long_term_public_key_hex: "d".repeat(64),184 },185 { type: "ADOPT_APPLIED" },186 ]);187 mocks.openSocket.mockResolvedValue(socket);188189 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");190 const sasIds: string[] = [];191 Object.defineProperty(globalThis, "chrome", {192 configurable: true,193 value: {194 runtime: {195 async sendMessage(message: { type: string; payload: unknown }) {196 mocks.pushes.push(message);197 if (message.type === "ADOPTION_SOURCE_SAS") {198 const p = message.payload as { id?: string };199 if (typeof p?.id === "string") {200 201 202 sasIds.push(p.id);203 }204 return Promise.resolve();205 }206 return Promise.resolve();207 },208 },209 },210 });211 void resolveSourceSasConfirmation; 212213 const started = await startRelayAdoptionSource();214 expect(started.invitation).toMatch(/^peach-pair-v1:/);215 await waitForPush("ADOPTION_SOURCE_SAS");216 217 218 await vi.waitFor(() => {219 expect(mocks.sasDecisions.length).toBe(0);220 });221 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();222 expect(mocks.upsert).not.toHaveBeenCalled();223224 225 const id = sasIds[0];226 expect(typeof id).toBe("string");227 resolveSourceSasConfirmation(id, true);228 mocks.sasDecisions.push({ id, ok: true });229230 await waitForPush("ADOPTION_SOURCE_RESULT");231232 expect(mocks.getRootKeyHex).toHaveBeenCalled();233 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({234 deviceId: "android-receiver",235 relayUrl: "wss://relay.peachpasswords.com",236 }));237 expect(mocks.pushes).toContainEqual(expect.objectContaining({238 type: "ADOPTION_SOURCE_RESULT",239 payload: expect.objectContaining({ status: "complete" }),240 }));241 expect(mocks.triggerSync).toHaveBeenCalledWith({242 reason: "manual",243 force: true,244 });245 const sentTypes = socket.send.mock.calls246 .map(([bytes]) => {247 try {248 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;249 } catch {250 return undefined;251 }252 });253 expect(sentTypes).toContain("ADOPT_COMPLETE");254 });255256 it("fails closed when the popup rejects the SAS", async () => {257 const socket = socketWithFrames([258 {259 type: "PEACH_HELLO",260 token: "AAECAwQFBgcICQoLDA0ODw",261 },262 {263 type: "ADOPT_REQUEST",264 device_id: "request-only-label",265 device_name: "New phone",266 device_type: "android",267 },268 { type: "ADOPT_CONFIRM", confirmed: false },269 ]);270 mocks.openSocket.mockResolvedValue(socket);271272 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");273 let pendingId: string | undefined;274 Object.defineProperty(globalThis, "chrome", {275 configurable: true,276 value: {277 runtime: {278 sendMessage(message: { type: string; payload: unknown }) {279 mocks.pushes.push(message);280 if (message.type === "ADOPTION_SOURCE_SAS") {281 const p = message.payload as { id?: string };282 if (typeof p?.id === "string") pendingId = p.id;283 }284 return Promise.resolve();285 },286 },287 },288 });289290 await startRelayAdoptionSource();291 await waitForPush("ADOPTION_SOURCE_SAS");292 expect(typeof pendingId).toBe("string");293 resolveSourceSasConfirmation(pendingId!, false);294 mocks.sasDecisions.push({ id: pendingId!, ok: false });295296 await waitForPush("ADOPTION_SOURCE_RESULT");297298 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();299 expect(mocks.upsert).not.toHaveBeenCalled();300 expect(mocks.pushes).toContainEqual({301 type: "ADOPTION_SOURCE_RESULT",302 payload: { status: "error", error: "adoption_rejected" },303 });304 });305306 it("fails closed when the user never answers the SAS", async () => {307 308 309 310 311 const socket = socketWithFrames([]);312 mocks.openSocket.mockResolvedValue(socket);313314 Object.defineProperty(globalThis, "chrome", {315 configurable: true,316 value: {317 runtime: {318 sendMessage(message: { type: string; payload: unknown }) {319 mocks.pushes.push(message);320 return Promise.resolve();321 },322 },323 },324 });325326 await startRelayAdoptionSource();327 cancelRelayAdoptionSource();328329 expect(socket.close).toHaveBeenCalled();330 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();331 expect(mocks.upsert).not.toHaveBeenCalled();332 });333334 it("cancelling the source screen closes the room without reading the root", async () => {335 const socket = socketWithFrames([]);336 mocks.openSocket.mockResolvedValue(socket);337338 await startRelayAdoptionSource();339 cancelRelayAdoptionSource();340341 expect(socket.close).toHaveBeenCalled();342 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();343 expect(mocks.upsert).not.toHaveBeenCalled();344 });345});346347describe("relay adoption receiver background", () => {348 beforeEach(async () => {349 350 351 352 353 const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");354 cancelRelayAdoptionSource();355 mocks.pushes.length = 0;356 mocks.openSocket.mockReset();357 mocks.getDeviceName.mockReset();358 mocks.getDeviceName.mockResolvedValue("Receiver Browser");359 mocks.createVault.mockReset();360 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });361 mocks.activateLicense.mockReset();362 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });363 mocks.localIdentity.mockReset();364 mocks.localIdentity.mockResolvedValue({365 deviceId: "receiver-id",366 deviceName: "Receiver Browser",367 deviceType: "extension",368 longTermPublicKeyHex: "11".repeat(32),369 signingPublicKeyHex: "22".repeat(32),370 });371 mocks.rollbackVault.mockReset();372 mocks.rollbackVault.mockResolvedValue(undefined);373 mocks.upsert.mockReset();374 mocks.upsert.mockResolvedValue(undefined);375 mocks.triggerSync.mockReset();376 mocks.triggerSync.mockResolvedValue(undefined);377 mocks.openSocket.mockResolvedValue(socketWithFrames([]));378 Object.defineProperty(globalThis, "chrome", {379 configurable: true,380 value: {381 runtime: {382 sendMessage(message: { type: string; payload?: unknown }) {383 mocks.pushes.push({ type: message.type, payload: message.payload });384 return Promise.resolve({ ok: true });385 },386 onMessage: {387 addListener() { },388 removeListener() { },389 },390 },391 },392 });393 });394395 it("opens a relay socket and returns an empty invitation", async () => {396 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");397 const started = await startRelayAdoptionReceiver("receiver-password");398 expect(started.invitation).toMatch(/^peach-pair-v1:/);399 400 const decoded = decodeInvitation(started.invitation);401 expect(decoded?.vault).toBe("empty");402 expect(mocks.openSocket).toHaveBeenCalled();403 });404405 it("rejects an empty password", async () => {406 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");407 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");408 });409410 it("the receiver wires requestAdoption, not grantAdoption", async () => {411 412 413 414 415 416 const rootKeyHex = "f0".repeat(32);417 const vaultId = "b".repeat(64);418 const socket = socketWithFrames([419 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },420 {421 type: "ADOPT_GRANT",422 root_key_hex: rootKeyHex,423 vault_id: vaultId,424 source: {425 device_id: "phone-source-id",426 device_name: "June's phone",427 device_type: "android",428 long_term_public_key_hex: "99".repeat(32),429 },430 },431 { type: "ADOPT_COMPLETE" },432 ]);433 mocks.openSocket.mockResolvedValue(socket);434 mocks.createVault.mockResolvedValue({ vaultId });435436 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");437 Object.defineProperty(globalThis, "chrome", {438 configurable: true,439 value: {440 runtime: {441 sendMessage(message: { type: string; payload?: unknown }) {442 mocks.pushes.push({ type: message.type, payload: message.payload });443 return Promise.resolve({ ok: true });444 },445 onMessage: { addListener: () => undefined, removeListener: () => undefined },446 },447 },448 });449 await startRelayAdoptionReceiver("receiver-password");450 await waitForPush("ADOPTION_SOURCE_RESULT");451452 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })453 .mock.calls454 .map(([bytes]) => {455 try {456 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;457 } catch {458 return undefined;459 }460 });461 462 463 expect(sentTypes).toContain("ADOPT_REQUEST");464 expect(sentTypes).not.toContain("ADOPT_GRANT");465 });466467 it("completes the handshake end-to-end: vault created → paired", async () => {468 469 470 471 472 473 const rootKeyHex = "f0".repeat(32);474 475 476 const vaultId = "b".repeat(64);477 const frames = [478 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },479 480 481 {482 type: "ADOPT_GRANT",483 root_key_hex: rootKeyHex,484 vault_id: vaultId,485 license: "PPK-test",486 source: {487 device_id: "phone-source-id",488 device_name: "June's phone",489 device_type: "android",490 long_term_public_key_hex: "99".repeat(32),491 signing_public_key_hex: "aa".repeat(32),492 relay_url: "wss://relay.peachpasswords.com",493 },494 },495 { type: "ADOPT_COMPLETE" },496 ];497 mocks.createVault.mockResolvedValue({ vaultId });498 mocks.openSocket.mockResolvedValue(socketWithFrames(frames));499500 501 502 Object.defineProperty(globalThis, "chrome", {503 configurable: true,504 value: {505 runtime: {506 sendMessage(message: { type: string; payload?: unknown }) {507 mocks.pushes.push({ type: message.type, payload: message.payload });508 return Promise.resolve({ ok: true });509 },510 onMessage: {511 addListener: () => undefined,512 removeListener: () => undefined,513 },514 },515 },516 });517518 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");519 await startRelayAdoptionReceiver("receiver-password");520521 await waitForPush("ADOPTION_SOURCE_RESULT");522523 524 525526 527 528 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);529530 531 532 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");533 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));534 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({535 deviceId: "phone-source-id",536 }));537 expect(mocks.rollbackVault).not.toHaveBeenCalled();538539 540 expect(mocks.pushes).toContainEqual(expect.objectContaining({541 type: "ADOPTION_SOURCE_RESULT",542 payload: expect.objectContaining({ status: "complete" }),543 }));544 });545546 it("rolls back a newly created vault when a later adoption step fails", async () => {547 const rootKeyHex = "f0".repeat(32);548 const vaultId = "b".repeat(64);549 mocks.createVault.mockResolvedValue({ vaultId });550 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));551 mocks.openSocket.mockResolvedValue(socketWithFrames([552 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },553 {554 type: "ADOPT_GRANT",555 root_key_hex: rootKeyHex,556 vault_id: vaultId,557 source: {558 device_id: "phone-source-id",559 device_name: "June's phone",560 device_type: "android",561 long_term_public_key_hex: "99".repeat(32),562 },563 },564 ]));565566 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");567 await startRelayAdoptionReceiver("receiver-password");568 await waitForPush("ADOPTION_SOURCE_RESULT");569570 expect(mocks.rollbackVault).toHaveBeenCalledOnce();571 expect(mocks.upsert).not.toHaveBeenCalled();572 expect(mocks.pushes).toContainEqual(expect.objectContaining({573 type: "ADOPTION_SOURCE_RESULT",574 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),575 }));576 });577});578579function decodeInvitation(encoded: string): { vault?: string } | null {580 581 582 const at = encoded.indexOf(":");583 if (at < 0) return null;584 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");585 try {586 const json = Buffer.from(b64, "base64").toString("utf8");587 return JSON.parse(json) as { vault?: string };588 } catch {589 return null;590 }591}