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 createSyncAdapter: vi.fn(),19 sasDecisions: [] as Array<{ id: string; ok: boolean }>,20}));2122vi.mock("../shared/pairing-dial", () => ({23 openRelayPairingSocket: mocks.openSocket,24}));2526vi.mock("../shared/noise-js", () => ({27 noiseNKResponder: vi.fn(async () => ({28 handshakeHash: new Uint8Array(32).fill(7),29 encrypt: (bytes: Uint8Array) => bytes,30 decrypt: (bytes: Uint8Array) => bytes,31 close: vi.fn(),32 })),33}));3435vi.mock("../shared/rendezvous", async (importOriginal) => {36 const actual = await importOriginal<typeof import("../shared/rendezvous")>();37 return {38 ...actual,39 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",40 };41});424344vi.mock("./device-identity", () => ({45 getLocalPairingIdentity: async () => ({46 identity: {47 deviceId: "test-device",48 deviceName: "Test",49 deviceType: "extension",50 longTermPublicKeyHex: "aa".repeat(32),51 vaultId: "bb".repeat(32),52 },53 privateKey: new Uint8Array(32).fill(7),54 publicKey: new Uint8Array(32).fill(8),55 }),56 getDeviceName: mocks.getDeviceName,57 buildLocalIdentity: mocks.localIdentity,58}));5960vi.mock("./sync-settings", async (importOriginal) => ({61 ...(await importOriginal<typeof import("./sync-settings")>()),62 getSyncSettings: async () => ({63 mode: "public" as const,64 relayUrl: "wss://relay.peachpasswords.com",65 }),66}));6768vi.mock("./license", () => ({69 isProUser: mocks.isProUser,70 getLicenseKey: mocks.getLicenseKey,71 activateLicense: mocks.activateLicense,72}));7374vi.mock("./vault", () => ({75 isUnlocked: vi.fn(() => true),76 getRootKeyHex: mocks.getRootKeyHex,77 createVaultFromRootKey: mocks.createVault,78 rollbackAdoptedVault: mocks.rollbackVault,79 exportDuressSlotForAdoption: vi.fn(async () => null),80 installAdoptedDuressSlot: vi.fn(async () => undefined),81}));8283vi.mock("../shared/sync-identity", () => ({84 computeVaultId: vi.fn(async () => "b".repeat(64)),85}));8687vi.mock("../shared/storage", () => ({88 storageSet: mocks.storageSet,89}));9091vi.mock("./paired-devices", () => ({92 upsertPairedDevice: mocks.upsert,93}));9495vi.mock("./sync-trigger", () => ({96 triggerSyncIfReachable: mocks.triggerSync,97}));9899vi.mock("./sync-vault-adapter", () => ({100 createSyncVaultAdapter: mocks.createSyncAdapter,101}));102103import {104 cancelRelayAdoptionSource,105 startRelayAdoptionSource,106} from "./relay-adoption-bg";107108const encode = (value: unknown) =>109 new TextEncoder().encode(JSON.stringify(value));110111function socketWithFrames(112 frames: unknown[],113 options: { closeOnEmpty?: boolean } = {},114) {115 const queue = frames.map(encode);116 return {117 send: vi.fn(),118 receive: vi.fn(async () => {119 const next = queue.shift();120 if (!next) {121 122 123 124 if (options.closeOnEmpty) throw new Error("socket_closed");125 return new Promise<Uint8Array>(() => undefined);126 }127 return next;128 }),129 close: vi.fn(),130 closedBeforeFrame: vi.fn(() => false),131 };132}133134async function waitForPush(type: string): Promise<void> {135 await vi.waitFor(() => {136 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);137 });138}139140beforeEach(() => {141 mocks.pushes.length = 0;142 mocks.sasDecisions.length = 0;143 mocks.openSocket.mockReset();144 mocks.getLocalIdentity.mockReset();145 mocks.getRootKeyHex.mockReset();146 mocks.upsert.mockReset();147 mocks.triggerSync.mockReset();148 mocks.triggerSync.mockResolvedValue(undefined);149 mocks.isProUser.mockReset();150 mocks.isProUser.mockResolvedValue(true);151 mocks.getLicenseKey.mockReset();152 mocks.getLicenseKey.mockResolvedValue("PPK-source");153 mocks.getLocalIdentity.mockResolvedValue({154 identity: {155 deviceId: "browser-source",156 deviceName: "June's Browser",157 deviceType: "extension",158 longTermPublicKeyHex: "a".repeat(64),159 vaultId: "b".repeat(64),160 },161 privateKey: new Uint8Array(32).fill(1),162 publicKey: new Uint8Array(32).fill(2),163 });164 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));165 mocks.upsert.mockResolvedValue(undefined);166 mocks.createSyncAdapter.mockReturnValue({167 buildEntryManifest: vi.fn(async () => ({})),168 getEncryptedEntries: vi.fn(async () => ({})),169 buildTombstoneManifest: vi.fn(async () => ({})),170 applyEntries: vi.fn(async () => undefined),171 applyTombstones: vi.fn(async () => undefined),172 buildOutgoingDeviceManifest: vi.fn(async () => null),173 });174 Object.defineProperty(globalThis, "chrome", {175 configurable: true,176 value: {177 runtime: {178 async sendMessage(message: { type: string; payload: unknown }) {179 mocks.pushes.push(message);180 181 182 183 184 if (message.type === "ADOPTION_SOURCE_SAS") {185 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");186 const p = message.payload as { id?: string };187 if (typeof p?.id === "string") {188 mocks.sasDecisions.push({ id: p.id, ok: true });189 resolveSourceSasConfirmation(p.id, true);190 }191 }192 return Promise.resolve();193 },194 },195 },196 });197});198199describe("relay adoption source background boundary", () => {200 it("shows the SAS and waits for the popup's single confirmation", async () => {201 const socket = socketWithFrames([202 {203 type: "PEACH_HELLO",204 token: "AAECAwQFBgcICQoLDA0ODw",205 },206 {207 type: "ADOPT_REQUEST",208 device_id: "request-only-label",209 device_name: "June's phone",210 device_type: "android",211 },212 { type: "ADOPT_CONFIRM", confirmed: true },213 {214 type: "ADOPT_IDENTITY",215 device_id: "android-receiver",216 device_name: "June's phone",217 device_type: "android",218 long_term_public_key_hex: "d".repeat(64),219 },220 { type: "ADOPT_APPLIED" },221 ]);222 mocks.openSocket.mockResolvedValue(socket);223224 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");225 const sasIds: string[] = [];226 Object.defineProperty(globalThis, "chrome", {227 configurable: true,228 value: {229 runtime: {230 async sendMessage(message: { type: string; payload: unknown }) {231 mocks.pushes.push(message);232 if (message.type === "ADOPTION_SOURCE_SAS") {233 const p = message.payload as { id?: string };234 if (typeof p?.id === "string") {235 236 237 sasIds.push(p.id);238 }239 return Promise.resolve();240 }241 return Promise.resolve();242 },243 },244 },245 });246 void resolveSourceSasConfirmation; 247248 const started = await startRelayAdoptionSource();249 expect(started.invitation).toMatch(/^peach-pair-v1:/);250 await waitForPush("ADOPTION_SOURCE_SAS");251 252 253 await vi.waitFor(() => {254 expect(mocks.sasDecisions.length).toBe(0);255 });256 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();257 expect(mocks.upsert).not.toHaveBeenCalled();258259 260 const id = sasIds[0];261 expect(typeof id).toBe("string");262 resolveSourceSasConfirmation(id, true);263 mocks.sasDecisions.push({ id, ok: true });264265 await waitForPush("ADOPTION_SOURCE_RESULT");266267 expect(mocks.getRootKeyHex).toHaveBeenCalled();268 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({269 deviceId: "android-receiver",270 relayUrl: "wss://relay.peachpasswords.com",271 }));272 expect(mocks.pushes).toContainEqual(expect.objectContaining({273 type: "ADOPTION_SOURCE_RESULT",274 payload: expect.objectContaining({ status: "complete" }),275 }));276 expect(mocks.triggerSync).toHaveBeenCalledWith({277 reason: "manual",278 force: true,279 });280 const sentTypes = socket.send.mock.calls281 .map(([bytes]) => {282 try {283 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;284 } catch {285 return undefined;286 }287 });288 expect(sentTypes).toContain("ADOPT_COMPLETE");289 });290291 it("fails adoption without sending snapshot frames when duress is oversized", async () => {292 const socket = socketWithFrames([293 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },294 {295 type: "ADOPT_REQUEST",296 device_id: "request-only-label",297 device_name: "June's phone",298 device_type: "android",299 snapshot_capable: true,300 },301 { type: "ADOPT_CONFIRM", confirmed: true },302 {303 type: "ADOPT_IDENTITY",304 device_id: "android-receiver",305 device_name: "June's phone",306 device_type: "android",307 long_term_public_key_hex: "d".repeat(64),308 },309 { type: "ADOPT_APPLIED" },310 ]);311 mocks.openSocket.mockResolvedValue(socket);312 const { exportDuressSlotForAdoption } = await import("./vault");313 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(314 "x".repeat(768 * 1024),315 );316317 await startRelayAdoptionSource();318 await waitForPush("ADOPTION_SOURCE_RESULT");319320 expect(mocks.pushes).toContainEqual({321 type: "ADOPTION_SOURCE_RESULT",322 payload: {323 status: "error",324 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",325 },326 });327 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({328 type: "ADOPTION_SOURCE_RESULT",329 payload: expect.objectContaining({ status: "complete" }),330 }));331 const sentTypes = socket.send.mock.calls.map(([bytes]) =>332 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,333 );334 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");335 expect(mocks.triggerSync).not.toHaveBeenCalled();336 });337338 it("fails closed when the popup rejects the SAS", async () => {339 const socket = socketWithFrames([340 {341 type: "PEACH_HELLO",342 token: "AAECAwQFBgcICQoLDA0ODw",343 },344 {345 type: "ADOPT_REQUEST",346 device_id: "request-only-label",347 device_name: "New phone",348 device_type: "android",349 },350 { type: "ADOPT_CONFIRM", confirmed: false },351 ]);352 mocks.openSocket.mockResolvedValue(socket);353354 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");355 let pendingId: string | undefined;356 Object.defineProperty(globalThis, "chrome", {357 configurable: true,358 value: {359 runtime: {360 sendMessage(message: { type: string; payload: unknown }) {361 mocks.pushes.push(message);362 if (message.type === "ADOPTION_SOURCE_SAS") {363 const p = message.payload as { id?: string };364 if (typeof p?.id === "string") pendingId = p.id;365 }366 return Promise.resolve();367 },368 },369 },370 });371372 await startRelayAdoptionSource();373 await waitForPush("ADOPTION_SOURCE_SAS");374 expect(typeof pendingId).toBe("string");375 resolveSourceSasConfirmation(pendingId!, false);376 mocks.sasDecisions.push({ id: pendingId!, ok: false });377378 await waitForPush("ADOPTION_SOURCE_RESULT");379380 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();381 expect(mocks.upsert).not.toHaveBeenCalled();382 expect(mocks.pushes).toContainEqual({383 type: "ADOPTION_SOURCE_RESULT",384 payload: { status: "error", error: "adoption_rejected" },385 });386 });387388 it("rejects before reading the root key when neither peer has Pro", async () => {389 const socket = socketWithFrames([390 {391 type: "PEACH_HELLO",392 token: "AAECAwQFBgcICQoLDA0ODw",393 },394 {395 type: "ADOPT_REQUEST",396 device_id: "free-receiver",397 device_name: "Free phone",398 device_type: "android",399 },400 { type: "ADOPT_CONFIRM", confirmed: true },401 ]);402 mocks.isProUser.mockResolvedValue(false);403 mocks.getLicenseKey.mockResolvedValue(undefined);404 mocks.openSocket.mockResolvedValue(socket);405406 await startRelayAdoptionSource();407 await waitForPush("ADOPTION_SOURCE_RESULT");408409 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();410 expect(mocks.upsert).not.toHaveBeenCalled();411 expect(mocks.pushes).toContainEqual({412 type: "ADOPTION_SOURCE_RESULT",413 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },414 });415 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {416 try {417 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];418 } catch {419 return [];420 }421 });422 expect(sentFrames).toContainEqual({423 type: "ADOPT_REJECT",424 reason: "ADOPT_PRO_REQUIRED",425 });426 });427428 it("fails closed when the user never answers the SAS", async () => {429 430 431 432 433 const socket = socketWithFrames([]);434 mocks.openSocket.mockResolvedValue(socket);435436 Object.defineProperty(globalThis, "chrome", {437 configurable: true,438 value: {439 runtime: {440 sendMessage(message: { type: string; payload: unknown }) {441 mocks.pushes.push(message);442 return Promise.resolve();443 },444 },445 },446 });447448 await startRelayAdoptionSource();449 cancelRelayAdoptionSource();450451 expect(socket.close).toHaveBeenCalled();452 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();453 expect(mocks.upsert).not.toHaveBeenCalled();454 });455456 it("cancelling the source screen closes the room without reading the root", async () => {457 const socket = socketWithFrames([]);458 mocks.openSocket.mockResolvedValue(socket);459460 await startRelayAdoptionSource();461 cancelRelayAdoptionSource();462463 expect(socket.close).toHaveBeenCalled();464 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();465 expect(mocks.upsert).not.toHaveBeenCalled();466 });467});468469describe("relay adoption receiver background", () => {470 beforeEach(async () => {471 472 473 474 475 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");476 cancelRelayAdoptionSource();477 mocks.pushes.length = 0;478 mocks.openSocket.mockReset();479 mocks.getDeviceName.mockReset();480 mocks.getDeviceName.mockResolvedValue("Receiver Browser");481 mocks.createVault.mockReset();482 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });483 mocks.activateLicense.mockReset();484 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });485 mocks.isProUser.mockReset();486 mocks.isProUser.mockResolvedValue(true);487 mocks.getLicenseKey.mockReset();488 mocks.getLicenseKey.mockResolvedValue(undefined);489 mocks.localIdentity.mockReset();490 mocks.localIdentity.mockResolvedValue({491 deviceId: "receiver-id",492 deviceName: "Receiver Browser",493 deviceType: "extension",494 longTermPublicKeyHex: "11".repeat(32),495 signingPublicKeyHex: "22".repeat(32),496 });497 mocks.rollbackVault.mockReset();498 mocks.rollbackVault.mockResolvedValue(undefined);499 mocks.storageSet.mockReset();500 mocks.storageSet.mockResolvedValue(undefined);501 mocks.upsert.mockReset();502 mocks.upsert.mockResolvedValue(undefined);503 mocks.triggerSync.mockReset();504 mocks.triggerSync.mockResolvedValue(undefined);505 mocks.openSocket.mockResolvedValue(socketWithFrames([]));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: {515 addListener() { },516 removeListener() { },517 },518 },519 },520 });521 });522523 it("opens a relay socket and returns an empty invitation", async () => {524 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");525 const started = await startRelayAdoptionReceiver("receiver-password");526 expect(started.invitation).toMatch(/^peach-pair-v1:/);527 528 const decoded = decodeInvitation(started.invitation);529 expect(decoded?.vault).toBe("empty");530 expect(mocks.openSocket).toHaveBeenCalled();531 });532533 it("rejects an empty password", async () => {534 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");535 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");536 });537538 it("surfaces the Pro-required rejection without creating a vault", async () => {539 mocks.openSocket.mockResolvedValue(socketWithFrames([540 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },541 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },542 ]));543544 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");545 await startRelayAdoptionReceiver("receiver-password");546 await waitForPush("ADOPTION_SOURCE_RESULT");547548 expect(mocks.createVault).not.toHaveBeenCalled();549 expect(mocks.rollbackVault).not.toHaveBeenCalled();550 expect(mocks.pushes).toContainEqual({551 type: "ADOPTION_SOURCE_RESULT",552 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },553 });554 });555556 it("the receiver wires requestAdoption, not grantAdoption", async () => {557 558 559 560 561 562 const rootKeyHex = "f0".repeat(32);563 const vaultId = "b".repeat(64);564 const socket = socketWithFrames([565 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },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 },577 },578 { type: "ADOPT_COMPLETE" },579 ], { closeOnEmpty: true });580 mocks.openSocket.mockResolvedValue(socket);581 mocks.createVault.mockResolvedValue({ vaultId });582583 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");584 Object.defineProperty(globalThis, "chrome", {585 configurable: true,586 value: {587 runtime: {588 sendMessage(message: { type: string; payload?: unknown }) {589 mocks.pushes.push({ type: message.type, payload: message.payload });590 return Promise.resolve({ ok: true });591 },592 onMessage: { addListener: () => undefined, removeListener: () => undefined },593 },594 },595 });596 await startRelayAdoptionReceiver("receiver-password");597 await waitForPush("ADOPTION_SOURCE_RESULT");598599 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })600 .mock.calls601 .map(([bytes]) => {602 try {603 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;604 } catch {605 return undefined;606 }607 });608 609 610 expect(sentTypes).toContain("ADOPT_REQUEST");611 expect(sentTypes).not.toContain("ADOPT_GRANT");612 });613614 it("completes the handshake end-to-end: vault created → paired", async () => {615 616 617 618 619 620 const rootKeyHex = "f0".repeat(32);621 622 623 const vaultId = "b".repeat(64);624 const frames = [625 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },626 627 628 {629 type: "ADOPT_GRANT",630 root_key_hex: rootKeyHex,631 vault_id: vaultId,632 license: "PPK-test",633 source: {634 device_id: "phone-source-id",635 device_name: "June's phone",636 device_type: "android",637 long_term_public_key_hex: "99".repeat(32),638 signing_public_key_hex: "aa".repeat(32),639 relay_url: "wss://relay.peachpasswords.com",640 },641 },642 { type: "ADOPT_COMPLETE" },643 ];644 mocks.createVault.mockResolvedValue({ vaultId });645 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));646647 648 649 Object.defineProperty(globalThis, "chrome", {650 configurable: true,651 value: {652 runtime: {653 sendMessage(message: { type: string; payload?: unknown }) {654 mocks.pushes.push({ type: message.type, payload: message.payload });655 return Promise.resolve({ ok: true });656 },657 onMessage: {658 addListener: () => undefined,659 removeListener: () => undefined,660 },661 },662 },663 });664665 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");666 await startRelayAdoptionReceiver("receiver-password");667668 await waitForPush("ADOPTION_SOURCE_RESULT");669670 671 672 expect(mocks.pushes).toContainEqual({673 type: "ADOPTION_SOURCE_SAS",674 payload: expect.objectContaining({675 sas: expect.stringMatching(/^\d{8}$/),676 displayOnly: true,677 }),678 });679680 681 682 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);683684 685 686 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");687 expect(mocks.isProUser).toHaveBeenCalled();688 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));689 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({690 deviceId: "phone-source-id",691 }));692 expect(mocks.storageSet).toHaveBeenCalledWith(693 "peach_initial_sync_pending_device_id",694 "phone-source-id",695 );696 expect(mocks.rollbackVault).not.toHaveBeenCalled();697698 699 expect(mocks.pushes).toContainEqual(expect.objectContaining({700 type: "ADOPTION_SOURCE_RESULT",701 payload: expect.objectContaining({ status: "complete" }),702 }));703 });704705 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {706 const rootKeyHex = "f0".repeat(32);707 const vaultId = "b".repeat(64);708 mocks.createVault.mockResolvedValue({ vaultId });709 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });710 mocks.isProUser.mockResolvedValue(false);711 mocks.openSocket.mockResolvedValue(socketWithFrames([712 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },713 {714 type: "ADOPT_GRANT",715 root_key_hex: rootKeyHex,716 vault_id: vaultId,717 license: "PPK-invalid",718 source: {719 device_id: "phone-source-id",720 device_name: "June's phone",721 device_type: "android",722 long_term_public_key_hex: "99".repeat(32),723 },724 },725 ]));726727 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");728 await startRelayAdoptionReceiver("receiver-password");729 await waitForPush("ADOPTION_SOURCE_RESULT");730731 expect(mocks.rollbackVault).toHaveBeenCalledOnce();732 expect(mocks.localIdentity).not.toHaveBeenCalled();733 expect(mocks.upsert).not.toHaveBeenCalled();734 expect(mocks.pushes).toContainEqual(expect.objectContaining({735 type: "ADOPTION_SOURCE_RESULT",736 payload: expect.objectContaining({737 status: "error",738 error: "license_transfer_failed",739 }),740 }));741 });742743 it("rolls back a newly created vault when a later adoption step fails", async () => {744 const rootKeyHex = "f0".repeat(32);745 const vaultId = "b".repeat(64);746 mocks.createVault.mockResolvedValue({ vaultId });747 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));748 mocks.openSocket.mockResolvedValue(socketWithFrames([749 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },750 {751 type: "ADOPT_GRANT",752 root_key_hex: rootKeyHex,753 vault_id: vaultId,754 license: "PPK-test",755 source: {756 device_id: "phone-source-id",757 device_name: "June's phone",758 device_type: "android",759 long_term_public_key_hex: "99".repeat(32),760 },761 },762 ]));763764 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");765 await startRelayAdoptionReceiver("receiver-password");766 await waitForPush("ADOPTION_SOURCE_RESULT");767768 expect(mocks.rollbackVault).toHaveBeenCalledOnce();769 expect(mocks.upsert).not.toHaveBeenCalled();770 expect(mocks.pushes).toContainEqual(expect.objectContaining({771 type: "ADOPTION_SOURCE_RESULT",772 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),773 }));774 });775776 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {777 778 779 780 const rootKeyHex = "f0".repeat(32);781 const vaultId = "b".repeat(64);782 mocks.createVault.mockResolvedValue({ vaultId });783 mocks.openSocket.mockResolvedValue(socketWithFrames([784 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },785 {786 type: "ADOPT_GRANT",787 root_key_hex: rootKeyHex,788 vault_id: vaultId,789 license: "PPK-test",790 source: {791 device_id: "old-phone-id",792 device_name: "Old phone",793 device_type: "android",794 long_term_public_key_hex: "99".repeat(32),795 },796 797 },798 { type: "ADOPT_COMPLETE" },799 ], { closeOnEmpty: true }));800801 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");802 await startRelayAdoptionReceiver("receiver-password");803 await waitForPush("ADOPTION_SOURCE_RESULT");804805 806 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });807 808 expect(mocks.pushes).toContainEqual(expect.objectContaining({809 type: "ADOPTION_SOURCE_RESULT",810 payload: expect.objectContaining({ status: "complete" }),811 }));812 813 expect(mocks.storageSet).toHaveBeenCalledWith(814 "peach_initial_sync_pending_device_id",815 "old-phone-id",816 );817 });818819});820821function decodeInvitation(encoded: string): { vault?: string } | null {822 823 824 const at = encoded.indexOf(":");825 if (at < 0) return null;826 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");827 try {828 const json = Buffer.from(b64, "base64").toString("utf8");829 return JSON.parse(json) as { vault?: string };830 } catch {831 return null;832 }833}