OverviewFilesCommitsIssuesDocsReleasesPeers

pearbrowser-desktop / test / http-bridge-sync.test.js

164 lines · javascript ref:
import test from 'node:test'
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import hypercoreCrypto from 'hypercore-crypto'
import b4a from 'b4a'

const { HttpBridge } = (await import('../backend/http-bridge.js')).default

const driveKey = 'a'.repeat(64)
const appPubkey = 'b'.repeat(64)
// Mirror HttpBridge._scopeAppId: a per-drive 64-hex HASH, not a `driveKey:appId`
// concat (which is ≥65 chars and exceeds the Autobase bridge's 64-char appId cap —
// the bug that silently broke every sync call and forced apps into dev mode).
const scope = (appId) => b4a.toString(hypercoreCrypto.data(b4a.from(`${driveKey}:${appId}`)), 'hex')

function makeReq (method, path, { headers = {}, body } = {}) {
  const req = new EventEmitter()
  req.method = method
  req.headers = headers
  req.socket = { remoteAddress: '127.0.0.1' }
  req.destroy = () => { req.destroyed = true }
  process.nextTick(() => {
    if (body !== undefined) req.emit('data', Buffer.from(JSON.stringify(body)))
    req.emit('end')
  })
  return req
}

function makeRes () {
  return {
    statusCode: 200,
    headers: {},
    chunks: [],
    ended: false,
    setHeader (name, value) { this.headers[name.toLowerCase()] = value },
    write (chunk) { if (chunk) this.chunks.push(Buffer.from(chunk)) },
    end (chunk) {
      if (chunk) this.chunks.push(Buffer.from(chunk))
      this.ended = true
      this.body = Buffer.concat(this.chunks).toString('utf8')
      this.json = this.body ? JSON.parse(this.body) : null
    }
  }
}

async function request (bridge, method, path, opts) {
  const req = makeReq(method, path, opts)
  const res = makeRes()
  const url = new URL(path, 'http://127.0.0.1')
  const handled = await bridge.handle(req, res, url)
  return { handled, req, res }
}

function makeBridge () {
  const calls = []
  const bridge = {
    async createSyncGroup (appId) {
      calls.push(['createSyncGroup', appId])
      return { inviteKey: 'c'.repeat(64), appId, writerPublicKey: 'd'.repeat(64) }
    },
    async joinSyncGroup (appId, inviteKey) {
      calls.push(['joinSyncGroup', appId, inviteKey])
      return { inviteKey, appId, writerPublicKey: 'e'.repeat(64) }
    },
    async append (appId, op) {
      calls.push(['append', appId, op])
      return { ok: true }
    },
    async get (appId, key) {
      calls.push(['get', appId, key])
      return { id: key, value: 7 }
    },
    async list (appId, prefix, opts) {
      calls.push(['list', appId, prefix, opts])
      return [{ key: `${prefix}!1`, value: { id: 1 } }]
    },
    getSyncStatus (appId) {
      calls.push(['status', appId])
      return { appId, inviteKey: 'f'.repeat(64), writerCount: 1, viewLength: 2 }
    }
  }
  const http = new HttpBridge(bridge, null, null, {
    validateToken: (token) => token === 'good' ? driveKey : null,
    identity: {
      getAppKeypair (keyHex) {
        assert.equal(keyHex, driveKey)
        return { publicKey: Buffer.from(appPubkey, 'hex') }
      }
    }
  })
  return { http, calls }
}

test('HttpBridge sync API requires a token and scopes app IDs to the drive', async () => {
  const { http, calls } = makeBridge()

  const unauthorized = await request(http, 'POST', '/api/sync/create', {
    body: { appId: 'shop' }
  })
  assert.equal(unauthorized.handled, true)
  assert.equal(unauthorized.res.statusCode, 401)
  assert.equal(unauthorized.res.json.error, 'Unauthorized')

  const badApp = await request(http, 'POST', '/api/sync/create', {
    headers: { 'x-pear-token': 'good' },
    body: { appId: '../shop' }
  })
  assert.equal(badApp.res.statusCode, 400)
  assert.equal(badApp.res.json.error, 'Invalid appId format')

  const create = await request(http, 'POST', '/api/sync/create', {
    headers: { 'x-pear-token': 'good' },
    body: { appId: 'shop' }
  })
  assert.equal(create.res.statusCode, 200)
  assert.equal(create.res.json.appId, 'shop')
  assert.ok(scope('shop').length <= 64, 'scoped appId must fit the bridge 64-char limit')
  assert.deepEqual(calls, [['createSyncGroup', scope('shop')]])
})

test('HttpBridge routes sync operations and identity through the authenticated app scope', async () => {
  const { http, calls } = makeBridge()
  const auth = { 'x-pear-token': 'good' }

  const inviteKey = '1'.repeat(64)
  const join = await request(http, 'POST', '/api/sync/join', {
    headers: auth,
    body: { appId: 'shop', inviteKey }
  })
  assert.equal(join.res.statusCode, 200)
  assert.equal(join.res.json.appId, 'shop')

  const append = await request(http, 'POST', '/api/sync/append', {
    headers: auth,
    body: { appId: 'shop', op: { type: 'product:create', data: { id: 'p1' } } }
  })
  assert.deepEqual(append.res.json, { ok: true })

  const get = await request(http, 'GET', '/api/sync/get?appId=shop&key=products!p1', { headers: auth })
  assert.deepEqual(get.res.json, { id: 'products!p1', value: 7 })

  const list = await request(http, 'GET', '/api/sync/list?appId=shop&prefix=products&limit=5000', { headers: auth })
  assert.deepEqual(list.res.json, [{ key: 'products!1', value: { id: 1 } }])

  const status = await request(http, 'GET', '/api/sync/status?appId=shop', { headers: auth })
  assert.equal(status.res.json.appId, 'shop')
  assert.equal(status.res.json.writerCount, 1)

  const identity = await request(http, 'GET', '/api/identity', { headers: auth })
  assert.deepEqual(identity.res.json, {
    publicKey: appPubkey,
    driveKey,
    algorithm: 'ed25519'
  })

  assert.deepEqual(calls, [
    ['joinSyncGroup', scope('shop'), inviteKey],
    ['append', scope('shop'), { type: 'product:create', data: { id: 'p1' } }],
    ['get', scope('shop'), 'products!p1'],
    ['list', scope('shop'), 'products', { limit: 1000 }],
    ['status', scope('shop')]
  ])
})