sync-coordinator.test.ts

9.1 KB · raw

1import { describe, it, expect, vi } from 'vitest';2import { runBlobSyncSession } from './sync-coordinator';3import type { SyncDeps } from './sync-coordinator';4import type { SyncTransport } from '../shared/websocket-sync-transport';5import type { SyncableDevice } from '../shared/sync-types';67// Builds a paired in-memory transport — one write on side A becomes a read on8// side B and vice versa. Returns [transportA, transportB].9function makePairedTransports(): [SyncTransport, SyncTransport] {10  const queueAtoB: Uint8Array[] = [];11  const queueBtoA: Uint8Array[] = [];12  const waitersAtoB: ((d: Uint8Array) => void)[] = [];13  const waitersBtoA: ((d: Uint8Array) => void)[] = [];1415  function push(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[], data: Uint8Array) {16    if (waiters.length > 0) waiters.shift()!(data);17    else queue.push(data);18  }1920  function pop(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[]): Promise<Uint8Array> {21    return new Promise(res => {22      if (queue.length > 0) res(queue.shift()!);23      else waiters.push(res);24    });25  }2627  const a: SyncTransport = {28    send: async (d) => push(queueAtoB, waitersAtoB, d),29    receive: () => pop(queueBtoA, waitersBtoA),30    close: async () => {},31  };32  const b: SyncTransport = {33    send: async (d) => push(queueBtoA, waitersBtoA, d),34    receive: () => pop(queueAtoB, waitersAtoB),35    close: async () => {},36  };37  return [a, b];38}3940const DEVICE_LIST: SyncableDevice[] = [41  { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 },42];4344function makeDeps(overrides: Partial<SyncDeps> = {}): SyncDeps {45  return {46    isVaultUnlocked: () => true,47    getVaultBlobForSync: async () => ({48      blob: JSON.stringify({ a: 'slotA', b: 'slotB' }),49      timestamp: 1000,50      devices: DEVICE_LIST,51    }),52    applyIncomingVault: async () => false,53    ...overrides,54  };55}5657describe('runBlobSyncSession', () => {58  it('initiator applies newer remote vault', async () => {59    const apply = vi.fn().mockResolvedValue(true);60    const [tInitiator, tResponder] = makePairedTransports();6162    // Responder side: sends a newer vault (timestamp 2000 > initiator's 1000)63    const responderDeps = makeDeps({64      getVaultBlobForSync: async () => ({65        blob: JSON.stringify({ a: 'newer', b: null }),66        timestamp: 2000,67        devices: DEVICE_LIST,68      }),69      applyIncomingVault: async () => false,70    });71    const initiatorDeps = makeDeps({ applyIncomingVault: apply });7273    const [result] = await Promise.all([74      runBlobSyncSession('initiator', tInitiator, initiatorDeps),75      runBlobSyncSession('responder', tResponder, responderDeps),76    ]);7778    expect(result.status).toBe('completed');79    expect(result.applied).toBe(true);80    expect(apply).toHaveBeenCalledWith(81      expect.stringContaining('newer'),82      2000,83      undefined,84      DEVICE_LIST,85    );86  });8788  it('initiator keeps local vault when remote is older', async () => {89    const apply = vi.fn().mockResolvedValue(false);90    const [tInitiator, tResponder] = makePairedTransports();9192    const responderDeps = makeDeps({93      getVaultBlobForSync: async () => ({94        blob: JSON.stringify({ a: 'old' }),95        timestamp: 500, // older96        devices: DEVICE_LIST,97      }),98      applyIncomingVault: async () => false,99    });100    const initiatorDeps = makeDeps({ applyIncomingVault: apply });101102    const [result] = await Promise.all([103      runBlobSyncSession('initiator', tInitiator, initiatorDeps),104      runBlobSyncSession('responder', tResponder, responderDeps),105    ]);106107    expect(result.status).toBe('completed');108    expect(result.applied).toBe(false);109  });110111  it('both sides exchange vaults bidirectionally', async () => {112    const applyI = vi.fn().mockResolvedValue(true);113    const applyR = vi.fn().mockResolvedValue(true);114    const [tInitiator, tResponder] = makePairedTransports();115116    const initiatorDeps = makeDeps({117      getVaultBlobForSync: async () => ({118        blob: JSON.stringify({ a: 'initiatorVault' }),119        timestamp: 1000,120        devices: DEVICE_LIST,121      }),122      applyIncomingVault: applyI,123    });124    const responderDeps = makeDeps({125      getVaultBlobForSync: async () => ({126        blob: JSON.stringify({ a: 'responderVault' }),127        timestamp: 2000,128        devices: DEVICE_LIST,129      }),130      applyIncomingVault: applyR,131    });132133    await Promise.all([134      runBlobSyncSession('initiator', tInitiator, initiatorDeps),135      runBlobSyncSession('responder', tResponder, responderDeps),136    ]);137138    // Initiator received responder's vault (newer), responder received initiator's (older)139    expect(applyI).toHaveBeenCalledWith(expect.stringContaining('responderVault'), 2000, undefined, DEVICE_LIST);140    expect(applyR).toHaveBeenCalledWith(expect.stringContaining('initiatorVault'), 1000, undefined, DEVICE_LIST);141  });142143  it('returns skipped when vault is locked', async () => {144    const [t] = makePairedTransports();145    const deps = makeDeps({ isVaultUnlocked: () => false });146    const result = await runBlobSyncSession('initiator', t, deps);147    expect(result.status).toBe('skipped');148    expect(result.error).toBe('vault_locked');149  });150151  it('transfers Pro license when receiver has none', async () => {152    const apply = vi.fn().mockResolvedValue(true);153    const [tInitiator, tResponder] = makePairedTransports();154155    const responderDeps = makeDeps({156      getVaultBlobForSync: async () => ({157        blob: JSON.stringify({ a: 'vault' }),158        timestamp: 2000,159        license: 'PRO-LICENSE-KEY',160        devices: DEVICE_LIST,161      }),162      applyIncomingVault: async () => false,163    });164    const initiatorDeps = makeDeps({ applyIncomingVault: apply });165166    await Promise.all([167      runBlobSyncSession('initiator', tInitiator, initiatorDeps),168      runBlobSyncSession('responder', tResponder, responderDeps),169    ]);170171    expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, 'PRO-LICENSE-KEY', DEVICE_LIST);172  });173174  it('does not override existing license when both sides have one', async () => {175    // Both sides have a license — the responder's apply function receives176    // the initiator's license but this is handled by vault.applyIncomingVault177    // which skips overwriting. The coordinator just passes it through.178    const apply = vi.fn().mockResolvedValue(false);179    const [tInitiator, tResponder] = makePairedTransports();180181    const responderDeps = makeDeps({182      getVaultBlobForSync: async () => ({183        blob: JSON.stringify({ a: 'vault' }),184        timestamp: 500,185        license: 'RESPONDER-LICENSE',186        devices: DEVICE_LIST,187      }),188      applyIncomingVault: async () => false,189    });190    const initiatorDeps = makeDeps({191      getVaultBlobForSync: async () => ({192        blob: JSON.stringify({ a: 'vault' }),193        timestamp: 1000,194        license: 'INITIATOR-LICENSE',195        devices: DEVICE_LIST,196      }),197      applyIncomingVault: apply,198    });199200    await Promise.all([201      runBlobSyncSession('initiator', tInitiator, initiatorDeps),202      runBlobSyncSession('responder', tResponder, responderDeps),203    ]);204205    // Initiator's apply receives 'RESPONDER-LICENSE' — vault.ts decides not to overwrite206    expect(apply).toHaveBeenCalledWith(expect.any(String), 500, 'RESPONDER-LICENSE', DEVICE_LIST);207  });208});209210describe('mesh device registry propagation', () => {211  it('passes remote device list to applyIncomingVault', async () => {212    const apply = vi.fn().mockResolvedValue(true);213    const [tInitiator, tResponder] = makePairedTransports();214215    const remoteDevices: SyncableDevice[] = [216      { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 },217      { publicKeyHex: 'bbb', deviceName: 'MacBook', pairedAt: 2000 },218      { publicKeyHex: 'ccc', deviceName: 'iPad', pairedAt: 3000 },219    ];220221    const responderDeps = makeDeps({222      getVaultBlobForSync: async () => ({223        blob: JSON.stringify({ a: 'vault' }),224        timestamp: 2000,225        devices: remoteDevices,226      }),227      applyIncomingVault: async () => false,228    });229    const initiatorDeps = makeDeps({ applyIncomingVault: apply });230231    await Promise.all([232      runBlobSyncSession('initiator', tInitiator, initiatorDeps),233      runBlobSyncSession('responder', tResponder, responderDeps),234    ]);235236    expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, undefined, remoteDevices);237  });238239  it('sends local device list in SYNC_VAULT_SEND', async () => {240    const remoteApply = vi.fn().mockResolvedValue(false);241    const [tInitiator, tResponder] = makePairedTransports();242243    const localDevices: SyncableDevice[] = [244      { publicKeyHex: 'ddd', deviceName: 'Work Mac', pairedAt: 4000 },245    ];246247    const initiatorDeps = makeDeps({248      getVaultBlobForSync: async () => ({249        blob: JSON.stringify({ a: 'vault' }),250        timestamp: 500,251        devices: localDevices,252      }),253    });254    const responderDeps = makeDeps({ applyIncomingVault: remoteApply });255256    await Promise.all([257      runBlobSyncSession('initiator', tInitiator, initiatorDeps),258      runBlobSyncSession('responder', tResponder, responderDeps),259    ]);260261    // Responder received the initiator's vault which included local devices262    expect(remoteApply).toHaveBeenCalledWith(expect.any(String), 500, undefined, localDevices);263  });264});