Skip to content

Commit 03c48a9

Browse files
committed
feat(testutil/validatormock): port Phase 1 foundation
Mirrors `charon/testutil/validatormock` (Go) into Rust. This first phase lays the foundation that the propose/attest/synccomm/component ports build on: - `meta.rs` - SpecMeta + MetaSlot + MetaEpoch value types (port of meta.go). - `sign.rs` - `Sign` trait + `Signer` + `SignFunc = Arc<dyn Sign>` (Go's SignFunc + NewSigner) backed by `pluto-crypto` BlstImpl. - `validators.rs` - local `ActiveValidators` newtype + `active_validators(client)` helper. Avoids a `pluto-testutil -> pluto-app` dep, since `pluto-app` already dev-depends on this crate. - `capture.rs` - test-only `SubmissionCapture` wiremock helper. Equivalent of Go's `beaconMock.SubmitAttestationsFunc = ...` callback fields - mounts a high-priority `Mock` on `BeaconMock::server()` and records POST bodies. - `error.rs` - module-wide `Error` + `SignError` (thiserror). - `testdata/TestAttest_*.golden` - byte-for-byte copies of the Go fixtures used by the Phase-2 attest port. The Cargo cycle `pluto-eth2util <-> pluto-testutil` is broken by moving `pluto-testutil` from `[dependencies]` to `[dev-dependencies]` in `crates/eth2util/Cargo.toml`. The three call sites in `eth2util` (signing, eth2exp, enr tests) are all `#[cfg(test)]`, so the move is a no-op for production code. 11 unit tests pass; clippy + nightly fmt clean.
1 parent df773fb commit 03c48a9

14 files changed

Lines changed: 1029 additions & 3 deletions

Cargo.lock

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/eth2util/Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ pbkdf2.workspace = true
2121
scrypt.workspace = true
2222
unicode-normalization.workspace = true
2323
zeroize.workspace = true
24-
pluto-testutil.workspace = true
2524
pluto-k1util.workspace = true
2625
chrono.workspace = true
2726
regex.workspace = true
@@ -41,8 +40,9 @@ reqwest = { workspace = true, features = ["json"] }
4140
url.workspace = true
4241

4342
[dev-dependencies]
44-
tempfile.workspace = true
4543
assert-json-diff.workspace = true
44+
pluto-testutil.workspace = true
45+
tempfile.workspace = true
4646
test-case.workspace = true
4747
wiremock.workspace = true
4848

crates/testutil/Cargo.toml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,28 @@ serde_json.workspace = true
1212

1313
[dependencies]
1414
anyhow.workspace = true
15+
async-trait.workspace = true
1516
bon.workspace = true
1617
chrono.workspace = true
18+
futures = { workspace = true }
19+
hex.workspace = true
1720
k256.workspace = true
21+
pluto-core.workspace = true
1822
pluto-crypto.workspace = true
1923
pluto-eth2api.workspace = true
24+
pluto-eth2util.workspace = true
2025
rand.workspace = true
21-
hex.workspace = true
26+
serde.workspace = true
2227
serde_json.workspace = true
2328
thiserror.workspace = true
2429
tokio.workspace = true
2530
tokio-util.workspace = true
31+
tracing.workspace = true
2632
tree_hash.workspace = true
2733
wiremock.workspace = true
2834

2935
[dev-dependencies]
36+
assert-json-diff.workspace = true
3037
reqwest.workspace = true
3138

3239
[lints]

crates/testutil/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,16 @@ pub mod random;
1414
/// Beacon node API mock utilities.
1515
pub mod beaconmock;
1616

17+
/// Validator mock — drives validator-side duties against a [`BeaconMock`].
18+
pub mod validatormock;
19+
1720
pub use beaconmock::{BeaconMock, MockState, Validator, ValidatorSet};
1821
pub use random::{
1922
random_deneb_versioned_attestation, random_eth2_signature, random_eth2_signature_bytes,
2023
random_root, random_root_bytes, random_slot, random_v_idx,
2124
};
25+
pub use validatormock::{
26+
ActiveValidators, EndpointMatch, Error as ValidatorMockError, MetaEpoch, MetaSlot,
27+
Result as ValidatorMockResult, Sign, SignError, SignFunc, Signer, SpecMeta, SubmissionCapture,
28+
active_validators,
29+
};
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
//! Test-only request-capture helper for [`crate::BeaconMock`].
2+
//!
3+
//! The Go validator mock tests assert on what the SUT submits by setting
4+
//! callback fields on `beaconmock.Mock` (`SubmitAttestationsFunc`,
5+
//! `SubmitAggregateAttestationsFunc`, ...). `BeaconMock` has no such hook, so
6+
//! tests register a high-priority [`wiremock::Mock`] that decodes the POST body
7+
//! into JSON and appends it into a shared buffer.
8+
//!
9+
//! Mounts above [`mount_endpoint_override`](crate::beaconmock) and the default
10+
//! routes, so the SUT sees a 200 and the test sees the request body.
11+
12+
use std::sync::{Arc, Mutex};
13+
14+
use serde_json::Value;
15+
use wiremock::{
16+
Mock, MockServer, Request, ResponseTemplate,
17+
matchers::{method, path, path_regex},
18+
};
19+
20+
/// Priority used by [`SubmissionCapture`]. Wiremock matches the lowest priority
21+
/// first (and rejects `0`); `1` wins over both [`crate::beaconmock`]'s defaults
22+
/// (`255`) and the override layer (`50`).
23+
pub const CAPTURE_PRIORITY: u8 = 1;
24+
25+
/// Endpoint matcher — plain path or wiremock regex.
26+
#[derive(Debug, Clone)]
27+
pub enum EndpointMatch {
28+
/// Exact path (e.g. `"/eth/v1/beacon/pool/attestations"`).
29+
Path(String),
30+
/// `wiremock` path regex (must start with `^`).
31+
Regex(String),
32+
}
33+
34+
impl EndpointMatch {
35+
/// Returns an [`EndpointMatch::Path`] from any string-like input.
36+
pub fn path(p: impl Into<String>) -> Self {
37+
Self::Path(p.into())
38+
}
39+
40+
/// Returns an [`EndpointMatch::Regex`] from any string-like input.
41+
pub fn regex(p: impl Into<String>) -> Self {
42+
Self::Regex(p.into())
43+
}
44+
}
45+
46+
/// Shared buffer of captured POST/PUT bodies, parsed as JSON.
47+
#[derive(Debug, Clone, Default)]
48+
pub struct SubmissionCapture {
49+
inner: Arc<Mutex<Vec<Value>>>,
50+
}
51+
52+
impl SubmissionCapture {
53+
/// Mounts a capture handler on `server` matching `http_method` +
54+
/// `endpoint`, responding with `response_body` (200) and recording every
55+
/// request body for later inspection.
56+
pub async fn mount(
57+
server: &MockServer,
58+
http_method: &'static str,
59+
endpoint: EndpointMatch,
60+
response_body: Value,
61+
) -> Self {
62+
let capture = Self::default();
63+
let writer = Arc::clone(&capture.inner);
64+
let response = ResponseTemplate::new(200).set_body_json(response_body);
65+
66+
let route = Mock::given(method(http_method));
67+
let route = match endpoint {
68+
EndpointMatch::Path(p) => route.and(path(p)),
69+
EndpointMatch::Regex(r) => route.and(path_regex(r)),
70+
};
71+
72+
route
73+
.respond_with(move |request: &Request| {
74+
if let Ok(value) = serde_json::from_slice::<Value>(&request.body) {
75+
writer.lock().expect("capture mutex poisoned").push(value);
76+
}
77+
response.clone()
78+
})
79+
.with_priority(CAPTURE_PRIORITY)
80+
.mount(server)
81+
.await;
82+
83+
capture
84+
}
85+
86+
/// Captured bodies in submission order. Does not drain.
87+
pub fn snapshot(&self) -> Vec<Value> {
88+
self.inner.lock().expect("capture mutex poisoned").clone()
89+
}
90+
91+
/// Drains the buffer and returns every captured body in submission order.
92+
pub fn take(&self) -> Vec<Value> {
93+
std::mem::take(&mut *self.inner.lock().expect("capture mutex poisoned"))
94+
}
95+
96+
/// Number of captured submissions.
97+
pub fn len(&self) -> usize {
98+
self.inner.lock().expect("capture mutex poisoned").len()
99+
}
100+
101+
/// True if nothing has been captured.
102+
pub fn is_empty(&self) -> bool {
103+
self.len() == 0
104+
}
105+
}
106+
107+
#[cfg(test)]
108+
mod tests {
109+
use super::*;
110+
use crate::beaconmock::BeaconMock;
111+
use serde_json::json;
112+
113+
#[tokio::test]
114+
async fn captures_post_body() {
115+
let mock = BeaconMock::builder().build().await.expect("build mock");
116+
let capture = SubmissionCapture::mount(
117+
mock.server(),
118+
"POST",
119+
EndpointMatch::path("/eth/v1/beacon/pool/attestations"),
120+
json!({}),
121+
)
122+
.await;
123+
124+
let url = format!("{}/eth/v1/beacon/pool/attestations", mock.uri());
125+
let body = json!([{ "slot": "1", "index": "0" }]);
126+
let status = reqwest::Client::new()
127+
.post(&url)
128+
.json(&body)
129+
.send()
130+
.await
131+
.expect("send")
132+
.status();
133+
assert_eq!(status, 200);
134+
135+
let captured = capture.take();
136+
assert_eq!(captured.len(), 1);
137+
assert_eq!(captured[0], body);
138+
}
139+
140+
#[tokio::test]
141+
async fn regex_endpoint_matches() {
142+
let mock = BeaconMock::builder().build().await.expect("build mock");
143+
let capture = SubmissionCapture::mount(
144+
mock.server(),
145+
"POST",
146+
EndpointMatch::regex(r"^/eth/v1/validator/duties/attester/[0-9]+$"),
147+
json!({"data": []}),
148+
)
149+
.await;
150+
151+
let url = format!("{}/eth/v1/validator/duties/attester/3", mock.uri());
152+
let status = reqwest::Client::new()
153+
.post(&url)
154+
.json(&json!(["1"]))
155+
.send()
156+
.await
157+
.expect("send")
158+
.status();
159+
assert_eq!(status, 200);
160+
assert_eq!(capture.len(), 1);
161+
}
162+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
//! Module-wide error type for the validator mock.
2+
//!
3+
//! Mirrors the structure of `pluto_eth2util::signing::SigningError`: a single
4+
//! `thiserror::Error` enum that the public API returns. Phase-2/3 submodules
5+
//! add new variants as their failure modes appear.
6+
7+
use pluto_eth2api::EthBeaconNodeApiClientError;
8+
use pluto_eth2util::{helpers::HelperError, signing::SigningError};
9+
10+
/// Result alias used by the validator mock.
11+
pub type Result<T> = std::result::Result<T, Error>;
12+
13+
/// Errors returned by the validator mock.
14+
#[derive(Debug, thiserror::Error)]
15+
pub enum Error {
16+
/// Beacon-node API call failed.
17+
#[error(transparent)]
18+
BeaconNode(#[from] EthBeaconNodeApiClientError),
19+
20+
/// Signing-helper failure (resolving domains, hashing roots, etc.).
21+
#[error(transparent)]
22+
Signing(#[from] SigningError),
23+
24+
/// Helper utility (slot/epoch arithmetic against the spec) failure.
25+
#[error(transparent)]
26+
Helper(#[from] HelperError),
27+
28+
/// Local signer could not produce a signature for the requested pubkey.
29+
#[error(transparent)]
30+
Sign(#[from] SignError),
31+
32+
/// Hash-tree-root computation failed.
33+
#[error("hash tree root: {0}")]
34+
HashTreeRoot(String),
35+
36+
/// Beacon response was malformed or missing data.
37+
#[error("malformed beacon response: {0}")]
38+
Malformed(String),
39+
40+
/// Required validator index missing from the active set.
41+
#[error("missing validator index {0}")]
42+
MissingValidatorIndex(u64),
43+
44+
/// Builder/proposal/block variant not supported.
45+
#[error("unsupported variant: {0}")]
46+
UnsupportedVariant(&'static str),
47+
}
48+
49+
/// Signer-specific errors. Wrapped into [`Error::Sign`] when surfaced from the
50+
/// validator-mock public API.
51+
#[derive(Debug, thiserror::Error)]
52+
pub enum SignError {
53+
/// No private key is registered for the requested public key.
54+
#[error("no secret found for pubkey")]
55+
UnknownPubkey,
56+
57+
/// Underlying BLS error.
58+
#[error(transparent)]
59+
Bls(#[from] pluto_crypto::types::Error),
60+
}

0 commit comments

Comments
 (0)