Discovery protocol: LookupRequest/LookupResponse handlers

Implement the coordinate discovery protocol with flood-based lookup
and reverse-path response routing.

Wire format: LookupRequest (0x30) encode/decode with TTL, visited
bloom filter, origin coords. LookupResponse (0x31) encode/decode
with target coords and Schnorr proof signature.

Handler logic: request dedup by request_id, visited filter loop
prevention, TTL enforcement, lazy purge of expired entries (10s).
Response routing: originator caches route in route_cache, transit
nodes reverse-path forward via recent_requests.

Node state: RecentRequest struct, route_cache (RouteCache), and
recent_requests map for dedup + reverse-path forwarding.

13 handler tests (9 unit + 4 integration) plus 4 protocol tests.
392 tests pass, clean build.
This commit is contained in:
Johnathan Corgan
2026-02-12 14:46:33 +00:00
parent 009101ee4a
commit 9a7fa921ab
8 changed files with 919 additions and 7 deletions
+361
View File
@@ -0,0 +1,361 @@
//! Discovery protocol tests: LookupRequest and LookupResponse.
//!
//! Unit tests for handler logic (dedup, visited filter, TTL, response
//! caching) and integration tests for multi-node forwarding and
//! reverse-path response routing.
use super::*;
use crate::node::RecentRequest;
use crate::protocol::{LookupRequest, LookupResponse};
use crate::tree::TreeCoordinate;
use spanning_tree::{cleanup_nodes, process_available_packets, run_tree_test};
// ============================================================================
// Unit Tests — LookupRequest Handler
// ============================================================================
#[tokio::test]
async fn test_request_decode_error() {
let mut node = make_node();
let from = make_node_addr(0xAA);
// Too-short payload: should log error and return without panic
node.handle_lookup_request(&from, &[0x00; 5]).await;
assert!(node.recent_requests.is_empty());
}
#[tokio::test]
async fn test_request_dedup() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let target = make_node_addr(0xBB);
let origin = make_node_addr(0xCC);
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
let request = LookupRequest::new(999, target, origin, coords, 5);
let payload = &request.encode()[1..]; // skip msg_type byte
// First request: accepted
node.handle_lookup_request(&from, payload).await;
assert_eq!(node.recent_requests.len(), 1);
// Duplicate request: dropped
node.handle_lookup_request(&from, payload).await;
assert_eq!(node.recent_requests.len(), 1);
}
#[tokio::test]
async fn test_request_visited_filter_self() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let target = make_node_addr(0xBB);
let origin = make_node_addr(0xCC);
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
let mut request = LookupRequest::new(888, target, origin, coords, 5);
// Mark ourselves as already visited
request.visited.insert(node.node_addr());
let payload = &request.encode()[1..];
node.handle_lookup_request(&from, payload).await;
// Request was recorded (dedup happens before visited check)
// but the handler should have stopped after detecting self in visited filter
assert!(node.recent_requests.contains_key(&888));
}
#[tokio::test]
async fn test_request_target_is_self() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let origin = make_node_addr(0xCC);
let my_addr = *node.node_addr();
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
// Request targeting us
let request = LookupRequest::new(777, my_addr, origin, coords, 5);
let payload = &request.encode()[1..];
// Should succeed without panic (response send will fail silently
// since we have no peers to route toward origin)
node.handle_lookup_request(&from, payload).await;
assert!(node.recent_requests.contains_key(&777));
}
#[tokio::test]
async fn test_request_ttl_zero_not_forwarded() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let target = make_node_addr(0xBB);
let origin = make_node_addr(0xCC);
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
let request = LookupRequest::new(666, target, origin, coords, 0);
let payload = &request.encode()[1..];
node.handle_lookup_request(&from, payload).await;
// Request recorded, but not forwarded (TTL=0, and no peers anyway)
assert!(node.recent_requests.contains_key(&666));
}
// ============================================================================
// Unit Tests — LookupResponse Handler
// ============================================================================
#[tokio::test]
async fn test_response_decode_error() {
let mut node = make_node();
let from = make_node_addr(0xAA);
node.handle_lookup_response(&from, &[0x00; 10]).await;
// No panic, no route cached
assert!(node.route_cache.is_empty());
}
#[tokio::test]
async fn test_response_originator_caches_route() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let target = make_node_addr(0xBB);
let root = make_node_addr(0xF0);
let coords = TreeCoordinate::from_addrs(vec![target, root]).unwrap();
// Create a valid response with a real proof signature
let proof_data = LookupResponse::proof_bytes(555, &target);
let target_identity = Identity::generate();
let proof = target_identity.sign(&proof_data);
let response = LookupResponse::new(555, target, coords.clone(), proof);
let payload = &response.encode()[1..]; // skip msg_type
// No entry in recent_requests for 555 → we're the originator
assert!(!node.recent_requests.contains_key(&555));
node.handle_lookup_response(&from, payload).await;
// Route should be cached
assert!(node.route_cache.contains(&target));
let cached = node.route_cache.get(&target).unwrap();
assert_eq!(cached.coords(), &coords);
}
#[tokio::test]
async fn test_response_transit_needs_recent_request() {
let mut node = make_node();
let from = make_node_addr(0xAA);
let target = make_node_addr(0xBB);
let root = make_node_addr(0xF0);
let coords = TreeCoordinate::from_addrs(vec![target, root]).unwrap();
let proof_data = LookupResponse::proof_bytes(444, &target);
let target_identity = Identity::generate();
let proof = target_identity.sign(&proof_data);
let response = LookupResponse::new(444, target, coords, proof);
let payload = &response.encode()[1..];
// Simulate being a transit node: record a recent_request for this ID
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
node.recent_requests.insert(
444,
RecentRequest::new(make_node_addr(0xDD), now_ms),
);
// Handle response — should try to reverse-path forward to 0xDD
// (will fail silently since 0xDD is not an actual peer)
node.handle_lookup_response(&from, payload).await;
// Should NOT cache in route_cache (we're transit, not originator)
assert!(!node.route_cache.contains(&target));
}
// ============================================================================
// Unit Tests — RecentRequest Expiry
// ============================================================================
#[tokio::test]
async fn test_recent_request_expiry() {
let mut node = make_node();
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
// Insert an old request (11 seconds ago)
node.recent_requests.insert(
123,
RecentRequest::new(make_node_addr(1), now_ms - 11_000),
);
// Insert a recent request
node.recent_requests.insert(
456,
RecentRequest::new(make_node_addr(2), now_ms),
);
assert_eq!(node.recent_requests.len(), 2);
// Trigger purge via a new lookup request
let target = make_node_addr(0xBB);
let origin = make_node_addr(0xCC);
let coords = TreeCoordinate::from_addrs(vec![origin, make_node_addr(0)]).unwrap();
let request = LookupRequest::new(789, target, origin, coords, 3);
let payload = &request.encode()[1..];
node.handle_lookup_request(&make_node_addr(0xAA), payload).await;
// Old entry (123) should be purged, recent entry (456) and new entry (789) kept
assert!(!node.recent_requests.contains_key(&123));
assert!(node.recent_requests.contains_key(&456));
assert!(node.recent_requests.contains_key(&789));
}
// ============================================================================
// Integration Tests — Multi-Node Forwarding
// ============================================================================
#[tokio::test]
async fn test_request_forwarding_two_node() {
// Set up a two-node topology: node0 — node1
// Send a LookupRequest from node0 targeting some unknown node.
// Node1 should receive the forwarded request.
let edges = vec![(0, 1)];
let mut nodes = run_tree_test(2, &edges, false).await;
let node0_addr = *nodes[0].node.node_addr();
let target = make_node_addr(0xEE); // unknown node
let root = make_node_addr(0);
let coords = TreeCoordinate::from_addrs(vec![node0_addr, root]).unwrap();
let request = LookupRequest::new(42, target, node0_addr, coords, 5);
let payload = &request.encode()[1..];
// Handle on node0 as if we received it from outside
nodes[0]
.node
.handle_lookup_request(&node0_addr, payload)
.await;
// Process packets — node1 should receive the forwarded request
tokio::time::sleep(Duration::from_millis(50)).await;
let count = process_available_packets(&mut nodes).await;
assert!(count > 0, "Expected forwarded LookupRequest to arrive at node 1");
// Node1 should have recorded the request
assert!(
nodes[1].node.recent_requests.contains_key(&42),
"Node 1 should have recorded the forwarded request"
);
cleanup_nodes(&mut nodes).await;
}
#[tokio::test]
async fn test_request_target_found_generates_response() {
// Set up a two-node topology: node0 — node1
// Node0 initiates a lookup targeting node1.
// Node1 receives, detects it's the target, generates a LookupResponse.
// Response routes back to node0 which caches the coordinates.
let edges = vec![(0, 1)];
let mut nodes = run_tree_test(2, &edges, false).await;
let node1_addr = *nodes[1].node.node_addr();
// Node0 initiates lookup (doesn't record in recent_requests)
nodes[0].node.initiate_lookup(&node1_addr, 5).await;
// Process packets in rounds to allow request + response
for _ in 0..4 {
tokio::time::sleep(Duration::from_millis(50)).await;
process_available_packets(&mut nodes).await;
}
// Node0 should have cached node1's route (it originated the request)
assert!(
nodes[0].node.route_cache.contains(&node1_addr),
"Node 0 should have cached node 1's route from LookupResponse"
);
cleanup_nodes(&mut nodes).await;
}
#[tokio::test]
async fn test_request_three_node_chain() {
// Topology: node0 — node1 — node2
// Node0 initiates a lookup targeting node2.
// Request should propagate: node0 → node1 → node2.
// Node2 generates response, reverse-path: node2 → node1 → node0.
let edges = vec![(0, 1), (1, 2)];
let mut nodes = run_tree_test(3, &edges, false).await;
let node2_addr = *nodes[2].node.node_addr();
// Node0 initiates lookup (doesn't record in recent_requests)
nodes[0].node.initiate_lookup(&node2_addr, 8).await;
// Process packets in rounds to allow multi-hop propagation + response
// Chain: node0→node1→node2 (request), node2→node1→node0 (response)
for _ in 0..10 {
tokio::time::sleep(Duration::from_millis(100)).await;
process_available_packets(&mut nodes).await;
}
// Node1 should have been a transit node (has the request_id in recent_requests)
assert!(
!nodes[1].node.recent_requests.is_empty(),
"Node 1 should have recorded the forwarded request"
);
// Node2 should have received the request (it's the target)
assert!(
!nodes[2].node.recent_requests.is_empty(),
"Node 2 should have received the request"
);
// Node0 should have cached node2's route
assert!(
nodes[0].node.route_cache.contains(&node2_addr),
"Node 0 should have cached node 2's route through 3-node chain"
);
cleanup_nodes(&mut nodes).await;
}
#[tokio::test]
async fn test_request_dedup_convergent_paths() {
// Topology: triangle (node0 — node1, node0 — node2, node1 — node2)
// A request from node0 reaches node2 via two paths: 0→1→2 and 0→2.
// The second arrival at node2 should be deduped.
let edges = vec![(0, 1), (0, 2), (1, 2)];
let mut nodes = run_tree_test(3, &edges, false).await;
let node0_addr = *nodes[0].node.node_addr();
let target = make_node_addr(0xEE);
let root = make_node_addr(0);
let coords = TreeCoordinate::from_addrs(vec![node0_addr, root]).unwrap();
let request = LookupRequest::new(300, target, node0_addr, coords, 5);
let payload = &request.encode()[1..];
// Node0 handles the request (forwards to both node1 and node2)
nodes[0]
.node
.handle_lookup_request(&node0_addr, payload)
.await;
// Process several rounds
for _ in 0..5 {
tokio::time::sleep(Duration::from_millis(50)).await;
process_available_packets(&mut nodes).await;
}
// Both node1 and node2 should have recorded the request
assert!(nodes[1].node.recent_requests.contains_key(&300));
assert!(nodes[2].node.recent_requests.contains_key(&300));
// The request should appear exactly once in each node's recent_requests
// (dedup prevents duplicate processing via convergent paths)
cleanup_nodes(&mut nodes).await;
}
+1
View File
@@ -6,6 +6,7 @@ use std::time::Duration;
mod bloom;
mod disconnect;
mod discovery;
mod forwarding;
mod handshake;
mod routing;