import { describe, test, expect } from 'vitest' import { createMemoryStores } from '../src/store/memory.js' import { createHostRegistry } from '../src/registry/hosts.js' import { createRoutingTable } from '../src/routing/table.js' import { createInMemoryRevocationBus } from '../src/routing/bus.js' import { createRevoker, createInMemoryRevokedTokenStore } from '../src/revoke/revoke.js' import { fingerprint } from '../src/ca/fingerprint.js' import { generateEd25519 } from '../src/util/crypto.js' import type { KillSignal } from 'relay-contracts' import type { NodeIdentity } from '../src/node-auth/identity.js' const node: NodeIdentity = { nodeId: 'spiffe://relay/node-a' } const ACCOUNT = '11111111-1111-4111-8111-111111111111' async function harness() { const stores = createMemoryStores() const hosts = createHostRegistry({ hosts: stores.hosts }) const routing = createRoutingTable({ routes: stores.routes }) const bus = createInMemoryRevocationBus() const tokens = createInMemoryRevokedTokenStore() const revoker = createRevoker({ hosts, routing, bus, tokens }) const { publicKeyRaw } = generateEd25519() const host = await hosts.bindHost({ accountId: ACCOUNT, subdomain: 'alice', agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) }) await routing.upsertRoute(node, host.hostId, { relayNodeId: node.nodeId, updatedAt: new Date().toISOString() }, 60) return { stores, hosts, routing, bus, tokens, revoker, host } } describe('T13 revocation (INV12/INV8/INV10)', () => { test('revokeHost: route dropped + host revoked + KillSignal on relay:revocations, zero payload', async () => { const h = await harness() const received: KillSignal[] = [] h.bus.subscribe((s) => received.push(s)) await h.revoker.revokeHost(h.host.hostId) expect(await h.routing.resolveRoute(h.host.hostId)).toBeNull() expect((await h.hosts.getHost(h.host.hostId))?.status).toBe('revoked') expect(received.length).toBe(1) expect(received[0]?.scope).toEqual({ kind: 'host', hostId: h.host.hostId }) // zero-payload: reason is short metadata, contains no terminal bytes expect(received[0]?.reason).toBe('revoked') }) test('revokeHost is idempotent', async () => { const h = await harness() await h.revoker.revokeHost(h.host.hostId) await expect(h.revoker.revokeHost(h.host.hostId)).resolves.toBeUndefined() }) test('revokeAccount publishes ONE account-scoped signal + cascades to hosts', async () => { const h = await harness() const received: KillSignal[] = [] h.bus.subscribe((s) => received.push(s)) await h.revoker.revokeAccount(ACCOUNT) expect((await h.hosts.getHost(h.host.hostId))?.status).toBe('revoked') const accountSignals = received.filter((s) => s.scope.kind === 'account') expect(accountSignals.length).toBe(1) }) test('revokeToken → isTokenRevoked true, stops validating', async () => { const h = await harness() const jti = 'jti-123' expect(await h.revoker.isTokenRevoked(jti)).toBe(false) await h.revoker.revokeToken(jti, Math.floor(Date.now() / 1000) + 3600) expect(await h.revoker.isTokenRevoked(jti)).toBe(true) }) })