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("sends no snapshot frames and completes via sync fallback when the vault exceeds the inline budget", async () => {369 370 371 372 const socket = socketWithFrames([373 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },374 {375 type: "ADOPT_REQUEST",376 device_id: "request-only-label",377 device_name: "June's phone",378 device_type: "android",379 snapshot_capable: true,380 },381 { type: "ADOPT_CONFIRM", confirmed: true },382 {383 type: "ADOPT_IDENTITY",384 device_id: "android-receiver",385 device_name: "June's phone",386 device_type: "android",387 long_term_public_key_hex: "d".repeat(64),388 },389 { type: "ADOPT_APPLIED" },390 391 392 393 { type: "ADOPT_SNAPSHOT_APPLIED", entries_received: 0, tombstones_received: 0 },394 ]);395 mocks.openSocket.mockResolvedValue(socket);396 397 const envelope = "c".repeat(690_000);398 const entries: Record<string, string> = {};399 for (let i = 0; i < 16; i++) entries[`big-${i}`] = envelope;400 mocks.createSyncAdapter.mockReturnValue({401 buildEntryManifest: vi.fn(async () => Object.fromEntries(402 Object.keys(entries).map((id) => [id, 1]),403 )),404 getEncryptedEntries: vi.fn(async () => entries),405 buildTombstoneManifest: vi.fn(async () => ({})),406 applyEntries: vi.fn(async () => undefined),407 applyTombstones: vi.fn(async () => undefined),408 buildOutgoingDeviceManifest: vi.fn(async () => null),409 applyIncomingDeviceManifest: vi.fn(async () => undefined),410 });411412 await startRelayAdoptionSource();413 await waitForPush("ADOPTION_SOURCE_RESULT");414415 expect(mocks.pushes).toContainEqual({416 type: "ADOPTION_SOURCE_RESULT",417 payload: expect.objectContaining({ status: "complete" }),418 });419 const sentTypes = socket.send.mock.calls.map(([bytes]) =>420 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,421 );422 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");423 424 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });425 });426427 it("fails closed when the popup rejects the SAS", async () => {428 const socket = socketWithFrames([429 {430 type: "PEACH_HELLO",431 token: "AAECAwQFBgcICQoLDA0ODw",432 },433 {434 type: "ADOPT_REQUEST",435 device_id: "request-only-label",436 device_name: "New phone",437 device_type: "android",438 },439 { type: "ADOPT_CONFIRM", confirmed: false },440 ]);441 mocks.openSocket.mockResolvedValue(socket);442443 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");444 let pendingId: string | undefined;445 Object.defineProperty(globalThis, "chrome", {446 configurable: true,447 value: {448 runtime: {449 sendMessage(message: { type: string; payload: unknown }) {450 mocks.pushes.push(message);451 if (message.type === "ADOPTION_SOURCE_SAS") {452 const p = message.payload as { id?: string };453 if (typeof p?.id === "string") pendingId = p.id;454 }455 return Promise.resolve();456 },457 },458 },459 });460461 await startRelayAdoptionSource();462 await waitForPush("ADOPTION_SOURCE_SAS");463 expect(typeof pendingId).toBe("string");464 resolveSourceSasConfirmation(pendingId!, false);465 mocks.sasDecisions.push({ id: pendingId!, ok: false });466467 await waitForPush("ADOPTION_SOURCE_RESULT");468469 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();470 expect(mocks.upsert).not.toHaveBeenCalled();471 expect(mocks.pushes).toContainEqual({472 type: "ADOPTION_SOURCE_RESULT",473 payload: { status: "error", error: "adoption_rejected" },474 });475 });476477 it("rejects before reading the root key when neither peer has Pro", async () => {478 const socket = socketWithFrames([479 {480 type: "PEACH_HELLO",481 token: "AAECAwQFBgcICQoLDA0ODw",482 },483 {484 type: "ADOPT_REQUEST",485 device_id: "free-receiver",486 device_name: "Free phone",487 device_type: "android",488 },489 { type: "ADOPT_CONFIRM", confirmed: true },490 ]);491 mocks.isProUser.mockResolvedValue(false);492 mocks.getLicenseKey.mockResolvedValue(undefined);493 mocks.openSocket.mockResolvedValue(socket);494495 await startRelayAdoptionSource();496 await waitForPush("ADOPTION_SOURCE_RESULT");497498 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();499 expect(mocks.upsert).not.toHaveBeenCalled();500 expect(mocks.pushes).toContainEqual({501 type: "ADOPTION_SOURCE_RESULT",502 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },503 });504 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {505 try {506 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];507 } catch {508 return [];509 }510 });511 expect(sentFrames).toContainEqual({512 type: "ADOPT_REJECT",513 reason: "ADOPT_PRO_REQUIRED",514 });515 });516517 it("fails closed when the user never answers the SAS", async () => {518 519 520 521 522 const socket = socketWithFrames([]);523 mocks.openSocket.mockResolvedValue(socket);524525 Object.defineProperty(globalThis, "chrome", {526 configurable: true,527 value: {528 runtime: {529 sendMessage(message: { type: string; payload: unknown }) {530 mocks.pushes.push(message);531 return Promise.resolve();532 },533 },534 },535 });536537 await startRelayAdoptionSource();538 cancelRelayAdoptionSource();539540 expect(socket.close).toHaveBeenCalled();541 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();542 expect(mocks.upsert).not.toHaveBeenCalled();543 });544545 it("cancelling the source screen closes the room without reading the root", async () => {546 const socket = socketWithFrames([]);547 mocks.openSocket.mockResolvedValue(socket);548549 await startRelayAdoptionSource();550 cancelRelayAdoptionSource();551552 expect(socket.close).toHaveBeenCalled();553 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();554 expect(mocks.upsert).not.toHaveBeenCalled();555 });556});557558describe("relay adoption receiver background", () => {559 beforeEach(async () => {560 561 562 563 564 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");565 cancelRelayAdoptionSource();566 mocks.pushes.length = 0;567 mocks.openSocket.mockReset();568 mocks.getDeviceName.mockReset();569 mocks.getDeviceName.mockResolvedValue("Receiver Browser");570 mocks.createVault.mockReset();571 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });572 mocks.activateLicense.mockReset();573 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });574 mocks.isProUser.mockReset();575 mocks.isProUser.mockResolvedValue(true);576 mocks.getLicenseKey.mockReset();577 mocks.getLicenseKey.mockResolvedValue(undefined);578 mocks.localIdentity.mockReset();579 mocks.localIdentity.mockResolvedValue({580 deviceId: "receiver-id",581 deviceName: "Receiver Browser",582 deviceType: "extension",583 longTermPublicKeyHex: "11".repeat(32),584 signingPublicKeyHex: "22".repeat(32),585 });586 mocks.rollbackVault.mockReset();587 mocks.rollbackVault.mockResolvedValue(undefined);588 mocks.storageSet.mockReset();589 mocks.storageSet.mockResolvedValue(undefined);590 mocks.upsert.mockReset();591 mocks.upsert.mockResolvedValue(undefined);592 mocks.setPairSecret.mockReset();593 mocks.setPairSecret.mockResolvedValue(undefined);594 mocks.triggerSync.mockReset();595 mocks.triggerSync.mockResolvedValue(undefined);596 mocks.openSocket.mockResolvedValue(socketWithFrames([]));597 Object.defineProperty(globalThis, "chrome", {598 configurable: true,599 value: {600 runtime: {601 sendMessage(message: { type: string; payload?: unknown }) {602 mocks.pushes.push({ type: message.type, payload: message.payload });603 return Promise.resolve({ ok: true });604 },605 onMessage: {606 addListener() { },607 removeListener() { },608 },609 },610 },611 });612 });613614 it("opens a relay socket and returns an empty invitation", async () => {615 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");616 const started = await startRelayAdoptionReceiver("receiver-password");617 expect(started.invitation).toMatch(/^peach-pair-v2:/);618 619 const decoded = decodeInvitation(started.invitation);620 expect(decoded?.vault).toBe("empty");621 expect(mocks.openSocket).toHaveBeenCalled();622 });623624 it("rejects an empty password", async () => {625 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");626 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");627 });628629 it("surfaces the Pro-required rejection without creating a vault", async () => {630 mocks.openSocket.mockResolvedValue(socketWithFrames([631 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },632 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },633 ]));634635 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");636 await startRelayAdoptionReceiver("receiver-password");637 await waitForPush("ADOPTION_SOURCE_RESULT");638639 expect(mocks.createVault).not.toHaveBeenCalled();640 expect(mocks.rollbackVault).not.toHaveBeenCalled();641 expect(mocks.pushes).toContainEqual({642 type: "ADOPTION_SOURCE_RESULT",643 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },644 });645 });646647 it("the receiver wires requestAdoption, not grantAdoption", async () => {648 649 650 651 652 653 const rootKeyHex = "f0".repeat(32);654 const vaultId = "b".repeat(64);655 const socket = socketWithFrames([656 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },657 {658 type: "ADOPT_GRANT",659 root_key_hex: rootKeyHex,660 vault_id: vaultId,661 license: "PPK-test",662 source: {663 device_id: "phone-source-id",664 device_name: "June's phone",665 device_type: "android",666 long_term_public_key_hex: "99".repeat(32),667 },668 },669 { type: "ADOPT_COMPLETE" },670 ], { closeOnEmpty: true });671 mocks.openSocket.mockResolvedValue(socket);672 mocks.createVault.mockResolvedValue({ vaultId });673674 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");675 Object.defineProperty(globalThis, "chrome", {676 configurable: true,677 value: {678 runtime: {679 sendMessage(message: { type: string; payload?: unknown }) {680 mocks.pushes.push({ type: message.type, payload: message.payload });681 return Promise.resolve({ ok: true });682 },683 onMessage: { addListener: () => undefined, removeListener: () => undefined },684 },685 },686 });687 await startRelayAdoptionReceiver("receiver-password");688 await waitForPush("ADOPTION_SOURCE_RESULT");689690 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })691 .mock.calls692 .map(([bytes]) => {693 try {694 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;695 } catch {696 return undefined;697 }698 });699 700 701 expect(sentTypes).toContain("ADOPT_REQUEST");702 expect(sentTypes).not.toContain("ADOPT_GRANT");703 });704705 it("completes the handshake end-to-end: vault created → paired", async () => {706 707 708 709 710 711 const rootKeyHex = "f0".repeat(32);712 713 714 const vaultId = "b".repeat(64);715 const frames = [716 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },717 718 719 {720 type: "ADOPT_GRANT",721 root_key_hex: rootKeyHex,722 vault_id: vaultId,723 license: "PPK-test",724 source: {725 device_id: "phone-source-id",726 device_name: "June's phone",727 device_type: "android",728 long_term_public_key_hex: "99".repeat(32),729 signing_public_key_hex: "aa".repeat(32),730 relay_url: "wss://relay.peachpasswords.com",731 },732 },733 { type: "ADOPT_COMPLETE" },734 ];735 mocks.createVault.mockResolvedValue({ vaultId });736 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));737738 739 740 Object.defineProperty(globalThis, "chrome", {741 configurable: true,742 value: {743 runtime: {744 sendMessage(message: { type: string; payload?: unknown }) {745 mocks.pushes.push({ type: message.type, payload: message.payload });746 return Promise.resolve({ ok: true });747 },748 onMessage: {749 addListener: () => undefined,750 removeListener: () => undefined,751 },752 },753 },754 });755756 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");757 await startRelayAdoptionReceiver("receiver-password");758759 await waitForPush("ADOPTION_SOURCE_RESULT");760761 762 763 expect(mocks.pushes).toContainEqual({764 type: "ADOPTION_SOURCE_SAS",765 payload: expect.objectContaining({766 sas: expect.stringMatching(/^\d{8}$/),767 displayOnly: true,768 }),769 });770771 772 773 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);774775 776 777 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");778 expect(mocks.isProUser).toHaveBeenCalled();779 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));780 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({781 deviceId: "phone-source-id",782 }));783 const receiverRow = mocks.upsert.mock.calls[0]?.[0] as Record<string, unknown>;784 expect(receiverRow.rendezvousSecretHex).toBeUndefined();785 expect(mocks.setPairSecret).toHaveBeenCalledWith(786 "phone-source-id",787 expect.stringMatching(/^[0-9a-f]{64}$/),788 expect.any(Uint8Array),789 );790 expect(mocks.storageSet).toHaveBeenCalledWith(791 "peach_initial_sync_pending_device_id",792 "phone-source-id",793 );794 expect(mocks.rollbackVault).not.toHaveBeenCalled();795796 797 expect(mocks.pushes).toContainEqual(expect.objectContaining({798 type: "ADOPTION_SOURCE_RESULT",799 payload: expect.objectContaining({ status: "complete" }),800 }));801 });802803 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {804 const rootKeyHex = "f0".repeat(32);805 const vaultId = "b".repeat(64);806 mocks.createVault.mockResolvedValue({ vaultId });807 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });808 mocks.isProUser.mockResolvedValue(false);809 mocks.openSocket.mockResolvedValue(socketWithFrames([810 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },811 {812 type: "ADOPT_GRANT",813 root_key_hex: rootKeyHex,814 vault_id: vaultId,815 license: "PPK-invalid",816 source: {817 device_id: "phone-source-id",818 device_name: "June's phone",819 device_type: "android",820 long_term_public_key_hex: "99".repeat(32),821 },822 },823 ]));824825 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");826 await startRelayAdoptionReceiver("receiver-password");827 await waitForPush("ADOPTION_SOURCE_RESULT");828829 expect(mocks.rollbackVault).toHaveBeenCalledOnce();830 expect(mocks.localIdentity).not.toHaveBeenCalled();831 expect(mocks.upsert).not.toHaveBeenCalled();832 expect(mocks.pushes).toContainEqual(expect.objectContaining({833 type: "ADOPTION_SOURCE_RESULT",834 payload: expect.objectContaining({835 status: "error",836 error: "license_transfer_failed",837 }),838 }));839 });840841 it("rolls back a newly created vault when a later adoption step fails", async () => {842 const rootKeyHex = "f0".repeat(32);843 const vaultId = "b".repeat(64);844 mocks.createVault.mockResolvedValue({ vaultId });845 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));846 mocks.openSocket.mockResolvedValue(socketWithFrames([847 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },848 {849 type: "ADOPT_GRANT",850 root_key_hex: rootKeyHex,851 vault_id: vaultId,852 license: "PPK-test",853 source: {854 device_id: "phone-source-id",855 device_name: "June's phone",856 device_type: "android",857 long_term_public_key_hex: "99".repeat(32),858 },859 },860 ]));861862 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");863 await startRelayAdoptionReceiver("receiver-password");864 await waitForPush("ADOPTION_SOURCE_RESULT");865866 expect(mocks.rollbackVault).toHaveBeenCalledOnce();867 expect(mocks.upsert).not.toHaveBeenCalled();868 expect(mocks.pushes).toContainEqual(expect.objectContaining({869 type: "ADOPTION_SOURCE_RESULT",870 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),871 }));872 });873874 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {875 876 877 878 const rootKeyHex = "f0".repeat(32);879 const vaultId = "b".repeat(64);880 mocks.createVault.mockResolvedValue({ vaultId });881 mocks.openSocket.mockResolvedValue(socketWithFrames([882 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },883 {884 type: "ADOPT_GRANT",885 root_key_hex: rootKeyHex,886 vault_id: vaultId,887 license: "PPK-test",888 source: {889 device_id: "old-phone-id",890 device_name: "Old phone",891 device_type: "android",892 long_term_public_key_hex: "99".repeat(32),893 },894 895 },896 { type: "ADOPT_COMPLETE" },897 ], { closeOnEmpty: true }));898899 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");900 await startRelayAdoptionReceiver("receiver-password");901 await waitForPush("ADOPTION_SOURCE_RESULT");902903 904 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });905 906 expect(mocks.pushes).toContainEqual(expect.objectContaining({907 type: "ADOPTION_SOURCE_RESULT",908 payload: expect.objectContaining({ status: "complete" }),909 }));910 911 expect(mocks.storageSet).toHaveBeenCalledWith(912 "peach_initial_sync_pending_device_id",913 "old-phone-id",914 );915 });916917 it("applies the source's device manifest after the snapshot, then acks", async () => {918 919 920 921 922 923 const rootKeyHex = "f0".repeat(32);924 const vaultId = "b".repeat(64);925 const longTermPubkeyHex = "99".repeat(32);926 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");927 const { deriveSyncSigningKeypair, pubkeyToHex } = await import(928 "../shared/sync-identity"929 );930 const issuerKp = await deriveSyncSigningKeypair(931 new Uint8Array(32).fill(0x55),932 "phone-source-id",933 );934 const manifest = await buildSignedDeviceManifest(935 "phone-source-id",936 [937 {938 deviceId: "peer-b",939 deviceName: "Pool B",940 deviceType: "extension",941 longTermPublicKeyHex: "11".repeat(32),942 signingPublicKeyHex: "22".repeat(32),943 wsPort: 49152,944 relayUrl: "wss://relay.peachpasswords.com",945 pairedAt: 1,946 },947 ],948 issuerKp.privateKey,949 );950 const socket = socketWithFrames([951 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },952 {953 type: "ADOPT_GRANT",954 root_key_hex: rootKeyHex,955 vault_id: vaultId,956 license: "PPK-test",957 snapshot_capable: true,958 source: {959 device_id: "phone-source-id",960 device_name: "June's phone",961 device_type: "android",962 long_term_public_key_hex: longTermPubkeyHex,963 signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),964 relay_url: "wss://relay.peachpasswords.com",965 },966 },967 968 969 { type: "ADOPT_COMPLETE" },970 971 972 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },973 { type: "ADOPT_DEVICE_MANIFEST", manifest },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).toHaveBeenCalledTimes(1);984 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];985 expect(forwarded).toEqual(manifest);986 expect(pubkey).toBeInstanceOf(Uint8Array);987 expect(pubkey.length).toBe(32);988 989 990 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })991 .mock.calls992 .map(([bytes]) => {993 try {994 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;995 } catch {996 return undefined;997 }998 });999 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1000 1001 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");1002 });10031004 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {1005 1006 1007 1008 1009 const rootKeyHex = "f0".repeat(32);1010 const vaultId = "b".repeat(64);1011 const longTermPubkeyHex = "99".repeat(32);1012 const socket = socketWithFrames([1013 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },1014 {1015 type: "ADOPT_GRANT",1016 root_key_hex: rootKeyHex,1017 vault_id: vaultId,1018 license: "PPK-test",1019 snapshot_capable: true,1020 source: {1021 device_id: "phone-source-id",1022 device_name: "June's phone",1023 device_type: "android",1024 long_term_public_key_hex: longTermPubkeyHex,1025 signing_public_key_hex: "aa".repeat(32),1026 relay_url: "wss://relay.peachpasswords.com",1027 },1028 },1029 { type: "ADOPT_COMPLETE" },1030 1031 1032 { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} },1033 ], { closeOnEmpty: true });1034 mocks.createVault.mockResolvedValue({ vaultId });1035 mocks.openSocket.mockResolvedValue(socket);1036 mocks.__applyIncomingDeviceManifest.mockClear();10371038 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");1039 await startRelayAdoptionReceiver("receiver-password");1040 await waitForPush("ADOPTION_SOURCE_RESULT");10411042 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();1043 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })1044 .mock.calls1045 .map(([bytes]) => {1046 try {1047 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;1048 } catch {1049 return undefined;1050 }1051 });1052 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");1053 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");1054 });1055});10561057function decodeInvitation(encoded: string): { vault?: string } | null {1058 1059 1060 const at = encoded.indexOf(":");1061 if (at < 0) return null;1062 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");1063 try {1064 const json = Buffer.from(b64, "base64").toString("utf8");1065 return JSON.parse(json) as { vault?: string };1066 } catch {1067 return null;1068 }1069}