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 exportDuressSlotForAdoption: vi.fn(async () => null),79 installAdoptedDuressSlot: vi.fn(async () => undefined),80}));8182vi.mock("../shared/sync-identity", () => ({83 computeVaultId: vi.fn(async () => "b".repeat(64)),84}));8586vi.mock("../shared/storage", () => ({87 storageSet: mocks.storageSet,88}));8990vi.mock("./paired-devices", () => ({91 upsertPairedDevice: mocks.upsert,92}));9394vi.mock("./sync-trigger", () => ({95 triggerSyncIfReachable: mocks.triggerSync,96}));9798import {99 cancelRelayAdoptionSource,100 startRelayAdoptionSource,101} from "./relay-adoption-bg";102103const encode = (value: unknown) =>104 new TextEncoder().encode(JSON.stringify(value));105106function socketWithFrames(107 frames: unknown[],108 options: { closeOnEmpty?: boolean } = {},109) {110 const queue = frames.map(encode);111 return {112 send: vi.fn(),113 receive: vi.fn(async () => {114 const next = queue.shift();115 if (!next) {116 117 118 119 if (options.closeOnEmpty) throw new Error("socket_closed");120 return new Promise<Uint8Array>(() => undefined);121 }122 return next;123 }),124 close: vi.fn(),125 closedBeforeFrame: vi.fn(() => false),126 };127}128129async function waitForPush(type: string): Promise<void> {130 await vi.waitFor(() => {131 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);132 });133}134135beforeEach(() => {136 mocks.pushes.length = 0;137 mocks.sasDecisions.length = 0;138 mocks.openSocket.mockReset();139 mocks.getLocalIdentity.mockReset();140 mocks.getRootKeyHex.mockReset();141 mocks.upsert.mockReset();142 mocks.triggerSync.mockReset();143 mocks.triggerSync.mockResolvedValue(undefined);144 mocks.isProUser.mockReset();145 mocks.isProUser.mockResolvedValue(true);146 mocks.getLicenseKey.mockReset();147 mocks.getLicenseKey.mockResolvedValue("PPK-source");148 mocks.getLocalIdentity.mockResolvedValue({149 identity: {150 deviceId: "browser-source",151 deviceName: "June's Browser",152 deviceType: "extension",153 longTermPublicKeyHex: "a".repeat(64),154 vaultId: "b".repeat(64),155 },156 privateKey: new Uint8Array(32).fill(1),157 publicKey: new Uint8Array(32).fill(2),158 });159 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));160 mocks.upsert.mockResolvedValue(undefined);161 Object.defineProperty(globalThis, "chrome", {162 configurable: true,163 value: {164 runtime: {165 async sendMessage(message: { type: string; payload: unknown }) {166 mocks.pushes.push(message);167 168 169 170 171 if (message.type === "ADOPTION_SOURCE_SAS") {172 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");173 const p = message.payload as { id?: string };174 if (typeof p?.id === "string") {175 mocks.sasDecisions.push({ id: p.id, ok: true });176 resolveSourceSasConfirmation(p.id, true);177 }178 }179 return Promise.resolve();180 },181 },182 },183 });184});185186describe("relay adoption source background boundary", () => {187 it("shows the SAS and waits for the popup's single confirmation", async () => {188 const socket = socketWithFrames([189 {190 type: "PEACH_HELLO",191 token: "AAECAwQFBgcICQoLDA0ODw",192 },193 {194 type: "ADOPT_REQUEST",195 device_id: "request-only-label",196 device_name: "June's phone",197 device_type: "android",198 },199 { type: "ADOPT_CONFIRM", confirmed: true },200 {201 type: "ADOPT_IDENTITY",202 device_id: "android-receiver",203 device_name: "June's phone",204 device_type: "android",205 long_term_public_key_hex: "d".repeat(64),206 },207 { type: "ADOPT_APPLIED" },208 ]);209 mocks.openSocket.mockResolvedValue(socket);210211 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");212 const sasIds: string[] = [];213 Object.defineProperty(globalThis, "chrome", {214 configurable: true,215 value: {216 runtime: {217 async sendMessage(message: { type: string; payload: unknown }) {218 mocks.pushes.push(message);219 if (message.type === "ADOPTION_SOURCE_SAS") {220 const p = message.payload as { id?: string };221 if (typeof p?.id === "string") {222 223 224 sasIds.push(p.id);225 }226 return Promise.resolve();227 }228 return Promise.resolve();229 },230 },231 },232 });233 void resolveSourceSasConfirmation; 234235 const started = await startRelayAdoptionSource();236 expect(started.invitation).toMatch(/^peach-pair-v1:/);237 await waitForPush("ADOPTION_SOURCE_SAS");238 239 240 await vi.waitFor(() => {241 expect(mocks.sasDecisions.length).toBe(0);242 });243 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();244 expect(mocks.upsert).not.toHaveBeenCalled();245246 247 const id = sasIds[0];248 expect(typeof id).toBe("string");249 resolveSourceSasConfirmation(id, true);250 mocks.sasDecisions.push({ id, ok: true });251252 await waitForPush("ADOPTION_SOURCE_RESULT");253254 expect(mocks.getRootKeyHex).toHaveBeenCalled();255 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({256 deviceId: "android-receiver",257 relayUrl: "wss://relay.peachpasswords.com",258 }));259 expect(mocks.pushes).toContainEqual(expect.objectContaining({260 type: "ADOPTION_SOURCE_RESULT",261 payload: expect.objectContaining({ status: "complete" }),262 }));263 expect(mocks.triggerSync).toHaveBeenCalledWith({264 reason: "manual",265 force: true,266 });267 const sentTypes = socket.send.mock.calls268 .map(([bytes]) => {269 try {270 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;271 } catch {272 return undefined;273 }274 });275 expect(sentTypes).toContain("ADOPT_COMPLETE");276 });277278 it("fails closed when the popup rejects the SAS", async () => {279 const socket = socketWithFrames([280 {281 type: "PEACH_HELLO",282 token: "AAECAwQFBgcICQoLDA0ODw",283 },284 {285 type: "ADOPT_REQUEST",286 device_id: "request-only-label",287 device_name: "New phone",288 device_type: "android",289 },290 { type: "ADOPT_CONFIRM", confirmed: false },291 ]);292 mocks.openSocket.mockResolvedValue(socket);293294 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");295 let pendingId: string | undefined;296 Object.defineProperty(globalThis, "chrome", {297 configurable: true,298 value: {299 runtime: {300 sendMessage(message: { type: string; payload: unknown }) {301 mocks.pushes.push(message);302 if (message.type === "ADOPTION_SOURCE_SAS") {303 const p = message.payload as { id?: string };304 if (typeof p?.id === "string") pendingId = p.id;305 }306 return Promise.resolve();307 },308 },309 },310 });311312 await startRelayAdoptionSource();313 await waitForPush("ADOPTION_SOURCE_SAS");314 expect(typeof pendingId).toBe("string");315 resolveSourceSasConfirmation(pendingId!, false);316 mocks.sasDecisions.push({ id: pendingId!, ok: false });317318 await waitForPush("ADOPTION_SOURCE_RESULT");319320 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();321 expect(mocks.upsert).not.toHaveBeenCalled();322 expect(mocks.pushes).toContainEqual({323 type: "ADOPTION_SOURCE_RESULT",324 payload: { status: "error", error: "adoption_rejected" },325 });326 });327328 it("rejects before reading the root key when neither peer has Pro", async () => {329 const socket = socketWithFrames([330 {331 type: "PEACH_HELLO",332 token: "AAECAwQFBgcICQoLDA0ODw",333 },334 {335 type: "ADOPT_REQUEST",336 device_id: "free-receiver",337 device_name: "Free phone",338 device_type: "android",339 },340 { type: "ADOPT_CONFIRM", confirmed: true },341 ]);342 mocks.isProUser.mockResolvedValue(false);343 mocks.getLicenseKey.mockResolvedValue(undefined);344 mocks.openSocket.mockResolvedValue(socket);345346 await startRelayAdoptionSource();347 await waitForPush("ADOPTION_SOURCE_RESULT");348349 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();350 expect(mocks.upsert).not.toHaveBeenCalled();351 expect(mocks.pushes).toContainEqual({352 type: "ADOPTION_SOURCE_RESULT",353 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },354 });355 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {356 try {357 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];358 } catch {359 return [];360 }361 });362 expect(sentFrames).toContainEqual({363 type: "ADOPT_REJECT",364 reason: "ADOPT_PRO_REQUIRED",365 });366 });367368 it("fails closed when the user never answers the SAS", async () => {369 370 371 372 373 const socket = socketWithFrames([]);374 mocks.openSocket.mockResolvedValue(socket);375376 Object.defineProperty(globalThis, "chrome", {377 configurable: true,378 value: {379 runtime: {380 sendMessage(message: { type: string; payload: unknown }) {381 mocks.pushes.push(message);382 return Promise.resolve();383 },384 },385 },386 });387388 await startRelayAdoptionSource();389 cancelRelayAdoptionSource();390391 expect(socket.close).toHaveBeenCalled();392 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();393 expect(mocks.upsert).not.toHaveBeenCalled();394 });395396 it("cancelling the source screen closes the room without reading the root", async () => {397 const socket = socketWithFrames([]);398 mocks.openSocket.mockResolvedValue(socket);399400 await startRelayAdoptionSource();401 cancelRelayAdoptionSource();402403 expect(socket.close).toHaveBeenCalled();404 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();405 expect(mocks.upsert).not.toHaveBeenCalled();406 });407});408409describe("relay adoption receiver background", () => {410 beforeEach(async () => {411 412 413 414 415 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");416 cancelRelayAdoptionSource();417 mocks.pushes.length = 0;418 mocks.openSocket.mockReset();419 mocks.getDeviceName.mockReset();420 mocks.getDeviceName.mockResolvedValue("Receiver Browser");421 mocks.createVault.mockReset();422 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });423 mocks.activateLicense.mockReset();424 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });425 mocks.isProUser.mockReset();426 mocks.isProUser.mockResolvedValue(true);427 mocks.getLicenseKey.mockReset();428 mocks.getLicenseKey.mockResolvedValue(undefined);429 mocks.localIdentity.mockReset();430 mocks.localIdentity.mockResolvedValue({431 deviceId: "receiver-id",432 deviceName: "Receiver Browser",433 deviceType: "extension",434 longTermPublicKeyHex: "11".repeat(32),435 signingPublicKeyHex: "22".repeat(32),436 });437 mocks.rollbackVault.mockReset();438 mocks.rollbackVault.mockResolvedValue(undefined);439 mocks.storageSet.mockReset();440 mocks.storageSet.mockResolvedValue(undefined);441 mocks.upsert.mockReset();442 mocks.upsert.mockResolvedValue(undefined);443 mocks.triggerSync.mockReset();444 mocks.triggerSync.mockResolvedValue(undefined);445 mocks.openSocket.mockResolvedValue(socketWithFrames([]));446 Object.defineProperty(globalThis, "chrome", {447 configurable: true,448 value: {449 runtime: {450 sendMessage(message: { type: string; payload?: unknown }) {451 mocks.pushes.push({ type: message.type, payload: message.payload });452 return Promise.resolve({ ok: true });453 },454 onMessage: {455 addListener() { },456 removeListener() { },457 },458 },459 },460 });461 });462463 it("opens a relay socket and returns an empty invitation", async () => {464 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");465 const started = await startRelayAdoptionReceiver("receiver-password");466 expect(started.invitation).toMatch(/^peach-pair-v1:/);467 468 const decoded = decodeInvitation(started.invitation);469 expect(decoded?.vault).toBe("empty");470 expect(mocks.openSocket).toHaveBeenCalled();471 });472473 it("rejects an empty password", async () => {474 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");475 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");476 });477478 it("surfaces the Pro-required rejection without creating a vault", async () => {479 mocks.openSocket.mockResolvedValue(socketWithFrames([480 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },481 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },482 ]));483484 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");485 await startRelayAdoptionReceiver("receiver-password");486 await waitForPush("ADOPTION_SOURCE_RESULT");487488 expect(mocks.createVault).not.toHaveBeenCalled();489 expect(mocks.rollbackVault).not.toHaveBeenCalled();490 expect(mocks.pushes).toContainEqual({491 type: "ADOPTION_SOURCE_RESULT",492 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },493 });494 });495496 it("the receiver wires requestAdoption, not grantAdoption", async () => {497 498 499 500 501 502 const rootKeyHex = "f0".repeat(32);503 const vaultId = "b".repeat(64);504 const socket = socketWithFrames([505 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },506 {507 type: "ADOPT_GRANT",508 root_key_hex: rootKeyHex,509 vault_id: vaultId,510 license: "PPK-test",511 source: {512 device_id: "phone-source-id",513 device_name: "June's phone",514 device_type: "android",515 long_term_public_key_hex: "99".repeat(32),516 },517 },518 { type: "ADOPT_COMPLETE" },519 ], { closeOnEmpty: true });520 mocks.openSocket.mockResolvedValue(socket);521 mocks.createVault.mockResolvedValue({ vaultId });522523 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");524 Object.defineProperty(globalThis, "chrome", {525 configurable: true,526 value: {527 runtime: {528 sendMessage(message: { type: string; payload?: unknown }) {529 mocks.pushes.push({ type: message.type, payload: message.payload });530 return Promise.resolve({ ok: true });531 },532 onMessage: { addListener: () => undefined, removeListener: () => undefined },533 },534 },535 });536 await startRelayAdoptionReceiver("receiver-password");537 await waitForPush("ADOPTION_SOURCE_RESULT");538539 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })540 .mock.calls541 .map(([bytes]) => {542 try {543 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;544 } catch {545 return undefined;546 }547 });548 549 550 expect(sentTypes).toContain("ADOPT_REQUEST");551 expect(sentTypes).not.toContain("ADOPT_GRANT");552 });553554 it("completes the handshake end-to-end: vault created → paired", async () => {555 556 557 558 559 560 const rootKeyHex = "f0".repeat(32);561 562 563 const vaultId = "b".repeat(64);564 const frames = [565 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },566 567 568 {569 type: "ADOPT_GRANT",570 root_key_hex: rootKeyHex,571 vault_id: vaultId,572 license: "PPK-test",573 source: {574 device_id: "phone-source-id",575 device_name: "June's phone",576 device_type: "android",577 long_term_public_key_hex: "99".repeat(32),578 signing_public_key_hex: "aa".repeat(32),579 relay_url: "wss://relay.peachpasswords.com",580 },581 },582 { type: "ADOPT_COMPLETE" },583 ];584 mocks.createVault.mockResolvedValue({ vaultId });585 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));586587 588 589 Object.defineProperty(globalThis, "chrome", {590 configurable: true,591 value: {592 runtime: {593 sendMessage(message: { type: string; payload?: unknown }) {594 mocks.pushes.push({ type: message.type, payload: message.payload });595 return Promise.resolve({ ok: true });596 },597 onMessage: {598 addListener: () => undefined,599 removeListener: () => undefined,600 },601 },602 },603 });604605 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");606 await startRelayAdoptionReceiver("receiver-password");607608 await waitForPush("ADOPTION_SOURCE_RESULT");609610 611 612 expect(mocks.pushes).toContainEqual({613 type: "ADOPTION_SOURCE_SAS",614 payload: expect.objectContaining({615 sas: expect.stringMatching(/^\d{8}$/),616 displayOnly: true,617 }),618 });619620 621 622 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);623624 625 626 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");627 expect(mocks.isProUser).toHaveBeenCalled();628 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));629 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({630 deviceId: "phone-source-id",631 }));632 expect(mocks.storageSet).toHaveBeenCalledWith(633 "peach_initial_sync_pending_device_id",634 "phone-source-id",635 );636 expect(mocks.rollbackVault).not.toHaveBeenCalled();637638 639 expect(mocks.pushes).toContainEqual(expect.objectContaining({640 type: "ADOPTION_SOURCE_RESULT",641 payload: expect.objectContaining({ status: "complete" }),642 }));643 });644645 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {646 const rootKeyHex = "f0".repeat(32);647 const vaultId = "b".repeat(64);648 mocks.createVault.mockResolvedValue({ vaultId });649 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });650 mocks.isProUser.mockResolvedValue(false);651 mocks.openSocket.mockResolvedValue(socketWithFrames([652 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },653 {654 type: "ADOPT_GRANT",655 root_key_hex: rootKeyHex,656 vault_id: vaultId,657 license: "PPK-invalid",658 source: {659 device_id: "phone-source-id",660 device_name: "June's phone",661 device_type: "android",662 long_term_public_key_hex: "99".repeat(32),663 },664 },665 ]));666667 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");668 await startRelayAdoptionReceiver("receiver-password");669 await waitForPush("ADOPTION_SOURCE_RESULT");670671 expect(mocks.rollbackVault).toHaveBeenCalledOnce();672 expect(mocks.localIdentity).not.toHaveBeenCalled();673 expect(mocks.upsert).not.toHaveBeenCalled();674 expect(mocks.pushes).toContainEqual(expect.objectContaining({675 type: "ADOPTION_SOURCE_RESULT",676 payload: expect.objectContaining({677 status: "error",678 error: "license_transfer_failed",679 }),680 }));681 });682683 it("rolls back a newly created vault when a later adoption step fails", async () => {684 const rootKeyHex = "f0".repeat(32);685 const vaultId = "b".repeat(64);686 mocks.createVault.mockResolvedValue({ vaultId });687 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));688 mocks.openSocket.mockResolvedValue(socketWithFrames([689 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },690 {691 type: "ADOPT_GRANT",692 root_key_hex: rootKeyHex,693 vault_id: vaultId,694 license: "PPK-test",695 source: {696 device_id: "phone-source-id",697 device_name: "June's phone",698 device_type: "android",699 long_term_public_key_hex: "99".repeat(32),700 },701 },702 ]));703704 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");705 await startRelayAdoptionReceiver("receiver-password");706 await waitForPush("ADOPTION_SOURCE_RESULT");707708 expect(mocks.rollbackVault).toHaveBeenCalledOnce();709 expect(mocks.upsert).not.toHaveBeenCalled();710 expect(mocks.pushes).toContainEqual(expect.objectContaining({711 type: "ADOPTION_SOURCE_RESULT",712 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),713 }));714 });715716 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {717 718 719 720 const rootKeyHex = "f0".repeat(32);721 const vaultId = "b".repeat(64);722 mocks.createVault.mockResolvedValue({ vaultId });723 mocks.openSocket.mockResolvedValue(socketWithFrames([724 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },725 {726 type: "ADOPT_GRANT",727 root_key_hex: rootKeyHex,728 vault_id: vaultId,729 license: "PPK-test",730 source: {731 device_id: "old-phone-id",732 device_name: "Old phone",733 device_type: "android",734 long_term_public_key_hex: "99".repeat(32),735 },736 737 },738 { type: "ADOPT_COMPLETE" },739 ], { closeOnEmpty: true }));740741 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");742 await startRelayAdoptionReceiver("receiver-password");743 await waitForPush("ADOPTION_SOURCE_RESULT");744745 746 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });747 748 expect(mocks.pushes).toContainEqual(expect.objectContaining({749 type: "ADOPTION_SOURCE_RESULT",750 payload: expect.objectContaining({ status: "complete" }),751 }));752 753 expect(mocks.storageSet).toHaveBeenCalledWith(754 "peach_initial_sync_pending_device_id",755 "old-phone-id",756 );757 });758759});760761function decodeInvitation(encoded: string): { vault?: string } | null {762 763 764 const at = encoded.indexOf(":");765 if (at < 0) return null;766 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");767 try {768 const json = Buffer.from(b64, "base64").toString("utf8");769 return JSON.parse(json) as { vault?: string };770 } catch {771 return null;772 }773}