1use std::{
4 collections::{HashMap, HashSet},
5 net::IpAddr,
6 sync::Arc,
7};
8
9use kameo::{
10 actor::ActorRef,
11 message::{Context, Message},
12 reply::ReplySender,
13};
14use tokio::sync::watch;
15use ts_control::{Node, UserId, UserProfile};
16use ts_transport::PeerId;
17
18use crate::{Error, env::Env, status::StatusNode};
19
20mod peer_db;
21
22pub use peer_db::PeerDb;
23
24pub struct PeerTracker {
26 peer_db: PeerDb,
27 seen_state_update: bool,
28 pending_requests: Vec<Pending>,
29 peer_watch: watch::Sender<Vec<StatusNode>>,
32 user_profiles: HashMap<UserId, UserProfile>,
39 tka_authority: Option<ts_tka::Authority>,
48 env: Env,
49}
50
51impl PeerTracker {
52 fn peer_by_name_opt(&self, name: &str) -> Option<&Node> {
53 self.peer_db.get(&name).map(|(_id, node)| node)
55 }
56
57 fn peer_by_tailnet_ip_opt(&self, ip: IpAddr) -> Option<&Node> {
58 self.peer_db.get(&ip).map(|(_id, node)| node)
59 }
60
61 fn status_peers(&self) -> Vec<StatusNode> {
63 self.peer_db
64 .peers()
65 .values()
66 .map(StatusNode::from_node)
67 .collect()
68 }
69
70 fn whois_opt(&self, addr: std::net::SocketAddr) -> Option<crate::status::WhoIs> {
71 let ip = crate::status::whois_addr(addr);
72 let node = self.peer_by_tailnet_ip_opt(ip).cloned()?;
73 let user = self.resolve_user(node.user_id);
77 Some(crate::status::WhoIs::from_node_with_user(node, user))
78 }
79
80 fn resolve_user(&self, user_id: UserId) -> Option<String> {
82 self.user_profiles
83 .get(&user_id)
84 .and_then(UserProfile::best_label)
85 }
86
87 fn tka_admits(&self, node: &Node) -> bool {
97 let Some(auth) = &self.tka_authority else {
98 return true;
99 };
100
101 if node.key_signature.is_empty() {
102 tracing::warn!(
105 stable_id = ?node.stable_id,
106 "TKA: rejecting unsigned peer under tailnet lock"
107 );
108 return false;
109 }
110
111 if let Err(e) = auth.node_key_authorized(&node.node_key.to_bytes(), &node.key_signature) {
112 tracing::warn!(
113 stable_id = ?node.stable_id,
114 error = %e,
115 "TKA: rejecting peer with unauthorized node key"
116 );
117 return false;
118 }
119
120 true
121 }
122}
123
124impl kameo::Actor for PeerTracker {
125 type Args = Env;
126 type Error = Error;
127
128 async fn on_start(env: Self::Args, slf: ActorRef<Self>) -> Result<Self, Self::Error> {
129 env.subscribe::<Arc<ts_control::StateUpdate>>(&slf).await?;
130
131 let (peer_watch, _) = watch::channel(Vec::new());
132
133 Ok(Self {
134 peer_db: PeerDb::default(),
135 pending_requests: Default::default(),
136 seen_state_update: false,
137 peer_watch,
138 user_profiles: HashMap::new(),
139 tka_authority: None,
142 env,
143 })
144 }
145}
146
147enum Pending {
148 PeerByName(PeerByName, ReplySender<Option<Node>>),
149 AcceptedRoute(PeerByAcceptedRoute, ReplySender<Vec<Node>>),
150 TailnetIp(PeerByTailnetIp, ReplySender<Option<Node>>),
151 Status(ReplySender<Vec<StatusNode>>),
152 WhoIs(Whois, ReplySender<Option<crate::status::WhoIs>>),
153}
154
155#[allow(missing_docs)]
159mod msg_impl {
160 use std::net::IpAddr;
161
162 use kameo::prelude::DelegatedReply;
163
164 use super::*;
165
166 #[kameo::messages]
167 impl PeerTracker {
168 #[message(ctx)]
172 pub async fn peer_by_name(
173 &mut self,
174 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
175 name: String,
176 ) -> DelegatedReply<Option<Node>> {
177 let (deleg, sender) = ctx.reply_sender();
178 let Some(sender) = sender else { return deleg };
179
180 if !self.seen_state_update {
181 tracing::debug!(query = name, "no peer state seen yet, queueing request");
182
183 self.pending_requests
184 .push(Pending::PeerByName(PeerByName { name }, sender));
185
186 return deleg;
187 }
188
189 sender.send(self.peer_by_name_opt(&name).cloned());
190
191 deleg
192 }
193
194 #[message(ctx)]
209 pub fn peer_by_accepted_route(
210 &mut self,
211 ctx: &mut Context<Self, DelegatedReply<Vec<Node>>>,
212 ip: IpAddr,
213 ) -> DelegatedReply<Vec<Node>> {
214 let (deleg, sender) = ctx.reply_sender();
215 let Some(sender) = sender else { return deleg };
216
217 if !self.seen_state_update {
218 tracing::debug!(query = %ip, "no peer state seen yet, queueing request");
219
220 self.pending_requests
221 .push(Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, sender));
222
223 return deleg;
224 }
225
226 sender.send(
227 self.peer_db
228 .get_route(ip.into())
229 .map(|(_id, node)| node.clone())
230 .collect(),
231 );
232
233 deleg
234 }
235
236 #[message(ctx)]
238 pub fn peer_by_tailnet_ip(
239 &mut self,
240 ctx: &mut Context<Self, DelegatedReply<Option<Node>>>,
241 ip: IpAddr,
242 ) -> DelegatedReply<Option<Node>> {
243 let (deleg, sender) = ctx.reply_sender();
244 let Some(sender) = sender else { return deleg };
245
246 if !self.seen_state_update {
247 tracing::debug!(query = %ip, "no peer state seen yet, queueing request");
248
249 self.pending_requests
250 .push(Pending::TailnetIp(PeerByTailnetIp { ip }, sender));
251
252 return deleg;
253 }
254
255 sender.send(self.peer_by_tailnet_ip_opt(ip).cloned());
256
257 deleg
258 }
259
260 #[message(ctx)]
267 pub fn get_status(
268 &mut self,
269 ctx: &mut Context<Self, DelegatedReply<Vec<StatusNode>>>,
270 ) -> DelegatedReply<Vec<StatusNode>> {
271 let (deleg, sender) = ctx.reply_sender();
272 let Some(sender) = sender else { return deleg };
273
274 if !self.seen_state_update {
275 tracing::debug!("no peer state seen yet, queueing status request");
276 self.pending_requests.push(Pending::Status(sender));
277 return deleg;
278 }
279
280 sender.send(self.status_peers());
281
282 deleg
283 }
284
285 #[message(ctx)]
296 pub fn whois(
297 &mut self,
298 ctx: &mut Context<Self, DelegatedReply<Option<crate::status::WhoIs>>>,
299 addr: std::net::SocketAddr,
300 ) -> DelegatedReply<Option<crate::status::WhoIs>> {
301 let (deleg, sender) = ctx.reply_sender();
302 let Some(sender) = sender else { return deleg };
303
304 if !self.seen_state_update {
305 tracing::debug!(query = %addr, "no peer state seen yet, queueing whois request");
306 self.pending_requests
307 .push(Pending::WhoIs(Whois { addr }, sender));
308 return deleg;
309 }
310
311 sender.send(self.whois_opt(addr));
312
313 deleg
314 }
315
316 #[message(derive(Clone))]
326 pub fn watch_netmap(&self) -> watch::Receiver<Vec<StatusNode>> {
327 self.peer_watch.subscribe()
328 }
329 }
330}
331
332pub use msg_impl::*;
333
334#[derive(Debug, Clone)]
335pub(crate) struct PeerState {
336 #[allow(unused)]
337 pub deletions: HashSet<PeerId>,
338 #[allow(unused)]
339 pub upserts: HashSet<PeerId>,
340 pub peers: Arc<PeerDb>,
341}
342
343impl Message<Arc<ts_control::StateUpdate>> for PeerTracker {
344 type Reply = ();
345
346 async fn handle(
347 &mut self,
348 msg: Arc<ts_control::StateUpdate>,
349 _ctx: &mut Context<Self, Self::Reply>,
350 ) {
351 for profile in &msg.user_profiles {
355 self.user_profiles.insert(profile.id, profile.clone());
356 }
357
358 let Some(peer_update) = &msg.peer_update else {
359 return;
360 };
361
362 let (upserts, deletions) = self.apply_peer_update(peer_update);
363
364 tracing::debug!(
365 n_upsert = upserts.len(),
366 n_delete = deletions.len(),
367 peer_count = self.peer_db.peers().len(),
368 "new peer state"
369 );
370
371 self.service_pending_requests();
372
373 self.peer_watch.send_replace(self.status_peers());
376
377 if let Err(e) = self
378 .env
379 .publish(Arc::new(PeerState {
380 upserts,
381 deletions,
382 peers: Arc::new(self.peer_db.clone()),
383 }))
384 .await
385 {
386 tracing::error!(error = %e, "publishing peer state update");
387 }
388 }
389}
390
391#[derive(Debug, Clone, Copy)]
396pub struct RepublishState;
397
398impl Message<RepublishState> for PeerTracker {
399 type Reply = ();
400
401 async fn handle(&mut self, _msg: RepublishState, _ctx: &mut Context<Self, Self::Reply>) {
402 if let Err(e) = self
406 .env
407 .publish(Arc::new(PeerState {
408 upserts: HashSet::default(),
409 deletions: HashSet::default(),
410 peers: Arc::new(self.peer_db.clone()),
411 }))
412 .await
413 {
414 tracing::error!(error = %e, "re-publishing peer state after exit-node change");
415 }
416 }
417}
418
419impl PeerTracker {
420 fn apply_peer_update(
429 &mut self,
430 peer_update: &ts_control::PeerUpdate,
431 ) -> (HashSet<PeerId>, HashSet<PeerId>) {
432 let mut upserts = HashSet::default();
433 let mut deletions = HashSet::default();
434
435 match peer_update {
436 ts_control::PeerUpdate::Full(new_nodes) => {
437 tracing::trace!("full peer update");
438
439 let retained_ids = new_nodes
447 .iter()
448 .filter(|node| self.tka_admits(node))
449 .map(|x| &x.stable_id)
450 .collect::<HashSet<_>>();
451
452 self.peer_db.retain(|id, peer| {
453 let retain = retained_ids.contains(&peer.stable_id);
454
455 if !retain {
456 deletions.insert(id);
457 }
458
459 retain
460 });
461
462 for node in new_nodes {
463 if !self.tka_admits(node) {
464 continue; }
466 let peer_id = self.peer_db.upsert(node);
467 upserts.insert(peer_id);
468 }
469 }
470
471 ts_control::PeerUpdate::Delta { remove, upsert } => {
472 tracing::trace!("delta peer update");
473
474 for peer in upsert {
475 if !self.tka_admits(peer) {
476 continue; }
478 let id = self.peer_db.upsert(peer);
479
480 upserts.insert(id);
481 }
482
483 for peer in remove {
484 let Some((id, _node)) = self.peer_db.remove(peer) else {
485 tracing::error!(control_node_id = peer, "removed peer was unknown");
486 continue;
487 };
488
489 deletions.insert(id);
490 }
491 }
492 }
493
494 (upserts, deletions)
495 }
496
497 #[cfg(test)]
501 fn for_test(env: Env, tka_authority: Option<ts_tka::Authority>) -> Self {
502 let (peer_watch, _) = watch::channel(Vec::new());
503 Self {
504 peer_db: PeerDb::default(),
505 seen_state_update: false,
506 pending_requests: Vec::new(),
507 peer_watch,
508 user_profiles: HashMap::new(),
509 tka_authority,
510 env,
511 }
512 }
513
514 fn service_pending_requests(&mut self) {
515 if self.seen_state_update {
516 return;
517 }
518
519 self.seen_state_update = true;
520
521 if !self.pending_requests.is_empty() {
522 tracing::debug!(
523 n_pending = self.pending_requests.len(),
524 "state update received, servicing pending requests"
525 );
526 }
527
528 for req in core::mem::take(&mut self.pending_requests) {
529 match req {
530 Pending::PeerByName(PeerByName { name }, reply) => {
531 reply.send(self.peer_by_name_opt(&name).cloned());
532 }
533 Pending::TailnetIp(PeerByTailnetIp { ip }, reply) => {
534 reply.send(self.peer_by_tailnet_ip_opt(ip).cloned());
535 }
536 Pending::AcceptedRoute(PeerByAcceptedRoute { ip }, reply) => {
537 reply.send(
538 self.peer_db
539 .get_route(ip.into())
540 .map(|(_id, node)| node.clone())
541 .collect(),
542 );
543 }
544 Pending::Status(reply) => {
545 reply.send(self.status_peers());
546 }
547 Pending::WhoIs(Whois { addr }, reply) => {
548 reply.send(self.whois_opt(addr));
549 }
550 }
551 }
552 }
553}
554
555#[cfg(test)]
556mod tka_tests {
557 use ed25519_dalek::{Signer, SigningKey};
567 use ts_control::{Node, StableNodeId, TailnetAddress};
568 use ts_tka::{
569 AumHash, Authority, Key, KeyKind, State,
570 cbor::{self, Value},
571 };
572
573 use super::*;
574
575 const SIG_KIND_DIRECT: u64 = 1;
577
578 const NODE_KEY_BYTES: [u8; 32] = [7u8; 32];
580
581 fn test_env() -> Env {
584 let (_shutdown_tx, shutdown_rx) = watch::channel(false);
585 Env::new(
586 ts_keys::NodeState::generate(),
587 shutdown_rx,
588 crate::env::ForwarderConfig {
589 accept_routes: false,
590 exit_node: None,
591 forward_routes: Vec::new(),
592 forward_tcp_ports: Vec::new(),
593 forward_udp_ports: Vec::new(),
594 forward_all_ports: false,
595 forward_exit_egress: false,
596 exit_proxy: None,
597 peerapi_port: None,
598 taildrop_dir: None,
599 enable_ipv6: false,
600 ingress_active: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
601 },
602 )
603 }
604
605 fn peer_node(stable_id: &str, node_key: [u8; 32], key_signature: Vec<u8>) -> Node {
607 Node {
608 id: 1,
609 stable_id: StableNodeId(stable_id.to_string()),
610 hostname: stable_id.to_string(),
611 user_id: 0,
612 tailnet: Some("ts.net".to_string()),
613 tags: Vec::new(),
614 tailnet_address: TailnetAddress {
615 ipv4: "100.64.0.1/32".parse().unwrap(),
616 ipv6: "fd7a:115c:a1e0::1/128".parse().unwrap(),
617 },
618 node_key: node_key.into(),
619 node_key_expiry: None,
620 key_signature,
621 machine_key: None,
622 disco_key: None,
623 accepted_routes: Vec::new(),
624 underlay_addresses: Vec::new(),
625 derp_region: None,
626 cap: Default::default(),
627 cap_map: Default::default(),
628 peerapi_port: None,
629 peerapi_dns_proxy: false,
630 is_wireguard_only: false,
631 exit_node_dns_resolvers: Vec::new(),
632 peer_relay: false,
633 service_vips: Default::default(),
634 }
635 }
636
637 fn direct_sig_cbor(node_key: &[u8], key_id: &[u8], signature: Option<&[u8]>) -> Vec<u8> {
642 let mut pairs = alloc_pairs(node_key, key_id);
643 if let Some(sig) = signature {
644 pairs.push((4, Some(Value::Bytes(sig.to_vec()))));
645 }
646 cbor::int_map(pairs).to_vec()
647 }
648
649 fn alloc_pairs(node_key: &[u8], key_id: &[u8]) -> Vec<(u64, Option<Value>)> {
650 vec![
651 (1, Some(Value::Uint(SIG_KIND_DIRECT))),
652 (2, Some(Value::Bytes(node_key.to_vec()))),
653 (3, Some(Value::Bytes(key_id.to_vec()))),
654 ]
655 }
656
657 fn authority_and_valid_sig() -> (Authority, Vec<u8>) {
660 let signing = SigningKey::from_bytes(&[42u8; 32]);
662 let trusted_pub = signing.verifying_key().to_bytes().to_vec();
663
664 let authority = Authority::from_state(
665 AumHash([0; 32]),
666 State {
667 keys: vec![Key {
668 kind: KeyKind::Ed25519,
669 votes: 1,
670 public: trusted_pub.clone(),
671 }],
672 },
673 );
674
675 let preimage = direct_sig_cbor(&NODE_KEY_BYTES, &trusted_pub, None);
677 let sig_hash = ts_tka::aum_hash(&preimage).0;
678 let signature = signing.sign(&sig_hash).to_bytes().to_vec();
679
680 let signed_cbor = direct_sig_cbor(&NODE_KEY_BYTES, &trusted_pub, Some(&signature));
681 assert!(
683 authority
684 .node_key_authorized(&NODE_KEY_BYTES, &signed_cbor)
685 .is_ok()
686 );
687
688 (authority, signed_cbor)
689 }
690
691 #[tokio::test]
692 async fn tka_inactive_upserts_all_peers() {
693 let mut tracker = PeerTracker::for_test(test_env(), None);
695
696 let signed = peer_node("signed", [1u8; 32], vec![0xde, 0xad, 0xbe, 0xef]);
697 let unsigned = peer_node("unsigned", [2u8; 32], vec![]);
698
699 assert!(tracker.tka_admits(&signed));
700 assert!(tracker.tka_admits(&unsigned));
701
702 tracker.peer_db.upsert(&signed);
703 tracker.peer_db.upsert(&unsigned);
704 assert_eq!(tracker.peer_db.peers().len(), 2);
705 }
706
707 #[tokio::test]
708 async fn tka_active_rejects_unsigned_peer() {
709 let (authority, _sig) = authority_and_valid_sig();
711 let mut tracker = PeerTracker::for_test(test_env(), Some(authority));
712
713 let unsigned = peer_node("unsigned", NODE_KEY_BYTES, vec![]);
714 assert!(!tracker.tka_admits(&unsigned));
715
716 if tracker.tka_admits(&unsigned) {
718 tracker.peer_db.upsert(&unsigned);
719 }
720 assert_eq!(tracker.peer_db.peers().len(), 0);
721 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
722 }
723
724 #[tokio::test]
725 async fn tka_active_rejects_bad_signature() {
726 let (authority, mut sig) = authority_and_valid_sig();
728 let last = sig.len() - 1;
730 sig[last] ^= 0xff;
731
732 let mut tracker = PeerTracker::for_test(test_env(), Some(authority));
733 let bad = peer_node("bad", NODE_KEY_BYTES, sig);
734 assert!(!tracker.tka_admits(&bad));
735
736 if tracker.tka_admits(&bad) {
737 tracker.peer_db.upsert(&bad);
738 }
739 assert_eq!(tracker.peer_db.peers().len(), 0);
740 }
741
742 #[tokio::test]
743 async fn tka_active_admits_authorized_peer() {
744 let (authority, sig) = authority_and_valid_sig();
746 let mut tracker = PeerTracker::for_test(test_env(), Some(authority));
747
748 let good = peer_node("good", NODE_KEY_BYTES, sig);
749 assert!(tracker.tka_admits(&good));
750
751 if tracker.tka_admits(&good) {
752 tracker.peer_db.upsert(&good);
753 }
754 assert_eq!(tracker.peer_db.peers().len(), 1);
755 assert!(tracker.peer_db.get(&good.node_key).is_some());
756 }
757
758 #[tokio::test]
766 async fn tka_active_delta_upsert_rejects_unauthorized() {
767 let (authority, _sig) = authority_and_valid_sig();
770 let mut tracker = PeerTracker::for_test(test_env(), Some(authority));
771
772 let unsigned = peer_node("unsigned", NODE_KEY_BYTES, vec![]);
773 let update = ts_control::PeerUpdate::Delta {
774 upsert: vec![unsigned.clone()],
775 remove: Vec::new(),
776 };
777
778 tracker.apply_peer_update(&update);
779
780 assert_eq!(tracker.peer_db.peers().len(), 0);
781 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
782 }
783
784 #[tokio::test]
785 async fn tka_active_delta_upsert_admits_authorized() {
786 let (authority, sig) = authority_and_valid_sig();
788 let mut tracker = PeerTracker::for_test(test_env(), Some(authority));
789
790 let good = peer_node("good", NODE_KEY_BYTES, sig);
791 let update = ts_control::PeerUpdate::Delta {
792 upsert: vec![good.clone()],
793 remove: Vec::new(),
794 };
795
796 tracker.apply_peer_update(&update);
797
798 assert_eq!(tracker.peer_db.peers().len(), 1);
799 assert!(tracker.peer_db.get(&good.node_key).is_some());
800 }
801
802 #[tokio::test]
803 async fn tka_active_full_admits_only_authorized_in_mixed_batch() {
804 let (authority, sig) = authority_and_valid_sig();
808 let mut bad_sig = sig.clone();
810 let last = bad_sig.len() - 1;
811 bad_sig[last] ^= 0xff;
812
813 let mut tracker = PeerTracker::for_test(test_env(), Some(authority));
814
815 let good = peer_node("good", NODE_KEY_BYTES, sig);
818 let unsigned = peer_node("unsigned", [8u8; 32], vec![]);
819 let bad = peer_node("bad", [9u8; 32], bad_sig);
820
821 let update =
822 ts_control::PeerUpdate::Full(vec![good.clone(), unsigned.clone(), bad.clone()]);
823
824 tracker.apply_peer_update(&update);
825
826 assert_eq!(tracker.peer_db.peers().len(), 1);
827 assert!(tracker.peer_db.get(&good.node_key).is_some());
828 assert!(tracker.peer_db.get(&unsigned.node_key).is_none());
829 assert!(tracker.peer_db.get(&bad.node_key).is_none());
830 }
831
832 #[tokio::test]
833 async fn tka_full_resync_revocation_behavior() {
834 let (authority, sig) = authority_and_valid_sig();
844 let mut tracker = PeerTracker::for_test(test_env(), Some(authority));
845
846 let good = peer_node("revoked", NODE_KEY_BYTES, sig.clone());
848 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![good.clone()]));
849 assert_eq!(tracker.peer_db.peers().len(), 1);
850 assert!(tracker.peer_db.get(&good.node_key).is_some());
851
852 let mut bad_sig = sig;
854 let last = bad_sig.len() - 1;
855 bad_sig[last] ^= 0xff;
856 let revoked = peer_node("revoked", NODE_KEY_BYTES, bad_sig);
857 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![revoked.clone()]));
858
859 assert_eq!(tracker.peer_db.peers().len(), 0);
861 assert!(tracker.peer_db.get(&revoked.node_key).is_none());
862 }
863
864 #[tokio::test]
865 async fn tka_inactive_full_resync_keeps_reincluded_peer() {
866 let mut tracker = PeerTracker::for_test(test_env(), None);
871
872 let peer = peer_node("p", NODE_KEY_BYTES, vec![0xde, 0xad]);
873 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer.clone()]));
874 assert_eq!(tracker.peer_db.peers().len(), 1);
875
876 let resynced = peer_node("p", NODE_KEY_BYTES, vec![0x00]);
878 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![resynced.clone()]));
879 assert_eq!(tracker.peer_db.peers().len(), 1);
880 assert!(tracker.peer_db.get(&resynced.node_key).is_some());
881 }
882
883 fn profile(id: ts_control::UserId, login: &str) -> ts_control::UserProfile {
888 ts_control::UserProfile {
889 id,
890 login_name: login.to_string(),
891 display_name: None,
892 }
893 }
894
895 #[tokio::test]
896 async fn whois_resolves_user_from_accumulated_profiles() {
897 let mut tracker = PeerTracker::for_test(test_env(), None);
898
899 let mut peer = peer_node("p", NODE_KEY_BYTES, Vec::new());
901 peer.user_id = 42;
902 tracker.apply_peer_update(&ts_control::PeerUpdate::Full(vec![peer]));
903 let addr = "100.64.0.1:0".parse().unwrap();
904
905 let who = tracker.whois_opt(addr).expect("peer is known");
907 assert_eq!(who.user, None);
908
909 tracker
911 .user_profiles
912 .insert(7, profile(7, "someone-else@example.com"));
913 assert_eq!(tracker.whois_opt(addr).unwrap().user, None);
914
915 tracker
918 .user_profiles
919 .insert(42, profile(42, "alice@example.com"));
920 assert_eq!(
921 tracker.whois_opt(addr).unwrap().user,
922 Some("alice@example.com".to_string())
923 );
924 }
925
926 #[test]
928 fn user_profile_best_label_prefers_login() {
929 assert_eq!(
930 profile(1, "alice@example.com").best_label(),
931 Some("alice@example.com".to_string())
932 );
933 let display_only = ts_control::UserProfile {
934 id: 2,
935 login_name: String::new(),
936 display_name: Some("Bob".to_string()),
937 };
938 assert_eq!(display_only.best_label(), Some("Bob".to_string()));
939 let empty = ts_control::UserProfile {
940 id: 3,
941 login_name: String::new(),
942 display_name: None,
943 };
944 assert_eq!(empty.best_label(), None);
945 }
946}