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 isProUser: vi.fn(),14 getLicenseKey: vi.fn(),15 localIdentity: vi.fn(),16 rollbackVault: vi.fn(),17 storageSet: vi.fn(),18 sasDecisions: [] as Array<{ id: string; ok: boolean }>,19}));2021vi.mock("../shared/pairing-dial", () => ({22 openRelayPairingSocket: mocks.openSocket,23}));2425vi.mock("../shared/noise-js", () => ({26 noiseNKResponder: vi.fn(async () => ({27 handshakeHash: new Uint8Array(32).fill(7),28 encrypt: (bytes: Uint8Array) => bytes,29 decrypt: (bytes: Uint8Array) => bytes,30 close: vi.fn(),31 })),32}));3334vi.mock("../shared/rendezvous", async (importOriginal) => {35 const actual = await importOriginal<typeof import("../shared/rendezvous")>();36 return {37 ...actual,38 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",39 };40});414243vi.mock("./device-identity", () => ({44 getLocalPairingIdentity: async () => ({45 identity: {46 deviceId: "test-device",47 deviceName: "Test",48 deviceType: "extension",49 longTermPublicKeyHex: "aa".repeat(32),50 vaultId: "bb".repeat(32),51 },52 privateKey: new Uint8Array(32).fill(7),53 publicKey: new Uint8Array(32).fill(8),54 }),55 getDeviceName: mocks.getDeviceName,56 buildLocalIdentity: mocks.localIdentity,57}));5859vi.mock("./sync-settings", async (importOriginal) => ({60 ...(await importOriginal<typeof import("./sync-settings")>()),61 getSyncSettings: async () => ({62 mode: "public" as const,63 relayUrl: "wss://relay.peachpasswords.com",64 }),65}));6667vi.mock("./license", () => ({68 isProUser: mocks.isProUser,69 getLicenseKey: mocks.getLicenseKey,70 activateLicense: mocks.activateLicense,71}));7273vi.mock("./vault", () => ({74 isUnlocked: vi.fn(() => true),75 getRootKeyHex: mocks.getRootKeyHex,76 createVaultFromRootKey: mocks.createVault,77 rollbackAdoptedVault: mocks.rollbackVault,78}));7980vi.mock("../shared/sync-identity", () => ({81 computeVaultId: vi.fn(async () => "b".repeat(64)),82}));8384vi.mock("../shared/storage", () => ({85 storageSet: mocks.storageSet,86}));8788vi.mock("./paired-devices", () => ({89 upsertPairedDevice: mocks.upsert,90}));9192vi.mock("./sync-trigger", () => ({93 triggerSyncIfReachable: mocks.triggerSync,94}));9596import {97 cancelRelayAdoptionSource,98 startRelayAdoptionSource,99} from "./relay-adoption-source-bg";100101const encode = (value: unknown) =>102 new TextEncoder().encode(JSON.stringify(value));103104function socketWithFrames(frames: unknown[]) {105 const queue = frames.map(encode);106 return {107 send: vi.fn(),108 receive: vi.fn(async () => {109 const next = queue.shift();110 if (!next) return new Promise<Uint8Array>(() => undefined);111 return next;112 }),113 close: vi.fn(),114 closedBeforeFrame: vi.fn(() => false),115 };116}117118async function waitForPush(type: string): Promise<void> {119 await vi.waitFor(() => {120 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);121 });122}123124beforeEach(() => {125 mocks.pushes.length = 0;126 mocks.sasDecisions.length = 0;127 mocks.openSocket.mockReset();128 mocks.getLocalIdentity.mockReset();129 mocks.getRootKeyHex.mockReset();130 mocks.upsert.mockReset();131 mocks.triggerSync.mockReset();132 mocks.triggerSync.mockResolvedValue(undefined);133 mocks.isProUser.mockReset();134 mocks.isProUser.mockResolvedValue(true);135 mocks.getLicenseKey.mockReset();136 mocks.getLicenseKey.mockResolvedValue("PPK-source");137 mocks.getLocalIdentity.mockResolvedValue({138 identity: {139 deviceId: "browser-source",140 deviceName: "June's Browser",141 deviceType: "extension",142 longTermPublicKeyHex: "a".repeat(64),143 vaultId: "b".repeat(64),144 },145 privateKey: new Uint8Array(32).fill(1),146 publicKey: new Uint8Array(32).fill(2),147 });148 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));149 mocks.upsert.mockResolvedValue(undefined);150 Object.defineProperty(globalThis, "chrome", {151 configurable: true,152 value: {153 runtime: {154 async sendMessage(message: { type: string; payload: unknown }) {155 mocks.pushes.push(message);156 157 158 159 160 if (message.type === "ADOPTION_SOURCE_SAS") {161 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");162 const p = message.payload as { id?: string };163 if (typeof p?.id === "string") {164 mocks.sasDecisions.push({ id: p.id, ok: true });165 resolveSourceSasConfirmation(p.id, true);166 }167 }168 return Promise.resolve();169 },170 },171 },172 });173});174175describe("relay adoption source background boundary", () => {176 it("shows the SAS and waits for the popup's single confirmation", async () => {177 const socket = socketWithFrames([178 {179 type: "PEACH_HELLO",180 token: "AAECAwQFBgcICQoLDA0ODw",181 },182 {183 type: "ADOPT_REQUEST",184 device_id: "request-only-label",185 device_name: "June's phone",186 device_type: "android",187 },188 { type: "ADOPT_CONFIRM", confirmed: true },189 {190 type: "ADOPT_IDENTITY",191 device_id: "android-receiver",192 device_name: "June's phone",193 device_type: "android",194 long_term_public_key_hex: "d".repeat(64),195 },196 { type: "ADOPT_APPLIED" },197 ]);198 mocks.openSocket.mockResolvedValue(socket);199200 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");201 const sasIds: string[] = [];202 Object.defineProperty(globalThis, "chrome", {203 configurable: true,204 value: {205 runtime: {206 async sendMessage(message: { type: string; payload: unknown }) {207 mocks.pushes.push(message);208 if (message.type === "ADOPTION_SOURCE_SAS") {209 const p = message.payload as { id?: string };210 if (typeof p?.id === "string") {211 212 213 sasIds.push(p.id);214 }215 return Promise.resolve();216 }217 return Promise.resolve();218 },219 },220 },221 });222 void resolveSourceSasConfirmation; 223224 const started = await startRelayAdoptionSource();225 expect(started.invitation).toMatch(/^peach-pair-v1:/);226 await waitForPush("ADOPTION_SOURCE_SAS");227 228 229 await vi.waitFor(() => {230 expect(mocks.sasDecisions.length).toBe(0);231 });232 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();233 expect(mocks.upsert).not.toHaveBeenCalled();234235 236 const id = sasIds[0];237 expect(typeof id).toBe("string");238 resolveSourceSasConfirmation(id, true);239 mocks.sasDecisions.push({ id, ok: true });240241 await waitForPush("ADOPTION_SOURCE_RESULT");242243 expect(mocks.getRootKeyHex).toHaveBeenCalled();244 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({245 deviceId: "android-receiver",246 relayUrl: "wss://relay.peachpasswords.com",247 }));248 expect(mocks.pushes).toContainEqual(expect.objectContaining({249 type: "ADOPTION_SOURCE_RESULT",250 payload: expect.objectContaining({ status: "complete" }),251 }));252 expect(mocks.triggerSync).toHaveBeenCalledWith({253 reason: "manual",254 force: true,255 });256 const sentTypes = socket.send.mock.calls257 .map(([bytes]) => {258 try {259 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;260 } catch {261 return undefined;262 }263 });264 expect(sentTypes).toContain("ADOPT_COMPLETE");265 });266267 it("fails closed when the popup rejects the SAS", async () => {268 const socket = socketWithFrames([269 {270 type: "PEACH_HELLO",271 token: "AAECAwQFBgcICQoLDA0ODw",272 },273 {274 type: "ADOPT_REQUEST",275 device_id: "request-only-label",276 device_name: "New phone",277 device_type: "android",278 },279 { type: "ADOPT_CONFIRM", confirmed: false },280 ]);281 mocks.openSocket.mockResolvedValue(socket);282283 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");284 let pendingId: string | undefined;285 Object.defineProperty(globalThis, "chrome", {286 configurable: true,287 value: {288 runtime: {289 sendMessage(message: { type: string; payload: unknown }) {290 mocks.pushes.push(message);291 if (message.type === "ADOPTION_SOURCE_SAS") {292 const p = message.payload as { id?: string };293 if (typeof p?.id === "string") pendingId = p.id;294 }295 return Promise.resolve();296 },297 },298 },299 });300301 await startRelayAdoptionSource();302 await waitForPush("ADOPTION_SOURCE_SAS");303 expect(typeof pendingId).toBe("string");304 resolveSourceSasConfirmation(pendingId!, false);305 mocks.sasDecisions.push({ id: pendingId!, ok: false });306307 await waitForPush("ADOPTION_SOURCE_RESULT");308309 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();310 expect(mocks.upsert).not.toHaveBeenCalled();311 expect(mocks.pushes).toContainEqual({312 type: "ADOPTION_SOURCE_RESULT",313 payload: { status: "error", error: "adoption_rejected" },314 });315 });316317 it("rejects before reading the root key when neither peer has Pro", async () => {318 const socket = socketWithFrames([319 {320 type: "PEACH_HELLO",321 token: "AAECAwQFBgcICQoLDA0ODw",322 },323 {324 type: "ADOPT_REQUEST",325 device_id: "free-receiver",326 device_name: "Free phone",327 device_type: "android",328 },329 { type: "ADOPT_CONFIRM", confirmed: true },330 ]);331 mocks.isProUser.mockResolvedValue(false);332 mocks.getLicenseKey.mockResolvedValue(undefined);333 mocks.openSocket.mockResolvedValue(socket);334335 await startRelayAdoptionSource();336 await waitForPush("ADOPTION_SOURCE_RESULT");337338 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();339 expect(mocks.upsert).not.toHaveBeenCalled();340 expect(mocks.pushes).toContainEqual({341 type: "ADOPTION_SOURCE_RESULT",342 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },343 });344 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {345 try {346 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];347 } catch {348 return [];349 }350 });351 expect(sentFrames).toContainEqual({352 type: "ADOPT_REJECT",353 reason: "ADOPT_PRO_REQUIRED",354 });355 });356357 it("fails closed when the user never answers the SAS", async () => {358 359 360 361 362 const socket = socketWithFrames([]);363 mocks.openSocket.mockResolvedValue(socket);364365 Object.defineProperty(globalThis, "chrome", {366 configurable: true,367 value: {368 runtime: {369 sendMessage(message: { type: string; payload: unknown }) {370 mocks.pushes.push(message);371 return Promise.resolve();372 },373 },374 },375 });376377 await startRelayAdoptionSource();378 cancelRelayAdoptionSource();379380 expect(socket.close).toHaveBeenCalled();381 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();382 expect(mocks.upsert).not.toHaveBeenCalled();383 });384385 it("cancelling the source screen closes the room without reading the root", async () => {386 const socket = socketWithFrames([]);387 mocks.openSocket.mockResolvedValue(socket);388389 await startRelayAdoptionSource();390 cancelRelayAdoptionSource();391392 expect(socket.close).toHaveBeenCalled();393 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();394 expect(mocks.upsert).not.toHaveBeenCalled();395 });396});397398describe("relay adoption receiver background", () => {399 beforeEach(async () => {400 401 402 403 404 const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");405 cancelRelayAdoptionSource();406 mocks.pushes.length = 0;407 mocks.openSocket.mockReset();408 mocks.getDeviceName.mockReset();409 mocks.getDeviceName.mockResolvedValue("Receiver Browser");410 mocks.createVault.mockReset();411 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });412 mocks.activateLicense.mockReset();413 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });414 mocks.isProUser.mockReset();415 mocks.isProUser.mockResolvedValue(true);416 mocks.getLicenseKey.mockReset();417 mocks.getLicenseKey.mockResolvedValue(undefined);418 mocks.localIdentity.mockReset();419 mocks.localIdentity.mockResolvedValue({420 deviceId: "receiver-id",421 deviceName: "Receiver Browser",422 deviceType: "extension",423 longTermPublicKeyHex: "11".repeat(32),424 signingPublicKeyHex: "22".repeat(32),425 });426 mocks.rollbackVault.mockReset();427 mocks.rollbackVault.mockResolvedValue(undefined);428 mocks.storageSet.mockReset();429 mocks.storageSet.mockResolvedValue(undefined);430 mocks.upsert.mockReset();431 mocks.upsert.mockResolvedValue(undefined);432 mocks.triggerSync.mockReset();433 mocks.triggerSync.mockResolvedValue(undefined);434 mocks.openSocket.mockResolvedValue(socketWithFrames([]));435 Object.defineProperty(globalThis, "chrome", {436 configurable: true,437 value: {438 runtime: {439 sendMessage(message: { type: string; payload?: unknown }) {440 mocks.pushes.push({ type: message.type, payload: message.payload });441 return Promise.resolve({ ok: true });442 },443 onMessage: {444 addListener() { },445 removeListener() { },446 },447 },448 },449 });450 });451452 it("opens a relay socket and returns an empty invitation", async () => {453 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");454 const started = await startRelayAdoptionReceiver("receiver-password");455 expect(started.invitation).toMatch(/^peach-pair-v1:/);456 457 const decoded = decodeInvitation(started.invitation);458 expect(decoded?.vault).toBe("empty");459 expect(mocks.openSocket).toHaveBeenCalled();460 });461462 it("rejects an empty password", async () => {463 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");464 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");465 });466467 it("surfaces the Pro-required rejection without creating a vault", async () => {468 mocks.openSocket.mockResolvedValue(socketWithFrames([469 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },470 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },471 ]));472473 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");474 await startRelayAdoptionReceiver("receiver-password");475 await waitForPush("ADOPTION_SOURCE_RESULT");476477 expect(mocks.createVault).not.toHaveBeenCalled();478 expect(mocks.rollbackVault).not.toHaveBeenCalled();479 expect(mocks.pushes).toContainEqual({480 type: "ADOPTION_SOURCE_RESULT",481 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },482 });483 });484485 it("the receiver wires requestAdoption, not grantAdoption", async () => {486 487 488 489 490 491 const rootKeyHex = "f0".repeat(32);492 const vaultId = "b".repeat(64);493 const socket = socketWithFrames([494 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },495 {496 type: "ADOPT_GRANT",497 root_key_hex: rootKeyHex,498 vault_id: vaultId,499 license: "PPK-test",500 source: {501 device_id: "phone-source-id",502 device_name: "June's phone",503 device_type: "android",504 long_term_public_key_hex: "99".repeat(32),505 },506 },507 { type: "ADOPT_COMPLETE" },508 ]);509 mocks.openSocket.mockResolvedValue(socket);510 mocks.createVault.mockResolvedValue({ vaultId });511512 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");513 Object.defineProperty(globalThis, "chrome", {514 configurable: true,515 value: {516 runtime: {517 sendMessage(message: { type: string; payload?: unknown }) {518 mocks.pushes.push({ type: message.type, payload: message.payload });519 return Promise.resolve({ ok: true });520 },521 onMessage: { addListener: () => undefined, removeListener: () => undefined },522 },523 },524 });525 await startRelayAdoptionReceiver("receiver-password");526 await waitForPush("ADOPTION_SOURCE_RESULT");527528 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })529 .mock.calls530 .map(([bytes]) => {531 try {532 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;533 } catch {534 return undefined;535 }536 });537 538 539 expect(sentTypes).toContain("ADOPT_REQUEST");540 expect(sentTypes).not.toContain("ADOPT_GRANT");541 });542543 it("completes the handshake end-to-end: vault created → paired", async () => {544 545 546 547 548 549 const rootKeyHex = "f0".repeat(32);550 551 552 const vaultId = "b".repeat(64);553 const frames = [554 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },555 556 557 {558 type: "ADOPT_GRANT",559 root_key_hex: rootKeyHex,560 vault_id: vaultId,561 license: "PPK-test",562 source: {563 device_id: "phone-source-id",564 device_name: "June's phone",565 device_type: "android",566 long_term_public_key_hex: "99".repeat(32),567 signing_public_key_hex: "aa".repeat(32),568 relay_url: "wss://relay.peachpasswords.com",569 },570 },571 { type: "ADOPT_COMPLETE" },572 ];573 mocks.createVault.mockResolvedValue({ vaultId });574 mocks.openSocket.mockResolvedValue(socketWithFrames(frames));575576 577 578 Object.defineProperty(globalThis, "chrome", {579 configurable: true,580 value: {581 runtime: {582 sendMessage(message: { type: string; payload?: unknown }) {583 mocks.pushes.push({ type: message.type, payload: message.payload });584 return Promise.resolve({ ok: true });585 },586 onMessage: {587 addListener: () => undefined,588 removeListener: () => undefined,589 },590 },591 },592 });593594 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");595 await startRelayAdoptionReceiver("receiver-password");596597 await waitForPush("ADOPTION_SOURCE_RESULT");598599 600 601 expect(mocks.pushes).toContainEqual({602 type: "ADOPTION_SOURCE_SAS",603 payload: expect.objectContaining({604 sas: expect.stringMatching(/^\d{8}$/),605 displayOnly: true,606 }),607 });608609 610 611 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);612613 614 615 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");616 expect(mocks.isProUser).toHaveBeenCalled();617 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));618 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({619 deviceId: "phone-source-id",620 }));621 expect(mocks.storageSet).toHaveBeenCalledWith(622 "peach_initial_sync_pending_device_id",623 "phone-source-id",624 );625 expect(mocks.rollbackVault).not.toHaveBeenCalled();626627 628 expect(mocks.pushes).toContainEqual(expect.objectContaining({629 type: "ADOPTION_SOURCE_RESULT",630 payload: expect.objectContaining({ status: "complete" }),631 }));632 });633634 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {635 const rootKeyHex = "f0".repeat(32);636 const vaultId = "b".repeat(64);637 mocks.createVault.mockResolvedValue({ vaultId });638 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });639 mocks.isProUser.mockResolvedValue(false);640 mocks.openSocket.mockResolvedValue(socketWithFrames([641 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },642 {643 type: "ADOPT_GRANT",644 root_key_hex: rootKeyHex,645 vault_id: vaultId,646 license: "PPK-invalid",647 source: {648 device_id: "phone-source-id",649 device_name: "June's phone",650 device_type: "android",651 long_term_public_key_hex: "99".repeat(32),652 },653 },654 ]));655656 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");657 await startRelayAdoptionReceiver("receiver-password");658 await waitForPush("ADOPTION_SOURCE_RESULT");659660 expect(mocks.rollbackVault).toHaveBeenCalledOnce();661 expect(mocks.localIdentity).not.toHaveBeenCalled();662 expect(mocks.upsert).not.toHaveBeenCalled();663 expect(mocks.pushes).toContainEqual(expect.objectContaining({664 type: "ADOPTION_SOURCE_RESULT",665 payload: expect.objectContaining({666 status: "error",667 error: "license_transfer_failed",668 }),669 }));670 });671672 it("rolls back a newly created vault when a later adoption step fails", async () => {673 const rootKeyHex = "f0".repeat(32);674 const vaultId = "b".repeat(64);675 mocks.createVault.mockResolvedValue({ vaultId });676 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));677 mocks.openSocket.mockResolvedValue(socketWithFrames([678 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },679 {680 type: "ADOPT_GRANT",681 root_key_hex: rootKeyHex,682 vault_id: vaultId,683 license: "PPK-test",684 source: {685 device_id: "phone-source-id",686 device_name: "June's phone",687 device_type: "android",688 long_term_public_key_hex: "99".repeat(32),689 },690 },691 ]));692693 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");694 await startRelayAdoptionReceiver("receiver-password");695 await waitForPush("ADOPTION_SOURCE_RESULT");696697 expect(mocks.rollbackVault).toHaveBeenCalledOnce();698 expect(mocks.upsert).not.toHaveBeenCalled();699 expect(mocks.pushes).toContainEqual(expect.objectContaining({700 type: "ADOPTION_SOURCE_RESULT",701 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),702 }));703 });704});705706function decodeInvitation(encoded: string): { vault?: string } | null {707 708 709 const at = encoded.indexOf(":");710 if (at < 0) return null;711 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");712 try {713 const json = Buffer.from(b64, "base64").toString("utf8");714 return JSON.parse(json) as { vault?: string };715 } catch {716 return null;717 }718}