| … | … | @@ -0,0 +1,80 @@ |
| 1 | // The operator MUST be able to evict a front over the UDS control plane — without restarting the process. |
| 2 | // |
| 3 | // Twice on 2026-07-27 the only way to stop the membrane serving a wrong head (a peer announcing a FORKED |
| 4 | // line whose rev sorts higher, so freshest-by-rev kept picking it) was to bounce atproto-bridge. That is |
| 5 | // not acceptable for a production read surface: a restart drops every OTHER fronted DID's warm replica too. |
| 6 | // See hyper-social#16 P0. The router-level behaviour (fallback reads, denial across re-announce, age-out) |
| 7 | // is proven over a real testnet in atproto-did-peer/test/evict.test.mjs; this asserts the CONTROL SURFACE |
| 8 | // is wired to it, since a capability with no operator surface is one nobody can use at 3am. |
| 9 | import test from 'brittle' |
| 10 | import { mkdtempSync, rmSync } from 'fs' |
| 11 | import { join } from 'path' |
| 12 | import { tmpdir } from 'os' |
| 13 | import { Client } from 'hyper-svc' |
| 14 | import { startControl } from '../lib/control.js' |
| 15 | import { mkDidRouter } from './mk-did-router.js' |
| 16 | |
| 17 | const DID = 'did:plc:evictme000000000000000' |
| 18 | |
| 19 | test('control plane: drop-front / allow-front / forget-did evict a cached front in place', async (t) => { |
| 20 | const fakeSwarm = { on () {}, off () {}, join () { return { flushed: async () => {} } }, flush: async () => {}, connections: new Set() } |
| 21 | const router = mkDidRouter(fakeSwarm, {}, { fronted: [DID], refreshMs: 0 }) |
| 22 | |
| 23 | // Two co-owners of one DID, as the bridge caches them: 'good' + 'forked' (the higher-rev fork). |
| 24 | const mkFront = (rev) => ({ handle: null, _repoKey: null, head: { rev }, seq: { core: { length: 3 } }, rpc: null, close: async () => {} }) |
| 25 | router._fronts.set(DID, new Map([['good', mkFront('3aaa')], ['forked', mkFront('3zzz')]])) |
| 26 | router._peers.set(DID, router._fronts.get(DID).get('forked')) |
| 27 | router._byDid.set(DID, new Map([['good', {}], ['forked', {}]])) |
| 28 | |
| 29 | const udsDir = mkdtempSync(join(tmpdir(), 'evict-uds-')) |
| 30 | const uds = join(udsDir, 'svc.sock') |
| 31 | const control = await startControl({ uds, name: 'atproto-bridge-evict-test', peers: router }) |
| 32 | const cli = await Client.connectUds(uds) |
| 33 | t.teardown(async () => { |
| 34 | await cli.close(); await control.close() |
| 35 | rmSync(udsDir, { recursive: true, force: true }) |
| 36 | }) |
| 37 | |
| 38 | t.is(router._freshestFront(DID).head.rev, '3zzz', 'the forked (higher-rev) front is what reads resolve to') |
| 39 | |
| 40 | // --- drop-front with forget: evict the fork and keep it out --- |
| 41 | const dropped = await cli.call('drop-front', { did: DID, firehoseKey: 'forked', forget: true }) |
| 42 | t.ok(dropped.ok, 'drop-front returns ok') |
| 43 | t.alike(dropped.dropped, ['forked'], 'drop-front reports the evicted firehose key') |
| 44 | t.is(router._freshestFront(DID).head.rev, '3aaa', 'reads now resolve to the good co-owner — no restart') |
| 45 | t.absent(router._fronts.get(DID).has('forked'), 'the evicted front is out of the co-owner set') |
| 46 | t.ok(router._isDenied(DID, 'forked'), 'the evicted firehose key is DENIED (a re-announce cannot resurrect it)') |
| 47 | |
| 48 | // The primary must have been re-pointed off the closed front, not left dangling. |
| 49 | t.is(router._peers.get(DID), router._fronts.get(DID).get('good'), 'primary re-pointed at a surviving front') |
| 50 | |
| 51 | const diag = await cli.call('diagnose') |
| 52 | t.alike(diag.denied[DID], ['forked'], 'diagnose SHOWS the denial (an operator can see what is evicted)') |
| 53 | |
| 54 | // --- allow-front: the undo --- |
| 55 | const allowed = await cli.call('allow-front', { did: DID, firehoseKey: 'forked' }) |
| 56 | t.alike(allowed.allowed, ['forked'], 'allow-front clears the denial') |
| 57 | t.absent(router._isDenied(DID, 'forked'), 'no longer denied — the next announce may re-front it') |
| 58 | |
| 59 | // --- forget-did: drop everything cached for the DID, deny nothing --- |
| 60 | const forgot = await cli.call('forget-did', { did: DID }) |
| 61 | t.ok(forgot.ok, 'forget-did returns ok') |
| 62 | t.alike(forgot.dropped, ['good'], 'forget-did drops the remaining front') |
| 63 | t.absent(router._fronts.has(DID), 'no cached fronts remain for the DID') |
| 64 | t.absent(router._byDid.has(DID), 'identified connections cleared — the next request re-fronts from scratch') |
| 65 | t.ok(router.isFronted(DID), 'the DID is STILL fronted (forget-did must not make it unservable)') |
| 66 | t.absent(router._denied.has(DID), 'forget-did denies nothing') |
| 67 | }) |
| 68 | |
| 69 | test('control plane: eviction handlers fail closed without a did / router', async (t) => { |
| 70 | const udsDir = mkdtempSync(join(tmpdir(), 'evict-uds2-')) |
| 71 | const uds = join(udsDir, 'svc.sock') |
| 72 | const control = await startControl({ uds, name: 'atproto-bridge-evict-test2', peers: null }) |
| 73 | const cli = await Client.connectUds(uds) |
| 74 | t.teardown(async () => { await cli.close(); await control.close(); rmSync(udsDir, { recursive: true, force: true }) }) |
| 75 | |
| 76 | for (const m of ['drop-front', 'allow-front', 'forget-did']) { |
| 77 | const r = await cli.call(m, {}) |
| 78 | t.absent(r.ok, `${m} with no peers router reports not-ok instead of throwing`) |
| 79 | } |
| 80 | }) |