OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / test / retired-repos.test.js

157 lines · javascript ref:
/**
 * Repo tombstones (services/retired-repos.js) + the no-delete invariant.
 *
 * Issue #135: deleting a bare repo by hand did not stick — four paths
 * resurrected or retained it. A tombstone propagates on the RepoEventLog so
 * every peer stops touching the repo, and THEN an operator removes bytes by
 * hand on each peer.
 *
 * The last test in this file is the important one: pear-git-v3 must never be
 * able to delete a bare repo itself. It is a regression guard, not a
 * description — if someone adds that capability, this fails.
 */

import test from 'brittle'
import { readdirSync, readFileSync, statSync } from '#node:fs'
import { join } from '#node:path'
import { fileURLToPath } from '#node:url'
import { dirname } from '#node:path'

import {
  RetiredRepos, foldRetired, OP_CREATED, OP_UPDATED, OP_RETIRED
} from '../lib/services/retired-repos.js'

const __dirname = dirname(fileURLToPath(import.meta.url))
const LIB = join(__dirname, '..', 'lib')

// A stand-in RepoEventLog: entries() yields decoded entries in seq order.
function fakeLog (entries) {
  return {
    async * entries () { for (const e of entries) yield e }
  }
}

test('op codes match the wire codec', (t) => {
  // repo-event-log-messages.js documents 0=created, 1=updated, 2=deleted.
  t.is(OP_CREATED, 0)
  t.is(OP_UPDATED, 1)
  t.is(OP_RETIRED, 2)
})

test('a retire op tombstones the repo', (t) => {
  const set = new RetiredRepos()
  set.applyEntry({ op: OP_CREATED, name: 'alpha' }, 0)
  t.absent(set.has('alpha'), 'created is not retired')

  set.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 1)
  t.ok(set.has('alpha'), 'retired after op 2')
  t.alike(set.names(), ['alpha'])
  t.is(set.size, 1)
})

test('a later create un-retires — the mistake is reversible', (t) => {
  const set = new RetiredRepos()
  set.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 0)
  t.ok(set.has('alpha'))

  set.applyEntry({ op: OP_CREATED, name: 'alpha' }, 1)
  t.absent(set.has('alpha'), 'un-retired by a later op 0')
  t.is(set.size, 0)

  set.applyEntry({ op: OP_UPDATED, name: 'alpha' }, 2)
  t.absent(set.has('alpha'), 'op 1 also un-retires')
})

test('older entries never clobber newer state (out-of-order replay)', (t) => {
  const set = new RetiredRepos()
  set.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 5)
  t.ok(set.has('alpha'))

  // A backlog entry arriving late must not resurrect it.
  set.applyEntry({ op: OP_CREATED, name: 'alpha' }, 2)
  t.ok(set.has('alpha'), 'stale seq 2 must not override seq 5')

  set.applyEntry({ op: OP_CREATED, name: 'alpha' }, 9)
  t.absent(set.has('alpha'), 'newer seq 9 does override')
})

test('unknown repos are not retired; junk entries are ignored', (t) => {
  const set = new RetiredRepos()
  t.absent(set.has('never-seen'))
  t.absent(set.applyEntry(null, 0), 'null entry')
  t.absent(set.applyEntry({ op: OP_RETIRED }, 1), 'entry with no name')
  t.is(set.size, 0)
})

test('folding a whole log rebuilds the set — derived, not stored', async (t) => {
  const set = await foldRetired(fakeLog([
    { op: OP_CREATED, name: 'alpha' },
    { op: OP_CREATED, name: 'beta' },
    { op: OP_RETIRED, name: 'alpha' },
    { op: OP_CREATED, name: 'gamma' },
    { op: OP_RETIRED, name: 'gamma' },
    { op: OP_CREATED, name: 'gamma' }
  ]))
  t.alike(set.names(), ['alpha'], 'alpha retired; gamma re-created; beta never retired')

  // Replay is idempotent — the log is replayed from seq 0 on every boot.
  const again = await foldRetired(fakeLog([
    { op: OP_CREATED, name: 'alpha' },
    { op: OP_CREATED, name: 'beta' },
    { op: OP_RETIRED, name: 'alpha' },
    { op: OP_CREATED, name: 'gamma' },
    { op: OP_RETIRED, name: 'gamma' },
    { op: OP_CREATED, name: 'gamma' }
  ]))
  t.alike(again.names(), set.names(), 'same fold, same result')
})

test('isRetired is a bindable predicate for the gate sites', (t) => {
  const set = new RetiredRepos()
  set.applyEntry({ op: OP_RETIRED, name: 'alpha' }, 0)
  const pred = set.isRetired
  t.ok(pred('alpha'))
  t.absent(pred('beta'))
})

test('folding an absent log is safe', async (t) => {
  const set = await foldRetired(null)
  t.is(set.size, 0, 'no log -> empty set, no throw')
})

// ───────────────────────────────────────────────────────────────────────────

test('INVARIANT: pear-git-v3 cannot delete a bare repo', (t) => {
  // The never-delete rule exists for two reasons: nothing should delete repos
  // on its own initiative, and no code bug should be able to mass-delete them.
  // Retiring is metadata only. If this test fails, someone gave the daemon the
  // ability to remove repo bytes — that is the thing we decided it must never
  // have, so the fix is to remove the capability, not to relax this test.
  const DELETE_CALLS = /\b(rmSync|rmdirSync|unlinkSync|rimraf)\s*\(|\brm\s*\(|\bunlink\s*\(/
  const REPO_PATHS = /reposDir|git-repos|PEAR_GIT_REPOS|\.git['"`]|repoPath/

  const offenders = []
  const walk = (dir) => {
    for (const e of readdirSync(dir)) {
      const p = join(dir, e)
      if (statSync(p).isDirectory()) {
        if (e === 'node_modules' || e === 'static') continue
        walk(p)
        continue
      }
      if (!e.endsWith('.js')) continue
      const src = readFileSync(p, 'utf8')
      src.split('\n').forEach((line, i) => {
        if (line.trim().startsWith('*') || line.trim().startsWith('//')) return
        if (DELETE_CALLS.test(line) && REPO_PATHS.test(line)) {
          offenders.push(`${p.slice(LIB.length + 1)}:${i + 1}  ${line.trim()}`)
        }
      })
    }
  }
  walk(LIB)

  t.alike(offenders, [],
    'no code path may delete repo bytes — found:\n  ' + offenders.join('\n  '))
})