//! Integration tests for the tines-bt transport, covering the F3 properties: //! the `from`/`Addr` check, `Contact`/`Lost`, multi-peer filtering, NodeId //! binding (UUID-checked advertisement + inbound-address lookup), and the //! central-peripheral role decision. //! //! Every test uses a mock [`Radio`] and never touches a real BLE radio, so the //! suite runs on a CI box with no Bluetooth stack. use std::collections::{BTreeSet, HashMap, VecDeque}; use tines_bt::{adv, resolve_bound, BtTransport, Radio, RadioError, RadioEvent}; use tines_core::consts::WIRE_VERSION; use tines_core::observation::NodeId; use tines_core::transport::{Event, Transport}; use bluer::{Address, AddressType, Uuid, UuidExt}; /// A canned [`Radio`] backed by queued events and a configurable MTU. struct MockRadio { events: VecDeque, _sent: Vec<(NodeId, Vec)>, mtu: usize, } impl MockRadio { fn new(mtu: usize) -> Self { MockRadio { events: VecDeque::new(), _sent: Vec::new(), mtu, } } fn push(&mut self, ev: RadioEvent) { self.events.push_back(ev); } } impl Radio for MockRadio { fn poll(&mut self) -> Option { self.events.pop_front() } fn send(&mut self, _me: NodeId, to: &NodeId, data: &[u8]) -> Result<(), RadioError> { self._sent.push((*to, data.to_vec())); Ok(()) } fn mtu(&self) -> usize { self.mtu } } impl Drop for MockRadio { fn drop(&mut self) { // Drain the queue so a caller that only polls a couple of times does not // leave events stranded behind the dead drop. self.events.clear(); } } /// Build a minimal frame: a `WIRE_VERSION` byte, then the 16-byte `from`. /// `frame_sender` reads `from` from bytes 2..18, so this matches the wire. fn frame(from: NodeId) -> Vec { let mut b = vec![0u8; 2 + 16]; b[2..2 + 16].copy_from_slice(&from); b[0] = WIRE_VERSION; b } #[test] fn from_check_and_peer_filter_drop_frames() { let me: NodeId = [1u8; 16]; let peer: NodeId = [2u8; 16]; // the connection's advertised NodeId (bound Addr) let spoof: NodeId = [9u8; 16]; // a declared sender that is not the bound peer let other: NodeId = [3u8; 16]; // a different bound peer entirely // A clean frame from `peer` is delivered unchanged. let mut good = MockRadio::new(23); good.push(RadioEvent::Data { from: peer, data: frame(peer), }); let mut t = BtTransport::new(me, peer, good); match t.poll() { Some(Event::Frame(from, data)) => { assert_eq!(from, peer); assert_eq!(&data[..], &frame(peer)[..]); } other => panic!("expected a Frame from {peer:?}, got {other:?}"), } // From-check violation: the frame's declared `from` (in the event) is the // bound peer, but the frame's actual `from` bytes say `spoof`. This is an // address-spoofing violation — dropped via the peeked `frame_sender`. let mut spoofed = MockRadio::new(23); spoofed.push(RadioEvent::Data { from: peer, data: frame(spoof), }); let mut t = BtTransport::new(me, peer, spoofed); assert_eq!( t.poll(), None, "a frame whose decoded `from` differs from the bound Addr is dropped" ); // Peer filter: a frame tagged as coming from a *different* bound peer is // dropped (a multi-peer radio emits events for every connection; the // transport only surfaces its own bound peer). let mut other_peer = MockRadio::new(23); other_peer.push(RadioEvent::Data { from: other, data: frame(other), }); let mut t = BtTransport::new(me, peer, other_peer); assert_eq!(t.poll(), None, "a frame from a non-bound peer is dropped"); // A spoofed frame sitting before a good one is simply skipped; the good one // is delivered. let mut mixed = MockRadio::new(23); mixed.push(RadioEvent::Data { from: peer, data: frame(spoof), }); mixed.push(RadioEvent::Data { from: peer, data: frame(peer), }); let mut t = BtTransport::new(me, peer, mixed); match t.poll() { Some(Event::Frame(from, data)) => { assert_eq!(from, peer); assert_eq!(&data[..], &frame(peer)[..]); } other => panic!("expected the clean Frame from {peer:?}, got {other:?}"), } } #[test] fn contact_and_lost_emitted() { let me: NodeId = [1u8; 16]; let peer: NodeId = [2u8; 16]; let mut radio = MockRadio::new(23); radio.push(RadioEvent::Contact { addr: peer }); radio.push(RadioEvent::Data { from: peer, data: frame(peer), }); radio.push(RadioEvent::Lost { addr: peer }); let mut t = BtTransport::new(me, peer, radio); assert_eq!(t.poll(), Some(Event::Contact(peer)), "up → Contact"); assert!(matches!(t.poll(), Some(Event::Frame(..))), "a frame passes"); assert_eq!(t.poll(), Some(Event::Lost(peer)), "end → Lost"); assert_eq!(t.poll(), None, "no more events once the connection is gone"); } #[test] fn frame_boundaries_preserved_at_transport() { // Two back-to-back frames arrive as two separate Data events; the // transport must surface them as two separate Frames, never merged. let me: NodeId = [1u8; 16]; let peer: NodeId = [2u8; 16]; let frame_a = frame(peer); let mut frame_b = frame(peer); frame_b[1] = 0xFF; // differ via the variant tag (skipped by frame_sender), so `from` stays == peer let a_expected = frame_a.clone(); let b_expected = frame_b.clone(); let mut radio = MockRadio::new(23); radio.push(RadioEvent::Data { from: peer, data: frame_a, }); radio.push(RadioEvent::Data { from: peer, data: frame_b, }); let mut t = BtTransport::new(me, peer, radio); match t.poll() { Some(Event::Frame(_, a)) => assert_eq!(&a[..], &a_expected[..]), other => panic!("expected the first frame, got {other:?}"), } match t.poll() { Some(Event::Frame(_, b)) => assert_eq!(&b[..], &b_expected[..]), other => panic!("expected the second frame, got {other:?}"), } assert_eq!(t.poll(), None); } #[test] fn is_central_by_node_id_order() { let a: NodeId = [0u8; 16]; let b: NodeId = [1u8; 16]; let big: NodeId = [255u8; 16]; assert!(tines_bt::is_central(a, b)); // smaller dials assert!(!tines_bt::is_central(b, a)); assert!(tines_bt::is_central(b, big)); assert!(!tines_bt::is_central(big, b)); } #[test] fn resolve_bound_rejects_unknown_inbound() { // An empty scan map: no address is eligible, so an inbound from an unknown // address binds to nothing and must be rejected. let empty: HashMap = HashMap::new(); let unknown = Address::new([0xAA; 6]); assert_eq!(resolve_bound(&empty, &unknown), None); // A peer we discovered and bound: its own address resolves. let node: NodeId = [4u8; 16]; let mut map = HashMap::new(); map.insert(Address::new([0xBB; 6]), (node, AddressType::LePublic)); let addr = Address::new([0xBB; 6]); assert_eq!(resolve_bound(&map, &addr), Some(node)); } #[test] fn decode_ad_node_id_requires_tines_uuid() { let me: NodeId = [7u8; 16]; let node: NodeId = [5u8; 16]; let allow: BTreeSet = BTreeSet::new(); // empty = any tines advertiser // An EIR-less service-data blob (wrong UUID) is rejected. let wrong_uuid: HashMap> = HashMap::from([(Uuid::from_u16(0x180F), node.to_vec())]); assert_eq!(adv::decode_ad_node_id(&wrong_uuid, me, &allow), None); // No service data at all is rejected. let empty: HashMap> = HashMap::new(); assert_eq!(adv::decode_ad_node_id(&empty, me, &allow), None); // The tines UUID with a valid NodeId is decoded. let good: HashMap> = HashMap::from([(Uuid::from_u16(adv::SERVICE_UUID), node.to_vec())]); assert_eq!(adv::decode_ad_node_id(&good, me, &allow), Some(node)); } #[test] fn decode_ad_node_id_honours_allowlist() { let me: NodeId = [7u8; 16]; let allowed: NodeId = [5u8; 16]; let denied: NodeId = [6u8; 16]; let service: HashMap> = HashMap::from([(Uuid::from_u16(adv::SERVICE_UUID), allowed.to_vec())]); // Allowlist containing `allowed`: the advertiser is eligible. let mut allow = BTreeSet::new(); allow.insert(allowed); assert_eq!(adv::decode_ad_node_id(&service, me, &allow), Some(allowed)); // Allowlist not containing this advertiser: rejected. let deny: BTreeSet = BTreeSet::from([denied]); assert_eq!(adv::decode_ad_node_id(&service, me, &deny), None); }