From 00a79fa47a5eb606d7b84831ac4e6e06735823d4 Mon Sep 17 00:00:00 2001 From: Andrus Salumets Date: Wed, 22 Apr 2026 17:54:47 +0700 Subject: [PATCH] chore(api): Add testing connect endpoint (#2574) --- Cargo.lock | 1 + nodes/api-common/src/paths.rs | 1 + nodes/node/binary/src/api/testing/backend.rs | 13 ++++-- nodes/node/binary/src/api/testing/handlers.rs | 33 ++++++++++++++- services/api/Cargo.toml | 1 + services/api/src/http/libp2p.rs | 33 ++++++++++++++- .../network/src/backends/libp2p/command.rs | 1 + .../features/test_harness.feature | 11 +++++ tests/src/cucumber/steps/manual_cluster.rs | 41 +++++++++++++++++++ .../src/cucumber/steps/manual_nodes/steps.rs | 28 +++++++++++++ .../testing_framework/src/node/http_client.rs | 21 +++++++++- 11 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 tests/cucumber_tests/features/test_harness.feature diff --git a/Cargo.lock b/Cargo.lock index dcca7b3ff..2e80b6eee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4211,6 +4211,7 @@ dependencies = [ "logos-blockchain-chain-service", "logos-blockchain-core", "logos-blockchain-ledger", + "logos-blockchain-libp2p", "logos-blockchain-network-service", "logos-blockchain-sdp-service", "logos-blockchain-storage-service", diff --git a/nodes/api-common/src/paths.rs b/nodes/api-common/src/paths.rs index 4f60e736f..b21760243 100644 --- a/nodes/api-common/src/paths.rs +++ b/nodes/api-common/src/paths.rs @@ -29,3 +29,4 @@ pub mod wallet { // testing paths pub const UPDATE_MEMBERSHIP: &str = "/test/membership/update"; +pub const DIAL_PEER: &str = "/test/network/dial_peer"; diff --git a/nodes/node/binary/src/api/testing/backend.rs b/nodes/node/binary/src/api/testing/backend.rs index ba06a13ae..5a667babd 100644 --- a/nodes/node/binary/src/api/testing/backend.rs +++ b/nodes/node/binary/src/api/testing/backend.rs @@ -6,11 +6,11 @@ use axum::{ HeaderValue, header::{CONTENT_TYPE, USER_AGENT}, }, - routing::get, + routing::{get, post}, }; use lb_api_service::Backend; -use lb_http_api_common::paths::MANTLE_SDP_DECLARATIONS; -pub use lb_network_service::backends::libp2p::Libp2p as NetworkBackend; +use lb_http_api_common::paths::{DIAL_PEER, MANTLE_SDP_DECLARATIONS}; +use lb_network_service::{NetworkService, backends::libp2p::Libp2p as NetworkBackend}; use overwatch::{overwatch::handle::OverwatchHandle, services::AsServiceId}; use tokio::net::TcpListener; use tower::limit::ConcurrencyLimitLayer; @@ -23,7 +23,10 @@ use tower_http::{ use tracing::Level as TracingLevel; use crate::{ - api::{backend::AxumBackendSettings, testing::handlers::get_sdp_declarations}, + api::{ + backend::AxumBackendSettings, + testing::handlers::{dial_peer, get_sdp_declarations}, + }, generic_services::{self, SdpService}, }; pub struct TestAxumBackend { @@ -45,6 +48,7 @@ where + Debug + Clone + 'static + + AsServiceId> + AsServiceId> + AsServiceId> + AsServiceId> @@ -81,6 +85,7 @@ where MANTLE_SDP_DECLARATIONS, get(get_sdp_declarations::), ) + .route(DIAL_PEER, post(dial_peer::)) .with_state(handle) .layer(axum::extract::DefaultBodyLimit::max( self.settings.max_body_size, diff --git a/nodes/node/binary/src/api/testing/handlers.rs b/nodes/node/binary/src/api/testing/handlers.rs index be64389cd..aef15f6cb 100644 --- a/nodes/node/binary/src/api/testing/handlers.rs +++ b/nodes/node/binary/src/api/testing/handlers.rs @@ -1,8 +1,11 @@ use std::fmt::{Debug, Display}; -use axum::{extract::State, response::Response}; -use lb_api_service::http::mantle; +use axum::{Json, extract::State, response::Response}; +use lb_api_service::http::{libp2p, mantle}; +use lb_libp2p::{Multiaddr, PeerId}; +use lb_network_service::{NetworkService, backends::libp2p::Libp2p as NetworkBackend}; use overwatch::{overwatch::OverwatchHandle, services::AsServiceId}; +use serde::{Deserialize, Serialize}; use super::backend::TestHttpCryptarchiaService; use crate::{ @@ -10,6 +13,11 @@ use crate::{ make_request_and_return_response, }; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DialPeerRequestBody { + pub addr: Multiaddr, +} + pub async fn get_sdp_declarations( State(handle): State>, ) -> Response @@ -25,3 +33,24 @@ where { make_request_and_return_response!(mantle::get_sdp_declarations::(&handle)) } + +pub async fn dial_peer( + State(handle): State>, + Json(req): Json, +) -> Response +where + RuntimeServiceId: Debug + + Send + + Sync + + Display + + 'static + + AsServiceId> + + AsServiceId> + + AsServiceId> + + AsServiceId>, +{ + make_request_and_return_response!(async move { + let peer_id: PeerId = libp2p::connect_peer::(&handle, req.addr).await?; + Ok::(peer_id) + }) +} diff --git a/services/api/Cargo.toml b/services/api/Cargo.toml index 6c8add5a5..bfe347b9d 100644 --- a/services/api/Cargo.toml +++ b/services/api/Cargo.toml @@ -30,6 +30,7 @@ lb-chain-leader-service = { workspace = true } lb-chain-service = { features = ["libp2p"], workspace = true } lb-core = { workspace = true } lb-ledger = { workspace = true } +lb-libp2p = { workspace = true } lb-network-service = { workspace = true } lb-sdp-service = { workspace = true } lb-storage-service = { features = ["rocksdb-backend"], workspace = true } diff --git a/services/api/src/http/libp2p.rs b/services/api/src/http/libp2p.rs index 2aa884edb..5cbf58b2b 100644 --- a/services/api/src/http/libp2p.rs +++ b/services/api/src/http/libp2p.rs @@ -1,8 +1,12 @@ use std::fmt::{Debug, Display}; +use lb_libp2p::{Multiaddr, PeerId}; use lb_network_service::{ NetworkService, - backends::libp2p::{Command, Libp2p, Libp2pInfo, NetworkCommand::Info}, + backends::libp2p::{ + Command, Dial, Libp2p, Libp2pInfo, + NetworkCommand::{Connect, Info}, + }, message::NetworkMsg, }; use overwatch::services::AsServiceId; @@ -29,3 +33,30 @@ where .await .map_err(|e| Box::new(e) as overwatch::DynError) } + +pub async fn connect_peer( + handle: &overwatch::overwatch::handle::OverwatchHandle, + addr: Multiaddr, +) -> Result +where + RuntimeServiceId: + AsServiceId> + Debug + Sync + Display + 'static, +{ + let relay = handle.relay().await?; + let (sender, receiver) = oneshot::channel(); + + relay + .send(NetworkMsg::Process(Command::Network(Connect(Dial { + addr, + retry_count: 0, + result_sender: sender, + })))) + .await + .map_err(|(e, _)| e)?; + + let dial_result = receiver + .await + .map_err(|e| Box::new(e) as overwatch::DynError)?; + + dial_result.map_err(|e| Box::new(e) as overwatch::DynError) +} diff --git a/services/network/src/backends/libp2p/command.rs b/services/network/src/backends/libp2p/command.rs index 989a02461..c411deb94 100644 --- a/services/network/src/backends/libp2p/command.rs +++ b/services/network/src/backends/libp2p/command.rs @@ -39,6 +39,7 @@ pub struct Dial { pub struct Libp2pInfo { pub listen_addresses: Vec, pub peer_id: PeerId, + #[serde(default)] pub connected_peers: Vec, pub n_peers: usize, pub n_connections: u32, diff --git a/tests/cucumber_tests/features/test_harness.feature b/tests/cucumber_tests/features/test_harness.feature new file mode 100644 index 000000000..19194a928 --- /dev/null +++ b/tests/cucumber_tests/features/test_harness.feature @@ -0,0 +1,11 @@ +Feature: Test harness + + @smoke_ci + Scenario: Two nodes connect at runtime + Given I have a cluster with capacity of 2 nodes + And I start node "NODE_1" + And I start node "NODE_2" + When I connect node "NODE_2" to node "NODE_1" at runtime + Then node "NODE_1" has at least 1 peers within 15 seconds + And node "NODE_2" has at least 1 peers within 15 seconds + And I stop all nodes diff --git a/tests/src/cucumber/steps/manual_cluster.rs b/tests/src/cucumber/steps/manual_cluster.rs index a43e18a06..6b0b20c47 100644 --- a/tests/src/cucumber/steps/manual_cluster.rs +++ b/tests/src/cucumber/steps/manual_cluster.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, hash::BuildHasher, time::Duration}; +use lb_libp2p::{Multiaddr, PeerId, Protocol}; use lb_testing_framework::{ DeploymentBuilder, LbcEnv, LbcLocalDeployer, NodeHttpClient, TopologyConfig, configs::wallet::WalletAccount, internal::DeploymentPlan, @@ -286,6 +287,46 @@ pub async fn assert_manual_node_has_peers( } } +pub async fn connect_manual_node_to_node( + world: &CucumberWorld, + source_node_name: &str, + target_node_name: &str, +) -> StepResult { + let source_client = world.resolve_node_http_client(source_node_name)?; + let target_client = world.resolve_node_http_client(target_node_name)?; + let target_network = target_client.network_info().await?; + let target_addr = compose_dial_addr( + target_network.listen_addresses.first(), + target_network.peer_id, + ) + .ok_or_else(|| StepError::LogicalError { + message: format!("node '{target_node_name}' has no listen address to dial"), + })?; + + source_client + .dial_peer(target_addr) + .await + .map(|_| ()) + .map_err(|error| StepError::LogicalError { + message: format!( + "failed to connect node '{source_node_name}' to node '{target_node_name}': {error}" + ), + }) +} + +fn compose_dial_addr(listen_addr: Option<&Multiaddr>, peer_id: PeerId) -> Option { + let mut addr = listen_addr?.clone(); + let has_peer_id = addr + .iter() + .any(|protocol| matches!(protocol, Protocol::P2p(_))); + + if !has_peer_id { + addr.push(Protocol::P2p(peer_id)); + } + + Some(addr) +} + pub fn build_user_wallets( world: &CucumberWorld, node_name: &str, diff --git a/tests/src/cucumber/steps/manual_nodes/steps.rs b/tests/src/cucumber/steps/manual_nodes/steps.rs index 1f9a7d6ad..de8b42527 100644 --- a/tests/src/cucumber/steps/manual_nodes/steps.rs +++ b/tests/src/cucumber/steps/manual_nodes/steps.rs @@ -11,6 +11,7 @@ use crate::{ steps::{ TARGET, manual_cluster::{ + assert_manual_node_has_peers, connect_manual_node_to_node, install_local_manual_cluster, rebuild_pending_local_manual_cluster, stop_active_manual_cluster, }, @@ -183,6 +184,33 @@ async fn step_start_manual_stand_alone_node( start_node(world, &step.value, &node_name, &Vec::new(), &Vec::new()).await } +#[when(expr = "I connect node {string} to node {string} at runtime")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step entrypoints must take `&mut World`" +)] +async fn step_connect_nodes_at_runtime( + world: &mut CucumberWorld, + source_node_name: String, + target_node_name: String, +) -> StepResult { + connect_manual_node_to_node(world, &source_node_name, &target_node_name).await +} + +#[then(expr = "node {string} has at least {int} peers within {int} seconds")] +#[expect( + clippy::needless_pass_by_ref_mut, + reason = "Cucumber step entrypoints must take `&mut World`" +)] +async fn step_node_has_peers( + world: &mut CucumberWorld, + node_name: String, + min_peers: usize, + timeout_secs: u64, +) -> StepResult { + assert_manual_node_has_peers(world, &node_name, min_peers, timeout_secs).await +} + #[when(expr = "I restart node {string}")] #[expect( clippy::needless_pass_by_ref_mut, diff --git a/tests/testing_framework/src/node/http_client.rs b/tests/testing_framework/src/node/http_client.rs index 500952e5c..7c7ab6962 100644 --- a/tests/testing_framework/src/node/http_client.rs +++ b/tests/testing_framework/src/node/http_client.rs @@ -10,10 +10,12 @@ use lb_http_api_common::{ bodies::wallet::transfer_funds::{ WalletTransferFundsRequestBody, WalletTransferFundsResponseBody, }, - paths::{MANTLE_SDP_DECLARATIONS, NETWORK_INFO}, + paths::{DIAL_PEER, MANTLE_SDP_DECLARATIONS, NETWORK_INFO}, }; +use lb_libp2p::{Multiaddr, PeerId}; use lb_network_service::backends::libp2p::Libp2pInfo; use reqwest::Url; +use serde::{Deserialize, Serialize}; #[derive(Clone)] pub struct NodeHttpClient { @@ -114,6 +116,18 @@ impl NodeHttpClient { self.get_sdp_declarations_at(self.base_url.clone()).await } + pub async fn dial_peer(&self, addr: Multiaddr) -> Result { + let testing_url = self + .testing_url + .clone() + .ok_or_else(|| Error::Client("testing api unavailable".to_owned()))?; + let request_url = Self::join_path(&testing_url, DIAL_PEER)?; + + self.http_client + .post::<_, PeerId>(request_url, &DialPeerRequestBody { addr }) + .await + } + #[must_use] pub const fn base_url(&self) -> &Url { &self.base_url @@ -149,3 +163,8 @@ impl NodeHttpClient { .map_err(Error::Url) } } + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct DialPeerRequestBody { + addr: Multiaddr, +}