OverviewFilesCommitsIssuesDocsReleasesPeers

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

219 lines · javascript ref:
import test from 'brittle'
import { ReputationSystem } from 'p2p-hiverelay/incentive/reputation/index.js'
import { mkdtemp, rm, readdir, readFile } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'

test('ReputationSystem - records challenges and computes score', async (t) => {
  const rep = new ReputationSystem()
  const relay = 'abc123'

  rep.recordChallenge(relay, true, 200)
  rep.recordChallenge(relay, true, 300)
  rep.recordChallenge(relay, false, 0)

  const record = rep.getRecord(relay)
  t.is(record.totalChallenges, 3)
  t.is(record.passedChallenges, 2)
  t.is(record.failedChallenges, 1)
  t.ok(record.score >= 0, 'score is non-negative')
  t.is(record.avgLatencyMs, 250)
})

test('ReputationSystem - reliability calculation', async (t) => {
  const rep = new ReputationSystem()
  const relay = 'def456'

  for (let i = 0; i < 8; i++) rep.recordChallenge(relay, true, 100)
  for (let i = 0; i < 2; i++) rep.recordChallenge(relay, false, 0)

  const reliability = rep.getReliability(relay)
  t.is(reliability, 0.8, '80% reliability')
})

test('ReputationSystem - decay reduces scores', async (t) => {
  const rep = new ReputationSystem()
  const relay = 'ghi789'

  rep.recordChallenge(relay, true, 100)
  const before = rep.getScore(relay)

  rep.applyDecay()
  const after = rep.getScore(relay)

  t.ok(after < before, 'score decreased after decay')
})

test('ReputationSystem - leaderboard ranking', async (t) => {
  const rep = new ReputationSystem()

  // Create 3 relays with different scores
  for (let i = 0; i < 15; i++) rep.recordChallenge('relay-a', true, 100)
  for (let i = 0; i < 12; i++) rep.recordChallenge('relay-b', true, 200)
  for (let i = 0; i < 10; i++) rep.recordChallenge('relay-c', true, 500)

  const board = rep.getLeaderboard()
  t.is(board.length, 3)
  t.is(board[0].relay, 'relay-a', 'highest score first')
})

test('ReputationSystem - leaderboard ties are deterministic', async (t) => {
  const rep = new ReputationSystem()
  rep.import({
    'relay-b': reputationRecord({ score: 100, avgLatencyMs: 250 }),
    'relay-a': reputationRecord({ score: 100, avgLatencyMs: 250 }),
    'relay-c': reputationRecord({ score: 100, avgLatencyMs: 250 })
  })

  t.alike(rep.getLeaderboard().map(r => r.relay), ['relay-a', 'relay-b', 'relay-c'])
})

test('ReputationSystem - selectRelays picks best', async (t) => {
  const rep = new ReputationSystem()

  for (let i = 0; i < 20; i++) rep.recordChallenge('good', true, 100)
  for (let i = 0; i < 15; i++) rep.recordChallenge('ok', true, 300)
  for (let i = 0; i < 10; i++) rep.recordChallenge('bad', false, 0)
  for (let i = 0; i < 5; i++) rep.recordChallenge('bad', true, 1000)

  const selected = rep.selectRelays(2)
  t.is(selected.length, 2)
  t.is(selected[0], 'good', 'best relay selected first')
})

test('ReputationSystem - selectRelays ties are deterministic', async (t) => {
  const rep = new ReputationSystem()
  rep.import({
    'relay-b': reputationRecord({ score: 100, avgLatencyMs: 100 }),
    'relay-a': reputationRecord({ score: 100, avgLatencyMs: 100 }),
    'relay-c': reputationRecord({ score: 100, avgLatencyMs: 100 })
  })

  t.alike(rep.selectRelays(3), ['relay-a', 'relay-b', 'relay-c'])
})

test('ReputationSystem - export and import', async (t) => {
  const rep1 = new ReputationSystem()
  for (let i = 0; i < 10; i++) rep1.recordChallenge('relay-x', true, 150)

  const exported = rep1.export()

  const rep2 = new ReputationSystem()
  rep2.import(exported)

  t.is(rep2.getScore('relay-x'), rep1.getScore('relay-x'))
  t.is(rep2.getReliability('relay-x'), 1.0)
})

test('ReputationSystem - export order is deterministic and skips forbidden keys', async (t) => {
  const rep = new ReputationSystem()
  const poisoned = JSON.parse(JSON.stringify({
    'relay-c': reputationRecord({ score: 3 }),
    'relay-a': reputationRecord({ score: 1 }),
    constructor: reputationRecord({ score: 99 }),
    'relay-b': reputationRecord({ score: 2 })
  }))
  Object.defineProperty(poisoned, '__proto__', {
    value: reputationRecord({ score: 99 }),
    enumerable: true
  })
  rep.import(poisoned)

  const exported = rep.export()
  t.alike(Object.keys(exported), ['relay-a', 'relay-b', 'relay-c'])
  t.absent(Object.prototype.hasOwnProperty.call(exported, '__proto__'))
  t.absent(Object.prototype.hasOwnProperty.call(exported, 'constructor'))
})

test('ReputationSystem - import sanitizes malformed persisted records', async (t) => {
  const rep = new ReputationSystem()
  rep.import({
    relay: {
      score: -10,
      totalChallenges: 10.9,
      passedChallenges: 50,
      failedChallenges: 50,
      avgLatencyMs: -1,
      totalBytesServed: '2048',
      totalUptimeHours: Number.POSITIVE_INFINITY,
      region: '<region>',
      geoBonus: 'yes',
      firstSeen: 123,
      lastActivity: 456
    },
    bad: null
  })

  const record = rep.getRecord('relay')
  t.is(record.score, 0)
  t.is(record.totalChallenges, 10)
  t.is(record.passedChallenges, 10)
  t.is(record.failedChallenges, 0)
  t.is(record.avgLatencyMs, 0)
  t.is(record.totalBytesServed, 2048)
  t.is(record.totalUptimeHours, 0)
  t.is(record.geoBonus, false)
  t.is(rep.getRecord('bad'), null)
})

test('ReputationSystem - save is atomic: round-trips and leaves no .tmp', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'hiverelay-rep-'))
  t.teardown(() => rm(dir, { recursive: true, force: true }))
  const filePath = join(dir, 'reputation.json')

  const rep = new ReputationSystem()
  for (let i = 0; i < 5; i++) rep.recordChallenge('relay-z', true, 120)
  await rep.save(filePath)

  // The tmp file must have been renamed away, never left behind.
  const files = await readdir(dir)
  t.absent(files.some(f => f.endsWith('.tmp')), 'no leftover .tmp after save')
  t.ok(files.includes('reputation.json'), 'final file present')

  const reloaded = await ReputationSystem.load(filePath)
  t.is(reloaded.getScore('relay-z'), rep.getScore('relay-z'), 'round-trips through disk')
})

test('ReputationSystem - failed save preserves the existing file (no corruption)', async (t) => {
  const dir = await mkdtemp(join(tmpdir(), 'hiverelay-rep-'))
  t.teardown(() => rm(dir, { recursive: true, force: true }))
  const filePath = join(dir, 'reputation.json')

  // Seed a known-good file.
  const good = new ReputationSystem()
  for (let i = 0; i < 3; i++) good.recordChallenge('relay-g', true, 100)
  await good.save(filePath)
  const goodBytes = await readFile(filePath, 'utf8')

  // Force the write to fail: point save at a path whose parent is a FILE,
  // so writeFile(tmp) throws. The original file must be untouched and no
  // tmp must be left behind.
  const rep = new ReputationSystem()
  rep.recordChallenge('relay-bad', true, 100)
  let errored = false
  rep.on('save-error', () => { errored = true })
  await rep.save(join(filePath, 'nested.json')) // filePath is a file, not a dir

  t.ok(errored, 'save-error emitted on failure')
  t.is(await readFile(filePath, 'utf8'), goodBytes, 'existing file untouched on failed save')
  const files = await readdir(dir)
  t.absent(files.some(f => f.endsWith('.tmp')), 'no leftover .tmp after failed save')
})

function reputationRecord (overrides = {}) {
  return {
    score: 100,
    totalChallenges: 10,
    passedChallenges: 10,
    failedChallenges: 0,
    avgLatencyMs: 100,
    totalBytesServed: 0,
    totalUptimeHours: 0,
    region: 'test',
    geoBonus: false,
    firstSeen: 1,
    lastActivity: 2,
    ...overrides
  }
}