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 completePairing: 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}));5455vi.mock("./sync-settings", async (importOriginal) => ({56 ...(await importOriginal<typeof import("./sync-settings")>()),57 getSyncSettings: async () => ({58 mode: "public" as const,59 relayUrl: "wss://relay.peachpasswords.com",60 }),61}));6263vi.mock("./license", () => ({64 isProUser: vi.fn(async () => false),65 getLicenseKey: vi.fn(async () => undefined),66}));6768vi.mock("./vault", () => ({69 isUnlocked: vi.fn(() => true),70 getRootKeyHex: mocks.getRootKeyHex,71}));7273vi.mock("../shared/sync-identity", () => ({74 computeVaultId: vi.fn(async () => "b".repeat(64)),75}));7677vi.mock("./paired-devices", () => ({78 upsertPairedDevice: mocks.upsert,79}));8081vi.mock("./sync-trigger", () => ({82 triggerSyncIfReachable: mocks.triggerSync,83}));8485import {86 cancelRelayAdoptionSource,87 startRelayAdoptionSource,88} from "./relay-adoption-source-bg";8990const encode = (value: unknown) =>91 new TextEncoder().encode(JSON.stringify(value));9293function socketWithFrames(frames: unknown[]) {94 const queue = frames.map(encode);95 return {96 send: vi.fn(),97 receive: vi.fn(async () => {98 const next = queue.shift();99 if (!next) return new Promise<Uint8Array>(() => undefined);100 return next;101 }),102 close: vi.fn(),103 closedBeforeFrame: vi.fn(() => false),104 };105}106107async function waitForPush(type: string): Promise<void> {108 await vi.waitFor(() => {109 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);110 });111}112113beforeEach(() => {114 mocks.pushes.length = 0;115 mocks.sasDecisions.length = 0;116 mocks.openSocket.mockReset();117 mocks.getLocalIdentity.mockReset();118 mocks.getRootKeyHex.mockReset();119 mocks.upsert.mockReset();120 mocks.triggerSync.mockReset();121 mocks.triggerSync.mockResolvedValue(undefined);122 mocks.getLocalIdentity.mockResolvedValue({123 identity: {124 deviceId: "browser-source",125 deviceName: "June's Browser",126 deviceType: "extension",127 longTermPublicKeyHex: "a".repeat(64),128 vaultId: "b".repeat(64),129 },130 privateKey: new Uint8Array(32).fill(1),131 publicKey: new Uint8Array(32).fill(2),132 });133 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));134 mocks.upsert.mockResolvedValue(undefined);135 Object.defineProperty(globalThis, "chrome", {136 configurable: true,137 value: {138 runtime: {139 async sendMessage(message: { type: string; payload: unknown }) {140 mocks.pushes.push(message);141 142 143 144 145 if (message.type === "ADOPTION_SOURCE_SAS") {146 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");147 const p = message.payload as { id?: string };148 if (typeof p?.id === "string") {149 mocks.sasDecisions.push({ id: p.id, ok: true });150 resolveSourceSasConfirmation(p.id, true);151 }152 }153 return Promise.resolve();154 },155 },156 },157 });158});159160describe("relay adoption source background boundary", () => {161 it("shows the SAS and waits for the popup's single confirmation", async () => {162 const socket = socketWithFrames([163 {164 type: "PEACH_HELLO",165 token: "AAECAwQFBgcICQoLDA0ODw",166 },167 {168 type: "ADOPT_REQUEST",169 device_id: "request-only-label",170 device_name: "June's phone",171 device_type: "android",172 },173 { type: "ADOPT_CONFIRM", confirmed: true },174 {175 type: "ADOPT_IDENTITY",176 device_id: "android-receiver",177 device_name: "June's phone",178 device_type: "android",179 long_term_public_key_hex: "d".repeat(64),180 },181 { type: "ADOPT_APPLIED" },182 ]);183 mocks.openSocket.mockResolvedValue(socket);184185 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");186 const sasIds: string[] = [];187 Object.defineProperty(globalThis, "chrome", {188 configurable: true,189 value: {190 runtime: {191 async sendMessage(message: { type: string; payload: unknown }) {192 mocks.pushes.push(message);193 if (message.type === "ADOPTION_SOURCE_SAS") {194 const p = message.payload as { id?: string };195 if (typeof p?.id === "string") {196 197 198 sasIds.push(p.id);199 }200 return Promise.resolve();201 }202 return Promise.resolve();203 },204 },205 },206 });207 void resolveSourceSasConfirmation; 208209 const started = await startRelayAdoptionSource();210 expect(started.invitation).toMatch(/^peach-pair-v1:/);211 await waitForPush("ADOPTION_SOURCE_SAS");212 213 214 await vi.waitFor(() => {215 expect(mocks.sasDecisions.length).toBe(0);216 });217 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();218 expect(mocks.upsert).not.toHaveBeenCalled();219220 221 const id = sasIds[0];222 expect(typeof id).toBe("string");223 resolveSourceSasConfirmation(id, true);224 mocks.sasDecisions.push({ id, ok: true });225226 await waitForPush("ADOPTION_SOURCE_RESULT");227228 expect(mocks.getRootKeyHex).toHaveBeenCalled();229 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({230 deviceId: "android-receiver",231 relayUrl: "wss://relay.peachpasswords.com",232 }));233 expect(mocks.pushes).toContainEqual(expect.objectContaining({234 type: "ADOPTION_SOURCE_RESULT",235 payload: expect.objectContaining({ status: "complete" }),236 }));237 expect(mocks.triggerSync).toHaveBeenCalledWith({238 reason: "manual",239 force: true,240 });241 const sentTypes = socket.send.mock.calls242 .map(([bytes]) => {243 try {244 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;245 } catch {246 return undefined;247 }248 });249 expect(sentTypes).toContain("ADOPT_COMPLETE");250 });251252 it("fails closed when the popup rejects the SAS", async () => {253 const socket = socketWithFrames([254 {255 type: "PEACH_HELLO",256 token: "AAECAwQFBgcICQoLDA0ODw",257 },258 {259 type: "ADOPT_REQUEST",260 device_id: "request-only-label",261 device_name: "New phone",262 device_type: "android",263 },264 { type: "ADOPT_CONFIRM", confirmed: false },265 ]);266 mocks.openSocket.mockResolvedValue(socket);267268 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");269 let pendingId: string | undefined;270 Object.defineProperty(globalThis, "chrome", {271 configurable: true,272 value: {273 runtime: {274 sendMessage(message: { type: string; payload: unknown }) {275 mocks.pushes.push(message);276 if (message.type === "ADOPTION_SOURCE_SAS") {277 const p = message.payload as { id?: string };278 if (typeof p?.id === "string") pendingId = p.id;279 }280 return Promise.resolve();281 },282 },283 },284 });285286 await startRelayAdoptionSource();287 await waitForPush("ADOPTION_SOURCE_SAS");288 expect(typeof pendingId).toBe("string");289 resolveSourceSasConfirmation(pendingId!, false);290 mocks.sasDecisions.push({ id: pendingId!, ok: false });291292 await waitForPush("ADOPTION_SOURCE_RESULT");293294 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();295 expect(mocks.upsert).not.toHaveBeenCalled();296 expect(mocks.pushes).toContainEqual({297 type: "ADOPTION_SOURCE_RESULT",298 payload: { status: "error", error: "adoption_rejected" },299 });300 });301302 it("fails closed when the user never answers the SAS", async () => {303 304 305 306 307 const socket = socketWithFrames([]);308 mocks.openSocket.mockResolvedValue(socket);309310 Object.defineProperty(globalThis, "chrome", {311 configurable: true,312 value: {313 runtime: {314 sendMessage(message: { type: string; payload: unknown }) {315 mocks.pushes.push(message);316 return Promise.resolve();317 },318 },319 },320 });321322 await startRelayAdoptionSource();323 cancelRelayAdoptionSource();324325 expect(socket.close).toHaveBeenCalled();326 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();327 expect(mocks.upsert).not.toHaveBeenCalled();328 });329330 it("cancelling the source screen closes the room without reading the root", async () => {331 const socket = socketWithFrames([]);332 mocks.openSocket.mockResolvedValue(socket);333334 await startRelayAdoptionSource();335 cancelRelayAdoptionSource();336337 expect(socket.close).toHaveBeenCalled();338 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();339 expect(mocks.upsert).not.toHaveBeenCalled();340 });341});342343describe("relay adoption receiver background", () => {344 beforeEach(async () => {345 346 347 348 349 const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");350 cancelRelayAdoptionSource();351 mocks.pushes.length = 0;352 mocks.openSocket.mockReset();353 mocks.getDeviceName.mockReset();354 mocks.getDeviceName.mockResolvedValue("Receiver Browser");355 mocks.createVault.mockReset();356 mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId: "ee".repeat(32) } });357 mocks.activateLicense.mockReset();358 mocks.activateLicense.mockResolvedValue({ ok: true });359 mocks.localIdentity.mockReset();360 mocks.localIdentity.mockResolvedValue({361 ok: true,362 data: {363 deviceId: "receiver-id",364 deviceName: "Receiver Browser",365 deviceType: "extension",366 longTermPublicKeyHex: "11".repeat(32),367 signingPublicKeyHex: "22".repeat(32),368 },369 });370 mocks.completePairing.mockReset();371 mocks.completePairing.mockResolvedValue({ ok: true });372 mocks.upsert.mockReset();373 mocks.upsert.mockResolvedValue(undefined);374 mocks.triggerSync.mockReset();375 mocks.triggerSync.mockResolvedValue(undefined);376 mocks.openSocket.mockResolvedValue(socketWithFrames([]));377 Object.defineProperty(globalThis, "chrome", {378 configurable: true,379 value: {380 runtime: {381 sendMessage(message: { type: string; payload?: unknown }) {382 383 if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {384 return Promise.resolve(mocks.createVault(message.payload));385 }386 if (message.type === "ACTIVATE_LICENSE") {387 return Promise.resolve(mocks.activateLicense(message.payload));388 }389 if (message.type === "GET_LOCAL_IDENTITY") {390 return Promise.resolve(mocks.localIdentity());391 }392 if (message.type === "COMPLETE_PAIRING") {393 return Promise.resolve(mocks.completePairing(message.payload));394 }395 if (message.type === "TRIGGER_SYNC") {396 return Promise.resolve({ ok: true });397 }398 mocks.pushes.push({ type: message.type, payload: message.payload });399 return Promise.resolve({ ok: true });400 },401 onMessage: {402 addListener() { },403 removeListener() { },404 },405 },406 },407 });408 });409410 it("opens a relay socket and returns an empty invitation", async () => {411 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");412 const started = await startRelayAdoptionReceiver("receiver-password");413 expect(started.invitation).toMatch(/^peach-pair-v1:/);414 415 const decoded = decodeInvitation(started.invitation);416 expect(decoded?.vault).toBe("empty");417 expect(mocks.openSocket).toHaveBeenCalled();418 });419420 it("rejects an empty password", async () => {421 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");422 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");423 });424425 it("the receiver wires requestAdoption, not grantAdoption", async () => {426 427 428 429 430 431 const rootKeyHex = "f0".repeat(32);432 const vaultId = "b".repeat(64);433 const socket = socketWithFrames([434 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },435 {436 type: "ADOPT_GRANT",437 root_key_hex: rootKeyHex,438 vault_id: vaultId,439 source: {440 device_id: "phone-source-id",441 device_name: "June's phone",442 device_type: "android",443 long_term_public_key_hex: "99".repeat(32),444 },445 },446 { type: "ADOPT_COMPLETE" },447 ]);448 mocks.openSocket.mockResolvedValue(socket);449450 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");451 Object.defineProperty(globalThis, "chrome", {452 configurable: true,453 value: {454 runtime: {455 sendMessage(message: { type: string; payload?: unknown }) {456 if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {457 return Promise.resolve({ ok: true, data: { vaultId } });458 }459 if (message.type === "GET_LOCAL_IDENTITY") {460 return Promise.resolve({461 ok: true,462 data: {463 deviceId: "receiver-id",464 deviceName: "Receiver Browser",465 deviceType: "extension",466 longTermPublicKeyHex: "11".repeat(32),467 },468 });469 }470 if (message.type === "COMPLETE_PAIRING") {471 return Promise.resolve({ ok: true });472 }473 if (message.type === "TRIGGER_SYNC") {474 return Promise.resolve({ ok: true });475 }476 mocks.pushes.push({ type: message.type, payload: message.payload });477 return Promise.resolve({ ok: true });478 },479 onMessage: { addListener: () => undefined, removeListener: () => undefined },480 },481 },482 });483 await startRelayAdoptionReceiver("receiver-password");484 await waitForPush("ADOPTION_SOURCE_RESULT");485486 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })487 .mock.calls488 .map(([bytes]) => {489 try {490 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;491 } catch {492 return undefined;493 }494 });495 496 497 expect(sentTypes).toContain("ADOPT_REQUEST");498 expect(sentTypes).not.toContain("ADOPT_GRANT");499 });500501 it("completes the handshake end-to-end: vault created → paired", async () => {502 503 504 505 506 507 const rootKeyHex = "f0".repeat(32);508 509 510 const vaultId = "b".repeat(64);511 const frames = [512 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },513 514 515 {516 type: "ADOPT_GRANT",517 root_key_hex: rootKeyHex,518 vault_id: vaultId,519 license: "PPK-test",520 source: {521 device_id: "phone-source-id",522 device_name: "June's phone",523 device_type: "android",524 long_term_public_key_hex: "99".repeat(32),525 signing_public_key_hex: "aa".repeat(32),526 relay_url: "wss://relay.peachpasswords.com",527 },528 },529 { type: "ADOPT_COMPLETE" },530 ];531 mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId } });532 mocks.openSocket.mockResolvedValue(socketWithFrames(frames));533534 535 536 Object.defineProperty(globalThis, "chrome", {537 configurable: true,538 value: {539 runtime: {540 sendMessage(message: { type: string; payload?: unknown }) {541 if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {542 return Promise.resolve(mocks.createVault(message.payload));543 }544 if (message.type === "ACTIVATE_LICENSE") {545 return Promise.resolve(mocks.activateLicense(message.payload));546 }547 if (message.type === "GET_LOCAL_IDENTITY") {548 return Promise.resolve(mocks.localIdentity());549 }550 if (message.type === "COMPLETE_PAIRING") {551 return Promise.resolve(mocks.completePairing(message.payload));552 }553 if (message.type === "TRIGGER_SYNC") {554 return Promise.resolve({ ok: true });555 }556 mocks.pushes.push({ type: message.type, payload: message.payload });557 return Promise.resolve({ ok: true });558 },559 onMessage: {560 addListener: () => undefined,561 removeListener: () => undefined,562 },563 },564 },565 });566567 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");568 await startRelayAdoptionReceiver("receiver-password");569570 await waitForPush("ADOPTION_SOURCE_RESULT");571572 573 574575 576 577 expect(mocks.createVault).toHaveBeenCalledWith(expect.objectContaining({578 password: "receiver-password",579 rootKeyHex,580 }));581582 583 584 expect(mocks.activateLicense).toHaveBeenCalledWith({ key: "PPK-test" });585 expect(mocks.localIdentity).toHaveBeenCalled();586 expect(mocks.completePairing).toHaveBeenCalled();587 const pairedCall = mocks.completePairing.mock.calls[0]?.[0] as588 { paired?: { deviceId?: string; deviceName?: string } };589 expect(pairedCall.paired?.deviceId).toBe("phone-source-id");590591 592 expect(mocks.pushes).toContainEqual(expect.objectContaining({593 type: "ADOPTION_SOURCE_RESULT",594 payload: expect.objectContaining({ status: "complete" }),595 }));596 });597});598599function decodeInvitation(encoded: string): { vault?: string } | null {600 601 602 const at = encoded.indexOf(":");603 if (at < 0) return null;604 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");605 try {606 const json = Buffer.from(b64, "base64").toString("utf8");607 return JSON.parse(json) as { vault?: string };608 } catch {609 return null;610 }611}