//! RFC 010 c5 — handshake state-machine tests (roadmap: happy path; hash //! mismatch; proto-version mismatch; name already claimed; simultaneous-connect //! tie-break; garbage before Hello). Pure — no IO, no actors, no runtime. #![cfg(feature = "cluster")] use smarm::cluster::envelope::{Frame, NodeMeta, RejectReason, PROTO_VERSION}; use smarm::cluster::handshake::{ dial_wins, HelloCtx, Initiator, InitiatorOutcome, Local, Responder, ResponderOutcome, }; use smarm::pg::Incarnation; const HASH: u64 = 0xDEAD_BEEF_CAFE_F00D; fn local(name: &str) -> Local { Local { node_name: name.into(), incarnation: Incarnation::new(7), build_hash: HASH, meta: NodeMeta { role: "worker".into(), region: "eu-west".into(), }, } } /// The Hello that `Initiator::new(&local(name))` emits, built by hand. fn hello_from(name: &str) -> Frame { let l = local(name); Frame::Hello { proto_version: PROTO_VERSION, build_hash: l.build_hash, node_name: l.node_name, incarnation: l.incarnation, meta: l.meta, } } #[test] fn happy_path_establishes_both_ends() { // alpha dials beta. let (initiator, hello) = Initiator::new(&local("alpha")); assert_eq!(hello, hello_from("alpha"), "initiator emits its identity"); let responder = Responder::new(local("beta")); let (reply, peer) = match responder.on_frame(hello, HelloCtx::default()) { ResponderOutcome::Accepted { reply, peer } => (reply, peer), other => panic!("expected Accepted, got {other:?}"), }; assert_eq!(peer.node_name, "alpha"); assert_eq!(peer.incarnation, Incarnation::new(7)); assert_eq!(peer.meta.role, "worker"); // The ack carries the responder's identity, no hash/version (one-sided // check — sound because equality is symmetric). let l = local("beta"); assert_eq!( reply, Frame::HelloAck { node_name: l.node_name, incarnation: l.incarnation, meta: l.meta, } ); match initiator.on_frame(reply) { InitiatorOutcome::Established(peer) => { assert_eq!(peer.node_name, "beta"); assert_eq!(peer.incarnation, Incarnation::new(7)); assert_eq!(peer.meta.region, "eu-west"); } other => panic!("expected Established, got {other:?}"), } } #[test] fn hash_mismatch_rejected() { let responder = Responder::new(local("beta")); let hello = Frame::Hello { proto_version: PROTO_VERSION, build_hash: HASH ^ 1, node_name: "alpha".into(), incarnation: Incarnation::new(7), meta: local("alpha").meta, }; match responder.on_frame(hello, HelloCtx::default()) { ResponderOutcome::Rejected { reply, reason } => { assert_eq!(reason, RejectReason::HashMismatch); assert_eq!(reply, Frame::HelloReject { reason }); } other => panic!("expected Rejected, got {other:?}"), } // The dialer side of the same story: a reject frame comes back. let (initiator, _hello) = Initiator::new(&local("alpha")); match initiator.on_frame(Frame::HelloReject { reason: RejectReason::HashMismatch, }) { InitiatorOutcome::Rejected(RejectReason::HashMismatch) => {} other => panic!("expected Rejected(HashMismatch), got {other:?}"), } } #[test] fn proto_version_mismatch_rejected_and_checked_first() { // Both proto and hash wrong: proto wins — nothing after the version can // be trusted, and HelloReject is the cross-version compatibility anchor. let responder = Responder::new(local("beta")); let hello = Frame::Hello { proto_version: PROTO_VERSION + 1, build_hash: HASH ^ 1, node_name: "alpha".into(), incarnation: Incarnation::new(7), meta: local("alpha").meta, }; match responder.on_frame(hello, HelloCtx::default()) { ResponderOutcome::Rejected { reason, .. } => { assert_eq!(reason, RejectReason::ProtoVersion); } other => panic!("expected Rejected, got {other:?}"), } } #[test] fn claimed_name_rejected() { let responder = Responder::new(local("beta")); let ctx = HelloCtx { name_claimed: true, dialing_this_peer: false, }; match responder.on_frame(hello_from("alpha"), ctx) { ResponderOutcome::Rejected { reason, .. } => { assert_eq!(reason, RejectReason::NameTaken); } other => panic!("expected Rejected, got {other:?}"), } } #[test] fn own_name_offered_rejected_as_name_taken() { // Self-connect or genuine collision: the responder's own name arrives. let responder = Responder::new(local("beta")); match responder.on_frame(hello_from("beta"), HelloCtx::default()) { ResponderOutcome::Rejected { reason, .. } => { assert_eq!(reason, RejectReason::NameTaken); } other => panic!("expected Rejected, got {other:?}"), } } #[test] fn hash_checked_before_name() { // Wrong hash AND claimed name: hash wins (validity before identity). let responder = Responder::new(local("beta")); let hello = Frame::Hello { proto_version: PROTO_VERSION, build_hash: HASH ^ 1, node_name: "alpha".into(), incarnation: Incarnation::new(7), meta: local("alpha").meta, }; let ctx = HelloCtx { name_claimed: true, dialing_this_peer: false, }; match responder.on_frame(hello, ctx) { ResponderOutcome::Rejected { reason, .. } => { assert_eq!(reason, RejectReason::HashMismatch); } other => panic!("expected Rejected, got {other:?}"), } } #[test] fn dial_wins_is_deterministic_and_antisymmetric() { // The smaller name's dial survives; both ends compute the same verdict. assert!(dial_wins("alpha", "beta")); assert!(!dial_wins("beta", "alpha")); for (a, b) in [("a", "b"), ("node-1", "node-2"), ("x", "xx")] { assert_ne!(dial_wins(a, b), dial_wins(b, a), "({a}, {b})"); } } #[test] fn simultaneous_connect_exactly_one_side_accepts() { // alpha and beta dial each other at once. Each responder sees the peer's // Hello while its own dial is in flight. let ctx = HelloCtx { name_claimed: false, dialing_this_peer: true, }; // On beta: inbound is alpha's dial; alpha < beta, so the inbound wins. let on_beta = Responder::new(local("beta")).on_frame(hello_from("alpha"), ctx); assert!( matches!(on_beta, ResponderOutcome::Accepted { .. }), "beta must accept alpha's dial, got {on_beta:?}" ); // On alpha: inbound is beta's dial; it loses — close silently, no frame // (ratified: both ends can compute the outcome, a reject adds nothing). let on_alpha = Responder::new(local("alpha")).on_frame(hello_from("beta"), ctx); assert!( matches!(on_alpha, ResponderOutcome::TieBreakLoss), "alpha must silently drop beta's dial, got {on_alpha:?}" ); } #[test] fn tiebreak_loss_only_applies_when_dialing() { // Same inbound Hello, no dial in flight: plain accept. let on_alpha = Responder::new(local("alpha")).on_frame(hello_from("beta"), HelloCtx::default()); assert!(matches!(on_alpha, ResponderOutcome::Accepted { .. })); } #[test] fn garbage_before_hello_fails_without_reply() { // Any valid-but-wrong frame before Hello is a protocol violation: close, // no reject frame. (Undecodable bytes are the codec's Err, not ours.) for frame in [ Frame::Heartbeat, Frame::HelloAck { node_name: "alpha".into(), incarnation: Incarnation::new(7), meta: local("alpha").meta, }, Frame::Demonitor { monitor_id: 3 }, ] { let out = Responder::new(local("beta")).on_frame(frame.clone(), HelloCtx::default()); match out { ResponderOutcome::Failed(f) => assert_eq!(f, frame), other => panic!("expected Failed({frame:?}), got {other:?}"), } } } #[test] fn garbage_before_ack_fails_the_initiator() { for frame in [ Frame::Heartbeat, hello_from("beta"), Frame::Demonitor { monitor_id: 3 }, ] { let (initiator, _hello) = Initiator::new(&local("alpha")); match initiator.on_frame(frame.clone()) { InitiatorOutcome::Failed(f) => assert_eq!(f, frame), other => panic!("expected Failed({frame:?}), got {other:?}"), } } }