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 setPairSecret: vi.fn(),10 pushes: [] as Array<{ type: string; payload: unknown }>,11 triggerSync: vi.fn(),12 createVault: vi.fn(),13 activateLicense: vi.fn(),14 isProUser: vi.fn(),15 getLicenseKey: vi.fn(),16 localIdentity: vi.fn(),17 rollbackVault: vi.fn(),18 createSyncAdapter: vi.fn(),19 __applyIncomingDeviceManifest: vi.fn(),20 storageSet: vi.fn(),21 sasDecisions: [] as Array<{ id: string; ok: boolean }>,22}));2324vi.mock("../shared/pairing-dial", () => ({25 openRelayPairingSocket: mocks.openSocket,26}));2728vi.mock("../shared/noise-js", () => ({29 noiseNKResponder: vi.fn(async () => ({30 handshakeHash: new Uint8Array(32).fill(7),31 encrypt: (bytes: Uint8Array) => bytes,32 decrypt: (bytes: Uint8Array) => bytes,33 close: vi.fn(),34 })),35}));3637vi.mock("../shared/rendezvous", async (importOriginal) => {38 const actual = await importOriginal<typeof import("../shared/rendezvous")>();39 return {40 ...actual,41 generatePairingRendezvousToken: () => "AAECAwQFBgcICQoLDA0ODw",42 };43});444546vi.mock("./device-identity", () => ({47 getLocalPairingIdentity: async () => ({48 identity: {49 deviceId: "test-device",50 deviceName: "Test",51 deviceType: "extension",52 longTermPublicKeyHex: "aa".repeat(32),53 vaultId: "bb".repeat(32),54 },55 privateKey: new Uint8Array(32).fill(7),56 publicKey: new Uint8Array(32).fill(8),57 }),58 getDeviceName: mocks.getDeviceName,59 buildLocalIdentity: mocks.localIdentity,60}));6162vi.mock("./sync-settings", async (importOriginal) => ({63 ...(await importOriginal<typeof import("./sync-settings")>()),64 getSyncSettings: async () => ({65 mode: "public" as const,66 relayUrl: "wss://relay.peachpasswords.com",67 }),68}));6970vi.mock("./license", () => ({71 isProUser: mocks.isProUser,72 getLicenseKey: mocks.getLicenseKey,73 activateLicense: mocks.activateLicense,74}));7576vi.mock("./vault", () => ({77 isUnlocked: vi.fn(() => true),78 getRootKeyHex: mocks.getRootKeyHex,79 createVaultFromRootKey: mocks.createVault,80 rollbackAdoptedVault: mocks.rollbackVault,81 exportDuressSlotForAdoption: vi.fn(async () => null),82 installAdoptedDuressSlot: vi.fn(async () => undefined),83}));8485vi.mock("../shared/sync-identity", async (importOriginal) => {86 87 88 89 90 const actual = await importOriginal<typeof import("../shared/sync-identity")>();91 return {92 ...actual,93 computeVaultId: vi.fn(async () => "b".repeat(64)),94 };95});9697vi.mock("../shared/storage", () => ({98 storageSet: mocks.storageSet,99}));100101vi.mock("./paired-devices", () => ({102 upsertPairedDevice: mocks.upsert,103}));104105vi.mock("./pair-secrets", () => ({106 setPairSecret: mocks.setPairSecret,107 isValidPairSecret: (value: unknown): value is string =>108 typeof value === "string" && /^[0-9a-f]{64}$/.test(value),109}));110111vi.mock("./sync-trigger", () => ({112 triggerSyncIfReachable: mocks.triggerSync,113}));114115vi.mock("./sync-vault-adapter", () => ({116 createSyncVaultAdapter: mocks.createSyncAdapter,117}));118119import {120 cancelRelayAdoptionSource,121 startRelayAdoptionSource,122} from "./relay-adoption-bg";123124const encode = (value: unknown) =>125 new TextEncoder().encode(JSON.stringify(value));126127function socketWithFrames(128 frames: unknown[],129 options: { closeOnEmpty?: boolean } = {},130) {131 const queue = frames.map(encode);132 return {133 send: vi.fn(),134 receive: vi.fn(async () => {135 const next = queue.shift();136 if (!next) {137 138 139 140 if (options.closeOnEmpty) throw new Error("socket_closed");141 return new Promise<Uint8Array>(() => undefined);142 }143 return next;144 }),145 close: vi.fn(),146 closedBeforeFrame: vi.fn(() => false),147 };148}149150async function waitForPush(type: string): Promise<void> {151 await vi.waitFor(() => {152 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);153 });154}155156beforeEach(() => {157 mocks.pushes.length = 0;158 mocks.sasDecisions.length = 0;159 mocks.openSocket.mockReset();160 mocks.getLocalIdentity.mockReset();161 mocks.getRootKeyHex.mockReset();162 mocks.upsert.mockReset();163 mocks.setPairSecret.mockReset();164 mocks.triggerSync.mockReset();165 mocks.triggerSync.mockResolvedValue(undefined);166 mocks.isProUser.mockReset();167 mocks.isProUser.mockResolvedValue(true);168 mocks.getLicenseKey.mockReset();169 mocks.getLicenseKey.mockResolvedValue("PPK-source");170 mocks.getLocalIdentity.mockResolvedValue({171 identity: {172 deviceId: "browser-source",173 deviceName: "June's Browser",174 deviceType: "extension",175 longTermPublicKeyHex: "a".repeat(64),176 vaultId: "b".repeat(64),177 },178 privateKey: new Uint8Array(32).fill(1),179 publicKey: new Uint8Array(32).fill(2),180 });181 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));182 mocks.upsert.mockResolvedValue(undefined);183 mocks.setPairSecret.mockResolvedValue(undefined);184 const applyIncomingDeviceManifest = vi.fn(async () => undefined);185 mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;186 mocks.createSyncAdapter.mockReturnValue({187 buildEntryManifest: vi.fn(async () => ({})),188 getEncryptedEntries: vi.fn(async () => ({})),189 buildTombstoneManifest: vi.fn(async () => ({})),190 applyEntries: vi.fn(async () => undefined),191 applyTombstones: vi.fn(async () => undefined),192 buildOutgoingDeviceManifest: vi.fn(async () => null),193 applyIncomingDeviceManifest,194 });195 Object.defineProperty(globalThis, "chrome", {196 configurable: true,197 value: {198 runtime: {199 async sendMessage(message: { type: string; payload: unknown }) {200 mocks.pushes.push(message);201 202 203 204 205 if (message.type === "ADOPTION_SOURCE_SAS") {206 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");207 const p = message.payload as { id?: string };208 if (typeof p?.id === "string") {209 mocks.sasDecisions.push({ id: p.id, ok: true });210 resolveSourceSasConfirmation(p.id, true);211 }212 }213 return Promise.resolve();214 },215 },216 },217 });218});219220describe("relay adoption source background boundary", () => {221 it("shows the SAS and waits for the popup's single confirmation", async () => {222 const socket = socketWithFrames([223 {224 type: "PEACH_HELLO",225 token: "AAECAwQFBgcICQoLDA0ODw",226 },227 {228 type: "ADOPT_REQUEST",229 device_id: "request-only-label",230 device_name: "June's phone",231 device_type: "android",232 },233 { type: "ADOPT_CONFIRM", confirmed: true },234 {235 type: "ADOPT_IDENTITY",236 device_id: "android-receiver",237 device_name: "June's phone",238 device_type: "android",239 long_term_public_key_hex: "d".repeat(64),240 },241 { type: "ADOPT_APPLIED" },242 ]);243 mocks.openSocket.mockResolvedValue(socket);244245 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");246 const sasIds: string[] = [];247 Object.defineProperty(globalThis, "chrome", {248 configurable: true,249 value: {250 runtime: {251 async sendMessage(message: { type: string; payload: unknown }) {252 mocks.pushes.push(message);253 if (message.type === "ADOPTION_SOURCE_SAS") {254 const p = message.payload as { id?: string };255 if (typeof p?.id === "string") {256 257 258 sasIds.push(p.id);259 }260 return Promise.resolve();261 }262 return Promise.resolve();263 },264 },265 },266 });267 void resolveSourceSasConfirmation; 268269 const started = await startRelayAdoptionSource();270 expect(started.invitation).toMatch(/^peach-pair-v2:/);271 await waitForPush("ADOPTION_SOURCE_SAS");272 273 274 await vi.waitFor(() => {275 expect(mocks.sasDecisions.length).toBe(0);276 });277 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();278 expect(mocks.upsert).not.toHaveBeenCalled();279280 281 const id = sasIds[0];282 expect(typeof id).toBe("string");283 resolveSourceSasConfirmation(id, true);284 mocks.sasDecisions.push({ id, ok: true });285286 await waitForPush("ADOPTION_SOURCE_RESULT");287288 expect(mocks.getRootKeyHex).toHaveBeenCalled();289 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({290 deviceId: "android-receiver",291 relayUrl: "wss://relay.peachpasswords.com",292 }));293 294 295 const sourceRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;296 expect(sourceRow.rendezvousSecretHex).toBeUndefined();297 expect(mocks.setPairSecret).toHaveBeenCalledWith(298 "android-receiver",299 expect.stringMatching(/^[0-9a-f]{64}$/),300 expect.any(Uint8Array),301 );302 expect(mocks.pushes).toContainEqual(expect.objectContaining({303 type: "ADOPTION_SOURCE_RESULT",304 payload: expect.objectContaining({ status: "complete" }),305 }));306 expect(mocks.triggerSync).toHaveBeenCalledWith({307 reason: "manual",308 force: true,309 });310 const sentTypes = socket.send.mock.calls311 .map(([bytes]) => {312 try {313 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;314 } catch {315 return undefined;316 }317 });318 expect(sentTypes).toContain("ADOPT_COMPLETE");319 });320321 it("fails adoption without sending snapshot frames when duress is oversized", async () => {322 const socket = socketWithFrames([323 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },324 {325 type: "ADOPT_REQUEST",326 device_id: "request-only-label",327 device_name: "June's phone",328 device_type: "android",329 snapshot_capable: true,330 },331 { type: "ADOPT_CONFIRM", confirmed: true },332 {333 type: "ADOPT_IDENTITY",334 device_id: "android-receiver",335 device_name: "June's phone",336 device_type: "android",337 long_term_public_key_hex: "d".repeat(64),338 },339 { type: "ADOPT_APPLIED" },340 ]);341 mocks.openSocket.mockResolvedValue(socket);342 const { exportDuressSlotForAdoption } = await import("./vault");343 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(344 "x".repeat(768 * 1024),345 );346347 await startRelayAdoptionSource();348 await waitForPush("ADOPTION_SOURCE_RESULT");349350 expect(mocks.pushes).toContainEqual({351 type: "ADOPTION_SOURCE_RESULT",352 payload: {353 status: "error",354 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",355 },356 });357 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({358 type: "ADOPTION_SOURCE_RESULT",359 payload: expect.objectContaining({ status: "complete" }),360 }));361 const sentTypes = socket.send.mock.calls.map(([bytes]) =>362 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,363 );364 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");365 expect(mocks.triggerSync).not.toHaveBeenCalled();366 });367368 it("fails closed when the popup rejects the SAS", async () => {369 const socket = socketWithFrames([370 {371 type: "PEACH_HELLO",372 token: "AAECAwQFBgcICQoLDA0ODw",373 },374 {375 type: "ADOPT_REQUEST",376 device_id: "request-only-label",377 device_name: "New phone",378 device_type: "android",379 },380 { type: "ADOPT_CONFIRM", confirmed: false },381 ]);382 mocks.openSocket.mockResolvedValue(socket);383384 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");385 let pendingId: string | undefined;386 Object.defineProperty(globalThis, "chrome", {387 configurable: true,388 value: {389 runtime: {390 sendMessage(message: { type: string; payload: unknown }) {391 mocks.pushes.push(message);392 if (message.type === "ADOPTION_SOURCE_SAS") {393 const p = message.payload as { id?: string };394 if (typeof p?.id === "string") pendingId = p.id;395 }396 return Promise.resolve();397 },398 },399 },400 });401402 await startRelayAdoptionSource();403 await waitForPush("ADOPTION_SOURCE_SAS");404 expect(typeof pendingId).toBe("string");405 resolveSourceSasConfirmation(pendingId!, false);406 mocks.sasDecisions.push({ id: pendingId!, ok: false });407408 await waitForPush("ADOPTION_SOURCE_RESULT");409410 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();411 expect(mocks.upsert).not.toHaveBeenCalled();412 expect(mocks.pushes).toContainEqual({413 type: "ADOPTION_SOURCE_RESULT",414 payload: { status: "error", error: "adoption_rejected" },415 });416 });417418 it("rejects before reading the root key when neither peer has Pro", async () => {419 const socket = socketWithFrames([420 {421 type: "PEACH_HELLO",422 token: "AAECAwQFBgcICQoLDA0ODw",423 },424 {425 type: "ADOPT_REQUEST",426 device_id: "free-receiver",427 device_name: "Free phone",428 device_type: "android",429 },430 { type: "ADOPT_CONFIRM", confirmed: true },431 ]);432 mocks.isProUser.mockResolvedValue(false);433 mocks.getLicenseKey.mockResolvedValue(undefined);434 mocks.openSocket.mockResolvedValue(socket);435436 await startRelayAdoptionSource();437 await waitForPush("ADOPTION_SOURCE_RESULT");438439 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();440 expect(mocks.upsert).not.toHaveBeenCalled();441 expect(mocks.pushes).toContainEqual({442 type: "ADOPTION_SOURCE_RESULT",443 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },444 });445 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {446 try {447 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];448 } catch {449 return [];450 }451 });452 expect(sentFrames).toContainEqual({453 type: "ADOPT_REJECT",454 reason: "ADOPT_PRO_REQUIRED",455 });456 });457458 it("fails closed when the user never answers the SAS", async () => {459 460 461 462 463 const socket = socketWithFrames([]);464 mocks.openSocket.mockResolvedValue(socket);465466 Object.defineProperty(globalThis, "chrome", {467 configurable: true,468 value: {469 runtime: {470 sendMessage(message: { type: string; payload: unknown }) {471 mocks.pushes.push(message);472 return Promise.resolve();473 },474 },475 },476 });477478 await startRelayAdoptionSource();479 cancelRelayAdoptionSource();480481 expect(socket.close).toHaveBeenCalled();482 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();483 expect(mocks.upsert).not.toHaveBeenCalled();484 });485486 it("cancelling the source screen closes the room without reading the root", async () => {487 const socket = socketWithFrames([]);488 mocks.openSocket.mockResolvedValue(socket);489490 await startRelayAdoptionSource();491 cancelRelayAdoptionSource();492493 expect(socket.close).toHaveBeenCalled();494 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();495 expect(mocks.upsert).not.toHaveBeenCalled();496 });497});498499describe("relay adoption receiver background", () => {500 beforeEach(async () => {501 502 503 504 505 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");506 cancelRelayAdoptionSource();507 mocks.pushes.length = 0;508 mocks.openSocket.mockReset();509 mocks.getDeviceName.mockReset();510 mocks.getDeviceName.mockResolvedValue("Receiver Browser");511 mocks.createVault.mockReset();512 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });513 mocks.activateLicense.mockReset();514 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });515 mocks.isProUser.mockReset();516 mocks.isProUser.mockResolvedValue(true);517 mocks.getLicenseKey.mockReset();518 mocks.getLicenseKey.mockResolvedValue(undefined);519 mocks.localIdentity.mockReset();520 mocks.localIdentity.mockResolvedValue({521 deviceId: "receiver-id",522 deviceName: "Receiver Browser",523 deviceType: "extension",524 longTermPublicKeyHex: "11".repeat(32),525 signingPublicKeyHex: "22".repeat(32),526 });527 mocks.rollbackVault.mockReset();528 mocks.rollbackVault.mockResolvedValue(undefined);529 mocks.storageSet.mockReset();530 mocks.storageSet.mockResolvedValue(undefined);531 mocks.upsert.mockReset();532 mocks.upsert.mockResolvedValue(undefined);533 mocks.setPairSecret.mockReset();534 mocks.setPairSecret.mockResolvedValue(undefined);535 mocks.triggerSync.mockReset();536 mocks.triggerSync.mockResolvedValue(undefined);537 mocks.openSocket.mockResolvedValue(socketWithFrames([]));538 Object.defineProperty(globalThis, "chrome", {539 configurable: true,540 value: {541 runtime: {542 sendMessage(message: { type: string; payload?: unknown }) {543 mocks.pushes.push({ type: message.type, payload: message.payload });544 return Promise.resolve({ ok: true });545 },546 onMessage: {547 addListener() { },548 removeListener() { },549 },550 },551 },552 });553 });554555 it("opens a relay socket and returns an empty invitation", async () => {556 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");557 const started = await startRelayAdoptionReceiver("receiver-password");558 expect(started.invitation).toMatch(/^peach-pair-v2:/);559 560 const decoded = decodeInvitation(started.invitation);561 expect(decoded?.vault).toBe("empty");562 expect(mocks.openSocket).toHaveBeenCalled();563 });564565 it("rejects an empty password", async () => {566 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");567 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");568 });569570 it("surfaces the Pro-required rejection without creating a vault", async () => {571 mocks.openSocket.mockResolvedValue(socketWithFrames([572 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },573 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },574 ]));575576 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");577 await startRelayAdoptionReceiver("receiver-password");578 await waitForPush("ADOPTION_SOURCE_RESULT");579580 expect(mocks.createVault).not.toHaveBeenCalled();581 expect(mocks.rollbackVault).not.toHaveBeenCalled();582 expect(mocks.pushes).toContainEqual({583 type: "ADOPTION_SOURCE_RESULT",584 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },585 });586 });587588 it("the receiver wires requestAdoption, not grantAdoption", async () => {589 590 591 592 593 594 const rootKeyHex = "f0".repeat(32);595 const vaultId = "b".repeat(64);596 const socket = socketWithFrames([597 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },598 {599 type: "ADOPT_GRANT",600 root_key_hex: rootKeyHex,601 vault_id: vaultId,602 license: "PPK-test",603 source: {604 device_id: "phone-source-id",605 device_name: "June's phone",606 device_type: "android",607 long_term_public_key_hex: "99".repeat(32),608 },609 },610 { type: "ADOPT_COMPLETE" },611 ], { closeOnEmpty: true });612 mocks.openSocket.mockResolvedValue(socket);613 mocks.createVault.mockResolvedValue({ vaultId });614615 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");616 Object.defineProperty(globalThis, "chrome", {617 configurable: true,618 value: {619 runtime: {620 sendMessage(message: { type: string; payload?: unknown }) {621 mocks.pushes.push({ type: message.type, payload: message.payload });622 return Promise.resolve({ ok: true });623 },624 onMessage: { addListener: () => undefined, removeListener: () => undefined },625 },626 },627 });628 await startRelayAdoptionReceiver("receiver-password");629 await waitForPush("ADOPTION_SOURCE_RESULT");630631 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })632 .mock.calls633 .map(([bytes]) => {634 try {635 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;636 } catch {637 return undefined;638 }639 });640 641 642 expect(sentTypes).toContain("ADOPT_REQUEST");643 expect(sentTypes).not.toContain("ADOPT_GRANT");644 });645646 it("completes the handshake end-to-end: vault created → paired", async () => {647 648 649 650 651 652 const rootKeyHex = "f0".repeat(32);653 654 655 const vaultId = "b".repeat(64);656 const frames = [657 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },658 659 660 {661 type: "ADOPT_GRANT",662 root_key_hex: rootKeyHex,663 vault_id: vaultId,664 license: "PPK-test",665 source: {666 device_id: "phone-source-id",667 device_name: "June's phone",668 device_type: "android",669 long_term_public_key_hex: "99".repeat(32),670 signing_public_key_hex: "aa".repeat(32),671 relay_url: "wss://relay.peachpasswords.com",672 },673 },674 { type: "ADOPT_COMPLETE" },675 ];676 mocks.createVault.mockResolvedValue({ vaultId });677 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));678679 680 681 Object.defineProperty(globalThis, "chrome", {682 configurable: true,683 value: {684 runtime: {685 sendMessage(message: { type: string; payload?: unknown }) {686 mocks.pushes.push({ type: message.type, payload: message.payload });687 return Promise.resolve({ ok: true });688 },689 onMessage: {690 addListener: () => undefined,691 removeListener: () => undefined,692 },693 },694 },695 });696697 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");698 await startRelayAdoptionReceiver("receiver-password");699700 await waitForPush("ADOPTION_SOURCE_RESULT");701702 703 704 expect(mocks.pushes).toContainEqual({705 type: "ADOPTION_SOURCE_SAS",706 payload: expect.objectContaining({707 sas: expect.stringMatching(/^\d{8}$/),708 displayOnly: true,709 }),710 });711712 713 714 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);715716 717 718 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");719 expect(mocks.isProUser).toHaveBeenCalled();720 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));721 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({722 deviceId: "phone-source-id",723 }));724 const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;725 expect(receiverRow.rendezvousSecretHex).toBeUndefined();726 expect(mocks.setPairSecret).toHaveBeenCalledWith(727 "phone-source-id",728 expect.stringMatching(/^[0-9a-f]{64}$/),729 expect.any(Uint8Array),730 );731 expect(mocks.storageSet).toHaveBeenCalledWith(732 "peach_initial_sync_pending_device_id",733 "phone-source-id",734 );735 expect(mocks.rollbackVault).not.toHaveBeenCalled();736737 738 expect(mocks.pushes).toContainEqual(expect.objectContaining({739 type: "ADOPTION_SOURCE_RESULT",740 payload: expect.objectContaining({ status: "complete" }),741 }));742 });743744 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {745 const rootKeyHex = "f0".repeat(32);746 const vaultId = "b".repeat(64);747 mocks.createVault.mockResolvedValue({ vaultId });748 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });749 mocks.isProUser.mockResolvedValue(false);750 mocks.openSocket.mockResolvedValue(socketWithFrames([751 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },752 {753 type: "ADOPT_GRANT",754 root_key_hex: rootKeyHex,755 vault_id: vaultId,756 license: "PPK-invalid",757 source: {758 device_id: "phone-source-id",759 device_name: "June's phone",760 device_type: "android",761 long_term_public_key_hex: "99".repeat(32),762 },763 },764 ]));765766 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");767 await startRelayAdoptionReceiver("receiver-password");768 await waitForPush("ADOPTION_SOURCE_RESULT");769770 expect(mocks.rollbackVault).toHaveBeenCalledOnce();771 expect(mocks.localIdentity).not.toHaveBeenCalled();772 expect(mocks.upsert).not.toHaveBeenCalled();773 expect(mocks.pushes).toContainEqual(expect.objectContaining({774 type: "ADOPTION_SOURCE_RESULT",775 payload: expect.objectContaining({776 status: "error",777 error: "license_transfer_failed",778 }),779 }));780 });781782 it("rolls back a newly created vault when a later adoption step fails", async () => {783 const rootKeyHex = "f0".repeat(32);784 const vaultId = "b".repeat(64);785 mocks.createVault.mockResolvedValue({ vaultId });786 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));787 mocks.openSocket.mockResolvedValue(socketWithFrames([788 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },789 {790 type: "ADOPT_GRANT",791 root_key_hex: rootKeyHex,792 vault_id: vaultId,793 license: "PPK-test",794 source: {795 device_id: "phone-source-id",796 device_name: "June's phone",797 device_type: "android",798 long_term_public_key_hex: "99".repeat(32),799 },800 },801 ]));802803 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");804 await startRelayAdoptionReceiver("receiver-password");805 await waitForPush("ADOPTION_SOURCE_RESULT");806807 expect(mocks.rollbackVault).toHaveBeenCalledOnce();808 expect(mocks.upsert).not.toHaveBeenCalled();809 expect(mocks.pushes).toContainEqual(expect.objectContaining({810 type: "ADOPTION_SOURCE_RESULT",811 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),812 }));813 });814815 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {816 817 818 819 const rootKeyHex = "f0".repeat(32);820 const vaultId = "b".repeat(64);821 mocks.createVault.mockResolvedValue({ vaultId });822 mocks.openSocket.mockResolvedValue(socketWithFrames([823 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },824 {825 type: "ADOPT_GRANT",826 root_key_hex: rootKeyHex,827 vault_id: vaultId,828 license: "PPK-test",829 source: {830 device_id: "old-phone-id",831 device_name: "Old phone",832 device_type: "android",833 long_term_public_key_hex: "99".repeat(32),834 },835 836 },837 { type: "ADOPT_COMPLETE" },838 ], { closeOnEmpty: true }));839840 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");841 await startRelayAdoptionReceiver("receiver-password");842 await waitForPush("ADOPTION_SOURCE_RESULT");843844 845 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });846 847 expect(mocks.pushes).toContainEqual(expect.objectContaining({848 type: "ADOPTION_SOURCE_RESULT",849 payload: expect.objectContaining({ status: "complete" }),850 }));851 852 expect(mocks.storageSet).toHaveBeenCalledWith(853 "peach_initial_sync_pending_device_id",854 "old-phone-id",855 );856 });857858 it("applies the source's device manifest after the snapshot, then acks", async () => {859 860 861 862 863 864 const rootKeyHex = "f0".repeat(32);865 const vaultId = "b".repeat(64);866 const longTermPubkeyHex = "99".repeat(32);867 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");868 const { deriveSyncSigningKeypair, pubkeyToHex } = await import(869 "../shared/sync-identity"870 );871 const issuerKp = await deriveSyncSigningKeypair(872 new Uint8Array(32).fill(0x55),873 "phone-source-id",874 );875 const manifest = await buildSignedDeviceManifest(876 "phone-source-id",877 [878 {879 deviceId: "peer-b",880 deviceName: "Pool B",881 deviceType: "extension",882 longTermPublicKeyHex: "11".repeat(32),883 signingPublicKeyHex: "22".repeat(32),884 wsPort: 49152,885 relayUrl: "wss://relay.peachpasswords.com",886 pairedAt: 1,887 },888 ],889 issuerKp.privateKey,890 );891 const socket = socketWithFrames([892 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },893 {894 type: "ADOPT_GRANT",895 root_key_hex: rootKeyHex,896 vault_id: vaultId,897 license: "PPK-test",898 snapshot_capable: true,899 source: {900 device_id: "phone-source-id",901 device_name: "June's phone",902 device_type: "android",903 long_term_public_key_hex: longTermPubkeyHex,904 signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),905 relay_url: "wss://relay.peachpasswords.com",906 },907 },908 909 910 { type: "ADOPT_COMPLETE" },911 912 913 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },914 { type: "ADOPT_DEVICE_MANIFEST", manifest },915 ], { closeOnEmpty: true });916 mocks.createVault.mockResolvedValue({ vaultId });917 mocks.openSocket.mockResolvedValue(socket);918 mocks.__applyIncomingDeviceManifest.mockClear();919920 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");921 await startRelayAdoptionReceiver("receiver-password");922 await waitForPush("ADOPTION_SOURCE_RESULT");923924 expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);925 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];926 expect(forwarded).toEqual(manifest);927 expect(pubkey).toBeInstanceOf(Uint8Array);928 expect(pubkey.length).toBe(32);929 930 931 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })932 .mock.calls933 .map(([bytes]) => {934 try {935 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;936 } catch {937 return undefined;938 }939 });940 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");941 942 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");943 });944945 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {946 947 948 949 950 const rootKeyHex = "f0".repeat(32);951 const vaultId = "b".repeat(64);952 const longTermPubkeyHex = "99".repeat(32);953 const socket = socketWithFrames([954 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },955 {956 type: "ADOPT_GRANT",957 root_key_hex: rootKeyHex,958 vault_id: vaultId,959 license: "PPK-test",960 snapshot_capable: true,961 source: {962 device_id: "phone-source-id",963 device_name: "June's phone",964 device_type: "android",965 long_term_public_key_hex: longTermPubkeyHex,966 signing_public_key_hex: "aa".repeat(32),967 relay_url: "wss://relay.peachpasswords.com",968 },969 },970 { type: "ADOPT_COMPLETE" },971 972 973 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },974 ], { closeOnEmpty: true });975 mocks.createVault.mockResolvedValue({ vaultId });976 mocks.openSocket.mockResolvedValue(socket);977 mocks.__applyIncomingDeviceManifest.mockClear();978979 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");980 await startRelayAdoptionReceiver("receiver-password");981 await waitForPush("ADOPTION_SOURCE_RESULT");982983 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();984 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })985 .mock.calls986 .map(([bytes]) => {987 try {988 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;989 } catch {990 return undefined;991 }992 });993 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");994 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");995 });996});997998function decodeInvitation(encoded: string): { vault?: string } | null {999 1000 1001 const at = encoded.indexOf(":");1002 if (at < 0) return null;1003 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1004 try {1005 const json = Buffer.from(b64, "base64").toString("utf8");1006 return JSON.parse(json) as { vault?: string };1007 } catch {1008 return null;1009 }1010}