// Unit: #74 Phase 1 — the replication-triggered SSH resync.
//
// The add/remove handlers resync only the daemon that took the write. A key
// enrolled on a PEER lands in the replicated IdentityRegistry view but stays
// blind in this daemon's authorized_keys until its next boot (#73). The
// autobase 'update' event (local append AND remote replication) closes that
// gap. These tests pin: (1) it debounces a burst into one rebuild; (2) an
// 'update' materializes a peer-enrolled key end-to-end via the REAL
// svc._resyncAuthorizedKeys; (3) detach() stops further triggers.
import test from 'brittle'
import { mkdtempSync, readFileSync, rmSync } from '#node:fs'
import os from '#node:os'
import { join } from '#node:path'
import { attachSshResyncTrigger } from '../lib/auth/ssh-resync-trigger.js'
import { sshKeyHandlers } from '../lib/handlers/ssh-keys.js'
import { readManagedFingerprints } from '../lib/auth/authorized-keys.js'
const KDIR = mkdtempSync(join(os.tmpdir(), 'pgv3-sshresync-'))
const PUBKEY = 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHxYcab1fod7CwpYISeVVOs7wG7EoYTC7AGJSUEOD/W1 contributor@test'
const delay = (ms) => new Promise((r) => setTimeout(r, ms))
// Minimal autobase-shaped event source — exercises the real on/off wiring
// without pulling in a runtime EventEmitter.
function fakeBase () {
const listeners = new Map()
return {
on (ev, fn) { if (!listeners.has(ev)) listeners.set(ev, new Set()); listeners.get(ev).add(fn) },
off (ev, fn) { const s = listeners.get(ev); if (s) s.delete(fn) },
emit (ev) { const s = listeners.get(ev); if (s) for (const fn of Array.from(s)) fn() }
}
}
test('debounces an update burst into a single resync, refires on a later update', async (t) => {
const base = fakeBase()
let calls = 0
const detach = attachSshResyncTrigger({
base,
resync: async () => { calls++ },
debounceMs: 25
})
base.emit('update'); base.emit('update'); base.emit('update') // burst
await delay(60)
t.is(calls, 1, 'three rapid updates coalesced into one resync')
base.emit('update')
await delay(60)
t.is(calls, 2, 'a later update fires again')
detach()
})
test('detach removes the listener and cancels a pending timer', async (t) => {
const base = fakeBase()
let calls = 0
const detach = attachSshResyncTrigger({
base,
resync: async () => { calls++ },
debounceMs: 25
})
base.emit('update') // schedules a resync 25ms out…
detach() // …cancelled before it fires
await delay(60)
t.is(calls, 0, 'pending resync cancelled by detach')
base.emit('update') // listener is gone
await delay(60)
t.is(calls, 0, 'no resync after detach')
})
test('no-op for an unusable base or resync (safe to wire unconditionally)', (t) => {
t.is(typeof attachSshResyncTrigger({ base: null, resync: () => {} }), 'function', 'null base → empty detach')
t.is(typeof attachSshResyncTrigger({ base: fakeBase(), resync: null }), 'function', 'null resync → empty detach')
// empty detach must be callable without throwing
attachSshResyncTrigger({ base: null, resync: null })()
t.pass('empty detach is callable')
})
// End-to-end: a peer enrolls a key (present in the replicated view, ABSENT
// from the local-db enumerator). An autobase 'update' must materialize it
// into THIS daemon's authorized_keys via the real resync — the #73 fix.
test('update materializes a peer-enrolled key into authorized_keys (#73)', async (t) => {
const remote = {
identityId: 'ssh:SHA256:peer',
method: 'ssh-key',
publicKey: PUBKEY,
credentials: { fingerprint: 'SHA256:peer', type: 'ssh-ed25519' }
}
let present = false // becomes true when the peer's write replicates in
const reg = {
async listAllIdentities () { return [] }, // local-db enumerator stays empty on a mirror
async listUsers () { return present ? [{ userId: 'peer', identities: ['ssh:SHA256:peer'] }] : [] },
async getUserWithIdentities (uid) {
return (present && uid === 'peer') ? { user: { userId: 'peer' }, identities: [remote] } : null
},
async isRevoked () { return false }
}
const akPath = join(KDIR, 'authorized_keys_e2e')
const svc = { identityRegistry: reg, _sync: { repos: new Set(['demo']) } }
for (const { name, fn } of sshKeyHandlers(svc, { authorizedKeysPath: akPath, wrapperPath: '/opt/pgv3-git-shell' })) {
void name; void fn // handlers register svc._resyncAuthorizedKeys as a side effect
}
const base = fakeBase()
const detach = attachSshResyncTrigger({
base,
resync: () => svc._resyncAuthorizedKeys(),
debounceMs: 25
})
// Before replication: an update with nothing new writes an empty block.
base.emit('update')
await delay(60)
t.alike(readManagedFingerprints(readFileSync(akPath, 'utf8')), [], 'no peer key yet')
// Peer's write replicates in; the autobase fires 'update'.
present = true
base.emit('update')
await delay(60)
t.alike(readManagedFingerprints(readFileSync(akPath, 'utf8')), ['SHA256:peer'],
'peer-enrolled key materialized on replication update, no reboot')
detach()
})
test.hook('cleanup', () => { rmSync(KDIR, { recursive: true, force: true }) })