OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / identity-switch.test.js

237 lines · javascript ref:
// Standalone identity-switch probe — exercises the path hyper-tps actually
// fails on: identity-manager.create → identity-manager.switch → vault.setup.
// The sister test identity-bootstrap.test.js only exercises vault.setup on
// the auto-migrated identity-0; it does NOT go through swapCore() and so
// can't catch the destroyAllSubscriptions / core.close cascade.
//
// SAFETY: storage is a fresh tmp dir under os.tmpdir(). The host's
// ~/.config/keet-cli/ is NEVER touched. Cleanup is unconditional.
//
// Run:
//   cd hyper-fleeky/packages/keet-daemon && bare test/identity-switch.test.js
//
// Exit code 0 = success, 1 = any failure (with diagnostics printed).

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 {}
}

function mktemp (prefix) {
  const dir = path.join(os.tmpdir(), 'keet-daemon-test-' + prefix + '-' + Date.now() + '-' + Math.random().toString(36).slice(2, 8))
  fs.mkdirSync(dir, { recursive: true })
  return dir
}

class TestClient {
  constructor (socketPath) {
    this._socketPath = socketPath
    this._pipe = null
    this._framed = null
    this._nextId = 1
    this._pending = new Map()
  }
  connect () {
    return new Promise((resolve, reject) => {
      const pipe = new Pipe(this._socketPath)
      const onErr = (err) => reject(err)
      pipe.once('error', onErr)
      pipe.once('connect', () => {
        pipe.removeListener('error', onErr)
        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++
    const payload = JSON.stringify({ id, method, params })
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this._pending.delete(id)
        reject(new Error('RPC timeout: ' + method + ' (' + timeoutMs + 'ms)'))
      }, timeoutMs)
      this._pending.set(id, {
        resolve: (v) => { clearTimeout(timer); resolve(v) },
        reject: (e) => { clearTimeout(timer); reject(e) }
      })
      this._framed.write(Buffer.from(payload, 'utf-8'))
    })
  }
  close () {
    try { this._framed && this._framed.destroy() } catch {}
    try { this._pipe && this._pipe.destroy() } catch {}
  }
}

async function main () {
  const baseDir = mktemp('switch')
  const socketPath = path.join(baseDir, 'daemon.sock')
  console.log('[test] baseDir = ' + baseDir)
  console.log('[test] socket  = ' + socketPath)
  console.log('[test] (host ~/.config/keet-cli is NOT used — this test is fully isolated)')

  let daemon
  const t0 = Date.now()
  try {
    daemon = await startDaemon({ storage: baseDir, socketPath })
    ok(true, 'startDaemon resolved in ' + (Date.now() - t0) + 'ms')
  } catch (err) {
    ok(false, 'startDaemon threw: ' + err.message)
    if (err.stack) console.log(err.stack)
    rmrf(baseDir)
    return
  }

  const client = new TestClient(socketPath)
  try {
    await client.connect()
    ok(true, 'UDS client connected')
  } catch (err) {
    ok(false, 'UDS connect failed: ' + err.message)
    try { await daemon.core.close() } catch {}
    rmrf(baseDir)
    return
  }

  // 1. List identities — fresh install path should yield identity-0
  let initialList
  try {
    initialList = await client.call('identity-manager.list', {}, 10000)
    ok(initialList && Array.isArray(initialList.identities), 'list.identities is an array')
    ok(initialList.activeId === 'identity-0', 'activeId === identity-0; got ' + initialList.activeId)
    ok(initialList.identities.length === 1, 'one identity present; got ' + (initialList.identities && initialList.identities.length))
  } catch (err) {
    ok(false, 'identity-manager.list threw: ' + err.message)
  }

  // 2. identity-manager.create — allocates identity-1
  let newId
  const tCreate = Date.now()
  try {
    const created = await client.call('identity-manager.create', { name: 'test-switch-' + Date.now().toString(36) }, 15000)
    const elapsed = Date.now() - tCreate
    newId = (created && created.id) || (created && created.entry && created.entry.id)
    ok(!!newId, 'identity-manager.create returned id in ' + elapsed + 'ms; got ' + (newId || JSON.stringify(created)))
    if (newId) {
      ok(newId === 'identity-1', 'new id is identity-1; got ' + newId)
      ok(fs.existsSync(path.join(baseDir, newId)), 'new identity dir created on disk: ' + newId)
    }
  } catch (err) {
    ok(false, 'identity-manager.create threw in ' + (Date.now() - tCreate) + 'ms: ' + err.message)
    if (err.stack) console.log(err.stack)
  }

  // 3. identity-manager.switch — THE PATH UNDER TEST
  //    Goes through _imSwitch → swapCore → uds.destroyAllSubscriptions
  //    → await core.close() → KeetCore.from(new storage) → backend.ready()
  //    → router.swapMetadata. If anything crashes, this is the call that
  //    surfaces it as `{ ok:false, error: ... }`.
  if (newId) {
    const tSwitch = Date.now()
    try {
      const result = await client.call('identity-manager.switch', { id: newId }, 30000)
      const elapsed = Date.now() - tSwitch
      ok(result && result.ok === true, 'identity-manager.switch returned {ok:true} in ' + elapsed + 'ms')
      ok(result && result.active && result.active.id === newId, 'switch result.active.id === ' + newId)
    } catch (err) {
      ok(false, 'identity-manager.switch threw in ' + (Date.now() - tSwitch) + 'ms: ' + err.message)
      if (err.stack) console.log(err.stack)
    }
  }

  // 4. Confirm registry reflects the swap
  if (newId) {
    try {
      const afterList = await client.call('identity-manager.list', {}, 10000)
      ok(afterList && afterList.activeId === newId, 'post-switch activeId === ' + newId + '; got ' + (afterList && afterList.activeId))
      ok(afterList && afterList.identities && afterList.identities.length === 2, 'two identities present; got ' + (afterList && afterList.identities && afterList.identities.length))
    } catch (err) {
      ok(false, 'post-switch identity-manager.list threw: ' + err.message)
    }
  }

  // 5. vault.setup on the freshly-switched-to identity
  if (newId) {
    const password = 'test-pw-' + Math.random().toString(36).slice(2, 10)
    const tVault = Date.now()
    try {
      const setupResult = await client.call('vault.setup', { password }, 90000)
      const elapsed = Date.now() - tVault
      ok(true, 'vault.setup OK in ' + elapsed + 'ms')
      ok(setupResult && setupResult.ok === true, 'vault.setup returned {ok:true}; got ' + JSON.stringify(setupResult))
    } catch (err) {
      ok(false, 'vault.setup failed in ' + (Date.now() - tVault) + 'ms: ' + err.message)
      if (err.stack) console.log(err.stack)
    }

    // 6. identity RPC should now return a publicKey
    try {
      const id = await client.call('identity', {}, 10000)
      ok(id && id.publicKey, 'identity.publicKey present after vault.setup: ' + (id && id.publicKey ? id.publicKey.slice(0, 16) + '…' : '<missing>'))
    } catch (err) {
      ok(false, 'identity RPC failed: ' + err.message)
    }

    // 7. Disk evidence on the NEW identity
    const idDir = path.join(baseDir, newId)
    ok(fs.existsSync(idDir), newId + ' dir exists on disk')
    const dbDir = path.join(idDir, '0', 'db')
    if (fs.existsSync(dbDir)) {
      const entries = fs.readdirSync(dbDir)
      ok(entries.length > 0, newId + '/0/db has ' + entries.length + ' files')
    } else {
      ok(false, newId + '/0/db does not exist')
    }
  }

  // Cleanup. After swapCore, daemon.core points to the ORIGINAL (closed) core;
  // the live one is on daemon.router.core. Try both, swallow.
  console.log('[test] cleaning up…')
  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('=== RESULT ===')
  if (exitCode === 0) console.log('ALL PASSED')
  else {
    console.log(failures.length + ' FAILED:')
    for (const f of failures) console.log('  - ' + f)
  }
  process.exit(exitCode)
}

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