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.sasDecisions.length = 0;353 mocks.openSocket.mockReset();354 mocks.getDeviceName.mockReset();355 mocks.getDeviceName.mockResolvedValue("Receiver Browser");356 mocks.createVault.mockReset();357 mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId: "ee".repeat(32) } });358 mocks.activateLicense.mockReset();359 mocks.activateLicense.mockResolvedValue({ ok: true });360 mocks.localIdentity.mockReset();361 mocks.localIdentity.mockResolvedValue({362 ok: true,363 data: {364 deviceId: "receiver-id",365 deviceName: "Receiver Browser",366 deviceType: "extension",367 longTermPublicKeyHex: "11".repeat(32),368 signingPublicKeyHex: "22".repeat(32),369 },370 });371 mocks.completePairing.mockReset();372 mocks.completePairing.mockResolvedValue({ ok: true });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 384 385 if (message.type === "RECEIVER_SAS_DECISION") {386 const p = message.payload as { id?: string; ok?: boolean };387 if (typeof p?.id === "string" && typeof p?.ok === "boolean") {388 mocks.sasDecisions.push({ id: p.id, ok: p.ok });389 }390 }391 if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {392 return Promise.resolve(mocks.createVault(message.payload));393 }394 if (message.type === "ACTIVATE_LICENSE") {395 return Promise.resolve(mocks.activateLicense(message.payload));396 }397 if (message.type === "GET_LOCAL_IDENTITY") {398 return Promise.resolve(mocks.localIdentity());399 }400 if (message.type === "COMPLETE_PAIRING") {401 return Promise.resolve(mocks.completePairing(message.payload));402 }403 if (message.type === "TRIGGER_SYNC") {404 return Promise.resolve({ ok: true });405 }406 mocks.pushes.push({ type: message.type, payload: message.payload });407 return Promise.resolve({ ok: true });408 },409 onMessage: {410 addListener() { },411 removeListener() { },412 },413 },414 },415 });416 });417418 it("opens a relay socket and returns an empty invitation", async () => {419 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");420 const started = await startRelayAdoptionReceiver("receiver-password");421 expect(started.invitation).toMatch(/^peach-pair-v1:/);422 423 const decoded = decodeInvitation(started.invitation);424 expect(decoded?.vault).toBe("empty");425 expect(mocks.openSocket).toHaveBeenCalled();426 });427428 it("rejects an empty password", async () => {429 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");430 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");431 });432433 it("the receiver wires requestAdoption, not grantAdoption", async () => {434 435 436 437 438 439 const rootKeyHex = "f0".repeat(32);440 const vaultId = "b".repeat(64);441 const socket = socketWithFrames([442 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },443 {444 type: "ADOPT_GRANT",445 root_key_hex: rootKeyHex,446 vault_id: vaultId,447 source: {448 device_id: "phone-source-id",449 device_name: "June's phone",450 device_type: "android",451 long_term_public_key_hex: "99".repeat(32),452 },453 },454 { type: "ADOPT_COMPLETE" },455 ]);456 mocks.openSocket.mockResolvedValue(socket);457458 const { startRelayAdoptionReceiver, resolveReceiverSasConfirmation } =459 await import("./relay-adoption-source-bg");460 Object.defineProperty(globalThis, "chrome", {461 configurable: true,462 value: {463 runtime: {464 sendMessage(message: { type: string; payload?: unknown }) {465 if (message.type === "RECEIVER_SAS_DECISION") return Promise.resolve({ ok: true });466 if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {467 return Promise.resolve({ ok: true, data: { vaultId } });468 }469 if (message.type === "GET_LOCAL_IDENTITY") {470 return Promise.resolve({471 ok: true,472 data: {473 deviceId: "receiver-id",474 deviceName: "Receiver Browser",475 deviceType: "extension",476 longTermPublicKeyHex: "11".repeat(32),477 },478 });479 }480 if (message.type === "COMPLETE_PAIRING") {481 return Promise.resolve({ ok: true });482 }483 if (message.type === "TRIGGER_SYNC") {484 return Promise.resolve({ ok: true });485 }486 mocks.pushes.push({ type: message.type, payload: message.payload });487 if (message.type === "ADOPTION_SOURCE_SAS") {488 const p = message.payload as { id?: string };489 if (typeof p?.id === "string") resolveReceiverSasConfirmation(p.id, true);490 }491 return Promise.resolve({ ok: true });492 },493 onMessage: { addListener: () => undefined, removeListener: () => undefined },494 },495 },496 });497 await startRelayAdoptionReceiver("receiver-password");498 await waitForPush("ADOPTION_SOURCE_RESULT");499500 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })501 .mock.calls502 .map(([bytes]) => {503 try {504 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;505 } catch {506 return undefined;507 }508 });509 510 511 expect(sentTypes).toContain("ADOPT_REQUEST");512 expect(sentTypes).not.toContain("ADOPT_GRANT");513 });514515 it("completes the handshake end-to-end: SAS confirm → vault created → paired", async () => {516 517 518 519 520 521 const { resolveReceiverSasConfirmation } = await import("./relay-adoption-source-bg");522 const rootKeyHex = "f0".repeat(32);523 524 525 const vaultId = "b".repeat(64);526 const frames = [527 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },528 529 530 {531 type: "ADOPT_GRANT",532 root_key_hex: rootKeyHex,533 vault_id: vaultId,534 license: "PPK-test",535 source: {536 device_id: "phone-source-id",537 device_name: "June's phone",538 device_type: "android",539 long_term_public_key_hex: "99".repeat(32),540 signing_public_key_hex: "aa".repeat(32),541 relay_url: "wss://relay.peachpasswords.com",542 },543 },544 { type: "ADOPT_COMPLETE" },545 ];546 mocks.createVault.mockResolvedValue({ ok: true, data: { vaultId } });547 mocks.openSocket.mockResolvedValue(socketWithFrames(frames));548549 550 551 Object.defineProperty(globalThis, "chrome", {552 configurable: true,553 value: {554 runtime: {555 sendMessage(message: { type: string; payload?: unknown }) {556 if (message.type === "RECEIVER_SAS_DECISION") {557 const p = message.payload as { id?: string; ok?: boolean };558 if (typeof p?.id === "string" && typeof p?.ok === "boolean") {559 mocks.sasDecisions.push({ id: p.id, ok: p.ok });560 }561 return Promise.resolve({ ok: true });562 }563 if (message.type === "CREATE_VAULT_FROM_ROOT_KEY") {564 return Promise.resolve(mocks.createVault(message.payload));565 }566 if (message.type === "ACTIVATE_LICENSE") {567 return Promise.resolve(mocks.activateLicense(message.payload));568 }569 if (message.type === "GET_LOCAL_IDENTITY") {570 return Promise.resolve(mocks.localIdentity());571 }572 if (message.type === "COMPLETE_PAIRING") {573 return Promise.resolve(mocks.completePairing(message.payload));574 }575 if (message.type === "TRIGGER_SYNC") {576 return Promise.resolve({ ok: true });577 }578 mocks.pushes.push({ type: message.type, payload: message.payload });579 580 581 582 if (message.type === "ADOPTION_SOURCE_SAS") {583 const p = message.payload as { id?: string };584 if (typeof p?.id === "string") {585 mocks.sasDecisions.push({ id: p.id, ok: true });586 resolveReceiverSasConfirmation(p.id, true);587 }588 }589 return Promise.resolve({ ok: true });590 },591 onMessage: {592 addListener: () => undefined,593 removeListener: () => undefined,594 },595 },596 },597 });598599 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");600 await startRelayAdoptionReceiver("receiver-password");601602 await waitForPush("ADOPTION_SOURCE_RESULT");603604 605 expect(mocks.pushes.some((p) => p.type === "ADOPTION_SOURCE_SAS")).toBe(true);606 expect(mocks.sasDecisions.some((d) => d.ok === true)).toBe(true);607608 609 610 expect(mocks.createVault).toHaveBeenCalledWith(expect.objectContaining({611 password: "receiver-password",612 rootKeyHex,613 }));614615 616 617 expect(mocks.activateLicense).toHaveBeenCalledWith({ key: "PPK-test" });618 expect(mocks.localIdentity).toHaveBeenCalled();619 expect(mocks.completePairing).toHaveBeenCalled();620 const pairedCall = mocks.completePairing.mock.calls[0]?.[0] as621 { paired?: { deviceId?: string; deviceName?: string } };622 expect(pairedCall.paired?.deviceId).toBe("phone-source-id");623624 625 expect(mocks.pushes).toContainEqual(expect.objectContaining({626 type: "ADOPTION_SOURCE_RESULT",627 payload: expect.objectContaining({ status: "complete" }),628 }));629 });630});631632function decodeInvitation(encoded: string): { vault?: string } | null {633 634 635 const at = encoded.indexOf(":");636 if (at < 0) return null;637 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");638 try {639 const json = Buffer.from(b64, "base64").toString("utf8");640 return JSON.parse(json) as { vault?: string };641 } catch {642 return null;643 }644}