OverviewFilesCommitsIssuesDocsReleasesPeers

keet-daemon / test / identity-import-proves-fix.test.js

249 lines · javascript ref:
// Proof test: identity-manager.import (which seeds identity bytes via
// restoreIdentity BEFORE awaiting onlineBoot-dependent RPCs) works on a
// fresh storage where identity-manager.switch + vault.setup hangs at 90s.
//
// If this test passes while test/identity-switch.test.js fails on the same
// daemon, the diagnosis is confirmed: vault.setup on a freshly-swapped-to
// identity blocks on onlineBoot.ready() that never resolves because
// _loadIdentity errors with "Identity is required". The fix path for
// hyper-tps's `--fresh` mode is to call identity-manager.import with a
// locally-generated mnemonic instead of identity-manager.create + .switch
// + vault.setup.
//
// 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-import-proves-fix.test.js
//
// Exit code 0 = success (fix path confirmed), 1 = any failure.

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 { generateMnemonic } from 'bip39-mnemonic'
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('import-proves-fix')
  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 — fully isolated)')

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

  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)
    Bare.exit(1)
  }

  // 1. Sanity: identity-0 was created by registry migration
  try {
    const list = await client.call('identity-manager.list', {}, 10000)
    ok(list && list.activeId === 'identity-0', 'pre-import activeId === identity-0; got ' + (list && list.activeId))
    ok(list && list.identities && list.identities.length === 1, 'one identity present; got ' + (list && list.identities && list.identities.length))
  } catch (err) {
    ok(false, 'identity-manager.list threw: ' + err.message)
  }

  // 2. Generate a fresh mnemonic locally. This is what hyper-tps would do
  //    in `--fresh` mode if it called identity-manager.import instead of
  //    create + switch + vault.setup.
  const phrase = generateMnemonic()
  const password = 'proof-pw-' + Math.random().toString(36).slice(2, 10)
  const name = 'fix-proof-' + Date.now().toString(36)
  ok(typeof phrase === 'string' && phrase.split(' ').length >= 12, 'generated 12+ word mnemonic locally')

  // 3. identity-manager.import — the path that does:
  //      registry.create → swapCore → restoreIdentity(phrase) → vault.encrypt
  //    restoreIdentity SEEDS the identity bytes inside the new core so
  //    onlineBoot.ready() can resolve afterward.
  //
  //    NOTE: identity-manager.import uses 90s as its operational ceiling
  //    (vault.setup's timeout in identity-switch.test.js). We use the
  //    same here so a hang would manifest identically — proving the
  //    diagnosis by contrast.
  let importResult
  const tImport = Date.now()
  try {
    importResult = await client.call('identity-manager.import', { phrase, password, name }, 90000)
    const elapsed = Date.now() - tImport
    ok(true, 'identity-manager.import returned in ' + elapsed + 'ms (vs vault.setup hang at 90000ms)')
    ok(importResult && importResult.ok === true, 'import returned {ok:true}; got ' + JSON.stringify(importResult))
    ok(importResult && importResult.identity && importResult.identity.id, 'import returned identity entry; got id=' + (importResult && importResult.identity && importResult.identity.id))
  } catch (err) {
    ok(false, 'identity-manager.import failed in ' + (Date.now() - tImport) + 'ms: ' + err.message)
    if (err.stack) console.log(err.stack)
  }

  const newId = importResult && importResult.identity && importResult.identity.id

  // 4. Post-import list — active should be the new id
  if (newId) {
    try {
      const list = await client.call('identity-manager.list', {}, 10000)
      ok(list && list.activeId === newId, 'post-import activeId === ' + newId + '; got ' + (list && list.activeId))
      ok(list && list.identities && list.identities.length === 2, 'two identities present; got ' + (list && list.identities && list.identities.length))
    } catch (err) {
      ok(false, 'post-import identity-manager.list threw: ' + err.message)
    }
  }

  // 5. The decisive check: `identity` RPC returns a publicKey WITHOUT
  //    hanging. In identity-switch.test.js this exact call timed out at
  //    10s because onlineBoot never resolved. Here, because we seeded
  //    identity bytes via restoreIdentity, it should return quickly.
  if (newId) {
    const tId = Date.now()
    try {
      const id = await client.call('identity', {}, 15000)
      const elapsed = Date.now() - tId
      ok(id && id.publicKey, 'identity RPC returned publicKey in ' + elapsed + 'ms (vs 10000ms hang after vault.setup); got ' + (id && id.publicKey ? id.publicKey.slice(0, 16) + '…' : '<missing>'))
    } catch (err) {
      ok(false, 'identity RPC failed in ' + (Date.now() - tId) + 'ms: ' + err.message)
    }
  }

  // 6. Disk evidence — vault file + identity db both present
  if (newId) {
    const idDir = path.join(baseDir, newId)
    ok(fs.existsSync(idDir), newId + ' dir exists on disk')
    const vaultFile = path.join(idDir, 'mnemonic.enc')
    ok(fs.existsSync(vaultFile), 'vault file mnemonic.enc exists (vault.encrypt ran)')
    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')
    }
  }

  // 7. Round-trip: vault.export with the password should yield the original phrase.
  //    This proves the vault encrypted what we passed AND the core's identity
  //    matches it.
  if (newId) {
    try {
      const exported = await client.call('vault.export', { password }, 10000)
      ok(exported && exported.phrase === phrase, 'vault.export round-trips the original mnemonic; ' + (exported && exported.phrase === phrase ? 'match' : 'MISMATCH'))
    } catch (err) {
      ok(false, 'vault.export failed: ' + err.message)
    }
  }

  // Cleanup — daemon.core was swapped; use daemon.router.core for the live one
  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 — fix path (restoreIdentity-then-vault) confirmed working on fresh storage.')
    console.log('hyper-tps --fresh should call identity-manager.import { phrase: generateMnemonic(), password, name }')
    console.log('instead of identity-manager.create + .switch + vault.setup.')
  } else {
    console.log(failures.length + ' FAILED:')
    for (const f of failures) console.log('  - ' + f)
  }
  Bare.exit(exitCode)
}

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