OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / home-get.test.js

156 lines · javascript ref:
// home.get — every identity has ONE home room (hyper-tps docs/home-world-design.md §2,
// gate H2). The daemon owns the pointer: identities.json entry `homeRoomId`.
//
//   1. a fresh identity: two CONCURRENT home.get calls create ONE room
//   2. a later call returns the same room, created:false
//   3. the pointer is on disk (identities.json), so it survives a restart
//   4. another identity gets its own home; switching back returns the first
//   5. a pointer to a room that is gone -> HOME_ROOM_MISSING, and NO new room
//
// SAFETY: fresh tmp storage; ~/.config/keet-cli is never opened. RoomMeta's legacy
// migration renames files under os.homedir(), so this REFUSES to run unless HOME
// is a scratch dir too:
//
//   cd hyper-fleeky/packages/keet-daemon && HOME=$(mktemp -d) bare test/home-get.test.js

import fs from 'bare-fs'
import path from 'bare-path'
import os from 'bare-os'
import Pipe from 'bare-pipe'
import FramedStream from 'framed-stream'
import { startDaemon } from '../index.js'

let exitCode = 0
const failures = []

function ok (cond, msg) {
  if (cond) console.log('  ok   ' + msg)
  else { console.log('  FAIL ' + msg); exitCode = 1; failures.push(msg) }
}

function rmrf (dir) {
  try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
}

class TestClient {
  constructor (socketPath) {
    this._socketPath = socketPath
    this._nextId = 1
    this._pending = new Map()
  }
  connect () {
    return new Promise((resolve, reject) => {
      const pipe = new Pipe(this._socketPath)
      pipe.once('error', reject)
      pipe.once('connect', () => {
        pipe.removeListener('error', reject)
        this._pipe = pipe
        const FS = FramedStream.default || FramedStream
        this._framed = new FS(pipe)
        this._framed.on('data', (buf) => {
          let msg
          try { msg = JSON.parse(buf.toString('utf-8')) } catch { return }
          const p = this._pending.get(msg.id)
          if (!p) return
          this._pending.delete(msg.id)
          if (msg.ok === false || msg.error) p.reject(new Error(msg.error || 'RPC failed'))
          else p.resolve(msg.data !== undefined ? msg.data : msg)
        })
        resolve()
      })
    })
  }
  call (method, params = {}, timeoutMs = 60000) {
    const id = this._nextId++
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this._pending.delete(id)
        reject(new Error('RPC timeout: ' + method))
      }, timeoutMs)
      this._pending.set(id, {
        resolve: (v) => { clearTimeout(timer); resolve(v) },
        reject: (e) => { clearTimeout(timer); reject(e) }
      })
      this._framed.write(Buffer.from(JSON.stringify({ id, method, params }), 'utf-8'))
    })
  }
  close () {
    try { this._framed && this._framed.destroy() } catch {}
    try { this._pipe && this._pipe.destroy() } catch {}
  }
}

function readRegistry (baseDir) {
  return JSON.parse(fs.readFileSync(path.join(baseDir, 'identities.json'), 'utf-8'))
}

async function main () {
  const home = os.homedir()
  if (!home.startsWith(os.tmpdir())) {
    console.log('REFUSING: HOME=' + home + ' is not under ' + os.tmpdir() + '. Run with HOME=$(mktemp -d).')
    Bare.exit(2)
  }
  const baseDir = path.join(os.tmpdir(), 'kd-home-' + Date.now().toString(36))
  fs.mkdirSync(baseDir, { recursive: true })
  const socketPath = path.join(baseDir, 'd.sock')
  console.log('[test] baseDir = ' + baseDir)

  const daemon = await startDaemon({ storage: baseDir, socketPath, pidFile: path.join(baseDir, 'd.pid') })
  const client = new TestClient(socketPath)
  await client.connect()

  // 1. concurrent first calls on a fresh identity -> one room
  const [a, b] = await Promise.all([client.call('home.get'), client.call('home.get')])
  ok(typeof a.roomId === 'string' && a.roomId.length > 0, 'home.get returns a roomId: ' + a.roomId)
  ok(a.roomId === b.roomId, 'two concurrent calls agree on one room')
  ok([a.created, b.created].filter(Boolean).length === 1, 'exactly one of them created it; got ' + a.created + '/' + b.created)
  const rooms1 = await client.call('rooms')
  ok(rooms1.filter(r => r.roomId === a.roomId).length === 1 && rooms1.length === 1, 'one room exists; got ' + rooms1.length)

  // 2. a later call: same room, not created
  const c = await client.call('home.get')
  ok(c.roomId === a.roomId && c.created === false, 'later call returns it with created:false')

  // 3. on disk
  const reg = readRegistry(baseDir)
  const entry0 = reg.identities.find(e => e.id === reg.activeId)
  ok(entry0 && entry0.homeRoomId === a.roomId, 'identities.json holds homeRoomId for ' + reg.activeId)

  // 4. another identity has its own home; switching back returns the first
  const created = await client.call('identity-manager.create', { name: 'home-test-2' })
  await client.call('identity-manager.switch', { id: created.id }, 60000)
  const d = await client.call('home.get')
  ok(d.created === true && d.roomId !== a.roomId, created.id + ' gets its own home: ' + d.roomId)
  await client.call('identity-manager.switch', { id: 'identity-0' }, 60000)
  const e = await client.call('home.get')
  ok(e.roomId === a.roomId && e.created === false, 'switching back returns the first home')

  // 5. a pointer to a room that is gone: error, and no new room
  const live = daemon.registry.getActive()
  const realHome = live.homeRoomId
  live.homeRoomId = 'y'.repeat(52)          // well-formed z32 length, no such room
  const before = (await client.call('rooms')).length
  let err = null
  try { await client.call('home.get') } catch (x) { err = x }
  ok(err && /HOME_ROOM_MISSING/.test(err.message), 'missing room -> HOME_ROOM_MISSING; got ' + (err ? err.message : 'no error'))
  const after = (await client.call('rooms')).length
  ok(after === before, 'no new room created on a missing home (' + before + ' -> ' + after + ')')
  ok(daemon.registry.getActive().homeRoomId === 'y'.repeat(52), 'the pointer is left as it was for someone to look at')
  live.homeRoomId = realHome

  client.close()
  try { await daemon.router.core.close() } catch {}
  try { await daemon.core.close() } catch {}
  try { await daemon.uds.stop() } catch {}
  rmrf(baseDir)

  console.log('')
  console.log(exitCode === 0 ? 'ALL PASSED' : failures.length + ' FAILED:\n  - ' + failures.join('\n  - '))
  Bare.exit(exitCode)
}

main().catch(err => {
  console.error('uncaught:', err && err.stack ? err.stack : err)
  Bare.exit(1)
})