1use crate::client::Client;
60use crate::request::{InfoQuery, InfoQueryType, IqError};
61use crate::types::events::Event;
62use log::{error, info, warn};
63
64use std::sync::Arc;
65use wacore::libsignal::protocol::KeyPair;
66use wacore::pair_code::{PairCodeState, PairCodeUtils, resolve_companion_platform};
67use wacore_binary::Jid;
68use wacore_binary::{NodeContent, NodeContentRef, NodeRef};
69
70pub use wacore::companion_reg::{CompanionOs, CompanionWebClientType};
71pub use wacore::pair_code::{PairCodeError, PairCodeOptions, PairCodeRejection};
72
73#[derive(Debug, thiserror::Error)]
78#[non_exhaustive]
79pub enum PairError {
80 #[error("{0}")]
81 PairCode(#[from] PairCodeError),
82
83 #[error("{0}")]
99 RequestFailed(#[from] IqError),
100}
101
102impl PairError {
106 pub fn rejection(&self) -> Option<PairCodeRejection> {
119 use crate::error::ErrorChainExt;
120 self.server_rejection()
121 .and_then(|rejection| PairCodeRejection::from_server(rejection.code, rejection.text))
122 }
123
124 pub fn lost_the_flow_to_another_request(&self) -> bool {
143 matches!(
144 self,
145 Self::PairCode(PairCodeError::CodeAlreadyOutstanding { .. } | PairCodeError::Cancelled)
146 )
147 }
148
149 pub fn backoff(&self) -> Option<std::time::Duration> {
156 use crate::error::ErrorChainExt;
157 self.server_rejection()
158 .and_then(|rejection| rejection.backoff)
159 .map(|secs| std::time::Duration::from_secs(u64::from(secs)))
160 }
161}
162
163impl Client {
164 #[cfg_attr(
217 feature = "tracing",
218 tracing::instrument(name = "wa.pair.code", level = "debug", skip_all, err(Debug))
219 )]
220 pub async fn pair_with_code(
221 self: &Arc<Self>,
222 options: PairCodeOptions,
223 ) -> Result<String, PairError> {
224 match self.pair_with_code_inner(options).await {
235 Ok(code) => Ok(code),
236 Err(e) if self.failure_is_not_this_flows_to_report(&e).await => Err(e),
237 Err(e) => {
238 self.core.event_bus.dispatch(Event::PairingCodeError(
239 crate::types::events::PairingCodeError::builder()
240 .maybe_rejection(e.rejection())
241 .maybe_backoff(e.backoff())
242 .error(e.to_string())
243 .build(),
244 ));
245 Err(e)
246 }
247 }
248 }
249
250 async fn failure_is_not_this_flows_to_report(self: &Arc<Self>, e: &PairError) -> bool {
265 if e.lost_the_flow_to_another_request() {
266 return true;
267 }
268 self.pair_code_state
271 .lock()
272 .await
273 .is_outstanding(wacore::time::now_secs())
274 }
275
276 async fn pair_with_code_inner(
277 self: &Arc<Self>,
278 options: PairCodeOptions,
279 ) -> Result<String, PairError> {
280 let phone_number: String = options
282 .phone_number
283 .chars()
284 .filter(|c| c.is_ascii_digit())
285 .collect();
286
287 if phone_number.is_empty() {
289 return Err(PairCodeError::PhoneNumberRequired.into());
290 }
291 if phone_number.len() < 7 {
292 return Err(PairCodeError::PhoneNumberTooShort.into());
293 }
294 if phone_number.starts_with('0') {
295 return Err(PairCodeError::PhoneNumberNotInternational.into());
296 }
297
298 let code = match &options.custom_code {
300 Some(custom) => {
301 if !PairCodeUtils::validate_code(custom) {
302 return Err(PairCodeError::InvalidCustomCode.into());
303 }
304 custom.to_uppercase()
305 }
306 None => PairCodeUtils::generate_code(),
307 };
308
309 let code_generation_ts = wacore::time::now_secs();
325 let claim = wacore::pair_code::PairCodeClaim::next();
326 {
327 let mut state = self.pair_code_state.lock().await;
328 if state.is_outstanding(code_generation_ts) {
329 return Err(PairCodeError::CodeAlreadyOutstanding {
330 remaining: state
331 .live_flow_remaining(code_generation_ts)
332 .unwrap_or_default(),
333 }
334 .into());
335 }
336 *state = PairCodeState::RequestingCode {
337 code_generation_ts,
338 claim,
339 };
340 }
341 let mut claim_guard = ClaimGuard {
346 client: Arc::clone(self),
347 claim,
348 armed: true,
349 };
350
351 info!(
352 target: "Client/PairCode",
353 "Starting pair code authentication for phone: {}",
354 phone_number
355 );
356
357 let ephemeral_keypair = KeyPair::generate(&mut rand::make_rng::<rand::rngs::StdRng>());
359
360 let device_snapshot = self.persistence_manager.get_device_snapshot();
362 let noise_static_pub: [u8; 32] = device_snapshot
363 .noise_key
364 .public_key
365 .public_key_bytes()
366 .try_into()
367 .expect("noise key is 32 bytes");
368
369 let code_clone = code.clone();
372 let ephemeral_pub: [u8; 32] = ephemeral_keypair
373 .public_key
374 .public_key_bytes()
375 .try_into()
376 .expect("ephemeral key is 32 bytes");
377
378 let wrapped_ephemeral = wacore::runtime::blocking(&*self.runtime, move || {
379 PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, &code_clone)
380 })
381 .await;
382
383 let (platform_id, platform_display) =
384 resolve_companion_platform(&options, &device_snapshot.device_props);
385 let platform_id_str = platform_id.to_string();
386
387 static OS_COERCE_WARNED: std::sync::Once = std::sync::Once::new();
393 let os_overridden = options
394 .display_os
395 .as_deref()
396 .is_some_and(|o| !o.trim().is_empty());
397 if !os_overridden
398 && let Some(os) = device_snapshot.device_props.os.as_deref()
399 && !os.trim().is_empty()
400 && CompanionOs::classify(os).is_none()
401 {
402 OS_COERCE_WARNED.call_once(|| {
403 warn!(
404 target: "Client/PairCode",
405 "companion_platform_display OS {os:?} is not a recognized OS; coerced to \"Linux\" for pair-code (the server would reject a non-OS display with bad-request)"
406 );
407 });
408 }
409
410 let req_id = self.generate_request_id();
411 let iq_content = PairCodeUtils::build_companion_hello_iq(
412 &phone_number,
413 &noise_static_pub,
414 &wrapped_ephemeral,
415 &platform_id_str,
416 &platform_display,
417 options.show_push_notification,
418 req_id.clone(),
419 );
420
421 let query = InfoQuery {
423 query_type: InfoQueryType::Set,
424 namespace: "md",
425 to: Jid::new("", wacore_binary::Server::Pn),
426 target: None,
427 content: Some(NodeContent::Nodes(
428 iq_content
429 .children()
430 .map(|c| c.to_vec())
431 .unwrap_or_default(),
432 )),
433 id: Some(req_id),
434 timeout: Some(std::time::Duration::from_secs(30)),
435 };
436
437 if !self.owns_code_claim(claim).await {
442 claim_guard.armed = false;
444 return Err(PairCodeError::Cancelled.into());
445 }
446
447 let response = match self.send_iq(query).await {
448 Ok(response) => response,
449 Err(e) => {
450 if !self.owns_code_claim(claim).await {
457 claim_guard.armed = false;
458 return Err(PairCodeError::Cancelled.into());
459 }
460 claim_guard.release_now().await;
461 return Err(e.into());
462 }
463 };
464
465 let Some(pairing_ref) = PairCodeUtils::parse_companion_hello_response(response.get())
466 else {
467 claim_guard.release_now().await;
468 return Err(PairCodeError::MissingPairingRef.into());
469 };
470
471 info!(
472 target: "Client/PairCode",
473 "Stage 1 complete, waiting for phone confirmation. Code: {}",
474 code
475 );
476
477 {
482 let mut state = self.pair_code_state.lock().await;
483 if !matches!(&*state, PairCodeState::RequestingCode { claim: c, .. } if *c == claim) {
484 claim_guard.armed = false;
485 return Err(PairCodeError::Cancelled.into());
486 }
487 *state = PairCodeState::WaitingForPhoneConfirmation {
488 pairing_ref,
489 phone_jid: phone_number,
490 pair_code: code.clone(),
491 ephemeral_keypair: Box::new(ephemeral_keypair),
492 code_generation_ts,
493 primary_hello_attempt_count: 0,
494 };
495 claim_guard.armed = false;
496 }
497
498 let elapsed = wacore::time::now_secs()
504 .saturating_sub(code_generation_ts)
505 .max(0) as u64;
506 let remaining =
507 PairCodeUtils::code_validity().saturating_sub(std::time::Duration::from_secs(elapsed));
508 self.core.event_bus.dispatch(Event::PairingCode(
509 crate::types::events::PairingCode::builder()
510 .code(code.clone())
511 .timeout(remaining)
512 .build(),
513 ));
514
515 Ok(code)
516 }
517
518 async fn owns_code_claim(self: &Arc<Self>, claim: wacore::pair_code::PairCodeClaim) -> bool {
523 matches!(&*self.pair_code_state.lock().await, PairCodeState::RequestingCode { claim: c, .. } if *c == claim)
524 }
525
526 async fn release_code_claim(self: &Arc<Self>, claim: wacore::pair_code::PairCodeClaim) {
527 let mut state = self.pair_code_state.lock().await;
528 if matches!(&*state, PairCodeState::RequestingCode { claim: c, .. } if *c == claim) {
529 *state = PairCodeState::Idle;
530 }
531 }
532
533 pub async fn cancel_pair_code(self: &Arc<Self>) {
546 let mut state = self.pair_code_state.lock().await;
547 if matches!(&*state, PairCodeState::Idle) {
548 return;
549 }
550 let rotated_adv_secret = state.awaiting_pair_success();
551 *state = PairCodeState::Idle;
552 if rotated_adv_secret {
553 replace_adv_secret_key(self).await;
555 }
556 }
557}
558
559struct ClaimGuard {
566 client: Arc<Client>,
567 claim: wacore::pair_code::PairCodeClaim,
568 armed: bool,
569}
570
571impl ClaimGuard {
572 async fn release_now(&mut self) {
577 self.armed = false;
578 self.client.release_code_claim(self.claim).await;
579 }
580}
581
582impl Drop for ClaimGuard {
583 fn drop(&mut self) {
584 if !self.armed {
585 return;
586 }
587 let client = Arc::clone(&self.client);
588 let claim = self.claim;
589 client.clone().runtime.spawn_detached(Box::pin(async move {
590 client.release_code_claim(claim).await;
591 }));
592 }
593}
594
595#[cfg_attr(
600 feature = "tracing",
601 tracing::instrument(name = "wa.pair.code_notification", level = "debug", skip_all)
602)]
603pub(crate) async fn handle_pair_code_notification(
604 client: &Arc<Client>,
605 node: &NodeRef<'_>,
606) -> bool {
607 let Some(reg_node) = node.get_optional_child_by_tag(&["link_code_companion_reg"]) else {
608 return false;
609 };
610
611 match reg_node.get_attr("stage").map(|v| v.as_str()).as_deref() {
612 Some("primary_hello") => handle_primary_hello(client, reg_node).await,
613 Some("refresh_code") => handle_refresh_code(client, reg_node).await,
614 other => {
615 warn!(
616 target: "Client/PairCode",
617 "Ignoring link_code_companion_reg notification with stage {other:?}"
618 );
619 false
620 }
621 }
622}
623
624async fn handle_primary_hello(client: &Arc<Client>, reg_node: &NodeRef<'_>) -> bool {
627 let primary_wrapped_ephemeral = match reg_node
629 .get_optional_child_by_tag(&["link_code_pairing_wrapped_primary_ephemeral_pub"])
630 .and_then(|n| match n.content.as_ref() {
631 Some(NodeContentRef::Bytes(b)) if b.len() == 80 => Some(b.to_vec()),
632 _ => None,
633 }) {
634 Some(b) => b,
635 None => {
636 warn!(
637 target: "Client/PairCode",
638 "Missing or invalid primary wrapped ephemeral pub in notification"
639 );
640 return false;
641 }
642 };
643
644 let primary_identity_pub: [u8; 32] = match reg_node
646 .get_optional_child_by_tag(&["primary_identity_pub"])
647 .and_then(|n| match n.content.as_ref() {
648 Some(NodeContentRef::Bytes(b)) if b.len() == 32 => b.as_ref().try_into().ok(),
649 _ => None,
650 }) {
651 Some(arr) => arr,
652 None => {
653 warn!(
654 target: "Client/PairCode",
655 "Missing or invalid primary identity pub in notification"
656 );
657 return false;
658 }
659 };
660
661 let notif_ref = match reg_node
664 .get_optional_child_by_tag(&["link_code_pairing_ref"])
665 .and_then(|n| match n.content.as_ref() {
666 Some(NodeContentRef::Bytes(b)) => Some(b.to_vec()),
667 _ => None,
668 }) {
669 Some(r) => r,
670 None => {
671 warn!(target: "Client/PairCode", "primary_hello missing link_code_pairing_ref");
672 return false;
673 }
674 };
675
676 let mut state_guard = client.pair_code_state.lock().await;
685 let (pairing_ref, phone_jid, pair_code, ephemeral_keypair, attempt) = match &mut *state_guard {
686 PairCodeState::WaitingForPhoneConfirmation {
687 pairing_ref,
688 phone_jid,
689 pair_code,
690 ephemeral_keypair,
691 code_generation_ts,
692 primary_hello_attempt_count,
693 } => {
694 if pairing_ref.as_slice() != notif_ref.as_slice() {
699 warn!(
700 target: "Client/PairCode",
701 "primary_hello ref does not match the outstanding request; ignoring"
702 );
703 return false;
704 }
705 let age = wacore::time::now_secs() - *code_generation_ts;
706 if age > PairCodeUtils::code_validity().as_secs() as i64 {
707 warn!(
708 target: "Client/PairCode",
709 "primary_hello arrived for an expired code ({age}s old); ignoring"
710 );
711 return false;
712 }
713 if *primary_hello_attempt_count >= PairCodeUtils::max_primary_hello_attempts() {
716 warn!(
717 target: "Client/PairCode",
718 "Exceeded max primary_hello attempts for this code; abandoning"
719 );
720 return false;
721 }
722 *primary_hello_attempt_count += 1;
723 (
724 pairing_ref.clone(),
725 phone_jid.clone(),
726 pair_code.clone(),
727 (**ephemeral_keypair).clone(),
728 *primary_hello_attempt_count,
729 )
730 }
731 _ => {
732 warn!(
733 target: "Client/PairCode",
734 "Received primary_hello but not in waiting state"
735 );
736 return false;
737 }
738 };
739
740 info!(
741 target: "Client/PairCode",
742 "Phone confirmed code entry, processing stage 2"
743 );
744
745 drop(state_guard);
747
748 let client = Arc::clone(client);
749 start_pair_success_timeout(Arc::clone(&client), pairing_ref.clone(), attempt);
755 client.clone().runtime.spawn_detached(Box::pin(async move {
756 run_stage_two(
757 client,
758 pairing_ref,
759 phone_jid,
760 pair_code,
761 ephemeral_keypair,
762 primary_wrapped_ephemeral,
763 primary_identity_pub,
764 attempt,
765 )
766 .await;
767 }));
768 true
769}
770
771#[allow(clippy::too_many_arguments)]
788async fn run_stage_two(
789 client: Arc<Client>,
790 pairing_ref: Vec<u8>,
791 phone_jid: String,
792 pair_code: String,
793 ephemeral_keypair: KeyPair,
794 primary_wrapped_ephemeral: Vec<u8>,
795 primary_identity_pub: [u8; 32],
796 attempt: u32,
797) {
798 let state_guard = client.pair_code_state.lock().await;
799 let still_ours = matches!(
806 &*state_guard,
807 PairCodeState::WaitingForPhoneConfirmation { pairing_ref: current, .. }
808 if current.as_slice() == pairing_ref.as_slice()
809 );
810 if !still_ours {
811 return;
812 }
813
814 let pair_code_clone = pair_code.clone();
817 let primary_ephemeral_pub = match wacore::runtime::blocking(&*client.runtime, move || {
818 PairCodeUtils::decrypt_primary_ephemeral_pub(&primary_wrapped_ephemeral, &pair_code_clone)
819 })
820 .await
821 {
822 Ok(pub_key) => pub_key,
823 Err(e) => {
824 error!(
825 target: "Client/PairCode",
826 "Failed to decrypt primary ephemeral pub: {e}"
827 );
828 return;
829 }
830 };
831
832 let device_snapshot = client.persistence_manager.get_device_snapshot();
834
835 let (wrapped_bundle, new_adv_secret) = match PairCodeUtils::prepare_key_bundle(
837 &ephemeral_keypair,
838 &primary_ephemeral_pub,
839 &primary_identity_pub,
840 &device_snapshot.identity_key,
841 ) {
842 Ok(result) => result,
843 Err(e) => {
844 error!(target: "Client/PairCode", "Failed to prepare key bundle: {e}");
845 return;
846 }
847 };
848
849 client
851 .persistence_manager
852 .process_command(crate::store::commands::DeviceCommand::SetAdvSecretKey(
853 new_adv_secret,
854 ))
855 .await;
856
857 let req_id = client.generate_request_id();
859 let identity_pub: [u8; 32] = device_snapshot
860 .identity_key
861 .public_key
862 .public_key_bytes()
863 .try_into()
864 .expect("identity key is 32 bytes");
865
866 let iq = PairCodeUtils::build_companion_finish_iq(
867 &phone_jid,
868 wrapped_bundle,
869 &identity_pub,
870 &pairing_ref,
871 req_id,
872 );
873
874 let answer = client
877 .send_iq_node_then(
878 iq,
879 Some(PairCodeUtils::companion_finish_iq_timeout()),
880 Some(Box::new(move || drop(state_guard))),
881 )
882 .await;
883
884 match answer {
885 Ok(_) => {
886 info!(
887 target: "Client/PairCode",
888 "Sent companion_finish, waiting for pair-success"
889 );
890 }
895 Err(e) => report_stage_two_failure(&client, &pairing_ref, attempt, e).await,
896 }
897}
898
899async fn report_stage_two_failure(
921 client: &Arc<Client>,
922 pairing_ref: &[u8],
923 attempt: u32,
924 error: IqError,
925) {
926 if error.is_timeout() {
927 warn!(
928 target: "Client/PairCode",
929 "companion_finish went unanswered; leaving the pair-success timer to write the code off"
930 );
931 return;
932 }
933
934 error!(target: "Client/PairCode", "companion_finish failed: {error}");
935 if !retire_stage_two_flow(client, pairing_ref, attempt).await {
936 return;
937 }
938
939 let error = PairError::from(error);
940 client.core.event_bus.dispatch(Event::PairingCodeError(
941 crate::types::events::PairingCodeError::builder()
942 .maybe_rejection(error.rejection())
943 .maybe_backoff(error.backoff())
944 .error(error.to_string())
945 .build(),
946 ));
947}
948
949fn start_pair_success_timeout(client: Arc<Client>, pairing_ref: Vec<u8>, attempt: u32) {
958 let timeout = PairCodeUtils::primary_hello_pair_success_timeout();
959 client.clone().runtime.spawn_detached(Box::pin(async move {
960 client.runtime.sleep(timeout).await;
961
962 if !retire_stage_two_flow(&client, &pairing_ref, attempt).await {
963 return;
964 }
965
966 warn!(
967 target: "Client/PairCode",
968 "No pair-success within {timeout:?} of companion_finish; the code will not complete"
969 );
970 client.core.event_bus.dispatch(Event::PairingCodeRefresh(
971 crate::types::events::PairingCodeRefresh::builder()
972 .force_manual(false)
973 .build(),
974 ));
975 }));
976}
977
978async fn retire_stage_two_flow(client: &Arc<Client>, pairing_ref: &[u8], attempt: u32) -> bool {
989 let mut state = client.pair_code_state.lock().await;
990 let still_ours = matches!(
991 &*state,
992 PairCodeState::WaitingForPhoneConfirmation {
993 pairing_ref: r,
994 primary_hello_attempt_count,
995 ..
996 } if r.as_slice() == pairing_ref
997 && *primary_hello_attempt_count == attempt
998 );
999 if !still_ours {
1000 return false;
1001 }
1002 *state = PairCodeState::Idle;
1003 replace_adv_secret_key(client).await;
1008 true
1009}
1010
1011async fn replace_adv_secret_key(client: &Arc<Client>) {
1026 use rand::RngExt as _;
1027 let mut adv_secret_key = [0u8; 32];
1028 rand::make_rng::<rand::rngs::StdRng>().fill(&mut adv_secret_key);
1029 client
1030 .persistence_manager
1031 .process_command(crate::store::commands::DeviceCommand::SetAdvSecretKey(
1032 adv_secret_key,
1033 ))
1034 .await;
1035 client.refresh_pairing_qr().await;
1038}
1039
1040async fn handle_refresh_code(client: &Arc<Client>, reg_node: &NodeRef<'_>) -> bool {
1045 let notif_ref = match reg_node
1046 .get_optional_child_by_tag(&["link_code_pairing_ref"])
1047 .and_then(|n| match n.content.as_ref() {
1048 Some(NodeContentRef::Bytes(b)) => Some(b.to_vec()),
1049 _ => None,
1050 }) {
1051 Some(r) => r,
1052 None => {
1053 warn!(target: "Client/PairCode", "refresh_code missing link_code_pairing_ref");
1054 return false;
1055 }
1056 };
1057
1058 let force_manual = reg_node
1059 .get_attr("force_manual_refresh")
1060 .map(|v| v.as_str().as_ref() == "true")
1061 .unwrap_or(false);
1062
1063 let matches_current = {
1070 let mut state_guard = client.pair_code_state.lock().await;
1071 let matches = matches!(
1072 &*state_guard,
1073 PairCodeState::WaitingForPhoneConfirmation { pairing_ref, .. }
1074 if pairing_ref.as_slice() == notif_ref.as_slice()
1075 );
1076 if matches {
1077 *state_guard = PairCodeState::Idle;
1078 }
1079 matches
1080 };
1081 if !matches_current {
1082 warn!(
1083 target: "Client/PairCode",
1084 "refresh_code ref does not match the outstanding request; ignoring"
1085 );
1086 return false;
1087 }
1088
1089 info!(
1090 target: "Client/PairCode",
1091 "Server requested pair-code refresh (force_manual={force_manual})"
1092 );
1093 client.core.event_bus.dispatch(Event::PairingCodeRefresh(
1094 crate::types::events::PairingCodeRefresh::builder()
1095 .force_manual(force_manual)
1096 .build(),
1097 ));
1098 true
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104
1105 #[test]
1109 fn rejection_codes_match_wa_web() {
1110 assert_eq!(PairCodeRejection::BadRequest.code(), 400);
1111 assert_eq!(PairCodeRejection::Forbidden.code(), 403);
1112 assert_eq!(PairCodeRejection::RateOverlimit.code(), 429);
1113 assert_eq!(PairCodeRejection::FeatureNotAvailable.code(), 452);
1114 assert_eq!(PairCodeRejection::InternalServerError.code(), 500);
1115 assert_eq!(
1118 PairCodeRejection::from(418),
1119 PairCodeRejection::Unknown(418)
1120 );
1121 }
1122
1123 #[tokio::test]
1128 async fn an_outstanding_code_is_not_reported_as_a_failure() {
1129 let client = create_test_client().await;
1130 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1131 client.subscribe_handler(collector.clone()).detach();
1132
1133 let now = wacore::time::now_secs();
1135 *client.pair_code_state.lock().await = PairCodeState::RequestingCode {
1136 code_generation_ts: now,
1137 claim: wacore::pair_code::PairCodeClaim::next(),
1138 };
1139
1140 let err = client
1141 .pair_with_code(PairCodeOptions {
1142 phone_number: "15551234567".to_string(),
1143 ..Default::default()
1144 })
1145 .await
1146 .expect_err("a second code must be refused while one is live");
1147 assert!(
1148 err.lost_the_flow_to_another_request(),
1149 "expected CodeAlreadyOutstanding, got: {err:?}"
1150 );
1151
1152 tokio::task::yield_now().await;
1154 assert!(
1155 !collector
1156 .events()
1157 .iter()
1158 .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1159 "a still-live code must not be reported as 'no code is coming'"
1160 );
1161 }
1162
1163 #[tokio::test]
1168 async fn a_superseded_request_is_not_reported_as_a_failure() {
1169 let (client, transport) = create_iq_test_client().await;
1170 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1171 client.subscribe_handler(collector.clone()).detach();
1172
1173 let pending = {
1174 let client = client.clone();
1175 tokio::spawn(async move { client.pair_with_code(options()).await })
1176 };
1177 poll_until("the companion_hello to be on the wire", || {
1178 !transport.sent().is_empty()
1179 })
1180 .await;
1181
1182 client.cancel_pair_code().await;
1183 answer_companion_hello(&client, &transport, 0, b"3@2:late").await;
1184
1185 let err = pending
1186 .await
1187 .expect("the pair-code task should not panic")
1188 .expect_err("a cancelled request must not report a usable code");
1189 assert!(
1190 err.lost_the_flow_to_another_request(),
1191 "expected Cancelled, got {err:?}"
1192 );
1193
1194 tokio::task::yield_now().await;
1195 assert!(
1196 !collector
1197 .events()
1198 .iter()
1199 .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1200 "a withdrawn request must not report against the flow that replaced it"
1201 );
1202 }
1203
1204 #[tokio::test]
1210 async fn a_withdrawn_request_reports_cancellation_not_its_iq_failure() {
1211 let (client, transport) = create_iq_test_client().await;
1212 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1213 client.subscribe_handler(collector.clone()).detach();
1214
1215 let pending = {
1216 let client = client.clone();
1217 tokio::spawn(async move { client.pair_with_code(options()).await })
1218 };
1219 poll_until("the companion_hello to be on the wire", || {
1220 !transport.sent().is_empty()
1221 })
1222 .await;
1223
1224 client.cancel_pair_code().await;
1225
1226 let hello = crate::test_utils::decode_sent_iq(&transport, 0).await;
1228 let id = hello
1229 .get()
1230 .attrs()
1231 .optional_string("id")
1232 .expect("companion_hello carries an id")
1233 .into_owned();
1234 let refusal = NodeBuilder::new("iq")
1235 .attrs([
1236 ("from", "s.whatsapp.net".to_string()),
1237 ("type", "error".to_string()),
1238 ("id", id.clone()),
1239 ])
1240 .children([NodeBuilder::new("error")
1241 .attrs([
1242 ("code", "429".to_string()),
1243 ("text", "rate-overlimit".to_string()),
1244 ])
1245 .build()])
1246 .build();
1247 crate::test_utils::answer_iq(&client, &id, &refusal).await;
1248
1249 let err = pending
1250 .await
1251 .expect("the pair-code task should not panic")
1252 .expect_err("a withdrawn request must not report a usable code");
1253 assert!(
1254 err.lost_the_flow_to_another_request(),
1255 "losing the slot outranks how the request ended, got {err:?}"
1256 );
1257
1258 tokio::task::yield_now().await;
1259 assert!(
1260 !collector
1261 .events()
1262 .iter()
1263 .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1264 "a withdrawn request's IQ failure must not report against its replacement"
1265 );
1266 }
1267
1268 #[tokio::test]
1273 async fn a_validation_failure_beside_a_live_code_is_not_reported() {
1274 let client = create_test_client().await;
1275 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1276 client.subscribe_handler(collector.clone()).detach();
1277
1278 *client.pair_code_state.lock().await = PairCodeState::RequestingCode {
1279 code_generation_ts: wacore::time::now_secs(),
1280 claim: wacore::pair_code::PairCodeClaim::next(),
1281 };
1282
1283 let err = client
1284 .pair_with_code(PairCodeOptions {
1285 phone_number: "123".to_string(),
1286 ..Default::default()
1287 })
1288 .await
1289 .expect_err("a 3-digit number must be refused");
1290 assert!(
1291 matches!(err, PairError::PairCode(PairCodeError::PhoneNumberTooShort)),
1292 "validation must still win the race it already wins, got {err:?}"
1293 );
1294
1295 tokio::task::yield_now().await;
1296 assert!(
1297 !collector
1298 .events()
1299 .iter()
1300 .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1301 "a live code must not be reported as failed by an unrelated bad request"
1302 );
1303 }
1304
1305 #[test]
1309 fn a_contradicting_text_yields_no_classification() {
1310 let pe: PairError = IqError::ServerError {
1311 code: 429,
1312 text: "something-else".into(),
1313 error_type: None,
1314 backoff: None,
1315 }
1316 .into();
1317
1318 assert_eq!(
1319 pe.rejection(),
1320 None,
1321 "a pairing WA Web would reject must not drive throttle handling"
1322 );
1323 assert!(pe.to_string().contains("429"), "got: {pe}");
1326 }
1327
1328 #[test]
1332 fn an_absent_text_still_classifies_by_code() {
1333 let pe: PairError = IqError::ServerError {
1334 code: 429,
1335 text: String::new(),
1336 error_type: None,
1337 backoff: None,
1338 }
1339 .into();
1340
1341 assert_eq!(pe.rejection(), Some(PairCodeRejection::RateOverlimit));
1342 assert!(pe.rejection().is_some_and(PairCodeRejection::is_throttled));
1343 }
1344
1345 #[test]
1348 fn rate_overlimit_is_recoverable_as_a_typed_status() {
1349 let pe: PairError = IqError::ServerError {
1350 code: 429,
1351 text: "rate-overlimit".into(),
1352 error_type: None,
1353 backoff: Some(30),
1354 }
1355 .into();
1356
1357 assert_eq!(pe.rejection(), Some(PairCodeRejection::RateOverlimit));
1358 assert_eq!(pe.backoff(), Some(std::time::Duration::from_secs(30)));
1359 assert!(
1360 pe.rejection().is_some_and(PairCodeRejection::is_throttled),
1361 "429 must read as throttled"
1362 );
1363 assert!(
1366 pe.to_string().contains("429") && pe.to_string().contains("rate-overlimit"),
1367 "Display should carry the server's code and text, got: {pe}"
1368 );
1369 }
1370
1371 #[test]
1375 fn feature_not_available_is_not_throttled() {
1376 let pe: PairError = IqError::ServerError {
1377 code: 452,
1378 text: "feature-not-available".into(),
1379 error_type: None,
1380 backoff: None,
1381 }
1382 .into();
1383
1384 assert_eq!(pe.rejection(), Some(PairCodeRejection::FeatureNotAvailable));
1385 assert!(!PairCodeRejection::FeatureNotAvailable.is_throttled());
1386 assert_eq!(pe.backoff(), None);
1387 }
1388
1389 #[test]
1392 fn local_failure_reports_no_rejection() {
1393 let pe: PairError = PairCodeError::PhoneNumberTooShort.into();
1394 assert_eq!(pe.rejection(), None);
1395 assert_eq!(pe.backoff(), None);
1396 }
1397
1398 #[tokio::test]
1406 async fn failed_request_dispatches_pairing_code_error() {
1407 let client = create_test_client().await;
1408 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1409 client.subscribe_handler(collector.clone()).detach();
1410
1411 let err = client
1412 .pair_with_code(PairCodeOptions {
1413 phone_number: "123".to_string(),
1414 ..Default::default()
1415 })
1416 .await
1417 .expect_err("a 3-digit number must be refused");
1418 assert!(matches!(
1419 err,
1420 PairError::PairCode(PairCodeError::PhoneNumberTooShort)
1421 ));
1422
1423 poll_until("a PairingCodeError to reach the bus", || {
1424 collector
1425 .events()
1426 .iter()
1427 .any(|e| matches!(&**e, Event::PairingCodeError(_)))
1428 })
1429 .await;
1430
1431 let events = collector.events();
1432 let dispatched = events
1433 .iter()
1434 .find_map(|e| match &**e {
1435 Event::PairingCodeError(e) => Some(e.clone()),
1436 _ => None,
1437 })
1438 .expect("just polled for it");
1439 assert_eq!(
1440 dispatched.rejection, None,
1441 "a local validation failure never reached the server"
1442 );
1443 assert_eq!(dispatched.backoff, None);
1444 assert!(
1445 dispatched.error.contains("too short"),
1446 "the message should say what failed, got: {}",
1447 dispatched.error
1448 );
1449 }
1450
1451 #[test]
1452 fn pair_error_request_failed_preserves_iq_source() {
1453 let iq = IqError::ServerError {
1454 code: 400,
1455 text: "bad-request".into(),
1456 error_type: None,
1457 backoff: None,
1458 };
1459 let pe: PairError = iq.into();
1460 let src = std::error::Error::source(&pe).expect("source preserved");
1461 let downcast = src.downcast_ref::<IqError>().expect("downcasts to IqError");
1462 assert!(matches!(downcast, IqError::ServerError { code: 400, .. }));
1463 }
1464
1465 #[test]
1466 fn pair_error_paircode_walks_to_curve_error() {
1467 use wacore::libsignal::protocol::CurveError;
1468 let pe: PairError =
1470 PairCodeError::EphemeralKeyAgreement(CurveError::NoKeyTypeIdentifier).into();
1471 assert_eq!(pe.to_string(), "ephemeral key agreement failed");
1472 let src = std::error::Error::source(&pe).expect("source preserved");
1474 let pce = src
1475 .downcast_ref::<PairCodeError>()
1476 .expect("downcasts to PairCodeError");
1477 assert!(matches!(pce, PairCodeError::EphemeralKeyAgreement(_)));
1478 let curve = std::error::Error::source(pce)
1479 .expect("inner source preserved")
1480 .downcast_ref::<CurveError>()
1481 .expect("downcasts to CurveError");
1482 assert!(matches!(curve, CurveError::NoKeyTypeIdentifier));
1483 }
1484
1485 use crate::test_utils::{create_iq_test_client, create_test_client, poll_until};
1494 use wacore::libsignal::protocol::KeyPair;
1495 use wacore_binary::Node;
1496 use wacore_binary::builder::NodeBuilder;
1497
1498 fn primary_hello_notif(reg_ref: &[u8]) -> Node {
1499 NodeBuilder::new("notification")
1500 .attr("type", "link_code_companion_reg")
1501 .attr("from", "s.whatsapp.net")
1502 .children([NodeBuilder::new("link_code_companion_reg")
1503 .attr("stage", "primary_hello")
1504 .children([
1505 NodeBuilder::new("link_code_pairing_wrapped_primary_ephemeral_pub")
1507 .bytes(vec![7u8; 80])
1508 .build(),
1509 NodeBuilder::new("primary_identity_pub")
1510 .bytes(vec![9u8; 32])
1511 .build(),
1512 NodeBuilder::new("link_code_pairing_ref")
1513 .bytes(reg_ref.to_vec())
1514 .build(),
1515 ])
1516 .build()])
1517 .build()
1518 }
1519
1520 fn refresh_code_notif(reg_ref: &[u8], force_manual: Option<bool>) -> Node {
1521 let mut reg = NodeBuilder::new("link_code_companion_reg").attr("stage", "refresh_code");
1522 if let Some(f) = force_manual {
1523 reg = reg.attr("force_manual_refresh", if f { "true" } else { "false" });
1524 }
1525 NodeBuilder::new("notification")
1526 .attr("type", "link_code_companion_reg")
1527 .attr("from", "s.whatsapp.net")
1528 .children([reg
1529 .children([NodeBuilder::new("link_code_pairing_ref")
1530 .bytes(reg_ref.to_vec())
1531 .build()])
1532 .build()])
1533 .build()
1534 }
1535
1536 async fn set_waiting(client: &Arc<Client>, pairing_ref: Vec<u8>, ts: i64, count: u32) {
1537 *client.pair_code_state.lock().await = PairCodeState::WaitingForPhoneConfirmation {
1538 pairing_ref,
1539 phone_jid: "15551234567".to_string(),
1540 pair_code: "ABCD1234".to_string(),
1541 ephemeral_keypair: Box::new(KeyPair::generate(
1542 &mut rand::make_rng::<rand::rngs::StdRng>(),
1543 )),
1544 code_generation_ts: ts,
1545 primary_hello_attempt_count: count,
1546 };
1547 }
1548
1549 fn adv(client: &Arc<Client>) -> [u8; 32] {
1550 client
1551 .persistence_manager
1552 .get_device_snapshot()
1553 .adv_secret_key
1554 }
1555
1556 async fn is_waiting(client: &Arc<Client>) -> bool {
1557 matches!(
1558 &*client.pair_code_state.lock().await,
1559 PairCodeState::WaitingForPhoneConfirmation { .. }
1560 )
1561 }
1562
1563 async fn attempt_count(client: &Arc<Client>) -> Option<u32> {
1564 match &*client.pair_code_state.lock().await {
1565 PairCodeState::WaitingForPhoneConfirmation {
1566 primary_hello_attempt_count,
1567 ..
1568 } => Some(*primary_hello_attempt_count),
1569 _ => None,
1570 }
1571 }
1572
1573 #[tokio::test]
1578 async fn primary_hello_rejects_mismatched_ref() {
1579 let client = create_test_client().await;
1580 set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
1581 let adv_before = adv(&client);
1582
1583 let notif = primary_hello_notif(&[9, 9, 9, 9]);
1584 let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
1585
1586 assert!(!handled, "mismatched ref must be rejected");
1587 assert_eq!(
1588 adv(&client),
1589 adv_before,
1590 "no stage-2 crypto on ref mismatch"
1591 );
1592 assert!(
1593 is_waiting(&client).await,
1594 "state must be preserved so a later valid primary_hello can complete"
1595 );
1596 assert_eq!(
1597 attempt_count(&client).await,
1598 Some(0),
1599 "a ref-mismatched notification must not burn a retry slot"
1600 );
1601 }
1602
1603 #[tokio::test]
1607 async fn stale_mismatched_hellos_do_not_block_the_valid_one() {
1608 let client = create_test_client().await;
1609 let pairing_ref = vec![1, 2, 3, 4];
1610 set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
1611 let adv_before = adv(&client);
1612
1613 for _ in 0..(PairCodeUtils::max_primary_hello_attempts() + 2) {
1615 let bad = primary_hello_notif(&[9, 9, 9, 9]);
1616 let _ = handle_pair_code_notification(&client, &bad.as_node_ref()).await;
1617 }
1618 assert_eq!(
1619 attempt_count(&client).await,
1620 Some(0),
1621 "mismatched hellos must leave the attempt count untouched"
1622 );
1623
1624 let good = primary_hello_notif(&pairing_ref);
1625 let _ = handle_pair_code_notification(&client, &good.as_node_ref()).await;
1626 poll_until(
1627 "the genuine primary_hello to still reach stage 2 after stale mismatches",
1628 || adv(&client) != adv_before,
1629 )
1630 .await;
1631 }
1632
1633 #[tokio::test]
1636 async fn primary_hello_rejects_expired_code() {
1637 let client = create_test_client().await;
1638 let pairing_ref = vec![1, 2, 3, 4];
1639 let stale_ts =
1640 wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 20);
1641 set_waiting(&client, pairing_ref.clone(), stale_ts, 0).await;
1642 let adv_before = adv(&client);
1643
1644 let notif = primary_hello_notif(&pairing_ref);
1645 let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
1646
1647 assert!(
1648 !handled,
1649 "primary_hello for an expired code must be rejected"
1650 );
1651 assert_eq!(
1652 adv(&client),
1653 adv_before,
1654 "no stage-2 crypto on an expired code"
1655 );
1656 assert_eq!(
1657 attempt_count(&client).await,
1658 Some(0),
1659 "an expired-code notification must not burn a retry slot"
1660 );
1661 }
1662
1663 #[tokio::test]
1666 async fn primary_hello_rejects_beyond_max_attempts() {
1667 let client = create_test_client().await;
1668 let pairing_ref = vec![1, 2, 3, 4];
1669 set_waiting(
1670 &client,
1671 pairing_ref.clone(),
1672 wacore::time::now_secs(),
1673 PairCodeUtils::max_primary_hello_attempts(),
1674 )
1675 .await;
1676 let adv_before = adv(&client);
1677
1678 let notif = primary_hello_notif(&pairing_ref);
1679 let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
1680
1681 assert!(!handled, "the attempt past the cap must be rejected");
1682 assert_eq!(
1683 adv(&client),
1684 adv_before,
1685 "no stage-2 crypto once the per-code attempt cap is exhausted"
1686 );
1687 assert_eq!(
1688 attempt_count(&client).await,
1689 Some(PairCodeUtils::max_primary_hello_attempts()),
1690 "a rejected over-cap attempt must not push the counter past the max"
1691 );
1692 }
1693
1694 #[tokio::test]
1699 async fn primary_hello_valid_retry_reaches_stage2() {
1700 let client = create_test_client().await;
1701 let pairing_ref = vec![1, 2, 3, 4];
1702 set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 2).await;
1704 let adv_before = adv(&client);
1705
1706 let notif = primary_hello_notif(&pairing_ref);
1707 let _ = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
1708
1709 poll_until(
1710 "a valid in-window retry to reach stage 2 and rotate the adv secret",
1711 || adv(&client) != adv_before,
1712 )
1713 .await;
1714 }
1715
1716 #[tokio::test]
1719 async fn refresh_code_matching_ref_dispatches_event() {
1720 let client = create_test_client().await;
1721 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1722 client.subscribe_handler(collector.clone()).detach();
1723
1724 let pairing_ref = vec![5, 6, 7, 8];
1725 set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
1726
1727 let notif = refresh_code_notif(&pairing_ref, Some(true));
1728 let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
1729
1730 assert!(handled, "a matching refresh_code should be handled");
1731 let events = collector.events();
1732 assert!(
1733 events
1734 .iter()
1735 .any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if r.force_manual)),
1736 "expected PairingCodeRefresh{{force_manual:true}}, got: {events:?}"
1737 );
1738 }
1739
1740 #[tokio::test]
1744 async fn refresh_code_without_force_manual_defaults_false() {
1745 let client = create_test_client().await;
1746 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1747 client.subscribe_handler(collector.clone()).detach();
1748
1749 let pairing_ref = vec![5, 6, 7, 8];
1750 set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
1751
1752 let notif = refresh_code_notif(&pairing_ref, None);
1753 let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
1754
1755 assert!(handled, "a matching refresh_code should be handled");
1756 assert!(
1757 collector.events().iter().any(|e| matches!(
1758 &**e,
1759 Event::PairingCodeRefresh(r) if !r.force_manual
1760 )),
1761 "absent force_manual_refresh must dispatch force_manual: false"
1762 );
1763 }
1764
1765 #[tokio::test]
1768 async fn refresh_code_mismatched_ref_is_ignored() {
1769 let client = create_test_client().await;
1770 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1771 client.subscribe_handler(collector.clone()).detach();
1772
1773 set_waiting(&client, vec![5, 6, 7, 8], wacore::time::now_secs(), 0).await;
1774
1775 let notif = refresh_code_notif(&[1, 1, 1, 1], None);
1776 let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
1777
1778 assert!(!handled, "a non-matching refresh_code must be ignored");
1779 assert!(
1780 collector.events().is_empty(),
1781 "no event should fire for a refresh_code with an unknown ref"
1782 );
1783 }
1784
1785 async fn answer_companion_hello(
1797 client: &Arc<Client>,
1798 transport: &Arc<crate::transport::mock::CapturingMockTransport>,
1799 frame: usize,
1800 pairing_ref: &[u8],
1801 ) {
1802 let hello = crate::test_utils::decode_sent_iq(transport, frame).await;
1803 let id = hello
1804 .get()
1805 .attrs()
1806 .optional_string("id")
1807 .expect("companion_hello carries an id")
1808 .into_owned();
1809 let response = NodeBuilder::new("iq")
1810 .attrs([
1811 ("from", "s.whatsapp.net".to_string()),
1812 ("type", "result".to_string()),
1813 ("id", id.clone()),
1814 ])
1815 .children([NodeBuilder::new("link_code_companion_reg")
1816 .attr("stage", "companion_hello")
1817 .children([NodeBuilder::new("link_code_pairing_ref")
1818 .bytes(pairing_ref.to_vec())
1819 .build()])
1820 .build()])
1821 .build();
1822 crate::test_utils::answer_iq(client, &id, &response).await;
1823 }
1824
1825 fn options() -> PairCodeOptions {
1826 PairCodeOptions {
1827 phone_number: "15551234567".to_string(),
1828 ..Default::default()
1829 }
1830 }
1831
1832 async fn answer_companion_finish(
1837 client: &Arc<Client>,
1838 transport: &Arc<crate::transport::mock::CapturingMockTransport>,
1839 frame: usize,
1840 error: Option<(u16, &str)>,
1841 ) {
1842 let finish = crate::test_utils::decode_sent_iq(transport, frame).await;
1843 let id = finish
1844 .get()
1845 .attrs()
1846 .optional_string("id")
1847 .expect("companion_finish carries an id")
1848 .into_owned();
1849 let mut response = NodeBuilder::new("iq").attrs([
1850 ("from", "s.whatsapp.net".to_string()),
1851 ("id", id.clone()),
1852 (
1853 "type",
1854 if error.is_some() { "error" } else { "result" }.to_string(),
1855 ),
1856 ]);
1857 if let Some((code, text)) = error {
1858 response = response.children([NodeBuilder::new("error")
1859 .attrs([("code", code.to_string()), ("text", text.to_string())])
1860 .build()]);
1861 }
1862 crate::test_utils::answer_iq(client, &id, &response.build()).await;
1863 }
1864
1865 async fn reach_stage_two(
1867 client: &Arc<Client>,
1868 transport: &Arc<crate::transport::mock::CapturingMockTransport>,
1869 pairing_ref: &[u8],
1870 ) {
1871 set_waiting(client, pairing_ref.to_vec(), wacore::time::now_secs(), 0).await;
1872 let notif = primary_hello_notif(pairing_ref);
1873 assert!(handle_pair_code_notification(client, ¬if.as_node_ref()).await);
1874 poll_until("companion_finish to reach the transport", || {
1875 !transport.sent().is_empty()
1876 })
1877 .await;
1878 }
1879
1880 #[tokio::test(start_paused = true)]
1885 async fn an_accepted_companion_finish_keeps_the_flow_open() {
1886 let (client, transport) = create_iq_test_client().await;
1887 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1888 client.subscribe_handler(collector.clone()).detach();
1889 let pairing_ref = vec![1, 2, 3, 4];
1890 reach_stage_two(&client, &transport, &pairing_ref).await;
1891 let adv_after_stage_two = adv(&client);
1892
1893 answer_companion_finish(&client, &transport, 0, None).await;
1894
1895 advance_past(PairCodeUtils::companion_finish_iq_timeout()).await;
1896 for _ in 0..64 {
1899 tokio::task::yield_now().await;
1900 }
1901 assert!(
1902 is_waiting(&client).await,
1903 "an accepted bundle leaves pair-success still due"
1904 );
1905 assert_eq!(
1906 adv(&client),
1907 adv_after_stage_two,
1908 "the secret pair-success will verify against must survive"
1909 );
1910 assert!(
1911 !collector
1912 .events()
1913 .iter()
1914 .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1915 "nothing failed, so nothing may be reported"
1916 );
1917 }
1918
1919 #[tokio::test]
1926 async fn a_refused_companion_finish_reports_the_rejection() {
1927 let (client, transport) = create_iq_test_client().await;
1928 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1929 client.subscribe_handler(collector.clone()).detach();
1930 let pairing_ref = vec![1, 2, 3, 4];
1931 reach_stage_two(&client, &transport, &pairing_ref).await;
1932 let adv_after_stage_two = adv(&client);
1933
1934 answer_companion_finish(&client, &transport, 0, Some((400, "bad-request"))).await;
1935
1936 poll_until("the refusal to reach the consumer", || {
1937 collector
1938 .events()
1939 .iter()
1940 .any(|e| matches!(&**e, Event::PairingCodeError(_)))
1941 })
1942 .await;
1943 let reported = collector
1944 .events()
1945 .iter()
1946 .find_map(|e| match &**e {
1947 Event::PairingCodeError(e) => Some(e.clone()),
1948 _ => None,
1949 })
1950 .expect("the refusal was just observed");
1951 assert_eq!(
1952 reported.rejection,
1953 Some(PairCodeRejection::BadRequest),
1954 "the consumer must be able to branch on the status, not the message"
1955 );
1956 assert!(
1957 !is_waiting(&client).await,
1958 "a refused flow must free the slot so a replacement can be requested"
1959 );
1960 assert_ne!(
1961 adv(&client),
1962 adv_after_stage_two,
1963 "the secret this dead flow rotated must not outlive it"
1964 );
1965 }
1966
1967 #[tokio::test]
1971 async fn a_refusal_for_a_replaced_flow_is_not_reported() {
1972 let (client, _transport) = create_iq_test_client().await;
1973 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1974 client.subscribe_handler(collector.clone()).detach();
1975
1976 set_waiting(&client, vec![9, 9, 9, 9], wacore::time::now_secs(), 0).await;
1978 let adv_of_replacement = adv(&client);
1979 report_stage_two_failure(
1980 &client,
1981 &[1, 2, 3, 4],
1982 1,
1983 IqError::ServerError {
1984 code: 500,
1985 text: "internal-server-error".to_string(),
1986 error_type: None,
1987 backoff: None,
1988 },
1989 )
1990 .await;
1991
1992 assert!(
1993 !collector
1994 .events()
1995 .iter()
1996 .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1997 "the replacement flow has not failed and must not be reported as failed"
1998 );
1999 assert_eq!(
2000 adv(&client),
2001 adv_of_replacement,
2002 "the replacement's adv secret must survive"
2003 );
2004 assert!(is_waiting(&client).await, "the replacement keeps the slot");
2005
2006 set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 2).await;
2010 let adv_of_retry = adv(&client);
2011 report_stage_two_failure(
2012 &client,
2013 &[1, 2, 3, 4],
2014 1,
2015 IqError::ServerError {
2016 code: 400,
2017 text: "bad-request".to_string(),
2018 error_type: None,
2019 backoff: None,
2020 },
2021 )
2022 .await;
2023
2024 assert!(
2025 !collector
2026 .events()
2027 .iter()
2028 .any(|e| matches!(&**e, Event::PairingCodeError(_))),
2029 "an earlier attempt's refusal must not report the retry that replaced it"
2030 );
2031 assert_eq!(
2032 adv(&client),
2033 adv_of_retry,
2034 "the retry's adv secret must survive"
2035 );
2036 assert!(is_waiting(&client).await, "the retry keeps the slot");
2037 }
2038
2039 #[tokio::test(start_paused = true)]
2044 async fn an_unanswered_companion_finish_leaves_the_timer_in_charge() {
2045 let (client, transport) = create_iq_test_client().await;
2046 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
2047 client.subscribe_handler(collector.clone()).detach();
2048 reach_stage_two(&client, &transport, &[1, 2, 3, 4]).await;
2049
2050 advance_past(PairCodeUtils::companion_finish_iq_timeout()).await;
2051 for _ in 0..64 {
2052 tokio::task::yield_now().await;
2053 }
2054 assert!(
2055 is_waiting(&client).await,
2056 "the IQ giving up does not end the flow"
2057 );
2058 assert!(
2059 !collector
2060 .events()
2061 .iter()
2062 .any(|e| matches!(&**e, Event::PairingCodeError(_))),
2063 "silence is not a refusal and must not be reported as one"
2064 );
2065
2066 advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
2067 poll_until("the regeneration request", || {
2068 collector
2069 .events()
2070 .iter()
2071 .any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if !r.force_manual))
2072 })
2073 .await;
2074 }
2075
2076 #[tokio::test]
2080 async fn cancelling_after_stage_two_gives_up_the_secret_it_derived() {
2081 let (client, transport) = create_iq_test_client().await;
2082 reach_stage_two(&client, &transport, &[1, 2, 3, 4]).await;
2083 let rotated = adv(&client);
2084
2085 client.cancel_pair_code().await;
2086
2087 assert_ne!(
2088 adv(&client),
2089 rotated,
2090 "the cancelled flow's secret must not outlive it"
2091 );
2092 }
2093
2094 #[tokio::test]
2098 async fn cancelling_leaves_a_secret_stage_two_never_touched() {
2099 let (client, _transport) = create_iq_test_client().await;
2100 set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
2101 let before = adv(&client);
2102 client.cancel_pair_code().await;
2103 assert_eq!(adv(&client), before, "no stage 2 ran, so nothing rotated");
2104
2105 *client.pair_code_state.lock().await = PairCodeState::Completed;
2106 let paired = adv(&client);
2107 client.cancel_pair_code().await;
2108 assert_eq!(
2109 adv(&client),
2110 paired,
2111 "a paired device's adv secret signs its own identity"
2112 );
2113 }
2114
2115 #[tokio::test]
2116 async fn pair_with_code_refuses_to_supersede_a_live_code() {
2117 let (client, _transport) = create_iq_test_client().await;
2118 set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
2119
2120 let err = client
2121 .pair_with_code(options())
2122 .await
2123 .expect_err("a second code would strand the one already displayed");
2124
2125 assert!(
2126 matches!(
2127 err,
2128 PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { .. })
2129 ),
2130 "expected CodeAlreadyOutstanding, got {err:?}"
2131 );
2132 }
2133
2134 #[tokio::test]
2137 async fn cancel_pair_code_lets_a_replacement_be_requested() {
2138 let (client, transport) = create_iq_test_client().await;
2139 set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
2140
2141 client.cancel_pair_code().await;
2142
2143 let pending = {
2144 let client = client.clone();
2145 tokio::spawn(async move { client.pair_with_code(options()).await })
2146 };
2147 answer_companion_hello(&client, &transport, 0, b"3@2:fresh").await;
2148 let code = pending
2149 .await
2150 .expect("the pair-code task should not panic")
2151 .expect("a cancelled flow leaves the way clear");
2152 assert!(PairCodeUtils::validate_code(&code));
2153 }
2154
2155 #[tokio::test]
2158 async fn an_expired_code_does_not_block_a_new_one() {
2159 let (client, transport) = create_iq_test_client().await;
2160 let stale =
2161 wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 1);
2162 set_waiting(&client, vec![1, 2, 3, 4], stale, 0).await;
2163
2164 let pending = {
2165 let client = client.clone();
2166 tokio::spawn(async move { client.pair_with_code(options()).await })
2167 };
2168 answer_companion_hello(&client, &transport, 0, b"3@2:fresh").await;
2169 pending
2170 .await
2171 .expect("the pair-code task should not panic")
2172 .expect("an expired code must not block a new request");
2173 }
2174
2175 #[tokio::test]
2179 async fn refresh_code_clears_the_flow_it_asks_to_replace() {
2180 let client = create_test_client().await;
2181 let pairing_ref = vec![5, 6, 7, 8];
2182 set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2183
2184 let notif = refresh_code_notif(&pairing_ref, Some(true));
2185 assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await);
2186
2187 assert!(
2188 !is_waiting(&client).await,
2189 "a consumer acting on the refresh must not be rejected by the flow it replaces"
2190 );
2191 }
2192
2193 #[tokio::test]
2199 async fn a_request_racing_another_is_refused_too() {
2200 let (client, transport) = create_iq_test_client().await;
2201
2202 let first = {
2203 let client = client.clone();
2204 tokio::spawn(async move { client.pair_with_code(options()).await })
2205 };
2206 poll_until("the first companion_hello to be on the wire", || {
2207 !transport.sent().is_empty()
2208 })
2209 .await;
2210
2211 let second = client.pair_with_code(options()).await;
2212 assert!(
2213 matches!(
2214 second,
2215 Err(PairError::PairCode(
2216 PairCodeError::CodeAlreadyOutstanding { .. }
2217 ))
2218 ),
2219 "a request in flight already owns the slot, got {second:?}"
2220 );
2221
2222 answer_companion_hello(&client, &transport, 0, b"3@2:first").await;
2223 first
2224 .await
2225 .expect("the pair-code task should not panic")
2226 .expect("the winner still completes");
2227 }
2228
2229 #[tokio::test]
2232 async fn a_rejected_request_frees_the_slot() {
2233 let (client, transport) = create_iq_test_client().await;
2234
2235 let first = {
2236 let client = client.clone();
2237 tokio::spawn(async move { client.pair_with_code(options()).await })
2238 };
2239 let hello = crate::test_utils::decode_sent_iq(&transport, 0).await;
2240 let id = hello
2241 .get()
2242 .attrs()
2243 .optional_string("id")
2244 .expect("companion_hello carries an id")
2245 .into_owned();
2246 let error = NodeBuilder::new("iq")
2247 .attrs([
2248 ("from", "s.whatsapp.net".to_string()),
2249 ("type", "error".to_string()),
2250 ("id", id.clone()),
2251 ])
2252 .children([NodeBuilder::new("error")
2253 .attrs([
2254 ("code", "400".to_string()),
2255 ("text", "bad-request".to_string()),
2256 ])
2257 .build()])
2258 .build();
2259 crate::test_utils::answer_iq(&client, &id, &error).await;
2260 first
2261 .await
2262 .expect("the pair-code task should not panic")
2263 .expect_err("the server rejected this one");
2264
2265 let retry = {
2266 let client = client.clone();
2267 tokio::spawn(async move { client.pair_with_code(options()).await })
2268 };
2269 answer_companion_hello(&client, &transport, 1, b"3@2:second").await;
2270 retry
2271 .await
2272 .expect("the pair-code task should not panic")
2273 .expect("a rejected request must not leave the slot taken");
2274 }
2275
2276 async fn advance_past(d: std::time::Duration) {
2280 for _ in 0..64 {
2281 tokio::task::yield_now().await;
2282 }
2283 tokio::time::advance(d + std::time::Duration::from_secs(1)).await;
2284 }
2285
2286 #[tokio::test]
2292 async fn a_claim_is_identified_by_more_than_the_second_it_started_in() {
2293 let (client, transport) = create_iq_test_client().await;
2294
2295 let first = {
2296 let client = client.clone();
2297 tokio::spawn(async move { client.pair_with_code(options()).await })
2298 };
2299 poll_until("the first companion_hello", || !transport.sent().is_empty()).await;
2300
2301 client.cancel_pair_code().await;
2303 let second = {
2304 let client = client.clone();
2305 tokio::spawn(async move { client.pair_with_code(options()).await })
2306 };
2307 poll_until("the replacement's companion_hello", || {
2308 transport.sent().len() >= 2
2309 })
2310 .await;
2311
2312 answer_companion_hello(&client, &transport, 0, b"3@2:first").await;
2313 let stale = first
2314 .await
2315 .expect("the pair-code task should not panic")
2316 .expect_err("the cancelled request must not install its flow");
2317 assert!(
2318 matches!(stale, PairError::PairCode(PairCodeError::Cancelled)),
2319 "expected Cancelled, got {stale:?}"
2320 );
2321
2322 answer_companion_hello(&client, &transport, 1, b"3@2:second").await;
2323 second
2324 .await
2325 .expect("the pair-code task should not panic")
2326 .expect("the replacement owns the slot and must complete");
2327 assert!(
2328 matches!(
2329 &*client.pair_code_state.lock().await,
2330 PairCodeState::WaitingForPhoneConfirmation { pairing_ref, .. }
2331 if pairing_ref.as_slice() == b"3@2:second"
2332 ),
2333 "the replacement's flow must be the one left standing"
2334 );
2335 }
2336
2337 #[tokio::test]
2342 async fn a_pending_pair_success_still_owns_the_slot() {
2343 let (client, _transport) = create_iq_test_client().await;
2344 let expired =
2345 wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 1);
2346 set_waiting(&client, vec![1, 2, 3, 4], expired, 1).await;
2348
2349 let err = client
2350 .pair_with_code(options())
2351 .await
2352 .expect_err("a pending link still owns the flow");
2353 assert!(
2354 matches!(
2355 err,
2356 PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { .. })
2357 ),
2358 "expected CodeAlreadyOutstanding, got {err:?}"
2359 );
2360 }
2361
2362 #[tokio::test(start_paused = true)]
2366 async fn a_retry_gets_its_own_response_window() {
2367 let (client, transport) = create_iq_test_client().await;
2368 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
2369 client.subscribe_handler(collector.clone()).detach();
2370 let pairing_ref = vec![1, 2, 3, 4];
2371 set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2372
2373 let notif = primary_hello_notif(&pairing_ref);
2374 assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await);
2375 poll_until("the first companion_finish", || {
2376 !transport.sent().is_empty()
2377 })
2378 .await;
2379
2380 advance_past(std::time::Duration::from_secs(50)).await;
2382 let retry = primary_hello_notif(&pairing_ref);
2383 assert!(handle_pair_code_notification(&client, &retry.as_node_ref()).await);
2384 poll_until("the second companion_finish", || {
2385 transport.sent().len() >= 2
2386 })
2387 .await;
2388
2389 advance_past(std::time::Duration::from_secs(15)).await;
2391 assert!(
2392 !collector
2393 .events()
2394 .iter()
2395 .any(|e| matches!(&**e, Event::PairingCodeRefresh(_))),
2396 "the first attempt's timer must not cut the retry's window short"
2397 );
2398
2399 advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
2400 poll_until("the retry's own timeout", || {
2401 collector
2402 .events()
2403 .iter()
2404 .any(|e| matches!(&**e, Event::PairingCodeRefresh(_)))
2405 })
2406 .await;
2407 }
2408
2409 #[tokio::test]
2414 async fn a_teardown_does_not_leave_the_slot_claimed() {
2415 let (client, _transport) = create_iq_test_client().await;
2416 set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
2417
2418 client.cleanup_connection_state().await;
2419
2420 assert!(
2421 matches!(&*client.pair_code_state.lock().await, PairCodeState::Idle),
2422 "a flow scoped to a dead connection must not outlive it"
2423 );
2424 }
2425
2426 #[tokio::test]
2430 async fn dropping_the_request_hands_the_claim_back() {
2431 let (client, transport) = create_iq_test_client().await;
2432
2433 {
2434 let client = client.clone();
2435 let task = tokio::spawn(async move { client.pair_with_code(options()).await });
2436 poll_until("the companion_hello to be on the wire", || {
2437 !transport.sent().is_empty()
2438 })
2439 .await;
2440 task.abort();
2441 }
2442
2443 poll_until("the abandoned claim to be released", || {
2444 matches!(
2445 client.pair_code_state.try_lock().as_deref(),
2446 Some(PairCodeState::Idle)
2447 )
2448 })
2449 .await;
2450 }
2451
2452 #[tokio::test(start_paused = true)]
2459 async fn a_failed_request_hands_the_claim_back_before_it_returns() {
2460 let (client, _transport) = create_iq_test_client().await;
2461 client.set_connected_for_test(false);
2462
2463 client
2464 .pair_with_code(options())
2465 .await
2466 .expect_err("stage 1 cannot complete while disconnected");
2467
2468 assert!(
2470 matches!(
2471 client.pair_code_state.try_lock().as_deref(),
2472 Some(PairCodeState::Idle)
2473 ),
2474 "the slot must be free the moment the error is returned"
2475 );
2476 }
2477
2478 #[tokio::test]
2484 async fn a_withdrawn_claim_stops_being_owned() {
2485 let (client, _transport) = create_iq_test_client().await;
2486 let claim = wacore::pair_code::PairCodeClaim::next();
2487 *client.pair_code_state.lock().await = PairCodeState::RequestingCode {
2488 code_generation_ts: wacore::time::now_secs(),
2489 claim,
2490 };
2491
2492 assert!(client.owns_code_claim(claim).await);
2493 client.cancel_pair_code().await;
2494 assert!(
2495 !client.owns_code_claim(claim).await,
2496 "a cancelled request must not reach the wire"
2497 );
2498
2499 *client.pair_code_state.lock().await = PairCodeState::RequestingCode {
2501 code_generation_ts: wacore::time::now_secs(),
2502 claim: wacore::pair_code::PairCodeClaim::next(),
2503 };
2504 assert!(!client.owns_code_claim(claim).await);
2505 }
2506
2507 #[tokio::test]
2517 async fn primary_hello_returns_before_stage_two_reaches_the_wire() {
2518 let (client, transport) = create_iq_test_client().await;
2519 let pairing_ref = vec![1, 2, 3, 4];
2520 set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2521
2522 let notif = primary_hello_notif(&pairing_ref);
2523 let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
2524
2525 assert!(handled, "a valid primary_hello is handled");
2526 assert!(
2527 transport.sent().is_empty(),
2528 "the ack must not wait on stage-2 crypto; companion_finish belongs to a later poll"
2529 );
2530 poll_until("companion_finish to reach the transport", || {
2531 !transport.sent().is_empty()
2532 })
2533 .await;
2534 }
2535
2536 #[tokio::test]
2540 async fn a_cancelled_request_does_not_install_its_flow() {
2541 let (client, transport) = create_iq_test_client().await;
2542
2543 let pending = {
2544 let client = client.clone();
2545 tokio::spawn(async move { client.pair_with_code(options()).await })
2546 };
2547 poll_until("the companion_hello to be on the wire", || {
2548 !transport.sent().is_empty()
2549 })
2550 .await;
2551
2552 client.cancel_pair_code().await;
2553 answer_companion_hello(&client, &transport, 0, b"3@2:late").await;
2554
2555 let err = pending
2556 .await
2557 .expect("the pair-code task should not panic")
2558 .expect_err("a cancelled request must not report a usable code");
2559 assert!(
2560 matches!(err, PairError::PairCode(PairCodeError::Cancelled)),
2561 "expected Cancelled, got {err:?}"
2562 );
2563 assert!(
2564 !is_waiting(&client).await,
2565 "the cancelled flow must stay cancelled"
2566 );
2567 }
2568
2569 #[tokio::test]
2575 async fn a_stage_two_task_does_not_answer_for_the_flow_that_replaced_it() {
2576 let (client, transport) = create_iq_test_client().await;
2577 set_waiting(&client, vec![9, 9, 9, 9], wacore::time::now_secs(), 0).await;
2579 let adv_before = adv(&client);
2580
2581 run_stage_two(
2582 client.clone(),
2583 vec![1, 2, 3, 4],
2584 "15551234567".to_string(),
2585 "ABCD1234".to_string(),
2586 KeyPair::generate(&mut rand::make_rng::<rand::rngs::StdRng>()),
2587 vec![7u8; 80],
2588 [9u8; 32],
2589 1,
2590 )
2591 .await;
2592
2593 assert_eq!(
2594 adv(&client),
2595 adv_before,
2596 "the replacement flow's adv secret must survive"
2597 );
2598 assert!(
2599 transport.sent().is_empty(),
2600 "no companion_finish may go out for a ref nobody is holding"
2601 );
2602 }
2603
2604 #[tokio::test(start_paused = true)]
2612 async fn a_primary_hello_that_never_pairs_asks_for_a_new_code() {
2613 let (client, transport) = create_iq_test_client().await;
2614 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
2615 client.subscribe_handler(collector.clone()).detach();
2616 let pairing_ref = vec![1, 2, 3, 4];
2617 set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2618
2619 let notif = primary_hello_notif(&pairing_ref);
2620 assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await);
2621 poll_until("companion_finish to reach the transport", || {
2622 !transport.sent().is_empty()
2623 })
2624 .await;
2625
2626 advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
2627 poll_until("the regeneration request", || {
2628 collector
2629 .events()
2630 .iter()
2631 .any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if !r.force_manual))
2632 })
2633 .await;
2634 assert!(
2635 !is_waiting(&client).await,
2636 "the abandoned flow must not reject the replacement it just asked for"
2637 );
2638 }
2639
2640 #[tokio::test]
2646 async fn a_stage_two_that_cannot_send_reports_the_failure_at_once() {
2647 let client = create_test_client().await;
2649 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
2650 client.subscribe_handler(collector.clone()).detach();
2651 let pairing_ref = vec![1, 2, 3, 4];
2652 set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2653
2654 let notif = primary_hello_notif(&pairing_ref);
2655 assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await);
2656
2657 poll_until("the failure to reach the consumer", || {
2658 collector
2659 .events()
2660 .iter()
2661 .any(|e| matches!(&**e, Event::PairingCodeError(_)))
2662 })
2663 .await;
2664 assert!(
2665 !is_waiting(&client).await,
2666 "a flow that could not send its bundle must not keep the slot"
2667 );
2668 }
2669
2670 #[tokio::test(start_paused = true)]
2673 async fn pair_success_silences_the_regeneration_timer() {
2674 let (client, transport) = create_iq_test_client().await;
2675 let collector = Arc::new(crate::test_utils::TestEventCollector::default());
2676 client.subscribe_handler(collector.clone()).detach();
2677 let pairing_ref = vec![1, 2, 3, 4];
2678 set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2679
2680 let notif = primary_hello_notif(&pairing_ref);
2681 assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await);
2682 poll_until("companion_finish to reach the transport", || {
2683 !transport.sent().is_empty()
2684 })
2685 .await;
2686
2687 *client.pair_code_state.lock().await = PairCodeState::Completed;
2689
2690 advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
2691 for _ in 0..64 {
2694 tokio::task::yield_now().await;
2695 }
2696 assert!(
2697 !collector
2698 .events()
2699 .iter()
2700 .any(|e| matches!(&**e, Event::PairingCodeRefresh(_))),
2701 "a completed pairing must not ask the consumer for another code"
2702 );
2703 }
2704
2705 #[tokio::test]
2708 async fn unknown_stage_is_ignored_and_preserves_state() {
2709 let client = create_test_client().await;
2710 set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
2711
2712 let notif = NodeBuilder::new("notification")
2713 .attr("type", "link_code_companion_reg")
2714 .attr("from", "s.whatsapp.net")
2715 .children([NodeBuilder::new("link_code_companion_reg")
2716 .attr("stage", "some_future_stage")
2717 .build()])
2718 .build();
2719 let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
2720
2721 assert!(!handled, "unknown stage must not be treated as handled");
2722 assert!(
2723 is_waiting(&client).await,
2724 "unknown stage must leave the outstanding flow untouched"
2725 );
2726 }
2727}