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 sasDecisions: [] as Array<{ id: string; ok: boolean }>,18}));1920vi.mock("../shared/pairing-dial", () => ({21 openRelayPairingSocket: mocks.openSocket,22}));2324vi.mock("../shared/noise-js", () => ({25 noiseNKResponder: vi.fn(async () => ({26 handshakeHash: new Uint8Array(32).fill(7),27 encrypt: (bytes: Uint8Array) => bytes,28 decrypt: (bytes: Uint8Array) => bytes,29 close: vi.fn(),30 })),31}));3233vi.mock("../shared/rendezvous", async (importOriginal) => {34 const actual = await importOriginal<typeof import("../shared/rendezvous")>();35 return {36 ...actual,37 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",38 };39});404142vi.mock("./device-identity", () => ({43 getLocalPairingIdentity: async () => ({44 identity: {45 deviceId: "test-device",46 deviceName: "Test",47 deviceType: "extension",48 longTermPublicKeyHex: "aa".repeat(32),49 vaultId: "bb".repeat(32),50 },51 privateKey: new Uint8Array(32).fill(7),52 publicKey: new Uint8Array(32).fill(8),53 }),54 getDeviceName: mocks.getDeviceName,55 buildLocalIdentity: mocks.localIdentity,56}));5758vi.mock("./sync-settings", async (importOriginal) => ({59 ...(await importOriginal<typeof import("./sync-settings")>()),60 getSyncSettings: async () => ({61 mode: "public" as const,62 relayUrl: "wss://relay.peachpasswords.com",63 }),64}));6566vi.mock("./license", () => ({67 isProUser: mocks.isProUser,68 getLicenseKey: mocks.getLicenseKey,69 activateLicense: mocks.activateLicense,70}));7172vi.mock("./vault", () => ({73 isUnlocked: vi.fn(() => true),74 getRootKeyHex: mocks.getRootKeyHex,75 createVaultFromRootKey: mocks.createVault,76 rollbackAdoptedVault: mocks.rollbackVault,77}));7879vi.mock("../shared/sync-identity", () => ({80 computeVaultId: vi.fn(async () => "b".repeat(64)),81}));8283vi.mock("./paired-devices", () => ({84 upsertPairedDevice: mocks.upsert,85}));8687vi.mock("./sync-trigger", () => ({88 triggerSyncIfReachable: mocks.triggerSync,89}));9091import {92 cancelRelayAdoptionSource,93 startRelayAdoptionSource,94} from "./relay-adoption-source-bg";9596const encode = (value: unknown) =>97 new TextEncoder().encode(JSON.stringify(value));9899function socketWithFrames(frames: unknown[]) {100 const queue = frames.map(encode);101 return {102 send: vi.fn(),103 receive: vi.fn(async () => {104 const next = queue.shift();105 if (!next) return new Promise<Uint8Array>(() => undefined);106 return next;107 }),108 close: vi.fn(),109 closedBeforeFrame: vi.fn(() => false),110 };111}112113async function waitForPush(type: string): Promise<void> {114 await vi.waitFor(() => {115 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);116 });117}118119beforeEach(() => {120 mocks.pushes.length = 0;121 mocks.sasDecisions.length = 0;122 mocks.openSocket.mockReset();123 mocks.getLocalIdentity.mockReset();124 mocks.getRootKeyHex.mockReset();125 mocks.upsert.mockReset();126 mocks.triggerSync.mockReset();127 mocks.triggerSync.mockResolvedValue(undefined);128 mocks.isProUser.mockReset();129 mocks.isProUser.mockResolvedValue(true);130 mocks.getLicenseKey.mockReset();131 mocks.getLicenseKey.mockResolvedValue("PPK-source");132 mocks.getLocalIdentity.mockResolvedValue({133 identity: {134 deviceId: "browser-source",135 deviceName: "June's Browser",136 deviceType: "extension",137 longTermPublicKeyHex: "a".repeat(64),138 vaultId: "b".repeat(64),139 },140 privateKey: new Uint8Array(32).fill(1),141 publicKey: new Uint8Array(32).fill(2),142 });143 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));144 mocks.upsert.mockResolvedValue(undefined);145 Object.defineProperty(globalThis, "chrome", {146 configurable: true,147 value: {148 runtime: {149 async sendMessage(message: { type: string; payload: unknown }) {150 mocks.pushes.push(message);151 152 153 154 155 if (message.type === "ADOPTION_SOURCE_SAS") {156 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");157 const p = message.payload as { id?: string };158 if (typeof p?.id === "string") {159 mocks.sasDecisions.push({ id: p.id, ok: true });160 resolveSourceSasConfirmation(p.id, true);161 }162 }163 return Promise.resolve();164 },165 },166 },167 });168});169170describe("relay adoption source background boundary", () => {171 it("shows the SAS and waits for the popup's single confirmation", async () => {172 const socket = socketWithFrames([173 {174 type: "PEACH_HELLO",175 token: "AAECAwQFBgcICQoLDA0ODw",176 },177 {178 type: "ADOPT_REQUEST",179 device_id: "request-only-label",180 device_name: "June's phone",181 device_type: "android",182 },183 { type: "ADOPT_CONFIRM", confirmed: true },184 {185 type: "ADOPT_IDENTITY",186 device_id: "android-receiver",187 device_name: "June's phone",188 device_type: "android",189 long_term_public_key_hex: "d".repeat(64),190 },191 { type: "ADOPT_APPLIED" },192 ]);193 mocks.openSocket.mockResolvedValue(socket);194195 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");196 const sasIds: string[] = [];197 Object.defineProperty(globalThis, "chrome", {198 configurable: true,199 value: {200 runtime: {201 async sendMessage(message: { type: string; payload: unknown }) {202 mocks.pushes.push(message);203 if (message.type === "ADOPTION_SOURCE_SAS") {204 const p = message.payload as { id?: string };205 if (typeof p?.id === "string") {206 207 208 sasIds.push(p.id);209 }210 return Promise.resolve();211 }212 return Promise.resolve();213 },214 },215 },216 });217 void resolveSourceSasConfirmation; 218219 const started = await startRelayAdoptionSource();220 expect(started.invitation).toMatch(/^peach-pair-v1:/);221 await waitForPush("ADOPTION_SOURCE_SAS");222 223 224 await vi.waitFor(() => {225 expect(mocks.sasDecisions.length).toBe(0);226 });227 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();228 expect(mocks.upsert).not.toHaveBeenCalled();229230 231 const id = sasIds[0];232 expect(typeof id).toBe("string");233 resolveSourceSasConfirmation(id, true);234 mocks.sasDecisions.push({ id, ok: true });235236 await waitForPush("ADOPTION_SOURCE_RESULT");237238 expect(mocks.getRootKeyHex).toHaveBeenCalled();239 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({240 deviceId: "android-receiver",241 relayUrl: "wss://relay.peachpasswords.com",242 }));243 expect(mocks.pushes).toContainEqual(expect.objectContaining({244 type: "ADOPTION_SOURCE_RESULT",245 payload: expect.objectContaining({ status: "complete" }),246 }));247 expect(mocks.triggerSync).toHaveBeenCalledWith({248 reason: "manual",249 force: true,250 });251 const sentTypes = socket.send.mock.calls252 .map(([bytes]) => {253 try {254 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;255 } catch {256 return undefined;257 }258 });259 expect(sentTypes).toContain("ADOPT_COMPLETE");260 });261262 it("fails closed when the popup rejects the SAS", async () => {263 const socket = socketWithFrames([264 {265 type: "PEACH_HELLO",266 token: "AAECAwQFBgcICQoLDA0ODw",267 },268 {269 type: "ADOPT_REQUEST",270 device_id: "request-only-label",271 device_name: "New phone",272 device_type: "android",273 },274 { type: "ADOPT_CONFIRM", confirmed: false },275 ]);276 mocks.openSocket.mockResolvedValue(socket);277278 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");279 let pendingId: string | undefined;280 Object.defineProperty(globalThis, "chrome", {281 configurable: true,282 value: {283 runtime: {284 sendMessage(message: { type: string; payload: unknown }) {285 mocks.pushes.push(message);286 if (message.type === "ADOPTION_SOURCE_SAS") {287 const p = message.payload as { id?: string };288 if (typeof p?.id === "string") pendingId = p.id;289 }290 return Promise.resolve();291 },292 },293 },294 });295296 await startRelayAdoptionSource();297 await waitForPush("ADOPTION_SOURCE_SAS");298 expect(typeof pendingId).toBe("string");299 resolveSourceSasConfirmation(pendingId!, false);300 mocks.sasDecisions.push({ id: pendingId!, ok: false });301302 await waitForPush("ADOPTION_SOURCE_RESULT");303304 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();305 expect(mocks.upsert).not.toHaveBeenCalled();306 expect(mocks.pushes).toContainEqual({307 type: "ADOPTION_SOURCE_RESULT",308 payload: { status: "error", error: "adoption_rejected" },309 });310 });311312 it("rejects before reading the root key when neither peer has Pro", async () => {313 const socket = socketWithFrames([314 {315 type: "PEACH_HELLO",316 token: "AAECAwQFBgcICQoLDA0ODw",317 },318 {319 type: "ADOPT_REQUEST",320 device_id: "free-receiver",321 device_name: "Free phone",322 device_type: "android",323 },324 { type: "ADOPT_CONFIRM", confirmed: true },325 ]);326 mocks.isProUser.mockResolvedValue(false);327 mocks.getLicenseKey.mockResolvedValue(undefined);328 mocks.openSocket.mockResolvedValue(socket);329330 await startRelayAdoptionSource();331 await waitForPush("ADOPTION_SOURCE_RESULT");332333 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();334 expect(mocks.upsert).not.toHaveBeenCalled();335 expect(mocks.pushes).toContainEqual({336 type: "ADOPTION_SOURCE_RESULT",337 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },338 });339 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {340 try {341 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];342 } catch {343 return [];344 }345 });346 expect(sentFrames).toContainEqual({347 type: "ADOPT_REJECT",348 reason: "ADOPT_PRO_REQUIRED",349 });350 });351352 it("fails closed when the user never answers the SAS", async () => {353 354 355 356 357 const socket = socketWithFrames([]);358 mocks.openSocket.mockResolvedValue(socket);359360 Object.defineProperty(globalThis, "chrome", {361 configurable: true,362 value: {363 runtime: {364 sendMessage(message: { type: string; payload: unknown }) {365 mocks.pushes.push(message);366 return Promise.resolve();367 },368 },369 },370 });371372 await startRelayAdoptionSource();373 cancelRelayAdoptionSource();374375 expect(socket.close).toHaveBeenCalled();376 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();377 expect(mocks.upsert).not.toHaveBeenCalled();378 });379380 it("cancelling the source screen closes the room without reading the root", async () => {381 const socket = socketWithFrames([]);382 mocks.openSocket.mockResolvedValue(socket);383384 await startRelayAdoptionSource();385 cancelRelayAdoptionSource();386387 expect(socket.close).toHaveBeenCalled();388 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();389 expect(mocks.upsert).not.toHaveBeenCalled();390 });391});392393describe("relay adoption receiver background", () => {394 beforeEach(async () => {395 396 397 398 399 const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");400 cancelRelayAdoptionSource();401 mocks.pushes.length = 0;402 mocks.openSocket.mockReset();403 mocks.getDeviceName.mockReset();404 mocks.getDeviceName.mockResolvedValue("Receiver Browser");405 mocks.createVault.mockReset();406 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });407 mocks.activateLicense.mockReset();408 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });409 mocks.isProUser.mockReset();410 mocks.isProUser.mockResolvedValue(true);411 mocks.getLicenseKey.mockReset();412 mocks.getLicenseKey.mockResolvedValue(undefined);413 mocks.localIdentity.mockReset();414 mocks.localIdentity.mockResolvedValue({415 deviceId: "receiver-id",416 deviceName: "Receiver Browser",417 deviceType: "extension",418 longTermPublicKeyHex: "11".repeat(32),419 signingPublicKeyHex: "22".repeat(32),420 });421 mocks.rollbackVault.mockReset();422 mocks.rollbackVault.mockResolvedValue(undefined);423 mocks.upsert.mockReset();424 mocks.upsert.mockResolvedValue(undefined);425 mocks.triggerSync.mockReset();426 mocks.triggerSync.mockResolvedValue(undefined);427 mocks.openSocket.mockResolvedValue(socketWithFrames([]));428 Object.defineProperty(globalThis, "chrome", {429 configurable: true,430 value: {431 runtime: {432 sendMessage(message: { type: string; payload?: unknown }) {433 mocks.pushes.push({ type: message.type, payload: message.payload });434 return Promise.resolve({ ok: true });435 },436 onMessage: {437 addListener() { },438 removeListener() { },439 },440 },441 },442 });443 });444445 it("opens a relay socket and returns an empty invitation", async () => {446 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");447 const started = await startRelayAdoptionReceiver("receiver-password");448 expect(started.invitation).toMatch(/^peach-pair-v1:/);449 450 const decoded = decodeInvitation(started.invitation);451 expect(decoded?.vault).toBe("empty");452 expect(mocks.openSocket).toHaveBeenCalled();453 });454455 it("rejects an empty password", async () => {456 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");457 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");458 });459460 it("surfaces the Pro-required rejection without creating a vault", async () => {461 mocks.openSocket.mockResolvedValue(socketWithFrames([462 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },463 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },464 ]));465466 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");467 await startRelayAdoptionReceiver("receiver-password");468 await waitForPush("ADOPTION_SOURCE_RESULT");469470 expect(mocks.createVault).not.toHaveBeenCalled();471 expect(mocks.rollbackVault).not.toHaveBeenCalled();472 expect(mocks.pushes).toContainEqual({473 type: "ADOPTION_SOURCE_RESULT",474 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },475 });476 });477478 it("the receiver wires requestAdoption, not grantAdoption", async () => {479 480 481 482 483 484 const rootKeyHex = "f0".repeat(32);485 const vaultId = "b".repeat(64);486 const socket = socketWithFrames([487 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },488 {489 type: "ADOPT_GRANT",490 root_key_hex: rootKeyHex,491 vault_id: vaultId,492 license: "PPK-test",493 source: {494 device_id: "phone-source-id",495 device_name: "June's phone",496 device_type: "android",497 long_term_public_key_hex: "99".repeat(32),498 },499 },500 { type: "ADOPT_COMPLETE" },501 ]);502 mocks.openSocket.mockResolvedValue(socket);503 mocks.createVault.mockResolvedValue({ vaultId });504505 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");506 Object.defineProperty(globalThis, "chrome", {507 configurable: true,508 value: {509 runtime: {510 sendMessage(message: { type: string; payload?: unknown }) {511 mocks.pushes.push({ type: message.type, payload: message.payload });512 return Promise.resolve({ ok: true });513 },514 onMessage: { addListener: () => undefined, removeListener: () => undefined },515 },516 },517 });518 await startRelayAdoptionReceiver("receiver-password");519 await waitForPush("ADOPTION_SOURCE_RESULT");520521 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })522 .mock.calls523 .map(([bytes]) => {524 try {525 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;526 } catch {527 return undefined;528 }529 });530 531 532 expect(sentTypes).toContain("ADOPT_REQUEST");533 expect(sentTypes).not.toContain("ADOPT_GRANT");534 });535536 it("completes the handshake end-to-end: vault created → paired", async () => {537 538 539 540 541 542 const rootKeyHex = "f0".repeat(32);543 544 545 const vaultId = "b".repeat(64);546 const frames = [547 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },548 549 550 {551 type: "ADOPT_GRANT",552 root_key_hex: rootKeyHex,553 vault_id: vaultId,554 license: "PPK-test",555 source: {556 device_id: "phone-source-id",557 device_name: "June's phone",558 device_type: "android",559 long_term_public_key_hex: "99".repeat(32),560 signing_public_key_hex: "aa".repeat(32),561 relay_url: "wss://relay.peachpasswords.com",562 },563 },564 { type: "ADOPT_COMPLETE" },565 ];566 mocks.createVault.mockResolvedValue({ vaultId });567 mocks.openSocket.mockResolvedValue(socketWithFrames(frames));568569 570 571 Object.defineProperty(globalThis, "chrome", {572 configurable: true,573 value: {574 runtime: {575 sendMessage(message: { type: string; payload?: unknown }) {576 mocks.pushes.push({ type: message.type, payload: message.payload });577 return Promise.resolve({ ok: true });578 },579 onMessage: {580 addListener: () => undefined,581 removeListener: () => undefined,582 },583 },584 },585 });586587 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");588 await startRelayAdoptionReceiver("receiver-password");589590 await waitForPush("ADOPTION_SOURCE_RESULT");591592 593 594 expect(mocks.pushes).toContainEqual({595 type: "ADOPTION_SOURCE_SAS",596 payload: expect.objectContaining({597 sas: expect.stringMatching(/^\d{8}$/),598 displayOnly: true,599 }),600 });601602 603 604 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);605606 607 608 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");609 expect(mocks.isProUser).toHaveBeenCalled();610 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));611 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({612 deviceId: "phone-source-id",613 }));614 expect(mocks.rollbackVault).not.toHaveBeenCalled();615616 617 expect(mocks.pushes).toContainEqual(expect.objectContaining({618 type: "ADOPTION_SOURCE_RESULT",619 payload: expect.objectContaining({ status: "complete" }),620 }));621 });622623 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {624 const rootKeyHex = "f0".repeat(32);625 const vaultId = "b".repeat(64);626 mocks.createVault.mockResolvedValue({ vaultId });627 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });628 mocks.isProUser.mockResolvedValue(false);629 mocks.openSocket.mockResolvedValue(socketWithFrames([630 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },631 {632 type: "ADOPT_GRANT",633 root_key_hex: rootKeyHex,634 vault_id: vaultId,635 license: "PPK-invalid",636 source: {637 device_id: "phone-source-id",638 device_name: "June's phone",639 device_type: "android",640 long_term_public_key_hex: "99".repeat(32),641 },642 },643 ]));644645 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");646 await startRelayAdoptionReceiver("receiver-password");647 await waitForPush("ADOPTION_SOURCE_RESULT");648649 expect(mocks.rollbackVault).toHaveBeenCalledOnce();650 expect(mocks.localIdentity).not.toHaveBeenCalled();651 expect(mocks.upsert).not.toHaveBeenCalled();652 expect(mocks.pushes).toContainEqual(expect.objectContaining({653 type: "ADOPTION_SOURCE_RESULT",654 payload: expect.objectContaining({655 status: "error",656 error: "license_transfer_failed",657 }),658 }));659 });660661 it("rolls back a newly created vault when a later adoption step fails", async () => {662 const rootKeyHex = "f0".repeat(32);663 const vaultId = "b".repeat(64);664 mocks.createVault.mockResolvedValue({ vaultId });665 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));666 mocks.openSocket.mockResolvedValue(socketWithFrames([667 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },668 {669 type: "ADOPT_GRANT",670 root_key_hex: rootKeyHex,671 vault_id: vaultId,672 license: "PPK-test",673 source: {674 device_id: "phone-source-id",675 device_name: "June's phone",676 device_type: "android",677 long_term_public_key_hex: "99".repeat(32),678 },679 },680 ]));681682 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");683 await startRelayAdoptionReceiver("receiver-password");684 await waitForPush("ADOPTION_SOURCE_RESULT");685686 expect(mocks.rollbackVault).toHaveBeenCalledOnce();687 expect(mocks.upsert).not.toHaveBeenCalled();688 expect(mocks.pushes).toContainEqual(expect.objectContaining({689 type: "ADOPTION_SOURCE_RESULT",690 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),691 }));692 });693});694695function decodeInvitation(encoded: string): { vault?: string } | null {696 697 698 const at = encoded.indexOf(":");699 if (at < 0) return null;700 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");701 try {702 const json = Buffer.from(b64, "base64").toString("utf8");703 return JSON.parse(json) as { vault?: string };704 } catch {705 return null;706 }707}