fips-core 0.3.11

Reusable FIPS mesh, endpoint, transport, and protocol library
Documentation
//! 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;
    }
}