Files
fips/src/proto/lookup/tests/core.rs
T

685 lines
22 KiB
Rust

//! Tests for the sans-IO lookup decision core.
use super::util::{
MockRoutingView, action_peers, empty_lookup, make_request, make_request_id, suppressing_lookup,
};
use crate::TreeCoordinate;
use crate::proto::lookup::*;
use crate::testutil::make_node_addr;
#[test]
fn picks_only_tree_peers_when_a_tree_peer_matches() {
let tree_peer = make_node_addr(1);
let non_tree_peer = make_node_addr(2);
let rv = MockRoutingView {
peers: vec![(tree_peer, true, true), (non_tree_peer, false, true)],
..Default::default()
};
let mut request = make_request(3);
match plan_forward(&mut request, &rv) {
ForwardOutcome::Forward {
actions,
used_fallback,
} => {
assert!(!used_fallback, "tree match must not use fallback");
assert_eq!(action_peers(&actions), vec![tree_peer]);
}
_ => panic!("expected Forward"),
}
}
#[test]
fn falls_back_to_non_tree_peers_when_no_tree_peer_matches() {
let non_tree_a = make_node_addr(3);
let non_tree_b = make_node_addr(4);
// A tree peer exists but does not reach the target.
let tree_no_reach = make_node_addr(5);
let rv = MockRoutingView {
peers: vec![
(tree_no_reach, true, false),
(non_tree_a, false, true),
(non_tree_b, false, true),
],
..Default::default()
};
let mut request = make_request(3);
match plan_forward(&mut request, &rv) {
ForwardOutcome::Forward {
actions,
used_fallback,
} => {
assert!(used_fallback, "no tree match must use fallback");
assert_eq!(action_peers(&actions), vec![non_tree_a, non_tree_b]);
}
_ => panic!("expected Forward via fallback"),
}
}
#[test]
fn returns_no_peers_when_nothing_reaches_target() {
let tree_peer = make_node_addr(6);
let non_tree_peer = make_node_addr(7);
let rv = MockRoutingView {
peers: vec![(tree_peer, true, false), (non_tree_peer, false, false)],
..Default::default()
};
let mut request = make_request(3);
assert!(matches!(
plan_forward(&mut request, &rv),
ForwardOutcome::NoPeers
));
}
#[test]
fn returns_ttl_exhausted_when_ttl_is_zero() {
let tree_peer = make_node_addr(8);
let rv = MockRoutingView {
peers: vec![(tree_peer, true, true)],
..Default::default()
};
let mut request = make_request(0);
assert!(matches!(
plan_forward(&mut request, &rv),
ForwardOutcome::TtlExhausted
));
}
#[test]
fn initiate_picks_only_tree_peers_and_never_falls_back() {
let tree_peer = make_node_addr(1);
let non_tree_peer = make_node_addr(2);
let rv = MockRoutingView {
peers: vec![(tree_peer, true, true), (non_tree_peer, false, true)],
..Default::default()
};
let request = make_request(3);
let actions = plan_initiate(&request, &rv);
assert_eq!(action_peers(&actions), vec![tree_peer]);
}
#[test]
fn initiate_returns_empty_when_only_non_tree_peers_reach() {
// A tree peer exists but cannot reach the target; only cross-links reach.
// plan_forward would fall back to the cross-links here; plan_initiate does
// NOT — the tree-only origination gap preserved for behavior-neutrality.
let tree_no_reach = make_node_addr(5);
let non_tree_a = make_node_addr(3);
let non_tree_b = make_node_addr(4);
let rv = MockRoutingView {
peers: vec![
(tree_no_reach, true, false),
(non_tree_a, false, true),
(non_tree_b, false, true),
],
..Default::default()
};
let request = make_request(3);
assert!(plan_initiate(&request, &rv).is_empty());
}
#[test]
fn initiate_returns_empty_when_nothing_reaches_target() {
let tree_peer = make_node_addr(6);
let non_tree_peer = make_node_addr(7);
let rv = MockRoutingView {
peers: vec![(tree_peer, true, false), (non_tree_peer, false, false)],
..Default::default()
};
let request = make_request(3);
assert!(plan_initiate(&request, &rv).is_empty());
}
#[test]
fn leaf_node_does_not_forward() {
let tree_peer = make_node_addr(1);
let rv = MockRoutingView {
peers: vec![(tree_peer, true, true)],
leaf: true,
..Default::default()
};
let mut request = make_request(3);
assert!(matches!(
plan_forward(&mut request, &rv),
ForwardOutcome::LeafNoForward
));
}
#[test]
fn forward_excludes_non_full_peers() {
let full_tree = make_node_addr(1);
let lite_tree = make_node_addr(2);
let rv = MockRoutingView {
peers: vec![(full_tree, true, true), (lite_tree, true, true)],
not_full: vec![lite_tree],
..Default::default()
};
let mut request = make_request(3);
match plan_forward(&mut request, &rv) {
ForwardOutcome::Forward {
actions,
used_fallback,
} => {
assert!(!used_fallback);
assert_eq!(action_peers(&actions), vec![full_tree]);
}
_ => panic!("expected Forward to the Full tree peer only"),
}
}
#[test]
fn forward_excludes_peers_below_min_mtu() {
let ok_tree = make_node_addr(1);
let small_tree = make_node_addr(2);
let rv = MockRoutingView {
peers: vec![(ok_tree, true, true), (small_tree, true, true)],
mtu_fail: vec![small_tree],
..Default::default()
};
let mut request = make_request(3);
match plan_forward(&mut request, &rv) {
ForwardOutcome::Forward {
actions,
used_fallback,
} => {
assert!(!used_fallback);
assert_eq!(action_peers(&actions), vec![ok_tree]);
}
_ => panic!("expected Forward to the MTU-satisfying tree peer only"),
}
}
#[test]
fn initiate_excludes_non_full_and_mtu_fail_peers() {
let good = make_node_addr(1);
let lite = make_node_addr(2);
let small = make_node_addr(3);
let rv = MockRoutingView {
peers: vec![(good, true, true), (lite, true, true), (small, true, true)],
not_full: vec![lite],
mtu_fail: vec![small],
..Default::default()
};
let request = make_request(3);
let actions = plan_initiate(&request, &rv);
assert_eq!(action_peers(&actions), vec![good]);
}
#[test]
fn response_route_uses_recorded_reverse_path() {
let mut lookup = empty_lookup();
let from = make_node_addr(9);
lookup
.recent_requests
.insert(42, RecentRequest::new(from, 0));
match plan_response_route(&lookup, 42) {
ResponseRouteDecision::ReversePath(peer) => assert_eq!(peer, from),
ResponseRouteDecision::NeedsTreeRoute => panic!("expected ReversePath"),
}
}
#[test]
fn response_route_needs_tree_route_when_no_record() {
let lookup = empty_lookup();
assert!(matches!(
plan_response_route(&lookup, 42),
ResponseRouteDecision::NeedsTreeRoute
));
}
#[test]
fn classify_response_transit_on_fresh_forwarded_request() {
let from_peer = make_node_addr(0x11);
let mut lookup = empty_lookup();
lookup
.recent_requests
.insert(42, RecentRequest::new(from_peer, 1000));
match classify_response(&mut lookup, 42) {
ResponseRoute::Transit { from_peer: peer } => assert_eq!(peer, from_peer),
_ => panic!("expected Transit"),
}
// The dedup flag must flip after the first transit.
assert!(lookup.recent_requests.get(&42).unwrap().response_forwarded);
}
#[test]
fn classify_response_already_forwarded_on_second_call() {
let from_peer = make_node_addr(0x22);
let mut lookup = empty_lookup();
lookup
.recent_requests
.insert(7, RecentRequest::new(from_peer, 1000));
assert!(matches!(
classify_response(&mut lookup, 7),
ResponseRoute::Transit { .. }
));
assert!(matches!(
classify_response(&mut lookup, 7),
ResponseRoute::AlreadyForwarded
));
}
#[test]
fn classify_response_originator_when_request_absent() {
let mut lookup = empty_lookup();
assert!(matches!(
classify_response(&mut lookup, 999),
ResponseRoute::Originator
));
}
#[test]
fn on_response_accepted_clears_state_and_emits_effects() {
let target = make_node_addr(0x5A);
let mut lookup = empty_lookup();
// Seed a backoff entry and a pending lookup for the target.
lookup.backoff.record_failure(&target, 1000);
assert!(!lookup.backoff.is_empty(), "precondition: backoff seeded");
lookup
.pending_lookups
.insert(target, PendingLookup::new(1000));
assert!(lookup.pending_lookups.contains_key(&target));
let coords = TreeCoordinate::root(target);
let now_ms = 12_345u64;
let path_mtu = 1400u16;
let actions = on_response_accepted(&mut lookup, 7, &target, coords, now_ms, path_mtu);
// Success state must be cleared.
assert!(
lookup.backoff.is_empty(),
"backoff entry must clear on success"
);
assert!(
!lookup.pending_lookups.contains_key(&target),
"pending lookup must be dropped"
);
// Exactly the four effect actions, in order.
assert_eq!(actions.len(), 4, "expected four effect actions");
match &actions[0] {
LookupAction::CacheCoords {
target: t,
now_ms: n,
path_mtu: p,
..
} => {
assert_eq!(*t, target);
assert_eq!(*n, now_ms);
assert_eq!(*p, path_mtu);
}
_ => panic!("action[0] must be CacheCoords"),
}
match &actions[1] {
LookupAction::WritePathMtu {
target: t,
now_ms: n,
path_mtu: p,
} => {
assert_eq!(*t, target);
assert_eq!(
*n, now_ms,
"the path-MTU entry must be stamped with the same instant the \
coordinates are cached at, or the clamp outlives its route"
);
assert_eq!(*p, path_mtu);
}
_ => panic!("action[1] must be WritePathMtu"),
}
match &actions[2] {
LookupAction::ResetWarmupIfEstablished { target: t } => assert_eq!(*t, target),
_ => panic!("action[2] must be ResetWarmupIfEstablished"),
}
match &actions[3] {
LookupAction::RetryQueuedPackets { target: t } => assert_eq!(*t, target),
_ => panic!("action[3] must be RetryQueuedPackets"),
}
}
#[test]
fn cache_coords_action_carries_the_request_id_the_response_arrived_with() {
let target = make_node_addr(0x5A);
let mut lookup = empty_lookup();
// Distinct from now_ms and path_mtu below, so substituting either for the
// correlator fails rather than coincidentally matching.
let request_id = 0xDEAD_BEEF_0000_0001u64;
let now_ms = 12_345u64;
let path_mtu = 1400u16;
let actions = on_response_accepted(
&mut lookup,
request_id,
&target,
TreeCoordinate::root(target),
now_ms,
path_mtu,
);
match &actions[0] {
LookupAction::CacheCoords { request_id: id, .. } => assert_eq!(
*id, request_id,
"the cache-coords action must carry the correlator of the response it \
was planned from, or the shell's sub-floor path-MTU warning cannot \
name the exchange its sibling warnings do"
),
_ => panic!("action[0] must be CacheCoords"),
}
}
#[test]
fn poll_pending_no_action_before_first_deadline() {
let target = make_node_addr(0x30);
let mut lookup = empty_lookup();
let t0 = 10_000u64;
lookup
.pending_lookups
.insert(target, PendingLookup::new(t0));
// Just before the attempt-1 deadline (1s): nothing fires.
let outcome = poll_pending(&mut lookup, t0 + 999, &[1, 2, 4, 8]);
assert!(outcome.retries.is_empty(), "no retry before deadline");
assert!(outcome.timeouts.is_empty(), "no timeout before deadline");
// Entry unchanged.
let entry = lookup.pending_lookups.get(&target).unwrap();
assert_eq!(entry.attempt, 1);
assert_eq!(entry.last_sent_ms, t0);
}
#[test]
fn poll_pending_retries_at_first_deadline() {
let target = make_node_addr(0x31);
let mut lookup = empty_lookup();
let t0 = 10_000u64;
lookup
.pending_lookups
.insert(target, PendingLookup::new(t0));
// At the attempt-1 deadline (t0 + 1000): one retry to attempt 2.
let outcome = poll_pending(&mut lookup, t0 + 1000, &[1, 2, 4, 8]);
assert_eq!(outcome.retries, vec![(target, 2)]);
assert!(outcome.timeouts.is_empty());
// Entry mutated: attempt bumped, last_sent refreshed.
let entry = lookup.pending_lookups.get(&target).unwrap();
assert_eq!(entry.attempt, 2);
assert_eq!(entry.last_sent_ms, t0 + 1000);
}
#[test]
fn poll_pending_final_timeout_at_max_attempt() {
let target = make_node_addr(0x32);
let mut lookup = empty_lookup();
// Drive the entry to attempt == max (4) with a known last_sent.
let tn = 50_000u64;
let mut entry = PendingLookup::new(tn);
entry.attempt = 4;
entry.last_sent_ms = tn;
lookup.pending_lookups.insert(target, entry);
// attempt_timeouts_secs[3] == 8 → deadline at tn + 8000.
let outcome = poll_pending(&mut lookup, tn + 8000, &[1, 2, 4, 8]);
assert!(outcome.retries.is_empty(), "max attempt cannot retry");
assert_eq!(
outcome.timeouts,
vec![(target, 1)],
"one timeout, failure #1"
);
// Entry removed and a backoff failure recorded.
assert!(
!lookup.pending_lookups.contains_key(&target),
"timed-out entry must be removed"
);
assert_eq!(lookup.backoff.failure_count(&target), 1);
}
// --- classify_request tests ---
#[test]
fn classify_request_forwards_fresh_and_records_it() {
let mut lookup = empty_lookup();
let from = make_node_addr(0x01);
let my_addr = make_node_addr(0x99);
let target = make_node_addr(0xAA);
let request = make_request_id(1, target, 3);
let outcome = classify_request(&mut lookup, &request, &from, &my_addr, 1000, 5000, 4096);
assert!(matches!(outcome, RequestOutcome::Forward));
// Recorded for reverse-path forwarding.
assert!(lookup.recent_requests.contains_key(&1));
assert_eq!(lookup.recent_requests.get(&1).unwrap().from_peer, from);
}
#[test]
fn classify_request_duplicate_on_second_call() {
let mut lookup = empty_lookup();
let from = make_node_addr(0x01);
let my_addr = make_node_addr(0x99);
let target = make_node_addr(0xAA);
let request = make_request_id(1, target, 3);
assert!(matches!(
classify_request(&mut lookup, &request, &from, &my_addr, 1000, 5000, 4096),
RequestOutcome::Forward
));
assert!(matches!(
classify_request(&mut lookup, &request, &from, &my_addr, 1000, 5000, 4096),
RequestOutcome::Duplicate
));
}
#[test]
fn classify_request_dedup_cache_full() {
let mut lookup = empty_lookup();
let from = make_node_addr(0x01);
let my_addr = make_node_addr(0x99);
let target = make_node_addr(0xAA);
// Fill the cache to max_recent with distinct request_ids.
let max_recent = 3usize;
for id in 100..(100 + max_recent as u64) {
lookup
.recent_requests
.insert(id, RecentRequest::new(from, 1000));
}
assert_eq!(lookup.recent_requests.len(), max_recent);
let request = make_request_id(1, target, 3);
match classify_request(
&mut lookup,
&request,
&from,
&my_addr,
1000,
5000,
max_recent,
) {
RequestOutcome::DedupCacheFull { len } => assert_eq!(len, max_recent),
_ => panic!("expected DedupCacheFull"),
}
// The new request must not have been recorded on the drop path.
assert!(!lookup.recent_requests.contains_key(&1));
}
#[test]
fn classify_request_respond_as_target() {
let mut lookup = empty_lookup();
let from = make_node_addr(0x01);
let my_addr = make_node_addr(0xAA);
// target == my_addr
let request = make_request_id(1, my_addr, 3);
assert!(matches!(
classify_request(&mut lookup, &request, &from, &my_addr, 1000, 5000, 4096),
RequestOutcome::RespondAsTarget
));
// Recorded before the target decision.
assert!(lookup.recent_requests.contains_key(&1));
}
#[test]
fn classify_request_ttl_exhausted_for_non_target() {
let mut lookup = empty_lookup();
let from = make_node_addr(0x01);
let my_addr = make_node_addr(0x99);
let target = make_node_addr(0xAA);
// ttl 0 → not forwardable, and not the target.
let request = make_request_id(1, target, 0);
assert!(matches!(
classify_request(&mut lookup, &request, &from, &my_addr, 1000, 5000, 4096),
RequestOutcome::TtlExhausted
));
}
#[test]
fn classify_request_forward_rate_limited() {
let mut lookup = empty_lookup();
let from = make_node_addr(0x01);
let my_addr = make_node_addr(0x99);
let target = make_node_addr(0xAA);
// Pre-seed the forward limiter so should_forward(target) returns false
// on the next call within the (default 2s) min interval.
assert!(lookup.forward_limiter.should_forward(&target, 1000));
let request = make_request_id(1, target, 3);
assert!(matches!(
classify_request(&mut lookup, &request, &from, &my_addr, 1000, 5000, 4096),
RequestOutcome::ForwardRateLimited
));
}
#[test]
fn classify_request_purges_expired_entries() {
let mut lookup = empty_lookup();
let from = make_node_addr(0x01);
let my_addr = make_node_addr(0x99);
let target = make_node_addr(0xAA);
// Seed an entry that is expired at now_ms with the given expiry window.
// is_expired: now - timestamp > expiry_ms → expired.
lookup
.recent_requests
.insert(55, RecentRequest::new(from, 1000));
// now_ms = 10_000, expiry_ms = 5000 → 9000 > 5000 → expired.
let request = make_request_id(1, target, 3);
let outcome = classify_request(&mut lookup, &request, &from, &my_addr, 10_000, 5000, 4096);
assert!(matches!(outcome, RequestOutcome::Forward));
// The expired entry (55) must have been purged.
assert!(!lookup.recent_requests.contains_key(&55));
// The fresh request is recorded.
assert!(lookup.recent_requests.contains_key(&1));
}
#[test]
fn poll_pending_full_ladder_end_to_end() {
let target = make_node_addr(0x33);
let mut lookup = empty_lookup();
let t0 = 0u64;
lookup
.pending_lookups
.insert(target, PendingLookup::new(t0));
let ladder = [1u64, 2, 4, 8];
// attempt 1 → 2 at deadline 1s
let o = poll_pending(&mut lookup, t0 + 1000, &ladder);
assert_eq!(o.retries, vec![(target, 2)]);
// attempt 2 → 3 at deadline 2s after last send
let o = poll_pending(&mut lookup, t0 + 1000 + 2000, &ladder);
assert_eq!(o.retries, vec![(target, 3)]);
// attempt 3 → 4 at deadline 4s after last send
let o = poll_pending(&mut lookup, t0 + 1000 + 2000 + 4000, &ladder);
assert_eq!(o.retries, vec![(target, 4)]);
// attempt 4 is max → final timeout at deadline 8s after last send
let last = t0 + 1000 + 2000 + 4000;
let o = poll_pending(&mut lookup, last + 8000, &ladder);
assert!(o.retries.is_empty());
assert_eq!(o.timeouts, vec![(target, 1)]);
assert!(!lookup.pending_lookups.contains_key(&target));
}
// --- initiate_gate / initiate_failed tests ---
#[test]
fn initiate_gate_deduplicated_when_pending() {
let dest = make_node_addr(0x40);
let mut lookup = empty_lookup();
lookup.pending_lookups.insert(dest, PendingLookup::new(500));
// reachable=true would otherwise Proceed, but the pending entry wins.
assert!(matches!(
initiate_gate(&mut lookup, &dest, 1000, true),
InitiateDecision::Deduplicated
));
}
#[test]
fn initiate_gate_suppressed_by_backoff() {
let dest = make_node_addr(0x41);
let mut lookup = suppressing_lookup();
// One failure arms suppression under with_params(30, 300).
lookup.backoff.record_failure(&dest, 1000);
assert!(
lookup.backoff.is_suppressed(&dest, 1000),
"precondition: suppressed"
);
match initiate_gate(&mut lookup, &dest, 1000, true) {
InitiateDecision::Suppressed { failures } => assert_eq!(failures, 1),
_ => panic!("expected Suppressed"),
}
// No pending entry was inserted on the suppress path.
assert!(!lookup.pending_lookups.contains_key(&dest));
}
#[test]
fn initiate_gate_bloom_miss_records_failure() {
let dest = make_node_addr(0x42);
let mut lookup = empty_lookup();
assert!(matches!(
initiate_gate(&mut lookup, &dest, 1000, false),
InitiateDecision::BloomMiss
));
// A backoff failure was recorded, and no pending entry created.
assert_eq!(lookup.backoff.failure_count(&dest), 1);
assert!(!lookup.pending_lookups.contains_key(&dest));
}
#[test]
fn initiate_gate_proceed_inserts_pending() {
let dest = make_node_addr(0x43);
let mut lookup = empty_lookup();
let now_ms = 7_777u64;
assert!(matches!(
initiate_gate(&mut lookup, &dest, now_ms, true),
InitiateDecision::Proceed
));
// The pending entry now exists, stamped with now_ms.
let entry = lookup
.pending_lookups
.get(&dest)
.expect("Proceed must insert a pending lookup");
assert_eq!(entry.last_sent_ms, now_ms);
assert_eq!(entry.attempt, 1);
}
#[test]
fn initiate_failed_drops_pending_and_records_failure() {
let dest = make_node_addr(0x44);
let mut lookup = empty_lookup();
lookup
.pending_lookups
.insert(dest, PendingLookup::new(1000));
initiate_failed(&mut lookup, &dest, 1000);
assert!(
!lookup.pending_lookups.contains_key(&dest),
"pending entry must be dropped"
);
assert_eq!(lookup.backoff.failure_count(&dest), 1);
}