// Tests of drand.ts: the release of a round from the public relays, raced, // with a fetch that stands for the relays. import { describe, expect, it } from 'vitest'; import { fetchRelease, RELAYS } from './drand.ts'; const CHAIN = '52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971'; // The release of round 32668196, as api.drand.sh gave it. const SIG = '8d7fdb1526df070026a1da478690f84e39daf609407f9516096da4ed2ae43641f250b8fc470bebc0de133b8d0a76e38a'; const RANDOMNESS = '46b86c3aaf32c9f0695ec2cdf6fa4b77812791917ef74e6544b8473a2b9c6315'; const ROUND = 32668196; type Answer = Response | Error | 'hang'; // A fetch that answers each relay as `answers` says, and records what it was asked. function relays(answers: Record, asked: string[] = []): typeof fetch { return ((url: string, init?: RequestInit) => { asked.push(url); const a = answers[new URL(url).host]!; if (a === 'hang') { return new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason))); } return a instanceof Error ? Promise.reject(a) : Promise.resolve(a); }) as typeof fetch; } const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); const good = () => json({ round: ROUND, signature: SIG, randomness: RANDOMNESS }); describe('fetchRelease', () => { it('asks every relay for the round of the chain, and takes the first release that holds together', async () => { const asked: string[] = []; const r = await fetchRelease(CHAIN, ROUND, relays({ 'api.drand.sh': new TypeError('offline'), 'api2.drand.sh': good(), 'api3.drand.sh': 'hang' }, asked)); expect(r).toEqual({ round: ROUND, signature: SIG, relay: 'https://api2.drand.sh' }); expect(asked).toEqual(RELAYS.map((h) => `${h}/v2/chains/${CHAIN}/rounds/${ROUND}`)); const noRandomness = await fetchRelease(CHAIN, ROUND, relays({ 'api.drand.sh': json({ round: ROUND, signature: SIG.toUpperCase() }), 'api2.drand.sh': 'hang', 'api3.drand.sh': 'hang' })); expect(noRandomness.signature).toBe(SIG); }); it('says when drand has not published the round yet', async () => { const notYet = () => new Response('not found', { status: 404 }); await expect(fetchRelease(CHAIN, ROUND, relays({ 'api.drand.sh': notYet(), 'api2.drand.sh': notYet(), 'api3.drand.sh': notYet() }))).rejects.toThrow( `drand aún no ha publicado la firma de la ronda ${ROUND}.`, ); }); it('names what went wrong with each relay', async () => { const answers: Record = { 'api.drand.sh': new Response('nope', { status: 500 }), 'api2.drand.sh': new Response('', { status: 200 }), 'api3.drand.sh': json({ round: ROUND + 1, signature: SIG }), }; await expect(fetchRelease(CHAIN, ROUND, relays(answers))).rejects.toThrow( `Ningún relay de drand dio la firma: api.drand.sh respondió HTTP 500; api2.drand.sh respondió algo que no es una firma; api3.drand.sh dio la ronda ${ROUND + 1}, no la ${ROUND}.`, ); const worse: Record = { 'api.drand.sh': json({ round: ROUND, signature: 'xyz' }), 'api2.drand.sh': json({ round: ROUND, signature: SIG, randomness: '00' }), 'api3.drand.sh': new Response('x'.repeat(9000), { status: 200 }), }; await expect(fetchRelease(CHAIN, ROUND, relays(worse))).rejects.toThrow( 'Ningún relay de drand dio la firma: api.drand.sh dio una firma que no es hexadecimal; api2.drand.sh dio una aleatoriedad que no es la de su firma; api3.drand.sh respondió demasiado.', ); const odd: Record = { 'api.drand.sh': new Response(null, { status: 200 }), 'api2.drand.sh': json(null), 'api3.drand.sh': new TypeError('blocked'), }; await expect(fetchRelease(CHAIN, ROUND, relays(odd))).rejects.toThrow( 'Ningún relay de drand dio la firma: api.drand.sh respondió algo que no es una firma; api2.drand.sh respondió algo que no es una firma; api3.drand.sh no se pudo conectar.', ); }); // Spec v0.16, §47.1: the client of the reference reads the answers of the relays with the strict reader of drand's // JSON, as a release that the caller gives: a name twice is malformed, and ROUND is another name, ignored. it('reads an answer with the strict reader of drand JSON', async () => { const text = (body: string): Response => new Response(body, { status: 200 }); const answers: Record = { 'api.drand.sh': text(`{"round":${ROUND},"round":${ROUND},"signature":"${SIG}"}`), 'api2.drand.sh': text(`{"round":${ROUND},"signature":""}`), 'api3.drand.sh': text(`{"round":${ROUND},"signature":"${SIG}","randomness":null}`), }; await expect(fetchRelease(CHAIN, ROUND, relays(answers))).rejects.toThrow( 'Ningún relay de drand dio la firma: api.drand.sh respondió algo que no es una firma; api2.drand.sh dio una firma que no es hexadecimal; api3.drand.sh respondió algo que no es una firma.', ); const other = await fetchRelease( CHAIN, ROUND, relays({ 'api.drand.sh': text(`{"\\u0072ound":${ROUND},"ROUND":${ROUND + 1},"signature":"${SIG}"}`), 'api2.drand.sh': 'hang', 'api3.drand.sh': 'hang' }), ); expect(other).toEqual({ round: ROUND, signature: SIG, relay: 'https://api.drand.sh' }); }); // The client of the reference reads the body with provider.ParseDrandJSON, for which a byte order mark before the JSON // is not a space of JSON. it('refuses an answer that starts with a byte order mark, as the client of the reference does', async () => { const body = new TextEncoder().encode(JSON.stringify({ round: ROUND, signature: SIG, randomness: RANDOMNESS })); const bom = new Response(new Uint8Array([0xef, 0xbb, 0xbf, ...body]), { status: 200 }); const offline = (): Error => new TypeError('offline'); await expect(fetchRelease(CHAIN, ROUND, relays({ 'api.drand.sh': bom, 'api2.drand.sh': offline(), 'api3.drand.sh': offline() }))).rejects.toThrow( 'Ningún relay de drand dio la firma: api.drand.sh respondió algo que no es una firma; api2.drand.sh no se pudo conectar; api3.drand.sh no se pudo conectar.', ); }); it('gives up after its timeout', async () => { await expect(fetchRelease(CHAIN, ROUND, relays({ 'api.drand.sh': 'hang', 'api2.drand.sh': 'hang', 'api3.drand.sh': 'hang' }), 20)).rejects.toThrow( 'Ningún relay de drand dio la firma: api.drand.sh no contestó a tiempo; api2.drand.sh no contestó a tiempo; api3.drand.sh no contestó a tiempo.', ); const late = ((_: string, init?: RequestInit) => new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError'))))) as typeof fetch; await expect(fetchRelease(CHAIN, ROUND, late, 20)).rejects.toThrow('api.drand.sh no contestó a tiempo'); }); });