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 it("applies the source's device manifest after the snapshot, then acks", async () => {43 44 45 46 47 48 const rootKeyHex = "f0".repeat(32);49 const vaultId = "b".repeat(64);50 const longTermPubkeyHex = "99".repeat(32);51 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");52 const { deriveSyncSigningKeypair, pubkeyToHex } = await import(53 "../shared/sync-identity"54 );55 const issuerKp = await deriveSyncSigningKeypair(56 new Uint8Array(32).fill(0x55),57 "phone-source-id",58 );59 const manifest = await buildSignedDeviceManifest(60 "phone-source-id",61 [62 {63 deviceId: "peer-b",64 deviceName: "Pool B",65 deviceType: "extension",66 longTermPublicKeyHex: "11".repeat(32),67 signingPublicKeyHex: "22".repeat(32),68 wsPort: 49152,69 relayUrl: "wss://relay.peachpasswords.com",70 pairedAt: 1,71 },72 ],73 issuerKp.privateKey,74 );75 const socket = socketWithFrames([76 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },77 {78 type: "ADOPT_GRANT",79 root_key_hex: rootKeyHex,80 vault_id: vaultId,81 license: "PPK-test",82 snapshot_capable: true,83 source: {84 device_id: "phone-source-id",85 device_name: "June's phone",86 device_type: "android",87 long_term_public_key_hex: longTermPubkeyHex,88 signing_public_key_hex: pubkeyToHex(issuerKp.publicKeyRaw),89 relay_url: "wss://relay.peachpasswords.com",90 },91 },92 93 94 { type: "ADOPT_COMPLETE" },95 { type: "ADOPT_DEVICE_MANIFEST", manifest },96 ], { closeOnEmpty: true });97 mocks.createVault.mockResolvedValue({ vaultId });98 mocks.openSocket.mockResolvedValue(socket);99 mocks.__applyIncomingDeviceManifest.mockClear();100101 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");102 await startRelayAdoptionReceiver("receiver-password");103 await waitForPush("ADOPTION_SOURCE_RESULT");104105 expect(mocks.__applyIncomingDeviceManifest).toHaveBeenCalledTimes(1);106 const [forwarded, pubkey] = mocks.__applyIncomingDeviceManifest.mock.calls[0];107 expect(forwarded).toEqual(manifest);108 expect(pubkey).toBeInstanceOf(Uint8Array);109 expect(pubkey.length).toBe(32);110 111 112 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })113 .mock.calls114 .map(([bytes]) => {115 try {116 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;117 } catch {118 return undefined;119 }120 });121 expect(sentTypes).toContain("ADOPT_DEVICE_MANIFEST_APPLIED");122 123 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST");124 });125126 it("does not call applyIncomingDeviceManifest when the source omits the manifest frame", async () => {127 128 129 130 131 const rootKeyHex = "f0".repeat(32);132 const vaultId = "b".repeat(64);133 const longTermPubkeyHex = "99".repeat(32);134 const socket = socketWithFrames([135 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },136 {137 type: "ADOPT_GRANT",138 root_key_hex: rootKeyHex,139 vault_id: vaultId,140 license: "PPK-test",141 snapshot_capable: true,142 source: {143 device_id: "phone-source-id",144 device_name: "June's phone",145 device_type: "android",146 long_term_public_key_hex: longTermPubkeyHex,147 signing_public_key_hex: "aa".repeat(32),148 relay_url: "wss://relay.peachpasswords.com",149 },150 },151 { type: "ADOPT_COMPLETE" },152 153 ], { closeOnEmpty: true });154 mocks.createVault.mockResolvedValue({ vaultId });155 mocks.openSocket.mockResolvedValue(socket);156 mocks.__applyIncomingDeviceManifest.mockClear();157158 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");159 await startRelayAdoptionReceiver("receiver-password");160 await waitForPush("ADOPTION_SOURCE_RESULT");161162 expect(mocks.__applyIncomingDeviceManifest).not.toHaveBeenCalled();163 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })164 .mock.calls165 .map(([bytes]) => {166 try {167 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;168 } catch {169 return undefined;170 }171 });172 expect(sentTypes).toContain("ADOPT_SNAPSHOT_APPLIED");173 expect(sentTypes).not.toContain("ADOPT_DEVICE_MANIFEST_APPLIED");174 });175});176177178vi.mock("./device-identity", () => ({179 getLocalPairingIdentity: async () => ({180 identity: {181 deviceId: "test-device",182 deviceName: "Test",183 deviceType: "extension",184 longTermPublicKeyHex: "aa".repeat(32),185 vaultId: "bb".repeat(32),186 },187 privateKey: new Uint8Array(32).fill(7),188 publicKey: new Uint8Array(32).fill(8),189 }),190 getDeviceName: mocks.getDeviceName,191 buildLocalIdentity: mocks.localIdentity,192}));193194vi.mock("./sync-settings", async (importOriginal) => ({195 ...(await importOriginal<typeof import("./sync-settings")>()),196 getSyncSettings: async () => ({197 mode: "public" as const,198 relayUrl: "wss://relay.peachpasswords.com",199 }),200}));201202vi.mock("./license", () => ({203 isProUser: mocks.isProUser,204 getLicenseKey: mocks.getLicenseKey,205 activateLicense: mocks.activateLicense,206}));207208vi.mock("./vault", () => ({209 isUnlocked: vi.fn(() => true),210 getRootKeyHex: mocks.getRootKeyHex,211 createVaultFromRootKey: mocks.createVault,212 rollbackAdoptedVault: mocks.rollbackVault,213 exportDuressSlotForAdoption: vi.fn(async () => null),214 installAdoptedDuressSlot: vi.fn(async () => undefined),215}));216217vi.mock("../shared/sync-identity", () => ({218 computeVaultId: vi.fn(async () => "b".repeat(64)),219}));220221vi.mock("../shared/storage", () => ({222 storageSet: mocks.storageSet,223}));224225vi.mock("./paired-devices", () => ({226 upsertPairedDevice: mocks.upsert,227}));228229vi.mock("./sync-trigger", () => ({230 triggerSyncIfReachable: mocks.triggerSync,231}));232233vi.mock("./sync-vault-adapter", () => ({234 createSyncVaultAdapter: mocks.createSyncAdapter,235}));236237import {238 cancelRelayAdoptionSource,239 startRelayAdoptionSource,240} from "./relay-adoption-bg";241242const encode = (value: unknown) =>243 new TextEncoder().encode(JSON.stringify(value));244245function socketWithFrames(246 frames: unknown[],247 options: { closeOnEmpty?: boolean } = {},248) {249 const queue = frames.map(encode);250 return {251 send: vi.fn(),252 receive: vi.fn(async () => {253 const next = queue.shift();254 if (!next) {255 256 257 258 if (options.closeOnEmpty) throw new Error("socket_closed");259 return new Promise<Uint8Array>(() => undefined);260 }261 return next;262 }),263 close: vi.fn(),264 closedBeforeFrame: vi.fn(() => false),265 };266}267268async function waitForPush(type: string): Promise<void> {269 await vi.waitFor(() => {270 expect(mocks.pushes.some((message) => message.type === type)).toBe(true);271 });272}273274beforeEach(() => {275 mocks.pushes.length = 0;276 mocks.sasDecisions.length = 0;277 mocks.openSocket.mockReset();278 mocks.getLocalIdentity.mockReset();279 mocks.getRootKeyHex.mockReset();280 mocks.upsert.mockReset();281 mocks.triggerSync.mockReset();282 mocks.triggerSync.mockResolvedValue(undefined);283 mocks.isProUser.mockReset();284 mocks.isProUser.mockResolvedValue(true);285 mocks.getLicenseKey.mockReset();286 mocks.getLicenseKey.mockResolvedValue("PPK-source");287 mocks.getLocalIdentity.mockResolvedValue({288 identity: {289 deviceId: "browser-source",290 deviceName: "June's Browser",291 deviceType: "extension",292 longTermPublicKeyHex: "a".repeat(64),293 vaultId: "b".repeat(64),294 },295 privateKey: new Uint8Array(32).fill(1),296 publicKey: new Uint8Array(32).fill(2),297 });298 mocks.getRootKeyHex.mockReturnValue("c".repeat(64));299 mocks.upsert.mockResolvedValue(undefined);300 const applyIncomingDeviceManifest = vi.fn(async () => undefined);301 mocks.__applyIncomingDeviceManifest = applyIncomingDeviceManifest;302 mocks.createSyncAdapter.mockReturnValue({303 buildEntryManifest: vi.fn(async () => ({})),304 getEncryptedEntries: vi.fn(async () => ({})),305 buildTombstoneManifest: vi.fn(async () => ({})),306 applyEntries: vi.fn(async () => undefined),307 applyTombstones: vi.fn(async () => undefined),308 buildOutgoingDeviceManifest: vi.fn(async () => null),309 applyIncomingDeviceManifest,310 });311 Object.defineProperty(globalThis, "chrome", {312 configurable: true,313 value: {314 runtime: {315 async sendMessage(message: { type: string; payload: unknown }) {316 mocks.pushes.push(message);317 318 319 320 321 if (message.type === "ADOPTION_SOURCE_SAS") {322 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");323 const p = message.payload as { id?: string };324 if (typeof p?.id === "string") {325 mocks.sasDecisions.push({ id: p.id, ok: true });326 resolveSourceSasConfirmation(p.id, true);327 }328 }329 return Promise.resolve();330 },331 },332 },333 });334});335336describe("relay adoption source background boundary", () => {337 it("shows the SAS and waits for the popup's single confirmation", async () => {338 const socket = socketWithFrames([339 {340 type: "PEACH_HELLO",341 token: "AAECAwQFBgcICQoLDA0ODw",342 },343 {344 type: "ADOPT_REQUEST",345 device_id: "request-only-label",346 device_name: "June's phone",347 device_type: "android",348 },349 { type: "ADOPT_CONFIRM", confirmed: true },350 {351 type: "ADOPT_IDENTITY",352 device_id: "android-receiver",353 device_name: "June's phone",354 device_type: "android",355 long_term_public_key_hex: "d".repeat(64),356 },357 { type: "ADOPT_APPLIED" },358 ]);359 mocks.openSocket.mockResolvedValue(socket);360361 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");362 const sasIds: string[] = [];363 Object.defineProperty(globalThis, "chrome", {364 configurable: true,365 value: {366 runtime: {367 async sendMessage(message: { type: string; payload: unknown }) {368 mocks.pushes.push(message);369 if (message.type === "ADOPTION_SOURCE_SAS") {370 const p = message.payload as { id?: string };371 if (typeof p?.id === "string") {372 373 374 sasIds.push(p.id);375 }376 return Promise.resolve();377 }378 return Promise.resolve();379 },380 },381 },382 });383 void resolveSourceSasConfirmation; 384385 const started = await startRelayAdoptionSource();386 expect(started.invitation).toMatch(/^peach-pair-v1:/);387 await waitForPush("ADOPTION_SOURCE_SAS");388 389 390 await vi.waitFor(() => {391 expect(mocks.sasDecisions.length).toBe(0);392 });393 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();394 expect(mocks.upsert).not.toHaveBeenCalled();395396 397 const id = sasIds[0];398 expect(typeof id).toBe("string");399 resolveSourceSasConfirmation(id, true);400 mocks.sasDecisions.push({ id, ok: true });401402 await waitForPush("ADOPTION_SOURCE_RESULT");403404 expect(mocks.getRootKeyHex).toHaveBeenCalled();405 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({406 deviceId: "android-receiver",407 relayUrl: "wss://relay.peachpasswords.com",408 }));409 expect(mocks.pushes).toContainEqual(expect.objectContaining({410 type: "ADOPTION_SOURCE_RESULT",411 payload: expect.objectContaining({ status: "complete" }),412 }));413 expect(mocks.triggerSync).toHaveBeenCalledWith({414 reason: "manual",415 force: true,416 });417 const sentTypes = socket.send.mock.calls418 .map(([bytes]) => {419 try {420 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;421 } catch {422 return undefined;423 }424 });425 expect(sentTypes).toContain("ADOPT_COMPLETE");426 });427428 it("fails adoption without sending snapshot frames when duress is oversized", async () => {429 const socket = socketWithFrames([430 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },431 {432 type: "ADOPT_REQUEST",433 device_id: "request-only-label",434 device_name: "June's phone",435 device_type: "android",436 snapshot_capable: true,437 },438 { type: "ADOPT_CONFIRM", confirmed: true },439 {440 type: "ADOPT_IDENTITY",441 device_id: "android-receiver",442 device_name: "June's phone",443 device_type: "android",444 long_term_public_key_hex: "d".repeat(64),445 },446 { type: "ADOPT_APPLIED" },447 ]);448 mocks.openSocket.mockResolvedValue(socket);449 const { exportDuressSlotForAdoption } = await import("./vault");450 vi.mocked(exportDuressSlotForAdoption).mockResolvedValueOnce(451 "x".repeat(768 * 1024),452 );453454 await startRelayAdoptionSource();455 await waitForPush("ADOPTION_SOURCE_RESULT");456457 expect(mocks.pushes).toContainEqual({458 type: "ADOPTION_SOURCE_RESULT",459 payload: {460 status: "error",461 error: "ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE",462 },463 });464 expect(mocks.pushes).not.toContainEqual(expect.objectContaining({465 type: "ADOPTION_SOURCE_RESULT",466 payload: expect.objectContaining({ status: "complete" }),467 }));468 const sentTypes = socket.send.mock.calls.map(([bytes]) =>469 JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type,470 );471 expect(sentTypes).not.toContain("ADOPT_SNAPSHOT");472 expect(mocks.triggerSync).not.toHaveBeenCalled();473 });474475 it("fails closed when the popup rejects the SAS", async () => {476 const socket = socketWithFrames([477 {478 type: "PEACH_HELLO",479 token: "AAECAwQFBgcICQoLDA0ODw",480 },481 {482 type: "ADOPT_REQUEST",483 device_id: "request-only-label",484 device_name: "New phone",485 device_type: "android",486 },487 { type: "ADOPT_CONFIRM", confirmed: false },488 ]);489 mocks.openSocket.mockResolvedValue(socket);490491 const { resolveSourceSasConfirmation } = await import("./relay-adoption-bg");492 let pendingId: string | undefined;493 Object.defineProperty(globalThis, "chrome", {494 configurable: true,495 value: {496 runtime: {497 sendMessage(message: { type: string; payload: unknown }) {498 mocks.pushes.push(message);499 if (message.type === "ADOPTION_SOURCE_SAS") {500 const p = message.payload as { id?: string };501 if (typeof p?.id === "string") pendingId = p.id;502 }503 return Promise.resolve();504 },505 },506 },507 });508509 await startRelayAdoptionSource();510 await waitForPush("ADOPTION_SOURCE_SAS");511 expect(typeof pendingId).toBe("string");512 resolveSourceSasConfirmation(pendingId!, false);513 mocks.sasDecisions.push({ id: pendingId!, ok: false });514515 await waitForPush("ADOPTION_SOURCE_RESULT");516517 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();518 expect(mocks.upsert).not.toHaveBeenCalled();519 expect(mocks.pushes).toContainEqual({520 type: "ADOPTION_SOURCE_RESULT",521 payload: { status: "error", error: "adoption_rejected" },522 });523 });524525 it("rejects before reading the root key when neither peer has Pro", async () => {526 const socket = socketWithFrames([527 {528 type: "PEACH_HELLO",529 token: "AAECAwQFBgcICQoLDA0ODw",530 },531 {532 type: "ADOPT_REQUEST",533 device_id: "free-receiver",534 device_name: "Free phone",535 device_type: "android",536 },537 { type: "ADOPT_CONFIRM", confirmed: true },538 ]);539 mocks.isProUser.mockResolvedValue(false);540 mocks.getLicenseKey.mockResolvedValue(undefined);541 mocks.openSocket.mockResolvedValue(socket);542543 await startRelayAdoptionSource();544 await waitForPush("ADOPTION_SOURCE_RESULT");545546 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();547 expect(mocks.upsert).not.toHaveBeenCalled();548 expect(mocks.pushes).toContainEqual({549 type: "ADOPTION_SOURCE_RESULT",550 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },551 });552 const sentFrames = socket.send.mock.calls.flatMap(([bytes]) => {553 try {554 return [JSON.parse(new TextDecoder().decode(bytes as Uint8Array))];555 } catch {556 return [];557 }558 });559 expect(sentFrames).toContainEqual({560 type: "ADOPT_REJECT",561 reason: "ADOPT_PRO_REQUIRED",562 });563 });564565 it("fails closed when the user never answers the SAS", async () => {566 567 568 569 570 const socket = socketWithFrames([]);571 mocks.openSocket.mockResolvedValue(socket);572573 Object.defineProperty(globalThis, "chrome", {574 configurable: true,575 value: {576 runtime: {577 sendMessage(message: { type: string; payload: unknown }) {578 mocks.pushes.push(message);579 return Promise.resolve();580 },581 },582 },583 });584585 await startRelayAdoptionSource();586 cancelRelayAdoptionSource();587588 expect(socket.close).toHaveBeenCalled();589 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();590 expect(mocks.upsert).not.toHaveBeenCalled();591 });592593 it("cancelling the source screen closes the room without reading the root", async () => {594 const socket = socketWithFrames([]);595 mocks.openSocket.mockResolvedValue(socket);596597 await startRelayAdoptionSource();598 cancelRelayAdoptionSource();599600 expect(socket.close).toHaveBeenCalled();601 expect(mocks.getRootKeyHex).not.toHaveBeenCalled();602 expect(mocks.upsert).not.toHaveBeenCalled();603 });604});605606describe("relay adoption receiver background", () => {607 beforeEach(async () => {608 609 610 611 612 const { cancelRelayAdoptionSource } = await import("./relay-adoption-bg");613 cancelRelayAdoptionSource();614 mocks.pushes.length = 0;615 mocks.openSocket.mockReset();616 mocks.getDeviceName.mockReset();617 mocks.getDeviceName.mockResolvedValue("Receiver Browser");618 mocks.createVault.mockReset();619 mocks.createVault.mockResolvedValue({ vaultId: "ee".repeat(32) });620 mocks.activateLicense.mockReset();621 mocks.activateLicense.mockResolvedValue({ valid: true, plan: "pro" });622 mocks.isProUser.mockReset();623 mocks.isProUser.mockResolvedValue(true);624 mocks.getLicenseKey.mockReset();625 mocks.getLicenseKey.mockResolvedValue(undefined);626 mocks.localIdentity.mockReset();627 mocks.localIdentity.mockResolvedValue({628 deviceId: "receiver-id",629 deviceName: "Receiver Browser",630 deviceType: "extension",631 longTermPublicKeyHex: "11".repeat(32),632 signingPublicKeyHex: "22".repeat(32),633 });634 mocks.rollbackVault.mockReset();635 mocks.rollbackVault.mockResolvedValue(undefined);636 mocks.storageSet.mockReset();637 mocks.storageSet.mockResolvedValue(undefined);638 mocks.upsert.mockReset();639 mocks.upsert.mockResolvedValue(undefined);640 mocks.triggerSync.mockReset();641 mocks.triggerSync.mockResolvedValue(undefined);642 mocks.openSocket.mockResolvedValue(socketWithFrames([]));643 Object.defineProperty(globalThis, "chrome", {644 configurable: true,645 value: {646 runtime: {647 sendMessage(message: { type: string; payload?: unknown }) {648 mocks.pushes.push({ type: message.type, payload: message.payload });649 return Promise.resolve({ ok: true });650 },651 onMessage: {652 addListener() { },653 removeListener() { },654 },655 },656 },657 });658 });659660 it("opens a relay socket and returns an empty invitation", async () => {661 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");662 const started = await startRelayAdoptionReceiver("receiver-password");663 expect(started.invitation).toMatch(/^peach-pair-v1:/);664 665 const decoded = decodeInvitation(started.invitation);666 expect(decoded?.vault).toBe("empty");667 expect(mocks.openSocket).toHaveBeenCalled();668 });669670 it("rejects an empty password", async () => {671 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");672 await expect(startRelayAdoptionReceiver("")).rejects.toThrow("ADOPT_INVALID_PASSWORD");673 });674675 it("surfaces the Pro-required rejection without creating a vault", async () => {676 mocks.openSocket.mockResolvedValue(socketWithFrames([677 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },678 { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" },679 ]));680681 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");682 await startRelayAdoptionReceiver("receiver-password");683 await waitForPush("ADOPTION_SOURCE_RESULT");684685 expect(mocks.createVault).not.toHaveBeenCalled();686 expect(mocks.rollbackVault).not.toHaveBeenCalled();687 expect(mocks.pushes).toContainEqual({688 type: "ADOPTION_SOURCE_RESULT",689 payload: { status: "error", error: "ADOPT_PRO_REQUIRED" },690 });691 });692693 it("the receiver wires requestAdoption, not grantAdoption", async () => {694 695 696 697 698 699 const rootKeyHex = "f0".repeat(32);700 const vaultId = "b".repeat(64);701 const socket = socketWithFrames([702 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },703 {704 type: "ADOPT_GRANT",705 root_key_hex: rootKeyHex,706 vault_id: vaultId,707 license: "PPK-test",708 source: {709 device_id: "phone-source-id",710 device_name: "June's phone",711 device_type: "android",712 long_term_public_key_hex: "99".repeat(32),713 },714 },715 { type: "ADOPT_COMPLETE" },716 ], { closeOnEmpty: true });717 mocks.openSocket.mockResolvedValue(socket);718 mocks.createVault.mockResolvedValue({ vaultId });719720 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");721 Object.defineProperty(globalThis, "chrome", {722 configurable: true,723 value: {724 runtime: {725 sendMessage(message: { type: string; payload?: unknown }) {726 mocks.pushes.push({ type: message.type, payload: message.payload });727 return Promise.resolve({ ok: true });728 },729 onMessage: { addListener: () => undefined, removeListener: () => undefined },730 },731 },732 });733 await startRelayAdoptionReceiver("receiver-password");734 await waitForPush("ADOPTION_SOURCE_RESULT");735736 const sentTypes = (socket.send as unknown as { mock: { calls: unknown[][] } })737 .mock.calls738 .map(([bytes]) => {739 try {740 return JSON.parse(new TextDecoder().decode(bytes as Uint8Array)).type;741 } catch {742 return undefined;743 }744 });745 746 747 expect(sentTypes).toContain("ADOPT_REQUEST");748 expect(sentTypes).not.toContain("ADOPT_GRANT");749 });750751 it("completes the handshake end-to-end: vault created → paired", async () => {752 753 754 755 756 757 const rootKeyHex = "f0".repeat(32);758 759 760 const vaultId = "b".repeat(64);761 const frames = [762 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },763 764 765 {766 type: "ADOPT_GRANT",767 root_key_hex: rootKeyHex,768 vault_id: vaultId,769 license: "PPK-test",770 source: {771 device_id: "phone-source-id",772 device_name: "June's phone",773 device_type: "android",774 long_term_public_key_hex: "99".repeat(32),775 signing_public_key_hex: "aa".repeat(32),776 relay_url: "wss://relay.peachpasswords.com",777 },778 },779 { type: "ADOPT_COMPLETE" },780 ];781 mocks.createVault.mockResolvedValue({ vaultId });782 mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));783784 785 786 Object.defineProperty(globalThis, "chrome", {787 configurable: true,788 value: {789 runtime: {790 sendMessage(message: { type: string; payload?: unknown }) {791 mocks.pushes.push({ type: message.type, payload: message.payload });792 return Promise.resolve({ ok: true });793 },794 onMessage: {795 addListener: () => undefined,796 removeListener: () => undefined,797 },798 },799 },800 });801802 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");803 await startRelayAdoptionReceiver("receiver-password");804805 await waitForPush("ADOPTION_SOURCE_RESULT");806807 808 809 expect(mocks.pushes).toContainEqual({810 type: "ADOPTION_SOURCE_SAS",811 payload: expect.objectContaining({812 sas: expect.stringMatching(/^\d{8}$/),813 displayOnly: true,814 }),815 });816817 818 819 expect(mocks.createVault).toHaveBeenCalledWith("receiver-password", rootKeyHex);820821 822 823 expect(mocks.activateLicense).toHaveBeenCalledWith("PPK-test");824 expect(mocks.isProUser).toHaveBeenCalled();825 expect(mocks.localIdentity).toHaveBeenCalledWith(expect.any(Uint8Array));826 expect(mocks.upsert).toHaveBeenCalledWith(expect.objectContaining({827 deviceId: "phone-source-id",828 }));829 expect(mocks.storageSet).toHaveBeenCalledWith(830 "peach_initial_sync_pending_device_id",831 "phone-source-id",832 );833 expect(mocks.rollbackVault).not.toHaveBeenCalled();834835 836 expect(mocks.pushes).toContainEqual(expect.objectContaining({837 type: "ADOPTION_SOURCE_RESULT",838 payload: expect.objectContaining({ status: "complete" }),839 }));840 });841842 it("rolls back and refuses completion when a carried Pro license is not activated", async () => {843 const rootKeyHex = "f0".repeat(32);844 const vaultId = "b".repeat(64);845 mocks.createVault.mockResolvedValue({ vaultId });846 mocks.activateLicense.mockResolvedValue({ valid: false, plan: "free" });847 mocks.isProUser.mockResolvedValue(false);848 mocks.openSocket.mockResolvedValue(socketWithFrames([849 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },850 {851 type: "ADOPT_GRANT",852 root_key_hex: rootKeyHex,853 vault_id: vaultId,854 license: "PPK-invalid",855 source: {856 device_id: "phone-source-id",857 device_name: "June's phone",858 device_type: "android",859 long_term_public_key_hex: "99".repeat(32),860 },861 },862 ]));863864 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");865 await startRelayAdoptionReceiver("receiver-password");866 await waitForPush("ADOPTION_SOURCE_RESULT");867868 expect(mocks.rollbackVault).toHaveBeenCalledOnce();869 expect(mocks.localIdentity).not.toHaveBeenCalled();870 expect(mocks.upsert).not.toHaveBeenCalled();871 expect(mocks.pushes).toContainEqual(expect.objectContaining({872 type: "ADOPTION_SOURCE_RESULT",873 payload: expect.objectContaining({874 status: "error",875 error: "license_transfer_failed",876 }),877 }));878 });879880 it("rolls back a newly created vault when a later adoption step fails", async () => {881 const rootKeyHex = "f0".repeat(32);882 const vaultId = "b".repeat(64);883 mocks.createVault.mockResolvedValue({ vaultId });884 mocks.localIdentity.mockRejectedValue(new Error("identity_failed"));885 mocks.openSocket.mockResolvedValue(socketWithFrames([886 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },887 {888 type: "ADOPT_GRANT",889 root_key_hex: rootKeyHex,890 vault_id: vaultId,891 license: "PPK-test",892 source: {893 device_id: "phone-source-id",894 device_name: "June's phone",895 device_type: "android",896 long_term_public_key_hex: "99".repeat(32),897 },898 },899 ]));900901 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");902 await startRelayAdoptionReceiver("receiver-password");903 await waitForPush("ADOPTION_SOURCE_RESULT");904905 expect(mocks.rollbackVault).toHaveBeenCalledOnce();906 expect(mocks.upsert).not.toHaveBeenCalled();907 expect(mocks.pushes).toContainEqual(expect.objectContaining({908 type: "ADOPTION_SOURCE_RESULT",909 payload: expect.objectContaining({ status: "error", error: "identity_failed" }),910 }));911 });912913 it("skips inline snapshot wait when grant lacks snapshot_capable and falls through to sync", async () => {914 915 916 917 const rootKeyHex = "f0".repeat(32);918 const vaultId = "b".repeat(64);919 mocks.createVault.mockResolvedValue({ vaultId });920 mocks.openSocket.mockResolvedValue(socketWithFrames([921 { type: "PEACH_HELLO", token: "AAECAwQFBgcICQoLDA0ODw" },922 {923 type: "ADOPT_GRANT",924 root_key_hex: rootKeyHex,925 vault_id: vaultId,926 license: "PPK-test",927 source: {928 device_id: "old-phone-id",929 device_name: "Old phone",930 device_type: "android",931 long_term_public_key_hex: "99".repeat(32),932 },933 934 },935 { type: "ADOPT_COMPLETE" },936 ], { closeOnEmpty: true }));937938 const { startRelayAdoptionReceiver } = await import("./relay-adoption-bg");939 await startRelayAdoptionReceiver("receiver-password");940 await waitForPush("ADOPTION_SOURCE_RESULT");941942 943 expect(mocks.triggerSync).toHaveBeenCalledWith({ reason: "manual", force: true });944 945 expect(mocks.pushes).toContainEqual(expect.objectContaining({946 type: "ADOPTION_SOURCE_RESULT",947 payload: expect.objectContaining({ status: "complete" }),948 }));949 950 expect(mocks.storageSet).toHaveBeenCalledWith(951 "peach_initial_sync_pending_device_id",952 "old-phone-id",953 );954 });955956});957958function decodeInvitation(encoded: string): { vault?: string } | null {959 960 961 const at = encoded.indexOf(":");962 if (at < 0) return null;963 const b64 = encoded.slice(at + 1).replace(/-/g, "+").replace(/_/g, "/");964 try {965 const json = Buffer.from(b64, "base64").toString("utf8");966 return JSON.parse(json) as { vault?: string };967 } catch {968 return null;969 }970}