From 14a6819cd2cd9231b98d3c2e0ef0ce919d65fcd9 Mon Sep 17 00:00:00 2001 From: Thomas Coratger <60488569+tcoratger@users.noreply.github.com> Date: Thu, 16 Apr 2026 00:52:18 +0200 Subject: [PATCH] test(api): add API endpoint response conformance test vectors Introduces an ApiEndpointTest fixture that generates JSON test vectors for the 4 deterministic API endpoints. Given genesis parameters, the fixture builds a store and computes the exact expected HTTP response (status code, content type, body) for each endpoint. Endpoints covered: - GET /lean/v0/health (static JSON payload) - GET /lean/v0/checkpoints/justified (slot + root from store) - GET /lean/v0/states/finalized (hex-encoded SSZ bytes) - GET /lean/v0/fork_choice (full tree with weights and checkpoints) 6 test vectors with two genesis configurations (4 and 12 validators) to verify that different validator counts produce different roots. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../testing/src/consensus_testing/__init__.py | 4 + .../test_fixtures/__init__.py | 2 + .../test_fixtures/api_endpoint.py | 152 ++++++++++++++++++ .../devnet/api/test_api_endpoints.py | 47 ++++++ 4 files changed, 205 insertions(+) create mode 100644 packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py create mode 100644 tests/consensus/devnet/api/test_api_endpoints.py diff --git a/packages/testing/src/consensus_testing/__init__.py b/packages/testing/src/consensus_testing/__init__.py index 077bc352..57fe7f0f 100644 --- a/packages/testing/src/consensus_testing/__init__.py +++ b/packages/testing/src/consensus_testing/__init__.py @@ -5,6 +5,7 @@ from . import forks from .genesis import generate_pre_state from .test_fixtures import ( + ApiEndpointTest, BaseConsensusFixture, ForkChoiceTest, GossipsubHandlerTest, @@ -36,6 +37,7 @@ SSZTestFiller = Type[SSZTest] NetworkingCodecTestFiller = Type[NetworkingCodecTest] GossipsubHandlerTestFiller = Type[GossipsubHandlerTest] +ApiEndpointTestFiller = Type[ApiEndpointTest] __all__ = [ # Public API @@ -54,6 +56,7 @@ "SSZTest", "NetworkingCodecTest", "GossipsubHandlerTest", + "ApiEndpointTest", # Test types "BaseForkChoiceStep", "TickStep", @@ -72,4 +75,5 @@ "SSZTestFiller", "NetworkingCodecTestFiller", "GossipsubHandlerTestFiller", + "ApiEndpointTestFiller", ] diff --git a/packages/testing/src/consensus_testing/test_fixtures/__init__.py b/packages/testing/src/consensus_testing/test_fixtures/__init__.py index 8ceb9b99..9be49522 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/__init__.py +++ b/packages/testing/src/consensus_testing/test_fixtures/__init__.py @@ -1,5 +1,6 @@ """Consensus test fixture format definitions (Pydantic models).""" +from .api_endpoint import ApiEndpointTest from .base import BaseConsensusFixture from .fork_choice import ForkChoiceTest from .gossipsub_handler import GossipsubHandlerTest @@ -16,4 +17,5 @@ "SSZTest", "NetworkingCodecTest", "GossipsubHandlerTest", + "ApiEndpointTest", ] diff --git a/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py b/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py new file mode 100644 index 00000000..fbffc666 --- /dev/null +++ b/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py @@ -0,0 +1,152 @@ +"""API endpoint response conformance fixtures.""" + +from collections.abc import Callable +from typing import Any, ClassVar + +from lean_spec.subspecs.containers import BlockBody, Slot, ValidatorIndex +from lean_spec.subspecs.containers.block import Block +from lean_spec.subspecs.containers.block.types import AggregatedAttestations +from lean_spec.subspecs.containers.state import State +from lean_spec.subspecs.forkchoice.store import Store +from lean_spec.subspecs.ssz.hash import hash_tree_root +from lean_spec.types import Bytes32, Uint64 + +from ..genesis import generate_pre_state +from .base import BaseConsensusFixture + + +def _make_genesis_block(state: State) -> Block: + """Build a slot-0 block anchored to the given genesis state.""" + body = BlockBody(attestations=AggregatedAttestations(data=[])) + return Block( + slot=Slot(0), + proposer_index=ValidatorIndex(0), + parent_root=Bytes32.zero(), + state_root=Bytes32(hash_tree_root(state)), + body=body, + ) + + +def _build_store(num_validators: int, genesis_time: int) -> Store: + """Build a deterministic genesis-only store. Same params always produce same roots.""" + state = generate_pre_state(genesis_time=Uint64(genesis_time), num_validators=num_validators) + block = _make_genesis_block(state) + # No validator identity — fixture only reads store data, never signs. + return Store.from_anchor(state, block, validator_id=None) + + +def _health_response(_store: Store) -> dict[str, Any]: + """Static liveness check. Independent of consensus state.""" + return { + "expected_status_code": 200, + "expected_content_type": "application/json", + "expected_body": {"status": "healthy", "service": "lean-rpc-api"}, + } + + +def _justified_response(store: Store) -> dict[str, Any]: + """Latest justified checkpoint: slot + root. Root varies with validator count.""" + return { + "expected_status_code": 200, + "expected_content_type": "application/json", + "expected_body": { + "slot": int(store.latest_justified.slot), + "root": "0x" + store.latest_justified.root.hex(), + }, + } + + +def _finalized_state_response(store: Store) -> dict[str, Any]: + """Full SSZ-encoded finalized state as hex bytes.""" + state = store.states[store.latest_finalized.root] + return { + "expected_status_code": 200, + "expected_content_type": "application/octet-stream", + "expected_body": "0x" + state.encode_bytes().hex(), + } + + +def _fork_choice_response(store: Store) -> dict[str, Any]: + """Fork choice tree: blocks with weights, head, checkpoints, validator count.""" + weights = store.compute_block_weights() + + # Only post-finalization blocks are relevant to head selection. + nodes = [ + { + "root": "0x" + root.hex(), + "slot": int(block.slot), + "parent_root": "0x" + block.parent_root.hex(), + "proposer_index": int(block.proposer_index), + "weight": weights.get(root, 0), + } + for root, block in store.blocks.items() + if block.slot >= store.latest_finalized.slot + ] + + # Validator count from head state (most current view). + head_state = store.states.get(store.head) + return { + "expected_status_code": 200, + "expected_content_type": "application/json", + "expected_body": { + "nodes": nodes, + "head": "0x" + store.head.hex(), + "justified": { + "slot": int(store.latest_justified.slot), + "root": "0x" + store.latest_justified.root.hex(), + }, + "finalized": { + "slot": int(store.latest_finalized.slot), + "root": "0x" + store.latest_finalized.root.hex(), + }, + "safe_target": "0x" + store.safe_target.hex(), + "validator_count": len(head_state.validators) if head_state is not None else 0, + }, + } + + +_ENDPOINT_HANDLERS: dict[str, Callable[[Store], dict[str, Any]]] = { + "/lean/v0/health": _health_response, + "/lean/v0/checkpoints/justified": _justified_response, + "/lean/v0/states/finalized": _finalized_state_response, + "/lean/v0/fork_choice": _fork_choice_response, +} +"""Maps endpoint paths to response builders.""" + + +class ApiEndpointTest(BaseConsensusFixture): + """Fixture for API endpoint response conformance. + + JSON output: endpoint, genesisParams, expectedStatusCode, + expectedContentType, expectedBody. + """ + + format_name: ClassVar[str] = "api_endpoint" + description: ClassVar[str] = "Tests API endpoint responses against known state" + + endpoint: str + """API path under test, e.g. /lean/v0/health.""" + + genesis_params: dict[str, int] + """Genesis store inputs: numValidators and genesisTime.""" + + expected_status_code: int = 0 + """HTTP status code. Filled by make_fixture.""" + + expected_content_type: str = "" + """Response MIME type. Filled by make_fixture.""" + + expected_body: Any = None + """Response payload. JSON dict or hex SSZ string. Filled by make_fixture.""" + + def make_fixture(self) -> "ApiEndpointTest": + """Build genesis store, compute expected response, populate output fields.""" + handler = _ENDPOINT_HANDLERS.get(self.endpoint) + if handler is None: + raise ValueError(f"Unknown endpoint: {self.endpoint}") + + store = _build_store( + num_validators=self.genesis_params.get("numValidators", 4), + genesis_time=self.genesis_params.get("genesisTime", 0), + ) + return self.model_copy(update=handler(store)) diff --git a/tests/consensus/devnet/api/test_api_endpoints.py b/tests/consensus/devnet/api/test_api_endpoints.py new file mode 100644 index 00000000..6c90341d --- /dev/null +++ b/tests/consensus/devnet/api/test_api_endpoints.py @@ -0,0 +1,47 @@ +""" +Test vectors for API endpoint responses. + +Each test generates a JSON fixture that client teams use to validate their +API server returns the exact same response given the same genesis parameters. +""" + +import pytest +from consensus_testing import ApiEndpointTestFiller + +pytestmark = pytest.mark.valid_until("Devnet") + +GENESIS_4V = {"numValidators": 4, "genesisTime": 0} +"""Minimal genesis: 4 validators at epoch 0.""" + +GENESIS_12V = {"numValidators": 12, "genesisTime": 0} +"""Larger genesis: 12 validators produce a different state root than 4.""" + + +def test_health(api_endpoint: ApiEndpointTestFiller) -> None: + """Health returns a fixed payload independent of consensus state.""" + api_endpoint(endpoint="/lean/v0/health", genesis_params=GENESIS_4V) + + +def test_justified_checkpoint_4v(api_endpoint: ApiEndpointTestFiller) -> None: + """Justified checkpoint at genesis with 4 validators.""" + api_endpoint(endpoint="/lean/v0/checkpoints/justified", genesis_params=GENESIS_4V) + + +def test_justified_checkpoint_12v(api_endpoint: ApiEndpointTestFiller) -> None: + """Justified checkpoint at genesis with 12 validators. Root differs from 4v.""" + api_endpoint(endpoint="/lean/v0/checkpoints/justified", genesis_params=GENESIS_12V) + + +def test_finalized_state_4v(api_endpoint: ApiEndpointTestFiller) -> None: + """Full SSZ-encoded finalized state for a 4-validator genesis.""" + api_endpoint(endpoint="/lean/v0/states/finalized", genesis_params=GENESIS_4V) + + +def test_fork_choice_4v(api_endpoint: ApiEndpointTestFiller) -> None: + """Fork choice tree at genesis: single node, zero attestation weights.""" + api_endpoint(endpoint="/lean/v0/fork_choice", genesis_params=GENESIS_4V) + + +def test_fork_choice_12v(api_endpoint: ApiEndpointTestFiller) -> None: + """Fork choice tree at genesis with 12 validators. Same shape, higher count.""" + api_endpoint(endpoint="/lean/v0/fork_choice", genesis_params=GENESIS_12V)