use crate::client::Client;
use crate::request::{InfoQuery, InfoQueryType, IqError};
use crate::types::events::Event;
use log::{error, info, warn};
use std::sync::Arc;
use wacore::libsignal::protocol::KeyPair;
use wacore::pair_code::{PairCodeState, PairCodeUtils, resolve_companion_platform};
use wacore_binary::Jid;
use wacore_binary::{NodeContent, NodeContentRef, NodeRef};
pub use wacore::companion_reg::{CompanionOs, CompanionWebClientType};
pub use wacore::pair_code::{PairCodeError, PairCodeOptions, PairCodeRejection};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PairError {
#[error("{0}")]
PairCode(#[from] PairCodeError),
#[error("{0}")]
RequestFailed(#[from] IqError),
}
impl PairError {
pub fn rejection(&self) -> Option<PairCodeRejection> {
use crate::error::ErrorChainExt;
self.server_rejection()
.and_then(|rejection| PairCodeRejection::from_server(rejection.code, rejection.text))
}
pub fn lost_the_flow_to_another_request(&self) -> bool {
matches!(
self,
Self::PairCode(PairCodeError::CodeAlreadyOutstanding { .. } | PairCodeError::Cancelled)
)
}
pub fn backoff(&self) -> Option<std::time::Duration> {
use crate::error::ErrorChainExt;
self.server_rejection()
.and_then(|rejection| rejection.backoff)
.map(|secs| std::time::Duration::from_secs(u64::from(secs)))
}
}
impl Client {
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "wa.pair.code", level = "debug", skip_all, err(Debug))
)]
pub async fn pair_with_code(
self: &Arc<Self>,
options: PairCodeOptions,
) -> Result<String, PairError> {
match self.pair_with_code_inner(options).await {
Ok(code) => Ok(code),
Err(e) if self.failure_is_not_this_flows_to_report(&e).await => Err(e),
Err(e) => {
self.core.event_bus.dispatch(Event::PairingCodeError(
crate::types::events::PairingCodeError::builder()
.maybe_rejection(e.rejection())
.maybe_backoff(e.backoff())
.error(e.to_string())
.build(),
));
Err(e)
}
}
}
async fn failure_is_not_this_flows_to_report(self: &Arc<Self>, e: &PairError) -> bool {
if e.lost_the_flow_to_another_request() {
return true;
}
self.pair_code_state
.lock()
.await
.is_outstanding(wacore::time::now_secs())
}
async fn pair_with_code_inner(
self: &Arc<Self>,
options: PairCodeOptions,
) -> Result<String, PairError> {
let phone_number: String = options
.phone_number
.chars()
.filter(|c| c.is_ascii_digit())
.collect();
if phone_number.is_empty() {
return Err(PairCodeError::PhoneNumberRequired.into());
}
if phone_number.len() < 7 {
return Err(PairCodeError::PhoneNumberTooShort.into());
}
if phone_number.starts_with('0') {
return Err(PairCodeError::PhoneNumberNotInternational.into());
}
let code = match &options.custom_code {
Some(custom) => {
if !PairCodeUtils::validate_code(custom) {
return Err(PairCodeError::InvalidCustomCode.into());
}
custom.to_uppercase()
}
None => PairCodeUtils::generate_code(),
};
let code_generation_ts = wacore::time::now_secs();
let claim = wacore::pair_code::PairCodeClaim::next();
{
let mut state = self.pair_code_state.lock().await;
if state.is_outstanding(code_generation_ts) {
return Err(PairCodeError::CodeAlreadyOutstanding {
remaining: state
.live_flow_remaining(code_generation_ts)
.unwrap_or_default(),
}
.into());
}
*state = PairCodeState::RequestingCode {
code_generation_ts,
claim,
};
}
let mut claim_guard = ClaimGuard {
client: Arc::clone(self),
claim,
armed: true,
};
info!(
target: "Client/PairCode",
"Starting pair code authentication for phone: {}",
phone_number
);
let ephemeral_keypair = KeyPair::generate(&mut rand::make_rng::<rand::rngs::StdRng>());
let device_snapshot = self.persistence_manager.get_device_snapshot();
let noise_static_pub: [u8; 32] = device_snapshot
.noise_key
.public_key
.public_key_bytes()
.try_into()
.expect("noise key is 32 bytes");
let code_clone = code.clone();
let ephemeral_pub: [u8; 32] = ephemeral_keypair
.public_key
.public_key_bytes()
.try_into()
.expect("ephemeral key is 32 bytes");
let wrapped_ephemeral = wacore::runtime::blocking(&*self.runtime, move || {
PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, &code_clone)
})
.await;
let (platform_id, platform_display) =
resolve_companion_platform(&options, &device_snapshot.device_props);
let platform_id_str = platform_id.to_string();
static OS_COERCE_WARNED: std::sync::Once = std::sync::Once::new();
let os_overridden = options
.display_os
.as_deref()
.is_some_and(|o| !o.trim().is_empty());
if !os_overridden
&& let Some(os) = device_snapshot.device_props.os.as_deref()
&& !os.trim().is_empty()
&& CompanionOs::classify(os).is_none()
{
OS_COERCE_WARNED.call_once(|| {
warn!(
target: "Client/PairCode",
"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)"
);
});
}
let req_id = self.generate_request_id();
let iq_content = PairCodeUtils::build_companion_hello_iq(
&phone_number,
&noise_static_pub,
&wrapped_ephemeral,
&platform_id_str,
&platform_display,
options.show_push_notification,
req_id.clone(),
);
let query = InfoQuery {
query_type: InfoQueryType::Set,
namespace: "md",
to: Jid::new("", wacore_binary::Server::Pn),
target: None,
content: Some(NodeContent::Nodes(
iq_content
.children()
.map(|c| c.to_vec())
.unwrap_or_default(),
)),
id: Some(req_id),
timeout: Some(std::time::Duration::from_secs(30)),
};
if !self.owns_code_claim(claim).await {
claim_guard.armed = false;
return Err(PairCodeError::Cancelled.into());
}
let response = match self.send_iq(query).await {
Ok(response) => response,
Err(e) => {
if !self.owns_code_claim(claim).await {
claim_guard.armed = false;
return Err(PairCodeError::Cancelled.into());
}
claim_guard.release_now().await;
return Err(e.into());
}
};
let Some(pairing_ref) = PairCodeUtils::parse_companion_hello_response(response.get())
else {
claim_guard.release_now().await;
return Err(PairCodeError::MissingPairingRef.into());
};
info!(
target: "Client/PairCode",
"Stage 1 complete, waiting for phone confirmation. Code: {}",
code
);
{
let mut state = self.pair_code_state.lock().await;
if !matches!(&*state, PairCodeState::RequestingCode { claim: c, .. } if *c == claim) {
claim_guard.armed = false;
return Err(PairCodeError::Cancelled.into());
}
*state = PairCodeState::WaitingForPhoneConfirmation {
pairing_ref,
phone_jid: phone_number,
pair_code: code.clone(),
ephemeral_keypair: Box::new(ephemeral_keypair),
code_generation_ts,
primary_hello_attempt_count: 0,
};
claim_guard.armed = false;
}
let elapsed = wacore::time::now_secs()
.saturating_sub(code_generation_ts)
.max(0) as u64;
let remaining =
PairCodeUtils::code_validity().saturating_sub(std::time::Duration::from_secs(elapsed));
self.core.event_bus.dispatch(Event::PairingCode(
crate::types::events::PairingCode::builder()
.code(code.clone())
.timeout(remaining)
.build(),
));
Ok(code)
}
async fn owns_code_claim(self: &Arc<Self>, claim: wacore::pair_code::PairCodeClaim) -> bool {
matches!(&*self.pair_code_state.lock().await, PairCodeState::RequestingCode { claim: c, .. } if *c == claim)
}
async fn release_code_claim(self: &Arc<Self>, claim: wacore::pair_code::PairCodeClaim) {
let mut state = self.pair_code_state.lock().await;
if matches!(&*state, PairCodeState::RequestingCode { claim: c, .. } if *c == claim) {
*state = PairCodeState::Idle;
}
}
pub async fn cancel_pair_code(self: &Arc<Self>) {
let mut state = self.pair_code_state.lock().await;
if matches!(&*state, PairCodeState::Idle) {
return;
}
let rotated_adv_secret = state.awaiting_pair_success();
*state = PairCodeState::Idle;
if rotated_adv_secret {
replace_adv_secret_key(self).await;
}
}
}
struct ClaimGuard {
client: Arc<Client>,
claim: wacore::pair_code::PairCodeClaim,
armed: bool,
}
impl ClaimGuard {
async fn release_now(&mut self) {
self.armed = false;
self.client.release_code_claim(self.claim).await;
}
}
impl Drop for ClaimGuard {
fn drop(&mut self) {
if !self.armed {
return;
}
let client = Arc::clone(&self.client);
let claim = self.claim;
client.clone().runtime.spawn_detached(Box::pin(async move {
client.release_code_claim(claim).await;
}));
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(name = "wa.pair.code_notification", level = "debug", skip_all)
)]
pub(crate) async fn handle_pair_code_notification(
client: &Arc<Client>,
node: &NodeRef<'_>,
) -> bool {
let Some(reg_node) = node.get_optional_child_by_tag(&["link_code_companion_reg"]) else {
return false;
};
match reg_node.get_attr("stage").map(|v| v.as_str()).as_deref() {
Some("primary_hello") => handle_primary_hello(client, reg_node).await,
Some("refresh_code") => handle_refresh_code(client, reg_node).await,
other => {
warn!(
target: "Client/PairCode",
"Ignoring link_code_companion_reg notification with stage {other:?}"
);
false
}
}
}
async fn handle_primary_hello(client: &Arc<Client>, reg_node: &NodeRef<'_>) -> bool {
let primary_wrapped_ephemeral = match reg_node
.get_optional_child_by_tag(&["link_code_pairing_wrapped_primary_ephemeral_pub"])
.and_then(|n| match n.content.as_ref() {
Some(NodeContentRef::Bytes(b)) if b.len() == 80 => Some(b.to_vec()),
_ => None,
}) {
Some(b) => b,
None => {
warn!(
target: "Client/PairCode",
"Missing or invalid primary wrapped ephemeral pub in notification"
);
return false;
}
};
let primary_identity_pub: [u8; 32] = match reg_node
.get_optional_child_by_tag(&["primary_identity_pub"])
.and_then(|n| match n.content.as_ref() {
Some(NodeContentRef::Bytes(b)) if b.len() == 32 => b.as_ref().try_into().ok(),
_ => None,
}) {
Some(arr) => arr,
None => {
warn!(
target: "Client/PairCode",
"Missing or invalid primary identity pub in notification"
);
return false;
}
};
let notif_ref = match reg_node
.get_optional_child_by_tag(&["link_code_pairing_ref"])
.and_then(|n| match n.content.as_ref() {
Some(NodeContentRef::Bytes(b)) => Some(b.to_vec()),
_ => None,
}) {
Some(r) => r,
None => {
warn!(target: "Client/PairCode", "primary_hello missing link_code_pairing_ref");
return false;
}
};
let mut state_guard = client.pair_code_state.lock().await;
let (pairing_ref, phone_jid, pair_code, ephemeral_keypair, attempt) = match &mut *state_guard {
PairCodeState::WaitingForPhoneConfirmation {
pairing_ref,
phone_jid,
pair_code,
ephemeral_keypair,
code_generation_ts,
primary_hello_attempt_count,
} => {
if pairing_ref.as_slice() != notif_ref.as_slice() {
warn!(
target: "Client/PairCode",
"primary_hello ref does not match the outstanding request; ignoring"
);
return false;
}
let age = wacore::time::now_secs() - *code_generation_ts;
if age > PairCodeUtils::code_validity().as_secs() as i64 {
warn!(
target: "Client/PairCode",
"primary_hello arrived for an expired code ({age}s old); ignoring"
);
return false;
}
if *primary_hello_attempt_count >= PairCodeUtils::max_primary_hello_attempts() {
warn!(
target: "Client/PairCode",
"Exceeded max primary_hello attempts for this code; abandoning"
);
return false;
}
*primary_hello_attempt_count += 1;
(
pairing_ref.clone(),
phone_jid.clone(),
pair_code.clone(),
(**ephemeral_keypair).clone(),
*primary_hello_attempt_count,
)
}
_ => {
warn!(
target: "Client/PairCode",
"Received primary_hello but not in waiting state"
);
return false;
}
};
info!(
target: "Client/PairCode",
"Phone confirmed code entry, processing stage 2"
);
drop(state_guard);
let client = Arc::clone(client);
start_pair_success_timeout(Arc::clone(&client), pairing_ref.clone(), attempt);
client.clone().runtime.spawn_detached(Box::pin(async move {
run_stage_two(
client,
pairing_ref,
phone_jid,
pair_code,
ephemeral_keypair,
primary_wrapped_ephemeral,
primary_identity_pub,
attempt,
)
.await;
}));
true
}
#[allow(clippy::too_many_arguments)]
async fn run_stage_two(
client: Arc<Client>,
pairing_ref: Vec<u8>,
phone_jid: String,
pair_code: String,
ephemeral_keypair: KeyPair,
primary_wrapped_ephemeral: Vec<u8>,
primary_identity_pub: [u8; 32],
attempt: u32,
) {
let state_guard = client.pair_code_state.lock().await;
let still_ours = matches!(
&*state_guard,
PairCodeState::WaitingForPhoneConfirmation { pairing_ref: current, .. }
if current.as_slice() == pairing_ref.as_slice()
);
if !still_ours {
return;
}
let pair_code_clone = pair_code.clone();
let primary_ephemeral_pub = match wacore::runtime::blocking(&*client.runtime, move || {
PairCodeUtils::decrypt_primary_ephemeral_pub(&primary_wrapped_ephemeral, &pair_code_clone)
})
.await
{
Ok(pub_key) => pub_key,
Err(e) => {
error!(
target: "Client/PairCode",
"Failed to decrypt primary ephemeral pub: {e}"
);
return;
}
};
let device_snapshot = client.persistence_manager.get_device_snapshot();
let (wrapped_bundle, new_adv_secret) = match PairCodeUtils::prepare_key_bundle(
&ephemeral_keypair,
&primary_ephemeral_pub,
&primary_identity_pub,
&device_snapshot.identity_key,
) {
Ok(result) => result,
Err(e) => {
error!(target: "Client/PairCode", "Failed to prepare key bundle: {e}");
return;
}
};
client
.persistence_manager
.process_command(crate::store::commands::DeviceCommand::SetAdvSecretKey(
new_adv_secret,
))
.await;
let req_id = client.generate_request_id();
let identity_pub: [u8; 32] = device_snapshot
.identity_key
.public_key
.public_key_bytes()
.try_into()
.expect("identity key is 32 bytes");
let iq = PairCodeUtils::build_companion_finish_iq(
&phone_jid,
wrapped_bundle,
&identity_pub,
&pairing_ref,
req_id,
);
let answer = client
.send_iq_node_then(
iq,
Some(PairCodeUtils::companion_finish_iq_timeout()),
Some(Box::new(move || drop(state_guard))),
)
.await;
match answer {
Ok(_) => {
info!(
target: "Client/PairCode",
"Sent companion_finish, waiting for pair-success"
);
}
Err(e) => report_stage_two_failure(&client, &pairing_ref, attempt, e).await,
}
}
async fn report_stage_two_failure(
client: &Arc<Client>,
pairing_ref: &[u8],
attempt: u32,
error: IqError,
) {
if error.is_timeout() {
warn!(
target: "Client/PairCode",
"companion_finish went unanswered; leaving the pair-success timer to write the code off"
);
return;
}
error!(target: "Client/PairCode", "companion_finish failed: {error}");
if !retire_stage_two_flow(client, pairing_ref, attempt).await {
return;
}
let error = PairError::from(error);
client.core.event_bus.dispatch(Event::PairingCodeError(
crate::types::events::PairingCodeError::builder()
.maybe_rejection(error.rejection())
.maybe_backoff(error.backoff())
.error(error.to_string())
.build(),
));
}
fn start_pair_success_timeout(client: Arc<Client>, pairing_ref: Vec<u8>, attempt: u32) {
let timeout = PairCodeUtils::primary_hello_pair_success_timeout();
client.clone().runtime.spawn_detached(Box::pin(async move {
client.runtime.sleep(timeout).await;
if !retire_stage_two_flow(&client, &pairing_ref, attempt).await {
return;
}
warn!(
target: "Client/PairCode",
"No pair-success within {timeout:?} of companion_finish; the code will not complete"
);
client.core.event_bus.dispatch(Event::PairingCodeRefresh(
crate::types::events::PairingCodeRefresh::builder()
.force_manual(false)
.build(),
));
}));
}
async fn retire_stage_two_flow(client: &Arc<Client>, pairing_ref: &[u8], attempt: u32) -> bool {
let mut state = client.pair_code_state.lock().await;
let still_ours = matches!(
&*state,
PairCodeState::WaitingForPhoneConfirmation {
pairing_ref: r,
primary_hello_attempt_count,
..
} if r.as_slice() == pairing_ref
&& *primary_hello_attempt_count == attempt
);
if !still_ours {
return false;
}
*state = PairCodeState::Idle;
replace_adv_secret_key(client).await;
true
}
async fn replace_adv_secret_key(client: &Arc<Client>) {
use rand::RngExt as _;
let mut adv_secret_key = [0u8; 32];
rand::make_rng::<rand::rngs::StdRng>().fill(&mut adv_secret_key);
client
.persistence_manager
.process_command(crate::store::commands::DeviceCommand::SetAdvSecretKey(
adv_secret_key,
))
.await;
client.refresh_pairing_qr().await;
}
async fn handle_refresh_code(client: &Arc<Client>, reg_node: &NodeRef<'_>) -> bool {
let notif_ref = match reg_node
.get_optional_child_by_tag(&["link_code_pairing_ref"])
.and_then(|n| match n.content.as_ref() {
Some(NodeContentRef::Bytes(b)) => Some(b.to_vec()),
_ => None,
}) {
Some(r) => r,
None => {
warn!(target: "Client/PairCode", "refresh_code missing link_code_pairing_ref");
return false;
}
};
let force_manual = reg_node
.get_attr("force_manual_refresh")
.map(|v| v.as_str().as_ref() == "true")
.unwrap_or(false);
let matches_current = {
let mut state_guard = client.pair_code_state.lock().await;
let matches = matches!(
&*state_guard,
PairCodeState::WaitingForPhoneConfirmation { pairing_ref, .. }
if pairing_ref.as_slice() == notif_ref.as_slice()
);
if matches {
*state_guard = PairCodeState::Idle;
}
matches
};
if !matches_current {
warn!(
target: "Client/PairCode",
"refresh_code ref does not match the outstanding request; ignoring"
);
return false;
}
info!(
target: "Client/PairCode",
"Server requested pair-code refresh (force_manual={force_manual})"
);
client.core.event_bus.dispatch(Event::PairingCodeRefresh(
crate::types::events::PairingCodeRefresh::builder()
.force_manual(force_manual)
.build(),
));
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejection_codes_match_wa_web() {
assert_eq!(PairCodeRejection::BadRequest.code(), 400);
assert_eq!(PairCodeRejection::Forbidden.code(), 403);
assert_eq!(PairCodeRejection::RateOverlimit.code(), 429);
assert_eq!(PairCodeRejection::FeatureNotAvailable.code(), 452);
assert_eq!(PairCodeRejection::InternalServerError.code(), 500);
assert_eq!(
PairCodeRejection::from(418),
PairCodeRejection::Unknown(418)
);
}
#[tokio::test]
async fn an_outstanding_code_is_not_reported_as_a_failure() {
let client = create_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let now = wacore::time::now_secs();
*client.pair_code_state.lock().await = PairCodeState::RequestingCode {
code_generation_ts: now,
claim: wacore::pair_code::PairCodeClaim::next(),
};
let err = client
.pair_with_code(PairCodeOptions {
phone_number: "15551234567".to_string(),
..Default::default()
})
.await
.expect_err("a second code must be refused while one is live");
assert!(
err.lost_the_flow_to_another_request(),
"expected CodeAlreadyOutstanding, got: {err:?}"
);
tokio::task::yield_now().await;
assert!(
!collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_))),
"a still-live code must not be reported as 'no code is coming'"
);
}
#[tokio::test]
async fn a_superseded_request_is_not_reported_as_a_failure() {
let (client, transport) = create_iq_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let pending = {
let client = client.clone();
tokio::spawn(async move { client.pair_with_code(options()).await })
};
poll_until("the companion_hello to be on the wire", || {
!transport.sent().is_empty()
})
.await;
client.cancel_pair_code().await;
answer_companion_hello(&client, &transport, 0, b"3@2:late").await;
let err = pending
.await
.expect("the pair-code task should not panic")
.expect_err("a cancelled request must not report a usable code");
assert!(
err.lost_the_flow_to_another_request(),
"expected Cancelled, got {err:?}"
);
tokio::task::yield_now().await;
assert!(
!collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_))),
"a withdrawn request must not report against the flow that replaced it"
);
}
#[tokio::test]
async fn a_withdrawn_request_reports_cancellation_not_its_iq_failure() {
let (client, transport) = create_iq_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let pending = {
let client = client.clone();
tokio::spawn(async move { client.pair_with_code(options()).await })
};
poll_until("the companion_hello to be on the wire", || {
!transport.sent().is_empty()
})
.await;
client.cancel_pair_code().await;
let hello = crate::test_utils::decode_sent_iq(&transport, 0).await;
let id = hello
.get()
.attrs()
.optional_string("id")
.expect("companion_hello carries an id")
.into_owned();
let refusal = NodeBuilder::new("iq")
.attrs([
("from", "s.whatsapp.net".to_string()),
("type", "error".to_string()),
("id", id.clone()),
])
.children([NodeBuilder::new("error")
.attrs([
("code", "429".to_string()),
("text", "rate-overlimit".to_string()),
])
.build()])
.build();
crate::test_utils::answer_iq(&client, &id, &refusal).await;
let err = pending
.await
.expect("the pair-code task should not panic")
.expect_err("a withdrawn request must not report a usable code");
assert!(
err.lost_the_flow_to_another_request(),
"losing the slot outranks how the request ended, got {err:?}"
);
tokio::task::yield_now().await;
assert!(
!collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_))),
"a withdrawn request's IQ failure must not report against its replacement"
);
}
#[tokio::test]
async fn a_validation_failure_beside_a_live_code_is_not_reported() {
let client = create_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
*client.pair_code_state.lock().await = PairCodeState::RequestingCode {
code_generation_ts: wacore::time::now_secs(),
claim: wacore::pair_code::PairCodeClaim::next(),
};
let err = client
.pair_with_code(PairCodeOptions {
phone_number: "123".to_string(),
..Default::default()
})
.await
.expect_err("a 3-digit number must be refused");
assert!(
matches!(err, PairError::PairCode(PairCodeError::PhoneNumberTooShort)),
"validation must still win the race it already wins, got {err:?}"
);
tokio::task::yield_now().await;
assert!(
!collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_))),
"a live code must not be reported as failed by an unrelated bad request"
);
}
#[test]
fn a_contradicting_text_yields_no_classification() {
let pe: PairError = IqError::ServerError {
code: 429,
text: "something-else".into(),
error_type: None,
backoff: None,
}
.into();
assert_eq!(
pe.rejection(),
None,
"a pairing WA Web would reject must not drive throttle handling"
);
assert!(pe.to_string().contains("429"), "got: {pe}");
}
#[test]
fn an_absent_text_still_classifies_by_code() {
let pe: PairError = IqError::ServerError {
code: 429,
text: String::new(),
error_type: None,
backoff: None,
}
.into();
assert_eq!(pe.rejection(), Some(PairCodeRejection::RateOverlimit));
assert!(pe.rejection().is_some_and(PairCodeRejection::is_throttled));
}
#[test]
fn rate_overlimit_is_recoverable_as_a_typed_status() {
let pe: PairError = IqError::ServerError {
code: 429,
text: "rate-overlimit".into(),
error_type: None,
backoff: Some(30),
}
.into();
assert_eq!(pe.rejection(), Some(PairCodeRejection::RateOverlimit));
assert_eq!(pe.backoff(), Some(std::time::Duration::from_secs(30)));
assert!(
pe.rejection().is_some_and(PairCodeRejection::is_throttled),
"429 must read as throttled"
);
assert!(
pe.to_string().contains("429") && pe.to_string().contains("rate-overlimit"),
"Display should carry the server's code and text, got: {pe}"
);
}
#[test]
fn feature_not_available_is_not_throttled() {
let pe: PairError = IqError::ServerError {
code: 452,
text: "feature-not-available".into(),
error_type: None,
backoff: None,
}
.into();
assert_eq!(pe.rejection(), Some(PairCodeRejection::FeatureNotAvailable));
assert!(!PairCodeRejection::FeatureNotAvailable.is_throttled());
assert_eq!(pe.backoff(), None);
}
#[test]
fn local_failure_reports_no_rejection() {
let pe: PairError = PairCodeError::PhoneNumberTooShort.into();
assert_eq!(pe.rejection(), None);
assert_eq!(pe.backoff(), None);
}
#[tokio::test]
async fn failed_request_dispatches_pairing_code_error() {
let client = create_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let err = client
.pair_with_code(PairCodeOptions {
phone_number: "123".to_string(),
..Default::default()
})
.await
.expect_err("a 3-digit number must be refused");
assert!(matches!(
err,
PairError::PairCode(PairCodeError::PhoneNumberTooShort)
));
poll_until("a PairingCodeError to reach the bus", || {
collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_)))
})
.await;
let events = collector.events();
let dispatched = events
.iter()
.find_map(|e| match &**e {
Event::PairingCodeError(e) => Some(e.clone()),
_ => None,
})
.expect("just polled for it");
assert_eq!(
dispatched.rejection, None,
"a local validation failure never reached the server"
);
assert_eq!(dispatched.backoff, None);
assert!(
dispatched.error.contains("too short"),
"the message should say what failed, got: {}",
dispatched.error
);
}
#[test]
fn pair_error_request_failed_preserves_iq_source() {
let iq = IqError::ServerError {
code: 400,
text: "bad-request".into(),
error_type: None,
backoff: None,
};
let pe: PairError = iq.into();
let src = std::error::Error::source(&pe).expect("source preserved");
let downcast = src.downcast_ref::<IqError>().expect("downcasts to IqError");
assert!(matches!(downcast, IqError::ServerError { code: 400, .. }));
}
#[test]
fn pair_error_paircode_walks_to_curve_error() {
use wacore::libsignal::protocol::CurveError;
let pe: PairError =
PairCodeError::EphemeralKeyAgreement(CurveError::NoKeyTypeIdentifier).into();
assert_eq!(pe.to_string(), "ephemeral key agreement failed");
let src = std::error::Error::source(&pe).expect("source preserved");
let pce = src
.downcast_ref::<PairCodeError>()
.expect("downcasts to PairCodeError");
assert!(matches!(pce, PairCodeError::EphemeralKeyAgreement(_)));
let curve = std::error::Error::source(pce)
.expect("inner source preserved")
.downcast_ref::<CurveError>()
.expect("downcasts to CurveError");
assert!(matches!(curve, CurveError::NoKeyTypeIdentifier));
}
use crate::test_utils::{create_iq_test_client, create_test_client, poll_until};
use wacore::libsignal::protocol::KeyPair;
use wacore_binary::Node;
use wacore_binary::builder::NodeBuilder;
fn primary_hello_notif(reg_ref: &[u8]) -> Node {
NodeBuilder::new("notification")
.attr("type", "link_code_companion_reg")
.attr("from", "s.whatsapp.net")
.children([NodeBuilder::new("link_code_companion_reg")
.attr("stage", "primary_hello")
.children([
NodeBuilder::new("link_code_pairing_wrapped_primary_ephemeral_pub")
.bytes(vec![7u8; 80])
.build(),
NodeBuilder::new("primary_identity_pub")
.bytes(vec![9u8; 32])
.build(),
NodeBuilder::new("link_code_pairing_ref")
.bytes(reg_ref.to_vec())
.build(),
])
.build()])
.build()
}
fn refresh_code_notif(reg_ref: &[u8], force_manual: Option<bool>) -> Node {
let mut reg = NodeBuilder::new("link_code_companion_reg").attr("stage", "refresh_code");
if let Some(f) = force_manual {
reg = reg.attr("force_manual_refresh", if f { "true" } else { "false" });
}
NodeBuilder::new("notification")
.attr("type", "link_code_companion_reg")
.attr("from", "s.whatsapp.net")
.children([reg
.children([NodeBuilder::new("link_code_pairing_ref")
.bytes(reg_ref.to_vec())
.build()])
.build()])
.build()
}
async fn set_waiting(client: &Arc<Client>, pairing_ref: Vec<u8>, ts: i64, count: u32) {
*client.pair_code_state.lock().await = PairCodeState::WaitingForPhoneConfirmation {
pairing_ref,
phone_jid: "15551234567".to_string(),
pair_code: "ABCD1234".to_string(),
ephemeral_keypair: Box::new(KeyPair::generate(
&mut rand::make_rng::<rand::rngs::StdRng>(),
)),
code_generation_ts: ts,
primary_hello_attempt_count: count,
};
}
fn adv(client: &Arc<Client>) -> [u8; 32] {
client
.persistence_manager
.get_device_snapshot()
.adv_secret_key
}
async fn is_waiting(client: &Arc<Client>) -> bool {
matches!(
&*client.pair_code_state.lock().await,
PairCodeState::WaitingForPhoneConfirmation { .. }
)
}
async fn attempt_count(client: &Arc<Client>) -> Option<u32> {
match &*client.pair_code_state.lock().await {
PairCodeState::WaitingForPhoneConfirmation {
primary_hello_attempt_count,
..
} => Some(*primary_hello_attempt_count),
_ => None,
}
}
#[tokio::test]
async fn primary_hello_rejects_mismatched_ref() {
let client = create_test_client().await;
set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
let adv_before = adv(&client);
let notif = primary_hello_notif(&[9, 9, 9, 9]);
let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
assert!(!handled, "mismatched ref must be rejected");
assert_eq!(
adv(&client),
adv_before,
"no stage-2 crypto on ref mismatch"
);
assert!(
is_waiting(&client).await,
"state must be preserved so a later valid primary_hello can complete"
);
assert_eq!(
attempt_count(&client).await,
Some(0),
"a ref-mismatched notification must not burn a retry slot"
);
}
#[tokio::test]
async fn stale_mismatched_hellos_do_not_block_the_valid_one() {
let client = create_test_client().await;
let pairing_ref = vec![1, 2, 3, 4];
set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
let adv_before = adv(&client);
for _ in 0..(PairCodeUtils::max_primary_hello_attempts() + 2) {
let bad = primary_hello_notif(&[9, 9, 9, 9]);
let _ = handle_pair_code_notification(&client, &bad.as_node_ref()).await;
}
assert_eq!(
attempt_count(&client).await,
Some(0),
"mismatched hellos must leave the attempt count untouched"
);
let good = primary_hello_notif(&pairing_ref);
let _ = handle_pair_code_notification(&client, &good.as_node_ref()).await;
poll_until(
"the genuine primary_hello to still reach stage 2 after stale mismatches",
|| adv(&client) != adv_before,
)
.await;
}
#[tokio::test]
async fn primary_hello_rejects_expired_code() {
let client = create_test_client().await;
let pairing_ref = vec![1, 2, 3, 4];
let stale_ts =
wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 20);
set_waiting(&client, pairing_ref.clone(), stale_ts, 0).await;
let adv_before = adv(&client);
let notif = primary_hello_notif(&pairing_ref);
let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
assert!(
!handled,
"primary_hello for an expired code must be rejected"
);
assert_eq!(
adv(&client),
adv_before,
"no stage-2 crypto on an expired code"
);
assert_eq!(
attempt_count(&client).await,
Some(0),
"an expired-code notification must not burn a retry slot"
);
}
#[tokio::test]
async fn primary_hello_rejects_beyond_max_attempts() {
let client = create_test_client().await;
let pairing_ref = vec![1, 2, 3, 4];
set_waiting(
&client,
pairing_ref.clone(),
wacore::time::now_secs(),
PairCodeUtils::max_primary_hello_attempts(),
)
.await;
let adv_before = adv(&client);
let notif = primary_hello_notif(&pairing_ref);
let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
assert!(!handled, "the attempt past the cap must be rejected");
assert_eq!(
adv(&client),
adv_before,
"no stage-2 crypto once the per-code attempt cap is exhausted"
);
assert_eq!(
attempt_count(&client).await,
Some(PairCodeUtils::max_primary_hello_attempts()),
"a rejected over-cap attempt must not push the counter past the max"
);
}
#[tokio::test]
async fn primary_hello_valid_retry_reaches_stage2() {
let client = create_test_client().await;
let pairing_ref = vec![1, 2, 3, 4];
set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 2).await;
let adv_before = adv(&client);
let notif = primary_hello_notif(&pairing_ref);
let _ = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
poll_until(
"a valid in-window retry to reach stage 2 and rotate the adv secret",
|| adv(&client) != adv_before,
)
.await;
}
#[tokio::test]
async fn refresh_code_matching_ref_dispatches_event() {
let client = create_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let pairing_ref = vec![5, 6, 7, 8];
set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
let notif = refresh_code_notif(&pairing_ref, Some(true));
let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
assert!(handled, "a matching refresh_code should be handled");
let events = collector.events();
assert!(
events
.iter()
.any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if r.force_manual)),
"expected PairingCodeRefresh{{force_manual:true}}, got: {events:?}"
);
}
#[tokio::test]
async fn refresh_code_without_force_manual_defaults_false() {
let client = create_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let pairing_ref = vec![5, 6, 7, 8];
set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
let notif = refresh_code_notif(&pairing_ref, None);
let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
assert!(handled, "a matching refresh_code should be handled");
assert!(
collector.events().iter().any(|e| matches!(
&**e,
Event::PairingCodeRefresh(r) if !r.force_manual
)),
"absent force_manual_refresh must dispatch force_manual: false"
);
}
#[tokio::test]
async fn refresh_code_mismatched_ref_is_ignored() {
let client = create_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
set_waiting(&client, vec![5, 6, 7, 8], wacore::time::now_secs(), 0).await;
let notif = refresh_code_notif(&[1, 1, 1, 1], None);
let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
assert!(!handled, "a non-matching refresh_code must be ignored");
assert!(
collector.events().is_empty(),
"no event should fire for a refresh_code with an unknown ref"
);
}
async fn answer_companion_hello(
client: &Arc<Client>,
transport: &Arc<crate::transport::mock::CapturingMockTransport>,
frame: usize,
pairing_ref: &[u8],
) {
let hello = crate::test_utils::decode_sent_iq(transport, frame).await;
let id = hello
.get()
.attrs()
.optional_string("id")
.expect("companion_hello carries an id")
.into_owned();
let response = NodeBuilder::new("iq")
.attrs([
("from", "s.whatsapp.net".to_string()),
("type", "result".to_string()),
("id", id.clone()),
])
.children([NodeBuilder::new("link_code_companion_reg")
.attr("stage", "companion_hello")
.children([NodeBuilder::new("link_code_pairing_ref")
.bytes(pairing_ref.to_vec())
.build()])
.build()])
.build();
crate::test_utils::answer_iq(client, &id, &response).await;
}
fn options() -> PairCodeOptions {
PairCodeOptions {
phone_number: "15551234567".to_string(),
..Default::default()
}
}
async fn answer_companion_finish(
client: &Arc<Client>,
transport: &Arc<crate::transport::mock::CapturingMockTransport>,
frame: usize,
error: Option<(u16, &str)>,
) {
let finish = crate::test_utils::decode_sent_iq(transport, frame).await;
let id = finish
.get()
.attrs()
.optional_string("id")
.expect("companion_finish carries an id")
.into_owned();
let mut response = NodeBuilder::new("iq").attrs([
("from", "s.whatsapp.net".to_string()),
("id", id.clone()),
(
"type",
if error.is_some() { "error" } else { "result" }.to_string(),
),
]);
if let Some((code, text)) = error {
response = response.children([NodeBuilder::new("error")
.attrs([("code", code.to_string()), ("text", text.to_string())])
.build()]);
}
crate::test_utils::answer_iq(client, &id, &response.build()).await;
}
async fn reach_stage_two(
client: &Arc<Client>,
transport: &Arc<crate::transport::mock::CapturingMockTransport>,
pairing_ref: &[u8],
) {
set_waiting(client, pairing_ref.to_vec(), wacore::time::now_secs(), 0).await;
let notif = primary_hello_notif(pairing_ref);
assert!(handle_pair_code_notification(client, ¬if.as_node_ref()).await);
poll_until("companion_finish to reach the transport", || {
!transport.sent().is_empty()
})
.await;
}
#[tokio::test(start_paused = true)]
async fn an_accepted_companion_finish_keeps_the_flow_open() {
let (client, transport) = create_iq_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let pairing_ref = vec![1, 2, 3, 4];
reach_stage_two(&client, &transport, &pairing_ref).await;
let adv_after_stage_two = adv(&client);
answer_companion_finish(&client, &transport, 0, None).await;
advance_past(PairCodeUtils::companion_finish_iq_timeout()).await;
for _ in 0..64 {
tokio::task::yield_now().await;
}
assert!(
is_waiting(&client).await,
"an accepted bundle leaves pair-success still due"
);
assert_eq!(
adv(&client),
adv_after_stage_two,
"the secret pair-success will verify against must survive"
);
assert!(
!collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_))),
"nothing failed, so nothing may be reported"
);
}
#[tokio::test]
async fn a_refused_companion_finish_reports_the_rejection() {
let (client, transport) = create_iq_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let pairing_ref = vec![1, 2, 3, 4];
reach_stage_two(&client, &transport, &pairing_ref).await;
let adv_after_stage_two = adv(&client);
answer_companion_finish(&client, &transport, 0, Some((400, "bad-request"))).await;
poll_until("the refusal to reach the consumer", || {
collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_)))
})
.await;
let reported = collector
.events()
.iter()
.find_map(|e| match &**e {
Event::PairingCodeError(e) => Some(e.clone()),
_ => None,
})
.expect("the refusal was just observed");
assert_eq!(
reported.rejection,
Some(PairCodeRejection::BadRequest),
"the consumer must be able to branch on the status, not the message"
);
assert!(
!is_waiting(&client).await,
"a refused flow must free the slot so a replacement can be requested"
);
assert_ne!(
adv(&client),
adv_after_stage_two,
"the secret this dead flow rotated must not outlive it"
);
}
#[tokio::test]
async fn a_refusal_for_a_replaced_flow_is_not_reported() {
let (client, _transport) = create_iq_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
set_waiting(&client, vec![9, 9, 9, 9], wacore::time::now_secs(), 0).await;
let adv_of_replacement = adv(&client);
report_stage_two_failure(
&client,
&[1, 2, 3, 4],
1,
IqError::ServerError {
code: 500,
text: "internal-server-error".to_string(),
error_type: None,
backoff: None,
},
)
.await;
assert!(
!collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_))),
"the replacement flow has not failed and must not be reported as failed"
);
assert_eq!(
adv(&client),
adv_of_replacement,
"the replacement's adv secret must survive"
);
assert!(is_waiting(&client).await, "the replacement keeps the slot");
set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 2).await;
let adv_of_retry = adv(&client);
report_stage_two_failure(
&client,
&[1, 2, 3, 4],
1,
IqError::ServerError {
code: 400,
text: "bad-request".to_string(),
error_type: None,
backoff: None,
},
)
.await;
assert!(
!collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_))),
"an earlier attempt's refusal must not report the retry that replaced it"
);
assert_eq!(
adv(&client),
adv_of_retry,
"the retry's adv secret must survive"
);
assert!(is_waiting(&client).await, "the retry keeps the slot");
}
#[tokio::test(start_paused = true)]
async fn an_unanswered_companion_finish_leaves_the_timer_in_charge() {
let (client, transport) = create_iq_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
reach_stage_two(&client, &transport, &[1, 2, 3, 4]).await;
advance_past(PairCodeUtils::companion_finish_iq_timeout()).await;
for _ in 0..64 {
tokio::task::yield_now().await;
}
assert!(
is_waiting(&client).await,
"the IQ giving up does not end the flow"
);
assert!(
!collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_))),
"silence is not a refusal and must not be reported as one"
);
advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
poll_until("the regeneration request", || {
collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if !r.force_manual))
})
.await;
}
#[tokio::test]
async fn cancelling_after_stage_two_gives_up_the_secret_it_derived() {
let (client, transport) = create_iq_test_client().await;
reach_stage_two(&client, &transport, &[1, 2, 3, 4]).await;
let rotated = adv(&client);
client.cancel_pair_code().await;
assert_ne!(
adv(&client),
rotated,
"the cancelled flow's secret must not outlive it"
);
}
#[tokio::test]
async fn cancelling_leaves_a_secret_stage_two_never_touched() {
let (client, _transport) = create_iq_test_client().await;
set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
let before = adv(&client);
client.cancel_pair_code().await;
assert_eq!(adv(&client), before, "no stage 2 ran, so nothing rotated");
*client.pair_code_state.lock().await = PairCodeState::Completed;
let paired = adv(&client);
client.cancel_pair_code().await;
assert_eq!(
adv(&client),
paired,
"a paired device's adv secret signs its own identity"
);
}
#[tokio::test]
async fn pair_with_code_refuses_to_supersede_a_live_code() {
let (client, _transport) = create_iq_test_client().await;
set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
let err = client
.pair_with_code(options())
.await
.expect_err("a second code would strand the one already displayed");
assert!(
matches!(
err,
PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { .. })
),
"expected CodeAlreadyOutstanding, got {err:?}"
);
}
#[tokio::test]
async fn cancel_pair_code_lets_a_replacement_be_requested() {
let (client, transport) = create_iq_test_client().await;
set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
client.cancel_pair_code().await;
let pending = {
let client = client.clone();
tokio::spawn(async move { client.pair_with_code(options()).await })
};
answer_companion_hello(&client, &transport, 0, b"3@2:fresh").await;
let code = pending
.await
.expect("the pair-code task should not panic")
.expect("a cancelled flow leaves the way clear");
assert!(PairCodeUtils::validate_code(&code));
}
#[tokio::test]
async fn an_expired_code_does_not_block_a_new_one() {
let (client, transport) = create_iq_test_client().await;
let stale =
wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 1);
set_waiting(&client, vec![1, 2, 3, 4], stale, 0).await;
let pending = {
let client = client.clone();
tokio::spawn(async move { client.pair_with_code(options()).await })
};
answer_companion_hello(&client, &transport, 0, b"3@2:fresh").await;
pending
.await
.expect("the pair-code task should not panic")
.expect("an expired code must not block a new request");
}
#[tokio::test]
async fn refresh_code_clears_the_flow_it_asks_to_replace() {
let client = create_test_client().await;
let pairing_ref = vec![5, 6, 7, 8];
set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
let notif = refresh_code_notif(&pairing_ref, Some(true));
assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await);
assert!(
!is_waiting(&client).await,
"a consumer acting on the refresh must not be rejected by the flow it replaces"
);
}
#[tokio::test]
async fn a_request_racing_another_is_refused_too() {
let (client, transport) = create_iq_test_client().await;
let first = {
let client = client.clone();
tokio::spawn(async move { client.pair_with_code(options()).await })
};
poll_until("the first companion_hello to be on the wire", || {
!transport.sent().is_empty()
})
.await;
let second = client.pair_with_code(options()).await;
assert!(
matches!(
second,
Err(PairError::PairCode(
PairCodeError::CodeAlreadyOutstanding { .. }
))
),
"a request in flight already owns the slot, got {second:?}"
);
answer_companion_hello(&client, &transport, 0, b"3@2:first").await;
first
.await
.expect("the pair-code task should not panic")
.expect("the winner still completes");
}
#[tokio::test]
async fn a_rejected_request_frees_the_slot() {
let (client, transport) = create_iq_test_client().await;
let first = {
let client = client.clone();
tokio::spawn(async move { client.pair_with_code(options()).await })
};
let hello = crate::test_utils::decode_sent_iq(&transport, 0).await;
let id = hello
.get()
.attrs()
.optional_string("id")
.expect("companion_hello carries an id")
.into_owned();
let error = NodeBuilder::new("iq")
.attrs([
("from", "s.whatsapp.net".to_string()),
("type", "error".to_string()),
("id", id.clone()),
])
.children([NodeBuilder::new("error")
.attrs([
("code", "400".to_string()),
("text", "bad-request".to_string()),
])
.build()])
.build();
crate::test_utils::answer_iq(&client, &id, &error).await;
first
.await
.expect("the pair-code task should not panic")
.expect_err("the server rejected this one");
let retry = {
let client = client.clone();
tokio::spawn(async move { client.pair_with_code(options()).await })
};
answer_companion_hello(&client, &transport, 1, b"3@2:second").await;
retry
.await
.expect("the pair-code task should not panic")
.expect("a rejected request must not leave the slot taken");
}
async fn advance_past(d: std::time::Duration) {
for _ in 0..64 {
tokio::task::yield_now().await;
}
tokio::time::advance(d + std::time::Duration::from_secs(1)).await;
}
#[tokio::test]
async fn a_claim_is_identified_by_more_than_the_second_it_started_in() {
let (client, transport) = create_iq_test_client().await;
let first = {
let client = client.clone();
tokio::spawn(async move { client.pair_with_code(options()).await })
};
poll_until("the first companion_hello", || !transport.sent().is_empty()).await;
client.cancel_pair_code().await;
let second = {
let client = client.clone();
tokio::spawn(async move { client.pair_with_code(options()).await })
};
poll_until("the replacement's companion_hello", || {
transport.sent().len() >= 2
})
.await;
answer_companion_hello(&client, &transport, 0, b"3@2:first").await;
let stale = first
.await
.expect("the pair-code task should not panic")
.expect_err("the cancelled request must not install its flow");
assert!(
matches!(stale, PairError::PairCode(PairCodeError::Cancelled)),
"expected Cancelled, got {stale:?}"
);
answer_companion_hello(&client, &transport, 1, b"3@2:second").await;
second
.await
.expect("the pair-code task should not panic")
.expect("the replacement owns the slot and must complete");
assert!(
matches!(
&*client.pair_code_state.lock().await,
PairCodeState::WaitingForPhoneConfirmation { pairing_ref, .. }
if pairing_ref.as_slice() == b"3@2:second"
),
"the replacement's flow must be the one left standing"
);
}
#[tokio::test]
async fn a_pending_pair_success_still_owns_the_slot() {
let (client, _transport) = create_iq_test_client().await;
let expired =
wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 1);
set_waiting(&client, vec![1, 2, 3, 4], expired, 1).await;
let err = client
.pair_with_code(options())
.await
.expect_err("a pending link still owns the flow");
assert!(
matches!(
err,
PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { .. })
),
"expected CodeAlreadyOutstanding, got {err:?}"
);
}
#[tokio::test(start_paused = true)]
async fn a_retry_gets_its_own_response_window() {
let (client, transport) = create_iq_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let pairing_ref = vec![1, 2, 3, 4];
set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
let notif = primary_hello_notif(&pairing_ref);
assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await);
poll_until("the first companion_finish", || {
!transport.sent().is_empty()
})
.await;
advance_past(std::time::Duration::from_secs(50)).await;
let retry = primary_hello_notif(&pairing_ref);
assert!(handle_pair_code_notification(&client, &retry.as_node_ref()).await);
poll_until("the second companion_finish", || {
transport.sent().len() >= 2
})
.await;
advance_past(std::time::Duration::from_secs(15)).await;
assert!(
!collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeRefresh(_))),
"the first attempt's timer must not cut the retry's window short"
);
advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
poll_until("the retry's own timeout", || {
collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeRefresh(_)))
})
.await;
}
#[tokio::test]
async fn a_teardown_does_not_leave_the_slot_claimed() {
let (client, _transport) = create_iq_test_client().await;
set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
client.cleanup_connection_state().await;
assert!(
matches!(&*client.pair_code_state.lock().await, PairCodeState::Idle),
"a flow scoped to a dead connection must not outlive it"
);
}
#[tokio::test]
async fn dropping_the_request_hands_the_claim_back() {
let (client, transport) = create_iq_test_client().await;
{
let client = client.clone();
let task = tokio::spawn(async move { client.pair_with_code(options()).await });
poll_until("the companion_hello to be on the wire", || {
!transport.sent().is_empty()
})
.await;
task.abort();
}
poll_until("the abandoned claim to be released", || {
matches!(
client.pair_code_state.try_lock().as_deref(),
Some(PairCodeState::Idle)
)
})
.await;
}
#[tokio::test(start_paused = true)]
async fn a_failed_request_hands_the_claim_back_before_it_returns() {
let (client, _transport) = create_iq_test_client().await;
client.set_connected_for_test(false);
client
.pair_with_code(options())
.await
.expect_err("stage 1 cannot complete while disconnected");
assert!(
matches!(
client.pair_code_state.try_lock().as_deref(),
Some(PairCodeState::Idle)
),
"the slot must be free the moment the error is returned"
);
}
#[tokio::test]
async fn a_withdrawn_claim_stops_being_owned() {
let (client, _transport) = create_iq_test_client().await;
let claim = wacore::pair_code::PairCodeClaim::next();
*client.pair_code_state.lock().await = PairCodeState::RequestingCode {
code_generation_ts: wacore::time::now_secs(),
claim,
};
assert!(client.owns_code_claim(claim).await);
client.cancel_pair_code().await;
assert!(
!client.owns_code_claim(claim).await,
"a cancelled request must not reach the wire"
);
*client.pair_code_state.lock().await = PairCodeState::RequestingCode {
code_generation_ts: wacore::time::now_secs(),
claim: wacore::pair_code::PairCodeClaim::next(),
};
assert!(!client.owns_code_claim(claim).await);
}
#[tokio::test]
async fn primary_hello_returns_before_stage_two_reaches_the_wire() {
let (client, transport) = create_iq_test_client().await;
let pairing_ref = vec![1, 2, 3, 4];
set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
let notif = primary_hello_notif(&pairing_ref);
let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
assert!(handled, "a valid primary_hello is handled");
assert!(
transport.sent().is_empty(),
"the ack must not wait on stage-2 crypto; companion_finish belongs to a later poll"
);
poll_until("companion_finish to reach the transport", || {
!transport.sent().is_empty()
})
.await;
}
#[tokio::test]
async fn a_cancelled_request_does_not_install_its_flow() {
let (client, transport) = create_iq_test_client().await;
let pending = {
let client = client.clone();
tokio::spawn(async move { client.pair_with_code(options()).await })
};
poll_until("the companion_hello to be on the wire", || {
!transport.sent().is_empty()
})
.await;
client.cancel_pair_code().await;
answer_companion_hello(&client, &transport, 0, b"3@2:late").await;
let err = pending
.await
.expect("the pair-code task should not panic")
.expect_err("a cancelled request must not report a usable code");
assert!(
matches!(err, PairError::PairCode(PairCodeError::Cancelled)),
"expected Cancelled, got {err:?}"
);
assert!(
!is_waiting(&client).await,
"the cancelled flow must stay cancelled"
);
}
#[tokio::test]
async fn a_stage_two_task_does_not_answer_for_the_flow_that_replaced_it() {
let (client, transport) = create_iq_test_client().await;
set_waiting(&client, vec![9, 9, 9, 9], wacore::time::now_secs(), 0).await;
let adv_before = adv(&client);
run_stage_two(
client.clone(),
vec![1, 2, 3, 4],
"15551234567".to_string(),
"ABCD1234".to_string(),
KeyPair::generate(&mut rand::make_rng::<rand::rngs::StdRng>()),
vec![7u8; 80],
[9u8; 32],
1,
)
.await;
assert_eq!(
adv(&client),
adv_before,
"the replacement flow's adv secret must survive"
);
assert!(
transport.sent().is_empty(),
"no companion_finish may go out for a ref nobody is holding"
);
}
#[tokio::test(start_paused = true)]
async fn a_primary_hello_that_never_pairs_asks_for_a_new_code() {
let (client, transport) = create_iq_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let pairing_ref = vec![1, 2, 3, 4];
set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
let notif = primary_hello_notif(&pairing_ref);
assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await);
poll_until("companion_finish to reach the transport", || {
!transport.sent().is_empty()
})
.await;
advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
poll_until("the regeneration request", || {
collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if !r.force_manual))
})
.await;
assert!(
!is_waiting(&client).await,
"the abandoned flow must not reject the replacement it just asked for"
);
}
#[tokio::test]
async fn a_stage_two_that_cannot_send_reports_the_failure_at_once() {
let client = create_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let pairing_ref = vec![1, 2, 3, 4];
set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
let notif = primary_hello_notif(&pairing_ref);
assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await);
poll_until("the failure to reach the consumer", || {
collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeError(_)))
})
.await;
assert!(
!is_waiting(&client).await,
"a flow that could not send its bundle must not keep the slot"
);
}
#[tokio::test(start_paused = true)]
async fn pair_success_silences_the_regeneration_timer() {
let (client, transport) = create_iq_test_client().await;
let collector = Arc::new(crate::test_utils::TestEventCollector::default());
client.subscribe_handler(collector.clone()).detach();
let pairing_ref = vec![1, 2, 3, 4];
set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
let notif = primary_hello_notif(&pairing_ref);
assert!(handle_pair_code_notification(&client, ¬if.as_node_ref()).await);
poll_until("companion_finish to reach the transport", || {
!transport.sent().is_empty()
})
.await;
*client.pair_code_state.lock().await = PairCodeState::Completed;
advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
for _ in 0..64 {
tokio::task::yield_now().await;
}
assert!(
!collector
.events()
.iter()
.any(|e| matches!(&**e, Event::PairingCodeRefresh(_))),
"a completed pairing must not ask the consumer for another code"
);
}
#[tokio::test]
async fn unknown_stage_is_ignored_and_preserves_state() {
let client = create_test_client().await;
set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
let notif = NodeBuilder::new("notification")
.attr("type", "link_code_companion_reg")
.attr("from", "s.whatsapp.net")
.children([NodeBuilder::new("link_code_companion_reg")
.attr("stage", "some_future_stage")
.build()])
.build();
let handled = handle_pair_code_notification(&client, ¬if.as_node_ref()).await;
assert!(!handled, "unknown stage must not be treated as handled");
assert!(
is_waiting(&client).await,
"unknown stage must leave the outstanding flow untouched"
);
}
}