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 createSyncAdapter: vi.fn(),18 __applyIncomingDeviceManifest: vi.fn(),19 storageSet: vi.fn(),20 sasDecisions: [] as Array<{ id: string; ok: boolean }>,21}));2223vi.mock("../shared/pairing-dial", () => ({24 openRelayPairingSocket: mocks.openSocket,25}));2627vi.mock("../shared/noise-js", () => ({28 noiseNKResponder: vi.fn(async () => ({29 handshakeHash: new Uint8Array(32).fill(7),30 encrypt: (bytes: Uint8Array) => bytes,31 decrypt: (bytes: Uint8Array) => bytes,32 close: vi.fn(),33 })),34}));3536vi.mock("../shared/rendezvous", async (importOriginal) => {37 const actual = await importOriginal<typeof import("../shared/rendezvous")>();38 return {39 ...actual,40 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",41 };42});434445vi.mock("./device-identity", () => ({46 getLocalPairingIdentity: async () => ({47 identity: {48 deviceId: "test-device",49 deviceName: "Test",50 deviceType: "extension",51 longTermPublicKeyHex: "aa".repeat(32),52 vaultId: "bb".repeat(32),53 },54 privateKey: new Uint8Array(32).fill(7),55 publicKey: new Uint8Array(32).fill(8),56 }),57 getDeviceName: mocks.getDeviceName,58 buildLocalIdentity: mocks.localIdentity,59}));6061vi.mock("./sync-settings", async (importOriginal) => ({62 ...(await importOriginal<typeof import("./sync-settings")>()),63 getSyncSettings: async () => ({64 mode: "public" as const,65 relayUrl: "wss://relay.peachpasswords.com",66 }),67}));6869vi.mock("./license", () => ({70 isProUser: mocks.isProUser,71 getLicenseKey: mocks.getLicenseKey,72 activateLicense: mocks.activateLicense,73}));7475vi.mock("./vault", () => ({76 isUnlocked: vi.fn(() => true),77 getRootKeyHex: mocks.getRootKeyHex,78 createVaultFromRootKey: mocks.createVault,79 rollbackAdoptedVault: mocks.rollbackVault,80 exportDuressSlotForAdoption: vi.fn(async () => null),81 installAdoptedDuressSlot: vi.fn(async () => undefined),82}));8384vi.mock("../shared/sync-identity", async (importOriginal) => {85 86 87 88 89 const actual = await importOriginal<typeof import("../shared/sync-identity")>();90 return {91 ...actual,92 computeVaultId: vi.fn(async () => "b".repeat(64)),93 };94});9596vi.mock("../shared/storage", () => ({97 storageSet: mocks.storageSet,98}));99100vi.mock("./paired-devices", () => ({101 upsertPairedDevice: mocks.upsert,102}));103104vi.mock("./sync-trigger", () => ({105 triggerSyncIfReachable: mocks.triggerSync,106}));107108vi.mock("./sync-vault-adapter", () => ({109 createSyncVaultAdapter: mocks.createSyncAdapter,110}));111112import {113 cancelRelayAdoptionSource,114 startRelayAdoptionSource,115} from "./relay-adoption-bg";116117const encode = (value: unknown) =>118 new TextEncoder().encode(JSON.stringify(value));119120function socketWithFrames(121 frames: unknown[],122 options: { closeOnEmpty?: boolean } = {},123) {124 const queue = frames.map(encode);125 return {126 send: vi.fn(),127 receive: vi.fn(async () => {128 const next = queue.shift();129 if (!next) {130 131 132 133 if (options.closeOnEmpty) throw new Error("socket_closed");134 return new Promise<Uint8Array>(() => undefined);135 }136 return next;137 }),138 close: vi.fn(),139 closedBeforeFrame: vi.fn(() => false),140 };141}142143async function waitForPush(type: string): Promise<void> {144 await vi.waitFor(() => {145 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);146 });147}148149beforeEach(() => {150 mocks.pushes.length = 0;151 mocks.sasDecisions.length = 0;152 mocks.openSocket.mockReset();153 mocks.getLocalIdentity.mockReset();154 mocks.getRootKeyHex.mockReset();155 mocks.upsert.mockReset();156 mocks.triggerSync.mockReset();157 mocks.triggerSync.mockResolvedValue(undefined);158 mocks.isProUser.mockReset();159 mocks.isProUser.mockResolvedValue(true);160 mocks.getLicenseKey.mockReset();161 mocks.getLicenseKey.mockResolvedValue("PPK-source");162 mocks.getLocalIdentity.mockResolvedValue({163 identity: {164 deviceId: "browser-source",165 deviceName: "June's Browser",166 deviceType: "extension",167 longTermPublicKeyHex: "a".repeat(64),168 vaultId: "b".repeat(64),169 },170 privateKey: new Uint8Array(32).fill(1),171 publicKey: new Uint8Array(32).fill(2),172 });173 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));174 mocks.upsert.mockResolvedValue(undefined);175 const applyIncomingDeviceManifest = vi.fn(async () => undefined);176 mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;177 mocks.createSyncAdapter.mockReturnValue({178 buildEntryManifest: vi.fn(async () => ({})),179 getEncryptedEntries: vi.fn(async () => ({})),180 buildTombstoneManifest: vi.fn(async () => ({})),181 applyEntries: vi.fn(async () => undefined),182 applyTombstones: vi.fn(async () => undefined),183 buildOutgoingDeviceManifest: vi.fn(async () => null),184 applyIncomingDeviceManifest,185 });186 Object.defineProperty(globalThis, "chrome", {187 configurable: true,188 value: {189 runtime: {190 async sendMessage(message: { type: string; payload: unknown }) {191 mocks.pushes.push(message);192 193 194 195 196 if (message.type === "ADOPTION_SOURCE_SAS") {197 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");198 const p = message.payload as { id?: string };199 if (typeof p?.id === "string") {200 mocks.sasDecisions.push({ id: p.id, ok: true });201 resolveSourceSasConfirmation(p.id, true);202 }203 }204 return Promise.resolve();205 },206 },207 },208 });209});210211describe("relay adoption source background boundary", () => {212 it("shows the SAS and waits for the popup's single confirmation", async () => {213 const socket = socketWithFrames([214 {215 type: "PEACH_HELLO",216 token: "AAECAwQFBgcICQoLDA0ODw",217 },218 {219 type: "ADOPT_REQUEST",220 device_id: "request-only-label",221 device_name: "June's phone",222 device_type: "android",223 },224 { type: "ADOPT_CONFIRM", confirmed: true },225 {226 type: "ADOPT_IDENTITY",227 device_id: "android-receiver",228 device_name: "June's phone",229 device_type: "android",230 long_term_public_key_hex: "d".repeat(64),231 },232 { type: "ADOPT_APPLIED" },233 ]);234 mocks.openSocket.mockResolvedValue(socket);235236 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");237 const sasIds: string[] = [];238 Object.defineProperty(globalThis, "chrome", {239 configurable: true,240 value: {241 runtime: {242 async sendMessage(message: { type: string; payload: unknown }) {243 mocks.pushes.push(message);244 if (message.type === "ADOPTION_SOURCE_SAS") {245 const p = message.payload as { id?: string };246 if (typeof p?.id === "string") {247 248 249 sasIds.push(p.id);250 }251 return Promise.resolve();252 }253 return Promise.resolve();254 },255 },256 },257 });258 void resolveSourceSasConfirmation; 259260 const started = await startRelayAdoptionSource();261 expect(started.invitation).toMatch(/^peach-pair-v1:/);262 await waitForPush("ADOPTION_SOURCE_SAS");263 264 265 await vi.waitFor(() => {266 expect(mocks.sasDecisions.length).toBe(0);267 });268 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();269 expect(mocks.upsert).not.toHaveBeenCalled();270271 272 const id = sasIds[0];273 expect(typeof id).toBe("string");274 resolveSourceSasConfirmation(id, true);275 mocks.sasDecisions.push({ id, ok: true });276277 await waitForPush("ADOPTION_SOURCE_RESULT");278279 expect(mocks.getRootKeyHex).toHaveBeenCalled();280 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({281 deviceId: "android-receiver",282 relayUrl: "wss://relay.peachpasswords.com",283 }));284 expect(mocks.pushes).toContainEqual(expect.objectContaining({285 type: "ADOPTION_SOURCE_RESULT",286 payload: expect.objectContaining({ status: "complete" }),287 }));288 expect(mocks.triggerSync).toHaveBeenCalledWith({289 reason: "manual",290 force: true,291 });292 const sentTypes = socket.send.mock.calls293 .map(([bytes]) => {294 try {295 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;296 } catch {297 return undefined;298 }299 });300 expect(sentTypes).toContain("ADOPT_COMPLETE");301 });302303 it("fails adoption without sending snapshot frames when duress is oversized", async () => {304 const socket = socketWithFrames([305 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },306 {307 type: "ADOPT_REQUEST",308 device_id: "request-only-label",309 device_name: "June's phone",310 device_type: "android",311 snapshot_capable: true,312 },313 { type: "ADOPT_CONFIRM", confirmed: true },314 {315 type: "ADOPT_IDENTITY",316 device_id: "android-receiver",317 device_name: "June's phone",318 device_type: "android",319 long_term_public_key_hex: "d".repeat(64),320 },321 { type: "ADOPT_APPLIED" },322 ]);323 mocks.openSocket.mockResolvedValue(socket);324 const { exportDuressSlotForAdoption } = await import("./vault");325 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(326 "x".repeat(768 * 1024),327 );328329 await startRelayAdoptionSource();330 await waitForPush("ADOPTION_SOURCE_RESULT");331332 expect(mocks.pushes).toContainEqual({333 type: "ADOPTION_SOURCE_RESULT",334 payload: {335 status: "error",336 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",337 },338 });339 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({340 type: "ADOPTION_SOURCE_RESULT",341 payload: expect.objectContaining({ status: "complete" }),342 }));343 const sentTypes = socket.send.mock.calls.map(([bytes]) =>344 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,345 );346 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");347 expect(mocks.triggerSync).not.toHaveBeenCalled();348 });349350 it("fails closed when the popup rejects the SAS", async () => {351 const socket = socketWithFrames([352 {353 type: "PEACH_HELLO",354 token: "AAECAwQFBgcICQoLDA0ODw",355 },356 {357 type: "ADOPT_REQUEST",358 device_id: "request-only-label",359 device_name: "New phone",360 device_type: "android",361 },362 { type: "ADOPT_CONFIRM", confirmed: false },363 ]);364 mocks.openSocket.mockResolvedValue(socket);365366 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");367 let pendingId: string | undefined;368 Object.defineProperty(globalThis, "chrome", {369 configurable: true,370 value: {371 runtime: {372 sendMessage(message: { type: string; payload: unknown }) {373 mocks.pushes.push(message);374 if (message.type === "ADOPTION_SOURCE_SAS") {375 const p = message.payload as { id?: string };376 if (typeof p?.id === "string") pendingId = p.id;377 }378 return Promise.resolve();379 },380 },381 },382 });383384 await startRelayAdoptionSource();385 await waitForPush("ADOPTION_SOURCE_SAS");386 expect(typeof pendingId).toBe("string");387 resolveSourceSasConfirmation(pendingId!, false);388 mocks.sasDecisions.push({ id: pendingId!, ok: false });389390 await waitForPush("ADOPTION_SOURCE_RESULT");391392 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();393 expect(mocks.upsert).not.toHaveBeenCalled();394 expect(mocks.pushes).toContainEqual({395 type: "ADOPTION_SOURCE_RESULT",396 payload: { status: "error", error: "adoption_rejected" },397 });398 });399400 it("rejects before reading the root key when neither peer has Pro", async () => {401 const socket = socketWithFrames([402 {403 type: "PEACH_HELLO",404 token: "AAECAwQFBgcICQoLDA0ODw",405 },406 {407 type: "ADOPT_REQUEST",408 device_id: "free-receiver",409 device_name: "Free phone",410 device_type: "android",411 },412 { type: "ADOPT_CONFIRM", confirmed: true },413 ]);414 mocks.isProUser.mockResolvedValue(false);415 mocks.getLicenseKey.mockResolvedValue(undefined);416 mocks.openSocket.mockResolvedValue(socket);417418 await startRelayAdoptionSource();419 await waitForPush("ADOPTION_SOURCE_RESULT");420421 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();422 expect(mocks.upsert).not.toHaveBeenCalled();423 expect(mocks.pushes).toContainEqual({424 type: "ADOPTION_SOURCE_RESULT",425 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },426 });427 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {428 try {429 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];430 } catch {431 return [];432 }433 });434 expect(sentFrames).toContainEqual({435 type: "ADOPT_REJECT",436 reason: "ADOPT_PRO_REQUIRED",437 });438 });439440 it("fails closed when the user never answers the SAS", async () => {441 442 443 444 445 const socket = socketWithFrames([]);446 mocks.openSocket.mockResolvedValue(socket);447448 Object.defineProperty(globalThis, "chrome", {449 configurable: true,450 value: {451 runtime: {452 sendMessage(message: { type: string; payload: unknown }) {453 mocks.pushes.push(message);454 return Promise.resolve();455 },456 },457 },458 });459460 await startRelayAdoptionSource();461 cancelRelayAdoptionSource();462463 expect(socket.close).toHaveBeenCalled();464 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();465 expect(mocks.upsert).not.toHaveBeenCalled();466 });467468 it("cancelling the source screen closes the room without reading the root", async () => {469 const socket = socketWithFrames([]);470 mocks.openSocket.mockResolvedValue(socket);471472 await startRelayAdoptionSource();473 cancelRelayAdoptionSource();474475 expect(socket.close).toHaveBeenCalled();476 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();477 expect(mocks.upsert).not.toHaveBeenCalled();478 });479});480481describe("relay adoption receiver background", () => {482 beforeEach(async () => {483 484 485 486 487 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");488 cancelRelayAdoptionSource();489 mocks.pushes.length = 0;490 mocks.openSocket.mockReset();491 mocks.getDeviceName.mockReset();492 mocks.getDeviceName.mockResolvedValue("Receiver Browser");493 mocks.createVault.mockReset();494 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });495 mocks.activateLicense.mockReset();496 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });497 mocks.isProUser.mockReset();498 mocks.isProUser.mockResolvedValue(true);499 mocks.getLicenseKey.mockReset();500 mocks.getLicenseKey.mockResolvedValue(undefined);501 mocks.localIdentity.mockReset();502 mocks.localIdentity.mockResolvedValue({503 deviceId: "receiver-id",504 deviceName: "Receiver Browser",505 deviceType: "extension",506 longTermPublicKeyHex: "11".repeat(32),507 signingPublicKeyHex: "22".repeat(32),508 });509 mocks.rollbackVault.mockReset();510 mocks.rollbackVault.mockResolvedValue(undefined);511 mocks.storageSet.mockReset();512 mocks.storageSet.mockResolvedValue(undefined);513 mocks.upsert.mockReset();514 mocks.upsert.mockResolvedValue(undefined);515 mocks.triggerSync.mockReset();516 mocks.triggerSync.mockResolvedValue(undefined);517 mocks.openSocket.mockResolvedValue(socketWithFrames([]));518 Object.defineProperty(globalThis, "chrome", {519 configurable: true,520 value: {521 runtime: {522 sendMessage(message: { type: string; payload?: unknown }) {523 mocks.pushes.push({ type: message.type, payload: message.payload });524 return Promise.resolve({ ok: true });525 },526 onMessage: {527 addListener() { },528 removeListener() { },529 },530 },531 },532 });533 });534535 it("opens a relay socket and returns an empty invitation", async () => {536 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");537 const started = await startRelayAdoptionReceiver("receiver-password");538 expect(started.invitation).toMatch(/^peach-pair-v1:/);539 540 const decoded = decodeInvitation(started.invitation);541 expect(decoded?.vault).toBe("empty");542 expect(mocks.openSocket).toHaveBeenCalled();543 });544545 it("rejects an empty password", async () => {546 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");547 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");548 });549550 it("surfaces the Pro-required rejection without creating a vault", async () => {551 mocks.openSocket.mockResolvedValue(socketWithFrames([552 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },553 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },554 ]));555556 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");557 await startRelayAdoptionReceiver("receiver-password");558 await waitForPush("ADOPTION_SOURCE_RESULT");559560 expect(mocks.createVault).not.toHaveBeenCalled();561 expect(mocks.rollbackVault).not.toHaveBeenCalled();562 expect(mocks.pushes).toContainEqual({563 type: "ADOPTION_SOURCE_RESULT",564 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },565 });566 });567568 it("the receiver wires requestAdoption, not grantAdoption", async () => {569 570 571 572 573 574 const rootKeyHex = "f0".repeat(32);575 const vaultId = "b".repeat(64);576 const socket = socketWithFrames([577 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },578 {579 type: "ADOPT_GRANT",580 root_key_hex: rootKeyHex,581 vault_id: vaultId,582 license: "PPK-test",583 source: {584 device_id: "phone-source-id",585 device_name: "June's phone",586 device_type: "android",587 long_term_public_key_hex: "99".repeat(32),588 },589 },590 { type: "ADOPT_COMPLETE" },591 ], { closeOnEmpty: true });592 mocks.openSocket.mockResolvedValue(socket);593 mocks.createVault.mockResolvedValue({ vaultId });594595 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");596 Object.defineProperty(globalThis, "chrome", {597 configurable: true,598 value: {599 runtime: {600 sendMessage(message: { type: string; payload?: unknown }) {601 mocks.pushes.push({ type: message.type, payload: message.payload });602 return Promise.resolve({ ok: true });603 },604 onMessage: { addListener: () => undefined, removeListener: () => undefined },605 },606 },607 });608 await startRelayAdoptionReceiver("receiver-password");609 await waitForPush("ADOPTION_SOURCE_RESULT");610611 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })612 .mock.calls613 .map(([bytes]) => {614 try {615 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;616 } catch {617 return undefined;618 }619 });620 621 622 expect(sentTypes).toContain("ADOPT_REQUEST");623 expect(sentTypes).not.toContain("ADOPT_GRANT");624 });625626 it("completes the handshake end-to-end: vault created → paired", async () => {627 628 629 630 631 632 const rootKeyHex = "f0".repeat(32);633 634 635 const vaultId = "b".repeat(64);636 const frames = [637 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },638 639 640 {641 type: "ADOPT_GRANT",642 root_key_hex: rootKeyHex,643 vault_id: vaultId,644 license: "PPK-test",645 source: {646 device_id: "phone-source-id",647 device_name: "June's phone",648 device_type: "android",649 long_term_public_key_hex: "99".repeat(32),650 signing_public_key_hex: "aa".repeat(32),651 relay_url: "wss://relay.peachpasswords.com",652 },653 },654 { type: "ADOPT_COMPLETE" },655 ];656 mocks.createVault.mockResolvedValue({ vaultId });657 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));658659 660 661 Object.defineProperty(globalThis, "chrome", {662 configurable: true,663 value: {664 runtime: {665 sendMessage(message: { type: string; payload?: unknown }) {666 mocks.pushes.push({ type: message.type, payload: message.payload });667 return Promise.resolve({ ok: true });668 },669 onMessage: {670 addListener: () => undefined,671 removeListener: () => undefined,672 },673 },674 },675 });676677 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");678 await startRelayAdoptionReceiver("receiver-password");679680 await waitForPush("ADOPTION_SOURCE_RESULT");681682 683 684 expect(mocks.pushes).toContainEqual({685 type: "ADOPTION_SOURCE_SAS",686 payload: expect.objectContaining({687 sas: expect.stringMatching(/^\d{8}$/),688 displayOnly: true,689 }),690 });691692 693 694 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);695696 697 698 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");699 expect(mocks.isProUser).toHaveBeenCalled();700 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));701 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({702 deviceId: "phone-source-id",703 }));704 expect(mocks.storageSet).toHaveBeenCalledWith(705 "peach_initial_sync_pending_device_id",706 "phone-source-id",707 );708 expect(mocks.rollbackVault).not.toHaveBeenCalled();709710 711 expect(mocks.pushes).toContainEqual(expect.objectContaining({712 type: "ADOPTION_SOURCE_RESULT",713 payload: expect.objectContaining({ status: "complete" }),714 }));715 });716717 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {718 const rootKeyHex = "f0".repeat(32);719 const vaultId = "b".repeat(64);720 mocks.createVault.mockResolvedValue({ vaultId });721 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });722 mocks.isProUser.mockResolvedValue(false);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-invalid",730 source: {731 device_id: "phone-source-id",732 device_name: "June's phone",733 device_type: "android",734 long_term_public_key_hex: "99".repeat(32),735 },736 },737 ]));738739 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");740 await startRelayAdoptionReceiver("receiver-password");741 await waitForPush("ADOPTION_SOURCE_RESULT");742743 expect(mocks.rollbackVault).toHaveBeenCalledOnce();744 expect(mocks.localIdentity).not.toHaveBeenCalled();745 expect(mocks.upsert).not.toHaveBeenCalled();746 expect(mocks.pushes).toContainEqual(expect.objectContaining({747 type: "ADOPTION_SOURCE_RESULT",748 payload: expect.objectContaining({749 status: "error",750 error: "license_transfer_failed",751 }),752 }));753 });754755 it("rolls back a newly created vault when a later adoption step fails", async () => {756 const rootKeyHex = "f0".repeat(32);757 const vaultId = "b".repeat(64);758 mocks.createVault.mockResolvedValue({ vaultId });759 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));760 mocks.openSocket.mockResolvedValue(socketWithFrames([761 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },762 {763 type: "ADOPT_GRANT",764 root_key_hex: rootKeyHex,765 vault_id: vaultId,766 license: "PPK-test",767 source: {768 device_id: "phone-source-id",769 device_name: "June's phone",770 device_type: "android",771 long_term_public_key_hex: "99".repeat(32),772 },773 },774 ]));775776 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");777 await startRelayAdoptionReceiver("receiver-password");778 await waitForPush("ADOPTION_SOURCE_RESULT");779780 expect(mocks.rollbackVault).toHaveBeenCalledOnce();781 expect(mocks.upsert).not.toHaveBeenCalled();782 expect(mocks.pushes).toContainEqual(expect.objectContaining({783 type: "ADOPTION_SOURCE_RESULT",784 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),785 }));786 });787788 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {789 790 791 792 const rootKeyHex = "f0".repeat(32);793 const vaultId = "b".repeat(64);794 mocks.createVault.mockResolvedValue({ vaultId });795 mocks.openSocket.mockResolvedValue(socketWithFrames([796 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },797 {798 type: "ADOPT_GRANT",799 root_key_hex: rootKeyHex,800 vault_id: vaultId,801 license: "PPK-test",802 source: {803 device_id: "old-phone-id",804 device_name: "Old phone",805 device_type: "android",806 long_term_public_key_hex: "99".repeat(32),807 },808 809 },810 { type: "ADOPT_COMPLETE" },811 ], { closeOnEmpty: true }));812813 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");814 await startRelayAdoptionReceiver("receiver-password");815 await waitForPush("ADOPTION_SOURCE_RESULT");816817 818 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });819 820 expect(mocks.pushes).toContainEqual(expect.objectContaining({821 type: "ADOPTION_SOURCE_RESULT",822 payload: expect.objectContaining({ status: "complete" }),823 }));824 825 expect(mocks.storageSet).toHaveBeenCalledWith(826 "peach_initial_sync_pending_device_id",827 "old-phone-id",828 );829 });830831 it("applies the source's device manifest after the snapshot, then acks", async () => {832 833 834 835 836 837 const rootKeyHex = "f0".repeat(32);838 const vaultId = "b".repeat(64);839 const longTermPubkeyHex = "99".repeat(32);840 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");841 const { deriveSyncSigningKeypair, pubkeyToHex } = await import(842 "../shared/sync-identity"843 );844 const issuerKp = await deriveSyncSigningKeypair(845 new Uint8Array(32).fill(0x55),846 "phone-source-id",847 );848 const manifest = await buildSignedDeviceManifest(849 "phone-source-id",850 [851 {852 deviceId: "peer-b",853 deviceName: "Pool B",854 deviceType: "extension",855 longTermPublicKeyHex: "11".repeat(32),856 signingPublicKeyHex: "22".repeat(32),857 wsPort: 49152,858 relayUrl: "wss://relay.peachpasswords.com",859 pairedAt: 1,860 },861 ],862 issuerKp.privateKey,863 );864 const socket = socketWithFrames([865 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },866 {867 type: "ADOPT_GRANT",868 root_key_hex: rootKeyHex,869 vault_id: vaultId,870 license: "PPK-test",871 snapshot_capable: true,872 source: {873 device_id: "phone-source-id",874 device_name: "June's phone",875 device_type: "android",876 long_term_public_key_hex: longTermPubkeyHex,877 signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),878 relay_url: "wss://relay.peachpasswords.com",879 },880 },881 882 883 { type: "ADOPT_COMPLETE" },884 885 886 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },887 { type: "ADOPT_DEVICE_MANIFEST", manifest },888 ], { closeOnEmpty: true });889 mocks.createVault.mockResolvedValue({ vaultId });890 mocks.openSocket.mockResolvedValue(socket);891 mocks.__applyIncomingDeviceManifest.mockClear();892893 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");894 await startRelayAdoptionReceiver("receiver-password");895 await waitForPush("ADOPTION_SOURCE_RESULT");896897 expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);898 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];899 expect(forwarded).toEqual(manifest);900 expect(pubkey).toBeInstanceOf(Uint8Array);901 expect(pubkey.length).toBe(32);902 903 904 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })905 .mock.calls906 .map(([bytes]) => {907 try {908 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;909 } catch {910 return undefined;911 }912 });913 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");914 915 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");916 });917918 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {919 920 921 922 923 const rootKeyHex = "f0".repeat(32);924 const vaultId = "b".repeat(64);925 const longTermPubkeyHex = "99".repeat(32);926 const socket = socketWithFrames([927 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },928 {929 type: "ADOPT_GRANT",930 root_key_hex: rootKeyHex,931 vault_id: vaultId,932 license: "PPK-test",933 snapshot_capable: true,934 source: {935 device_id: "phone-source-id",936 device_name: "June's phone",937 device_type: "android",938 long_term_public_key_hex: longTermPubkeyHex,939 signing_public_key_hex: "aa".repeat(32),940 relay_url: "wss://relay.peachpasswords.com",941 },942 },943 { type: "ADOPT_COMPLETE" },944 945 946 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },947 ], { closeOnEmpty: true });948 mocks.createVault.mockResolvedValue({ vaultId });949 mocks.openSocket.mockResolvedValue(socket);950 mocks.__applyIncomingDeviceManifest.mockClear();951952 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");953 await startRelayAdoptionReceiver("receiver-password");954 await waitForPush("ADOPTION_SOURCE_RESULT");955956 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();957 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })958 .mock.calls959 .map(([bytes]) => {960 try {961 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;962 } catch {963 return undefined;964 }965 });966 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");967 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");968 });969});970971function decodeInvitation(encoded: string): { vault?: string } | null {972 973 974 const at = encoded.indexOf(":");975 if (at < 0) return null;976 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");977 try {978 const json = Buffer.from(b64, "base64").toString("utf8");979 return JSON.parse(json) as { vault?: string };980 } catch {981 return null;982 }983}