mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
Add rustfmt.toml with stable defaults and apply cargo fmt to all source files. This establishes a consistent formatting baseline for CI enforcement.
369 lines
12 KiB
Rust
369 lines
12 KiB
Rust
//! Disconnect and peer removal integration tests.
|
|
//!
|
|
//! Tests that graceful disconnect messages propagate correctly through
|
|
//! multi-node networks and trigger proper cascading cleanup: peer removal,
|
|
//! tree reconvergence, and bloom filter recomputation.
|
|
|
|
use super::spanning_tree::*;
|
|
use super::*;
|
|
use crate::protocol::{Disconnect, DisconnectReason};
|
|
|
|
/// 3-node chain: middle node disconnects one peer.
|
|
///
|
|
/// Chain: 0 -- 1 -- 2. Node 1 sends Disconnect to node 0.
|
|
/// Verifies:
|
|
/// - Node 0 removes node 1 from its peer table
|
|
/// - Node 0's tree reconverges (becomes its own root since isolated)
|
|
/// - Node 1 still has node 2 as a peer
|
|
#[tokio::test]
|
|
async fn test_disconnect_chain_peer_removal() {
|
|
// Build 3-node chain: 0 -- 1 -- 2
|
|
let edges = vec![(0, 1), (1, 2)];
|
|
let mut nodes = run_tree_test(3, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node2_addr = *nodes[2].node.node_addr();
|
|
|
|
// Verify initial state: node 0 has 1 peer (node 1)
|
|
assert_eq!(nodes[0].node.peer_count(), 1);
|
|
assert!(nodes[0].node.get_peer(&node1_addr).is_some());
|
|
|
|
// Node 1 sends Disconnect(Shutdown) to node 0
|
|
let disconnect = Disconnect::new(DisconnectReason::Shutdown);
|
|
let plaintext = disconnect.encode();
|
|
nodes[1]
|
|
.node
|
|
.send_encrypted_link_message(&node0_addr, &plaintext)
|
|
.await
|
|
.expect("Failed to send disconnect");
|
|
|
|
// Process the disconnect at node 0
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// Node 0 should have removed node 1
|
|
assert_eq!(
|
|
nodes[0].node.peer_count(),
|
|
0,
|
|
"Node 0 should have no peers after disconnect"
|
|
);
|
|
assert!(
|
|
nodes[0].node.get_peer(&node1_addr).is_none(),
|
|
"Node 0 should not have node 1 as a peer"
|
|
);
|
|
|
|
// Node 0 becomes its own root (isolated)
|
|
assert!(
|
|
nodes[0].node.tree_state().is_root(),
|
|
"Isolated node 0 should be root"
|
|
);
|
|
|
|
// Node 1 still has node 2 as a peer (disconnect was only to node 0)
|
|
assert!(
|
|
nodes[1].node.get_peer(&node2_addr).is_some(),
|
|
"Node 1 should still have node 2"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// 4-node star: hub disconnects, spokes reconverge.
|
|
///
|
|
/// Star: 0 is hub, connected to 1, 2, 3. Hub sends Disconnect to all.
|
|
/// Verifies:
|
|
/// - All spokes remove hub from their peer tables
|
|
/// - Each spoke becomes its own root (since there are no spoke-spoke links)
|
|
#[tokio::test]
|
|
async fn test_disconnect_star_hub_departs() {
|
|
let edges = vec![(0, 1), (0, 2), (0, 3)];
|
|
let mut nodes = run_tree_test(4, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
|
|
let hub_addr = *nodes[0].node.node_addr();
|
|
|
|
// Hub sends Disconnect(Shutdown) to all spokes
|
|
let disconnect = Disconnect::new(DisconnectReason::Shutdown);
|
|
let plaintext = disconnect.encode();
|
|
for spoke_idx in 1..4 {
|
|
let spoke_addr = *nodes[spoke_idx].node.node_addr();
|
|
nodes[0]
|
|
.node
|
|
.send_encrypted_link_message(&spoke_addr, &plaintext)
|
|
.await
|
|
.expect("Failed to send disconnect");
|
|
}
|
|
|
|
// Process disconnects at all nodes
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// All spokes should have removed the hub
|
|
for (spoke_idx, spoke) in nodes[1..4].iter().enumerate() {
|
|
let spoke_idx = spoke_idx + 1; // adjust for slice offset
|
|
assert!(
|
|
spoke.node.get_peer(&hub_addr).is_none(),
|
|
"Spoke {} should have removed hub",
|
|
spoke_idx
|
|
);
|
|
assert_eq!(
|
|
spoke.node.peer_count(),
|
|
0,
|
|
"Spoke {} should have no peers (no spoke-spoke links)",
|
|
spoke_idx
|
|
);
|
|
assert!(
|
|
spoke.node.tree_state().is_root(),
|
|
"Isolated spoke {} should become root",
|
|
spoke_idx
|
|
);
|
|
}
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// 5-node chain: interior node departs, network splits into two components.
|
|
///
|
|
/// Chain: 0 -- 1 -- 2 -- 3 -- 4. Node 2 sends Disconnect to nodes 1 and 3.
|
|
/// Verifies:
|
|
/// - Peers removed correctly on both sides
|
|
/// - Bloom filters update so routing no longer bridges the partition
|
|
///
|
|
/// Note: Tree root reconvergence after partition is not tested here because
|
|
/// the tree protocol detects parent loss but not root unreachability. Nodes
|
|
/// whose parent is still connected may retain a stale root belief until the
|
|
/// root refresh timer fires. This is a known limitation of the current tree
|
|
/// protocol — bloom filter routing is the primary mechanism and it updates
|
|
/// immediately on peer removal.
|
|
#[tokio::test]
|
|
async fn test_disconnect_chain_partition() {
|
|
let edges = vec![(0, 1), (1, 2), (2, 3), (3, 4)];
|
|
let mut nodes = run_tree_test(5, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
|
|
let node2_addr = *nodes[2].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
let node3_addr = *nodes[3].node.node_addr();
|
|
|
|
// Node 2 sends Disconnect to nodes 1 and 3
|
|
let disconnect = Disconnect::new(DisconnectReason::Shutdown);
|
|
let plaintext = disconnect.encode();
|
|
nodes[2]
|
|
.node
|
|
.send_encrypted_link_message(&node1_addr, &plaintext)
|
|
.await
|
|
.expect("Failed to send disconnect to node 1");
|
|
nodes[2]
|
|
.node
|
|
.send_encrypted_link_message(&node3_addr, &plaintext)
|
|
.await
|
|
.expect("Failed to send disconnect to node 3");
|
|
|
|
// Process disconnects and let filters reconverge
|
|
drain_all_packets(&mut nodes, false).await;
|
|
|
|
// Nodes 1 and 3 should have removed node 2
|
|
assert!(
|
|
nodes[1].node.get_peer(&node2_addr).is_none(),
|
|
"Node 1 should not have node 2 as peer"
|
|
);
|
|
assert!(
|
|
nodes[3].node.get_peer(&node2_addr).is_none(),
|
|
"Node 3 should not have node 2 as peer"
|
|
);
|
|
|
|
// Within each component, peers are still connected
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node4_addr = *nodes[4].node.node_addr();
|
|
assert!(
|
|
nodes[0].node.get_peer(&node1_addr).is_some(),
|
|
"Node 0 should still have node 1 as peer"
|
|
);
|
|
assert!(
|
|
nodes[3].node.get_peer(&node4_addr).is_some(),
|
|
"Node 3 should still have node 4 as peer"
|
|
);
|
|
|
|
// Bloom filter check: node 0 should NOT see node 4 as reachable
|
|
// (bloom filters update immediately on peer removal via split-horizon recomputation)
|
|
let node0_reaches_node4 = nodes[0]
|
|
.node
|
|
.peers()
|
|
.any(|peer| peer.may_reach(&node4_addr));
|
|
assert!(
|
|
!node0_reaches_node4,
|
|
"Node 0 should not see node 4 as reachable after partition"
|
|
);
|
|
|
|
// And vice versa
|
|
let node4_reaches_node0 = nodes[4]
|
|
.node
|
|
.peers()
|
|
.any(|peer| peer.may_reach(&node0_addr));
|
|
assert!(
|
|
!node4_reaches_node0,
|
|
"Node 4 should not see node 0 as reachable after partition"
|
|
);
|
|
|
|
// Nodes within the same component should still see each other
|
|
let node0_reaches_node1 = nodes[0]
|
|
.node
|
|
.peers()
|
|
.any(|peer| peer.may_reach(&node1_addr));
|
|
assert!(
|
|
node0_reaches_node1,
|
|
"Node 0 should still see node 1 as reachable"
|
|
);
|
|
|
|
let node4_reaches_node3 = nodes[4]
|
|
.node
|
|
.peers()
|
|
.any(|peer| peer.may_reach(&node3_addr));
|
|
assert!(
|
|
node4_reaches_node3,
|
|
"Node 4 should still see node 3 as reachable"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// Removing a peer via disconnect must also remove the associated end-to-end session.
|
|
///
|
|
/// Regression test for issue #5: `remove_active_peer` previously left the
|
|
/// `SessionEntry` alive in `self.sessions` after evicting the peer from
|
|
/// `self.peers`. This caused:
|
|
/// 1. Stale "MMP session metrics" logs with frozen counters until
|
|
/// `purge_idle_sessions` eventually fired (up to idle_timeout_secs later).
|
|
/// 2. `initiate_session` silently returning `Ok(())` on the stale Established
|
|
/// entry's guard check, preventing a new session from being created even
|
|
/// after the link layer reconnected successfully.
|
|
#[tokio::test]
|
|
async fn test_disconnect_clears_session() {
|
|
use crate::identity::Identity;
|
|
use crate::node::session::{EndToEndState, SessionEntry};
|
|
use crate::noise::HandshakeState;
|
|
|
|
// Two-node topology: 0 -- 1.
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
// Inject a synthetic Established session entry into node 1's session table
|
|
// to simulate the state after a completed XK handshake with node 0.
|
|
let remote_identity = Identity::generate();
|
|
{
|
|
let our_identity = nodes[1].node.identity();
|
|
|
|
let mut initiator =
|
|
HandshakeState::new_initiator(our_identity.keypair(), remote_identity.pubkey_full());
|
|
let mut responder = HandshakeState::new_responder(remote_identity.keypair());
|
|
let mut init_epoch = [0u8; 8];
|
|
rand::Rng::fill_bytes(&mut rand::rng(), &mut init_epoch);
|
|
initiator.set_local_epoch(init_epoch);
|
|
let mut resp_epoch = [0u8; 8];
|
|
rand::Rng::fill_bytes(&mut rand::rng(), &mut resp_epoch);
|
|
responder.set_local_epoch(resp_epoch);
|
|
let msg1 = initiator.write_message_1().unwrap();
|
|
responder.read_message_1(&msg1).unwrap();
|
|
let msg2 = responder.write_message_2().unwrap();
|
|
initiator.read_message_2(&msg2).unwrap();
|
|
let session = initiator.into_session().unwrap();
|
|
|
|
let entry = SessionEntry::new(
|
|
node0_addr,
|
|
remote_identity.pubkey_full(),
|
|
EndToEndState::Established(session),
|
|
1_000,
|
|
true,
|
|
);
|
|
nodes[1].node.sessions.insert(node0_addr, entry);
|
|
}
|
|
|
|
assert_eq!(
|
|
nodes[1].node.session_count(),
|
|
1,
|
|
"Session should exist before disconnect"
|
|
);
|
|
assert_eq!(
|
|
nodes[1].node.peer_count(),
|
|
1,
|
|
"Peer should exist before disconnect"
|
|
);
|
|
|
|
// Node 0 sends Disconnect to node 1.
|
|
let disconnect = crate::protocol::Disconnect::new(DisconnectReason::Shutdown);
|
|
nodes[0]
|
|
.node
|
|
.send_encrypted_link_message(&node1_addr, &disconnect.encode())
|
|
.await
|
|
.expect("Failed to send disconnect");
|
|
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
// Peer must be gone.
|
|
assert_eq!(
|
|
nodes[1].node.peer_count(),
|
|
0,
|
|
"Peer should be removed after disconnect"
|
|
);
|
|
|
|
// Session must also be gone — core regression check for issue #5.
|
|
// Before the fix, session_count() would still be 1 here because
|
|
// remove_active_peer didn't remove self.sessions[node0_addr].
|
|
assert_eq!(
|
|
nodes[1].node.session_count(),
|
|
0,
|
|
"Session must be cleaned up when peer is removed (regression: issue #5)"
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
|
|
/// Verify that different disconnect reasons are handled correctly.
|
|
///
|
|
/// Sends each reason code and verifies the peer is removed regardless.
|
|
#[tokio::test]
|
|
async fn test_disconnect_all_reason_codes() {
|
|
let reasons = vec![
|
|
DisconnectReason::Shutdown,
|
|
DisconnectReason::Restart,
|
|
DisconnectReason::ProtocolError,
|
|
DisconnectReason::TransportFailure,
|
|
DisconnectReason::ResourceExhaustion,
|
|
];
|
|
|
|
for reason in reasons {
|
|
let edges = vec![(0, 1)];
|
|
let mut nodes = run_tree_test(2, &edges, false).await;
|
|
verify_tree_convergence(&nodes);
|
|
|
|
let node0_addr = *nodes[0].node.node_addr();
|
|
let node1_addr = *nodes[1].node.node_addr();
|
|
|
|
// Node 0 sends disconnect with this reason
|
|
let disconnect = Disconnect::new(reason);
|
|
let plaintext = disconnect.encode();
|
|
nodes[0]
|
|
.node
|
|
.send_encrypted_link_message(&node1_addr, &plaintext)
|
|
.await
|
|
.expect("Failed to send disconnect");
|
|
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
process_available_packets(&mut nodes).await;
|
|
|
|
assert!(
|
|
nodes[1].node.get_peer(&node0_addr).is_none(),
|
|
"Node 1 should remove peer for reason {:?}",
|
|
reason
|
|
);
|
|
|
|
cleanup_nodes(&mut nodes).await;
|
|
}
|
|
}
|