OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / test / unit / wizard.test.js

232 lines · javascript ref:
import test from 'brittle'
import { tmpdir } from 'os'
import { join } from 'path'
import { mkdtemp, rm, readFile, writeFile, stat } from 'fs/promises'
import { SetupWizard, WIZARD_SCHEMA_VERSION } from 'p2p-hiverelay/core/wizard.js'

const VALID_BTC = 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq'
const VALID_BTC_LEGACY = '1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2'
const VALID_LIGHTNING_ADDRESS = 'operator@getalby.com'
const VALID_BOLT12 = 'lno1pqpz35xjueqd9ejqcfqw3gq'

async function makeWizard (t) {
  const dir = await mkdtemp(join(tmpdir(), 'bs-wizard-'))
  t.teardown(async () => { try { await rm(dir, { recursive: true, force: true }) } catch (_) {} })
  return new SetupWizard({ storagePath: join(dir, 'wizard.json') })
}

test('constructor requires storagePath', async (t) => {
  try {
    // eslint-disable-next-line no-new
    new SetupWizard({})
    t.fail('should throw')
  } catch (err) {
    t.ok(err.message.includes('storagePath'))
  }
})

test('initial state starts at welcome with sane defaults', async (t) => {
  const w = await makeWizard(t)
  const snap = w.snapshot()
  t.is(snap.step, 'welcome')
  t.absent(snap.isComplete)
  t.is(snap.acceptMode, 'review')
  t.ok(snap.relayName.length > 0, 'relayName has a generated default')
  t.is(snap.payoutDestination, null, 'no payout address yet')
  t.absent(snap.hasPayout, 'hasPayout false by default')
})

test('goToStep validates step name', async (t) => {
  const w = await makeWizard(t)
  const bad = w.goToStep({ step: 'made-up-step' })
  t.absent(bad.ok)
  t.ok(bad.reason.includes('unknown step'))
  const good = w.goToStep({ step: 'payout' })
  t.ok(good.ok)
  t.is(good.state.step, 'payout')
})

test('first goToStep stamps startedAt', async (t) => {
  const w = await makeWizard(t)
  t.is(w.snapshot().startedAt, null)
  w.goToStep({ step: 'relay_name' })
  t.ok(typeof w.snapshot().startedAt === 'number', 'startedAt set on first navigation')
})

test('setRelayName validates length and emptiness', async (t) => {
  const w = await makeWizard(t)
  t.absent(w.setRelayName({ relayName: '' }).ok, 'empty rejected')
  t.absent(w.setRelayName({ relayName: 'a'.repeat(61) }).ok, '>60 chars rejected')
  t.absent(w.setRelayName({ relayName: 42 }).ok, 'non-string rejected')
  const ok = w.setRelayName({ relayName: '  Tokyo Relay 01  ' })
  t.ok(ok.ok)
  t.is(ok.state.relayName, 'Tokyo Relay 01', 'whitespace trimmed')
})

test('setPayoutDestination accepts supported payout destinations', async (t) => {
  const w = await makeWizard(t)
  const ok = w.setPayoutDestination({ address: VALID_BTC })
  t.ok(ok.ok)
  t.is(ok.state.payoutDestination, VALID_BTC)
  t.ok(ok.state.hasPayout)
  // legacy base58 also accepted
  t.ok(w.setPayoutDestination({ address: VALID_BTC_LEGACY }).ok)
  t.ok(w.setPayoutDestination({ address: VALID_LIGHTNING_ADDRESS }).ok, 'lightning address accepted')
  t.is(w.snapshot().payoutDestination, VALID_LIGHTNING_ADDRESS)
  t.ok(w.setPayoutDestination({ address: VALID_BOLT12 }).ok, 'BOLT12 offer accepted')
  t.is(w.snapshot().payoutDestination, VALID_BOLT12)
})

test('setPayoutDestination rejects malformed input', async (t) => {
  const w = await makeWizard(t)
  t.absent(w.setPayoutDestination({ address: 'not-an-address' }).ok, 'garbage rejected')
  t.absent(w.setPayoutDestination({ address: 42 }).ok, 'non-string rejected')
})

test('setPayoutDestination with empty/null clears (skip)', async (t) => {
  const w = await makeWizard(t)
  w.setPayoutDestination({ address: VALID_BTC })
  t.ok(w.snapshot().hasPayout)
  const skip = w.setPayoutDestination({ address: '' })
  t.ok(skip.ok, 'empty string is a valid skip')
  t.is(skip.state.payoutDestination, null)
  t.absent(skip.state.hasPayout)
  t.ok(w.setPayoutDestination({ address: null }).ok, 'null is a valid skip')
  t.ok(w.setPayoutDestination({}).ok, 'missing address is a valid skip')
})

test('setAcceptMode validates against the four allowed values', async (t) => {
  const w = await makeWizard(t)
  for (const mode of ['open', 'review', 'allowlist', 'closed']) {
    t.ok(w.setAcceptMode({ acceptMode: mode }).ok, `${mode} accepted`)
  }
  t.absent(w.setAcceptMode({ acceptMode: 'random' }).ok, 'invalid mode rejected')
})

test('complete() sets step to complete and stamps completedAt', async (t) => {
  const w = await makeWizard(t)
  w.complete()
  const snap = w.snapshot()
  t.is(snap.step, 'complete')
  t.ok(snap.isComplete)
  t.ok(typeof snap.completedAt === 'number')
})

test('toConfig returns name, acceptMode, and subsidy.payoutDestination', async (t) => {
  const w = await makeWizard(t)
  w.setRelayName({ relayName: 'myrelay' })
  w.setPayoutDestination({ address: VALID_BTC })
  w.setAcceptMode({ acceptMode: 'allowlist' })
  const cfg = w.toConfig()
  t.is(cfg.name, 'myrelay')
  t.is(cfg.acceptMode, 'allowlist')
  t.is(cfg.subsidy.payoutDestination, VALID_BTC)
})

test('toConfig payoutDestination is null when skipped', async (t) => {
  const w = await makeWizard(t)
  w.setRelayName({ relayName: 'norpayout' })
  const cfg = w.toConfig()
  t.is(cfg.subsidy.payoutDestination, null)
})

test('save + load persists state across instances (payout address is plaintext — it is public)', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'bs-wizard-'))
  t.teardown(async () => { try { await rm(dir, { recursive: true, force: true }) } catch (_) {} })
  const path = join(dir, 'wizard.json')

  const a = new SetupWizard({ storagePath: path })
  a.setRelayName({ relayName: 'persisted' })
  a.setAcceptMode({ acceptMode: 'open' })
  a.setPayoutDestination({ address: VALID_BTC })
  a.complete()
  await a.save()

  const b = new SetupWizard({ storagePath: path })
  await b.load()
  t.is(b.snapshot().step, 'complete')
  t.is(b.snapshot().relayName, 'persisted')
  t.is(b.snapshot().acceptMode, 'open')
  t.is(b.snapshot().payoutDestination, VALID_BTC)
  // The address is public — it is stored plaintext on disk (no envelope).
  const raw = JSON.parse(await readFile(path, 'utf8'))
  t.is(raw.payoutDestination, VALID_BTC)
  t.absent(raw.lnbits, 'no legacy lnbits block written')
})

test('load is no-op on missing file (first run)', async (t) => {
  const w = await makeWizard(t)
  await w.load() // file doesn't exist yet
  t.is(w.snapshot().step, 'welcome', 'state untouched')
})

test('load tolerates corrupted JSON without crashing', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'bs-wizard-'))
  t.teardown(async () => { try { await rm(dir, { recursive: true, force: true }) } catch (_) {} })
  const path = join(dir, 'wizard.json')
  await writeFile(path, '{this is not json', 'utf8')

  const w = new SetupWizard({ storagePath: path })
  let errored = false
  w.on('load-error', () => { errored = true })
  await w.load()
  t.ok(errored, 'load-error event fired')
  t.is(w.snapshot().step, 'welcome', 'fallback to default state')
})

test('load of a legacy v2 file drops lnbits and maps the old step forward', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'bs-wizard-'))
  t.teardown(async () => { try { await rm(dir, { recursive: true, force: true }) } catch (_) {} })
  const path = join(dir, 'wizard.json')
  // Simulate a v2 wizard.json mid-wizard on the now-removed lnbits step.
  await writeFile(path, JSON.stringify({
    schemaVersion: 2,
    step: 'lnbits_connect',
    relayName: 'legacy-relay',
    lnbits: { url: 'http://lnbits', adminKey: { v: 1, iv: 'x', ciphertext: 'y', authTag: 'z' } },
    acceptMode: 'open',
    startedAt: 1000,
    completedAt: null
  }), 'utf8')

  const w = new SetupWizard({ storagePath: path })
  await w.load()
  const snap = w.snapshot()
  t.is(snap.relayName, 'legacy-relay', 'kept the name')
  t.is(snap.acceptMode, 'open', 'kept the accept mode')
  t.is(snap.step, 'payout', 'removed lnbits_connect step maps forward to payout')
  t.is(snap.payoutDestination, null, 'no payout carried over from v2')
  // After re-save, the legacy lnbits block is gone.
  await w.save()
  const raw = JSON.parse(await readFile(path, 'utf8'))
  t.is(raw.schemaVersion, WIZARD_SCHEMA_VERSION)
  t.absent(raw.lnbits, 'legacy lnbits block dropped on save')
})

test('reset clears state back to welcome', async (t) => {
  const w = await makeWizard(t)
  w.setRelayName({ relayName: 'x' })
  w.setPayoutDestination({ address: VALID_BTC })
  w.setAcceptMode({ acceptMode: 'open' })
  w.complete()
  w.reset()
  const snap = w.snapshot()
  t.is(snap.step, 'welcome')
  t.is(snap.acceptMode, 'review')
  t.absent(snap.isComplete)
  t.is(snap.payoutDestination, null)
})

test('schema version is exposed for forward compat checks', async (t) => {
  t.is(WIZARD_SCHEMA_VERSION, 3, 'schemaVersion is 3 after the lnbits→payout change')
})

test('storage permissions tightened to 0600 after save', async (t) => {
  const w = await makeWizard(t)
  w.setPayoutDestination({ address: VALID_BTC })
  await w.save()
  const st = await stat(w.storagePath)
  const perms = st.mode & 0o777
  t.is(perms, 0o600, 'wizard.json is owner-read/write only')
})