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(105 frames: unknown[],106 options: { closeOnEmpty?: boolean } = {},107) {108 const queue = frames.map(encode);109 return {110 send: vi.fn(),111 receive: vi.fn(async () => {112 const next = queue.shift();113 if (!next) {114 115 116 117 if (options.closeOnEmpty) throw new Error("socket_closed");118 return new Promise<Uint8Array>(() => undefined);119 }120 return next;121 }),122 close: vi.fn(),123 closedBeforeFrame: vi.fn(() => false),124 };125}126127async function waitForPush(type: string): Promise<void> {128 await vi.waitFor(() => {129 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);130 });131}132133beforeEach(() => {134 mocks.pushes.length = 0;135 mocks.sasDecisions.length = 0;136 mocks.openSocket.mockReset();137 mocks.getLocalIdentity.mockReset();138 mocks.getRootKeyHex.mockReset();139 mocks.upsert.mockReset();140 mocks.triggerSync.mockReset();141 mocks.triggerSync.mockResolvedValue(undefined);142 mocks.isProUser.mockReset();143 mocks.isProUser.mockResolvedValue(true);144 mocks.getLicenseKey.mockReset();145 mocks.getLicenseKey.mockResolvedValue("PPK-source");146 mocks.getLocalIdentity.mockResolvedValue({147 identity: {148 deviceId: "browser-source",149 deviceName: "June's Browser",150 deviceType: "extension",151 longTermPublicKeyHex: "a".repeat(64),152 vaultId: "b".repeat(64),153 },154 privateKey: new Uint8Array(32).fill(1),155 publicKey: new Uint8Array(32).fill(2),156 });157 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));158 mocks.upsert.mockResolvedValue(undefined);159 Object.defineProperty(globalThis, "chrome", {160 configurable: true,161 value: {162 runtime: {163 async sendMessage(message: { type: string; payload: unknown }) {164 mocks.pushes.push(message);165 166 167 168 169 if (message.type === "ADOPTION_SOURCE_SAS") {170 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");171 const p = message.payload as { id?: string };172 if (typeof p?.id === "string") {173 mocks.sasDecisions.push({ id: p.id, ok: true });174 resolveSourceSasConfirmation(p.id, true);175 }176 }177 return Promise.resolve();178 },179 },180 },181 });182});183184describe("relay adoption source background boundary", () => {185 it("shows the SAS and waits for the popup's single confirmation", async () => {186 const socket = socketWithFrames([187 {188 type: "PEACH_HELLO",189 token: "AAECAwQFBgcICQoLDA0ODw",190 },191 {192 type: "ADOPT_REQUEST",193 device_id: "request-only-label",194 device_name: "June's phone",195 device_type: "android",196 },197 { type: "ADOPT_CONFIRM", confirmed: true },198 {199 type: "ADOPT_IDENTITY",200 device_id: "android-receiver",201 device_name: "June's phone",202 device_type: "android",203 long_term_public_key_hex: "d".repeat(64),204 },205 { type: "ADOPT_APPLIED" },206 ]);207 mocks.openSocket.mockResolvedValue(socket);208209 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");210 const sasIds: string[] = [];211 Object.defineProperty(globalThis, "chrome", {212 configurable: true,213 value: {214 runtime: {215 async sendMessage(message: { type: string; payload: unknown }) {216 mocks.pushes.push(message);217 if (message.type === "ADOPTION_SOURCE_SAS") {218 const p = message.payload as { id?: string };219 if (typeof p?.id === "string") {220 221 222 sasIds.push(p.id);223 }224 return Promise.resolve();225 }226 return Promise.resolve();227 },228 },229 },230 });231 void resolveSourceSasConfirmation; 232233 const started = await startRelayAdoptionSource();234 expect(started.invitation).toMatch(/^peach-pair-v1:/);235 await waitForPush("ADOPTION_SOURCE_SAS");236 237 238 await vi.waitFor(() => {239 expect(mocks.sasDecisions.length).toBe(0);240 });241 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();242 expect(mocks.upsert).not.toHaveBeenCalled();243244 245 const id = sasIds[0];246 expect(typeof id).toBe("string");247 resolveSourceSasConfirmation(id, true);248 mocks.sasDecisions.push({ id, ok: true });249250 await waitForPush("ADOPTION_SOURCE_RESULT");251252 expect(mocks.getRootKeyHex).toHaveBeenCalled();253 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({254 deviceId: "android-receiver",255 relayUrl: "wss://relay.peachpasswords.com",256 }));257 expect(mocks.pushes).toContainEqual(expect.objectContaining({258 type: "ADOPTION_SOURCE_RESULT",259 payload: expect.objectContaining({ status: "complete" }),260 }));261 expect(mocks.triggerSync).toHaveBeenCalledWith({262 reason: "manual",263 force: true,264 });265 const sentTypes = socket.send.mock.calls266 .map(([bytes]) => {267 try {268 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;269 } catch {270 return undefined;271 }272 });273 expect(sentTypes).toContain("ADOPT_COMPLETE");274 });275276 it("fails closed when the popup rejects the SAS", async () => {277 const socket = socketWithFrames([278 {279 type: "PEACH_HELLO",280 token: "AAECAwQFBgcICQoLDA0ODw",281 },282 {283 type: "ADOPT_REQUEST",284 device_id: "request-only-label",285 device_name: "New phone",286 device_type: "android",287 },288 { type: "ADOPT_CONFIRM", confirmed: false },289 ]);290 mocks.openSocket.mockResolvedValue(socket);291292 const { resolveSourceSasConfirmation } = await import("./relay-adoption-source-bg");293 let pendingId: string | undefined;294 Object.defineProperty(globalThis, "chrome", {295 configurable: true,296 value: {297 runtime: {298 sendMessage(message: { type: string; payload: unknown }) {299 mocks.pushes.push(message);300 if (message.type === "ADOPTION_SOURCE_SAS") {301 const p = message.payload as { id?: string };302 if (typeof p?.id === "string") pendingId = p.id;303 }304 return Promise.resolve();305 },306 },307 },308 });309310 await startRelayAdoptionSource();311 await waitForPush("ADOPTION_SOURCE_SAS");312 expect(typeof pendingId).toBe("string");313 resolveSourceSasConfirmation(pendingId!, false);314 mocks.sasDecisions.push({ id: pendingId!, ok: false });315316 await waitForPush("ADOPTION_SOURCE_RESULT");317318 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();319 expect(mocks.upsert).not.toHaveBeenCalled();320 expect(mocks.pushes).toContainEqual({321 type: "ADOPTION_SOURCE_RESULT",322 payload: { status: "error", error: "adoption_rejected" },323 });324 });325326 it("rejects before reading the root key when neither peer has Pro", async () => {327 const socket = socketWithFrames([328 {329 type: "PEACH_HELLO",330 token: "AAECAwQFBgcICQoLDA0ODw",331 },332 {333 type: "ADOPT_REQUEST",334 device_id: "free-receiver",335 device_name: "Free phone",336 device_type: "android",337 },338 { type: "ADOPT_CONFIRM", confirmed: true },339 ]);340 mocks.isProUser.mockResolvedValue(false);341 mocks.getLicenseKey.mockResolvedValue(undefined);342 mocks.openSocket.mockResolvedValue(socket);343344 await startRelayAdoptionSource();345 await waitForPush("ADOPTION_SOURCE_RESULT");346347 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();348 expect(mocks.upsert).not.toHaveBeenCalled();349 expect(mocks.pushes).toContainEqual({350 type: "ADOPTION_SOURCE_RESULT",351 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },352 });353 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {354 try {355 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];356 } catch {357 return [];358 }359 });360 expect(sentFrames).toContainEqual({361 type: "ADOPT_REJECT",362 reason: "ADOPT_PRO_REQUIRED",363 });364 });365366 it("fails closed when the user never answers the SAS", async () => {367 368 369 370 371 const socket = socketWithFrames([]);372 mocks.openSocket.mockResolvedValue(socket);373374 Object.defineProperty(globalThis, "chrome", {375 configurable: true,376 value: {377 runtime: {378 sendMessage(message: { type: string; payload: unknown }) {379 mocks.pushes.push(message);380 return Promise.resolve();381 },382 },383 },384 });385386 await startRelayAdoptionSource();387 cancelRelayAdoptionSource();388389 expect(socket.close).toHaveBeenCalled();390 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();391 expect(mocks.upsert).not.toHaveBeenCalled();392 });393394 it("cancelling the source screen closes the room without reading the root", async () => {395 const socket = socketWithFrames([]);396 mocks.openSocket.mockResolvedValue(socket);397398 await startRelayAdoptionSource();399 cancelRelayAdoptionSource();400401 expect(socket.close).toHaveBeenCalled();402 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();403 expect(mocks.upsert).not.toHaveBeenCalled();404 });405});406407describe("relay adoption receiver background", () => {408 beforeEach(async () => {409 410 411 412 413 const { cancelRelayAdoptionSource } = await import("./relay-adoption-source-bg");414 cancelRelayAdoptionSource();415 mocks.pushes.length = 0;416 mocks.openSocket.mockReset();417 mocks.getDeviceName.mockReset();418 mocks.getDeviceName.mockResolvedValue("Receiver Browser");419 mocks.createVault.mockReset();420 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });421 mocks.activateLicense.mockReset();422 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });423 mocks.isProUser.mockReset();424 mocks.isProUser.mockResolvedValue(true);425 mocks.getLicenseKey.mockReset();426 mocks.getLicenseKey.mockResolvedValue(undefined);427 mocks.localIdentity.mockReset();428 mocks.localIdentity.mockResolvedValue({429 deviceId: "receiver-id",430 deviceName: "Receiver Browser",431 deviceType: "extension",432 longTermPublicKeyHex: "11".repeat(32),433 signingPublicKeyHex: "22".repeat(32),434 });435 mocks.rollbackVault.mockReset();436 mocks.rollbackVault.mockResolvedValue(undefined);437 mocks.storageSet.mockReset();438 mocks.storageSet.mockResolvedValue(undefined);439 mocks.upsert.mockReset();440 mocks.upsert.mockResolvedValue(undefined);441 mocks.triggerSync.mockReset();442 mocks.triggerSync.mockResolvedValue(undefined);443 mocks.openSocket.mockResolvedValue(socketWithFrames([]));444 Object.defineProperty(globalThis, "chrome", {445 configurable: true,446 value: {447 runtime: {448 sendMessage(message: { type: string; payload?: unknown }) {449 mocks.pushes.push({ type: message.type, payload: message.payload });450 return Promise.resolve({ ok: true });451 },452 onMessage: {453 addListener() { },454 removeListener() { },455 },456 },457 },458 });459 });460461 it("opens a relay socket and returns an empty invitation", async () => {462 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");463 const started = await startRelayAdoptionReceiver("receiver-password");464 expect(started.invitation).toMatch(/^peach-pair-v1:/);465 466 const decoded = decodeInvitation(started.invitation);467 expect(decoded?.vault).toBe("empty");468 expect(mocks.openSocket).toHaveBeenCalled();469 });470471 it("rejects an empty password", async () => {472 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");473 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");474 });475476 it("surfaces the Pro-required rejection without creating a vault", async () => {477 mocks.openSocket.mockResolvedValue(socketWithFrames([478 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },479 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },480 ]));481482 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");483 await startRelayAdoptionReceiver("receiver-password");484 await waitForPush("ADOPTION_SOURCE_RESULT");485486 expect(mocks.createVault).not.toHaveBeenCalled();487 expect(mocks.rollbackVault).not.toHaveBeenCalled();488 expect(mocks.pushes).toContainEqual({489 type: "ADOPTION_SOURCE_RESULT",490 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },491 });492 });493494 it("the receiver wires requestAdoption, not grantAdoption", async () => {495 496 497 498 499 500 const rootKeyHex = "f0".repeat(32);501 const vaultId = "b".repeat(64);502 const socket = socketWithFrames([503 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },504 {505 type: "ADOPT_GRANT",506 root_key_hex: rootKeyHex,507 vault_id: vaultId,508 license: "PPK-test",509 source: {510 device_id: "phone-source-id",511 device_name: "June's phone",512 device_type: "android",513 long_term_public_key_hex: "99".repeat(32),514 },515 },516 { type: "ADOPT_COMPLETE" },517 ], { closeOnEmpty: true });518 mocks.openSocket.mockResolvedValue(socket);519 mocks.createVault.mockResolvedValue({ vaultId });520521 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");522 Object.defineProperty(globalThis, "chrome", {523 configurable: true,524 value: {525 runtime: {526 sendMessage(message: { type: string; payload?: unknown }) {527 mocks.pushes.push({ type: message.type, payload: message.payload });528 return Promise.resolve({ ok: true });529 },530 onMessage: { addListener: () => undefined, removeListener: () => undefined },531 },532 },533 });534 await startRelayAdoptionReceiver("receiver-password");535 await waitForPush("ADOPTION_SOURCE_RESULT");536537 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })538 .mock.calls539 .map(([bytes]) => {540 try {541 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;542 } catch {543 return undefined;544 }545 });546 547 548 expect(sentTypes).toContain("ADOPT_REQUEST");549 expect(sentTypes).not.toContain("ADOPT_GRANT");550 });551552 it("completes the handshake end-to-end: vault created → paired", async () => {553 554 555 556 557 558 const rootKeyHex = "f0".repeat(32);559 560 561 const vaultId = "b".repeat(64);562 const frames = [563 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },564 565 566 {567 type: "ADOPT_GRANT",568 root_key_hex: rootKeyHex,569 vault_id: vaultId,570 license: "PPK-test",571 source: {572 device_id: "phone-source-id",573 device_name: "June's phone",574 device_type: "android",575 long_term_public_key_hex: "99".repeat(32),576 signing_public_key_hex: "aa".repeat(32),577 relay_url: "wss://relay.peachpasswords.com",578 },579 },580 { type: "ADOPT_COMPLETE" },581 ];582 mocks.createVault.mockResolvedValue({ vaultId });583 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));584585 586 587 Object.defineProperty(globalThis, "chrome", {588 configurable: true,589 value: {590 runtime: {591 sendMessage(message: { type: string; payload?: unknown }) {592 mocks.pushes.push({ type: message.type, payload: message.payload });593 return Promise.resolve({ ok: true });594 },595 onMessage: {596 addListener: () => undefined,597 removeListener: () => undefined,598 },599 },600 },601 });602603 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");604 await startRelayAdoptionReceiver("receiver-password");605606 await waitForPush("ADOPTION_SOURCE_RESULT");607608 609 610 expect(mocks.pushes).toContainEqual({611 type: "ADOPTION_SOURCE_SAS",612 payload: expect.objectContaining({613 sas: expect.stringMatching(/^\d{8}$/),614 displayOnly: true,615 }),616 });617618 619 620 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);621622 623 624 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");625 expect(mocks.isProUser).toHaveBeenCalled();626 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));627 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({628 deviceId: "phone-source-id",629 }));630 expect(mocks.storageSet).toHaveBeenCalledWith(631 "peach_initial_sync_pending_device_id",632 "phone-source-id",633 );634 expect(mocks.rollbackVault).not.toHaveBeenCalled();635636 637 expect(mocks.pushes).toContainEqual(expect.objectContaining({638 type: "ADOPTION_SOURCE_RESULT",639 payload: expect.objectContaining({ status: "complete" }),640 }));641 });642643 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {644 const rootKeyHex = "f0".repeat(32);645 const vaultId = "b".repeat(64);646 mocks.createVault.mockResolvedValue({ vaultId });647 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });648 mocks.isProUser.mockResolvedValue(false);649 mocks.openSocket.mockResolvedValue(socketWithFrames([650 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },651 {652 type: "ADOPT_GRANT",653 root_key_hex: rootKeyHex,654 vault_id: vaultId,655 license: "PPK-invalid",656 source: {657 device_id: "phone-source-id",658 device_name: "June's phone",659 device_type: "android",660 long_term_public_key_hex: "99".repeat(32),661 },662 },663 ]));664665 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");666 await startRelayAdoptionReceiver("receiver-password");667 await waitForPush("ADOPTION_SOURCE_RESULT");668669 expect(mocks.rollbackVault).toHaveBeenCalledOnce();670 expect(mocks.localIdentity).not.toHaveBeenCalled();671 expect(mocks.upsert).not.toHaveBeenCalled();672 expect(mocks.pushes).toContainEqual(expect.objectContaining({673 type: "ADOPTION_SOURCE_RESULT",674 payload: expect.objectContaining({675 status: "error",676 error: "license_transfer_failed",677 }),678 }));679 });680681 it("rolls back a newly created vault when a later adoption step fails", async () => {682 const rootKeyHex = "f0".repeat(32);683 const vaultId = "b".repeat(64);684 mocks.createVault.mockResolvedValue({ vaultId });685 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));686 mocks.openSocket.mockResolvedValue(socketWithFrames([687 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },688 {689 type: "ADOPT_GRANT",690 root_key_hex: rootKeyHex,691 vault_id: vaultId,692 license: "PPK-test",693 source: {694 device_id: "phone-source-id",695 device_name: "June's phone",696 device_type: "android",697 long_term_public_key_hex: "99".repeat(32),698 },699 },700 ]));701702 const { startRelayAdoptionReceiver } = await import("./relay-adoption-source-bg");703 await startRelayAdoptionReceiver("receiver-password");704 await waitForPush("ADOPTION_SOURCE_RESULT");705706 expect(mocks.rollbackVault).toHaveBeenCalledOnce();707 expect(mocks.upsert).not.toHaveBeenCalled();708 expect(mocks.pushes).toContainEqual(expect.objectContaining({709 type: "ADOPTION_SOURCE_RESULT",710 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),711 }));712 });713});714715function decodeInvitation(encoded: string): { vault?: string } | null {716 717 718 const at = encoded.indexOf(":");719 if (at < 0) return null;720 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");721 try {722 const json = Buffer.from(b64, "base64").toString("utf8");723 return JSON.parse(json) as { vault?: string };724 } catch {725 return null;726 }727}