1use std::fmt;
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::access::{EgressPool, SessionStore};
15use crate::browser::{BrowserBackend, BrowserBudget};
16use crate::retry::RetryPolicy;
17use crate::robots::RobotsCache;
18use crate::throttle::HostThrottle;
19use crate::transport::HttpFetcher;
20#[cfg(feature = "impersonate")]
21use crate::transport::ImpersonateFetcher;
22
23const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
24const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
25const DEFAULT_REDIRECT_LIMIT: usize = 8;
26const DEFAULT_PER_HOST_INTERVAL: Duration = Duration::from_millis(100);
27const GLOBAL_THROTTLE_KEY: &str = "*global*";
29
30#[derive(Clone)]
38pub struct Client {
39 http: Arc<HttpFetcher>,
40 egress: Arc<EgressPool>,
43 sessions: Arc<SessionStore>,
46 throttle: HostThrottle,
47 global_throttle: Option<HostThrottle>,
49 retry: RetryPolicy,
50 user_agents: Arc<[String]>,
53 enrich: bool,
55 robots: Option<RobotsCache>,
57 browser: Option<Arc<dyn BrowserBackend>>,
60 #[cfg(feature = "impersonate")]
64 impersonate: Option<Arc<ImpersonateFetcher>>,
65 browser_budget: Arc<BrowserBudget>,
68 escalation_budget: Arc<crate::escalation::EscalationBudget>,
74 escalation_enabled: bool,
78}
79
80impl Client {
81 pub fn builder() -> ClientBuilder {
83 ClientBuilder::default()
84 }
85
86 #[must_use]
91 pub fn egress_summary(&self) -> Vec<crate::access::EgressSummary> {
92 self.egress.summary()
93 }
94
95 #[must_use]
99 pub fn session_names(&self) -> Vec<String> {
100 self.sessions.names()
101 }
102
103 #[must_use]
107 pub fn egress_names(&self) -> Vec<String> {
108 self.egress.names()
109 }
110
111 #[must_use]
124 pub fn with_egress_subset(&self, names: &[String]) -> Self {
125 Self {
126 http: Arc::clone(&self.http),
127 egress: Arc::new(self.egress.subset(names)),
128 sessions: Arc::clone(&self.sessions),
129 throttle: self.throttle.clone(),
130 global_throttle: self.global_throttle.clone(),
131 retry: self.retry.clone(),
132 user_agents: Arc::clone(&self.user_agents),
133 enrich: self.enrich,
134 robots: self.robots.clone(),
135 browser: self.browser.clone(),
136 #[cfg(feature = "impersonate")]
137 impersonate: self.impersonate.clone(),
138 browser_budget: Arc::clone(&self.browser_budget),
139 escalation_budget: Arc::clone(&self.escalation_budget),
140 escalation_enabled: self.escalation_enabled,
141 }
142 }
143}
144
145#[derive(Debug, Clone)]
147pub struct RawResponse {
148 pub status: u16,
150 pub final_url: String,
152 pub body: String,
154}
155
156impl fmt::Debug for Client {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 f.debug_struct("Client")
159 .field("throttle", &self.throttle)
160 .field("global_throttle", &self.global_throttle)
161 .field("retry", &self.retry)
162 .field("user_agents", &self.user_agents)
163 .field("enrich", &self.enrich)
164 .field("robots", &self.robots.is_some())
165 .field("browser", &self.browser.is_some())
166 .field("browser_budget", &self.browser_budget)
167 .field("escalation_budget", &self.escalation_budget)
168 .field("escalation_enabled", &self.escalation_enabled)
169 .finish_non_exhaustive()
170 }
171}
172
173pub const BOT_PROTECTED_TAG: &str = "bot-protected";
181
182mod builder;
183mod probe;
184mod util;
185pub use builder::{ClientBuilder, DEFAULT_BROWSER_BUDGET, DEFAULT_ESCALATION_BUDGET};
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::browser::RenderedPage;
191 use crate::check::{MatchKind, UncertainReason};
192 use crate::error::{Error, Result};
193 use crate::site::{HttpMethod, ProtectionKind, Signal, Site, UrlTemplate};
194 use crate::username::Username;
195 use std::time::Instant;
196 use wiremock::matchers::{any, method, path};
197 use wiremock::{Mock, MockServer, ResponseTemplate};
198
199 use crate::test_fixtures::{default_site, test_client};
200
201 fn build_client() -> Client {
202 test_client()
203 }
204
205 fn site_with(server: &MockServer, signals: Vec<Signal>) -> Site {
206 let mut s = default_site("Mock", &format!("{}/{{username}}", server.uri()));
207 s.signals = signals;
208 s
209 }
210
211 fn user() -> Username {
212 Username::new("alice").unwrap()
213 }
214
215 #[tokio::test]
216 async fn regex_check_short_circuits_before_any_request() {
217 let server = MockServer::start().await;
221 Mock::given(any())
222 .respond_with(ResponseTemplate::new(200))
223 .mount(&server)
224 .await;
225 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
226 site.regex_check = Some("^[A-Za-z]{8,}$".into());
228 let outcome = build_client().check(&site, &user()).await;
229 assert_eq!(outcome.kind, MatchKind::Uncertain);
230 assert!(
231 matches!(outcome.reason, Some(UncertainReason::UsernameNotAllowed)),
232 "expected UsernameNotAllowed, got {:?}",
233 outcome.reason,
234 );
235 let recvd = server.received_requests().await.unwrap_or_default();
238 assert_eq!(
239 recvd.len(),
240 0,
241 "regex_check mismatch must skip the HTTP request entirely"
242 );
243 }
244
245 #[tokio::test]
246 async fn geo_constrained_site_with_no_egress_is_geo_unavailable() {
247 let server = MockServer::start().await;
250 Mock::given(any())
251 .respond_with(ResponseTemplate::new(200))
252 .mount(&server)
253 .await;
254 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
255 site.access = crate::access::AccessPolicy {
258 geo: vec![crate::access::CountryCode::new("pl").unwrap()],
259 ..crate::access::AccessPolicy::default()
260 };
261 let outcome = build_client().check(&site, &user()).await;
262 assert_eq!(outcome.kind, MatchKind::Uncertain);
263 assert!(
264 matches!(outcome.reason, Some(UncertainReason::GeoUnavailable)),
265 "expected GeoUnavailable, got {:?}",
266 outcome.reason,
267 );
268 let recvd = server.received_requests().await.unwrap_or_default();
271 assert_eq!(
272 recvd.len(),
273 0,
274 "geo-unavailable must skip the HTTP request entirely"
275 );
276 }
277
278 #[tokio::test]
279 async fn session_headers_are_sent_on_probe() {
280 let server = MockServer::start().await;
283 Mock::given(any())
284 .and(wiremock::matchers::header("cookie", "sessionid=real"))
285 .respond_with(ResponseTemplate::new(200))
286 .mount(&server)
287 .await;
288 let mut headers = std::collections::BTreeMap::new();
289 headers.insert("Cookie".to_string(), "sessionid=real".to_string());
290 let mut store = SessionStore::new();
291 store.insert("acct", crate::access::Session::from_headers(headers));
292 let client = Client::builder()
293 .timeout(Duration::from_secs(2))
294 .min_request_interval(Duration::ZERO)
295 .max_retries(0)
296 .sessions(store)
297 .build()
298 .expect("client builds");
299 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
300 site.access.session = Some("acct".to_string());
301 let outcome = client.check(&site, &user()).await;
302 assert_eq!(
303 outcome.kind,
304 MatchKind::Found,
305 "session cookie should unlock the 200 (got {:?})",
306 outcome.reason,
307 );
308 }
309
310 #[tokio::test]
311 async fn missing_named_session_is_session_required() {
312 let server = MockServer::start().await;
313 Mock::given(any())
314 .respond_with(ResponseTemplate::new(200))
315 .mount(&server)
316 .await;
317 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
318 site.access.session = Some("not-configured".to_string());
320 let outcome = build_client().check(&site, &user()).await;
321 assert_eq!(outcome.kind, MatchKind::Uncertain);
322 assert!(
323 matches!(outcome.reason, Some(UncertainReason::SessionRequired)),
324 "expected SessionRequired, got {:?}",
325 outcome.reason,
326 );
327 let recvd = server.received_requests().await.unwrap_or_default();
328 assert_eq!(
329 recvd.len(),
330 0,
331 "a missing session must skip the request, not probe unauthenticated"
332 );
333 }
334
335 #[cfg(feature = "impersonate")]
336 #[tokio::test]
337 async fn impersonate_routes_pure_tls_fingerprint_site() {
338 let server = MockServer::start().await;
339 Mock::given(any())
340 .respond_with(ResponseTemplate::new(200))
341 .mount(&server)
342 .await;
343 let client = Client::builder()
344 .timeout(Duration::from_secs(2))
345 .min_request_interval(Duration::ZERO)
346 .max_retries(0)
347 .build()
348 .expect("client builds with impersonate");
349 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
350 site.protection = vec![crate::site::ProtectionKind::TlsFingerprint];
353 let outcome = client.check(&site, &user()).await;
354 assert_eq!(
355 outcome.kind,
356 MatchKind::Found,
357 "expected Found (reason {:?})",
358 outcome.reason,
359 );
360 let recvd = server.received_requests().await.expect("received requests");
364 assert_eq!(recvd.len(), 1, "expected exactly one request");
365 let ua = recvd[0]
366 .headers
367 .get("user-agent")
368 .and_then(|v| v.to_str().ok())
369 .unwrap_or("");
370 assert!(
371 ua.contains("Chrome/"),
372 "expected Chrome-shaped UA from wreq, got {ua:?}"
373 );
374 }
375
376 #[tokio::test]
377 async fn regex_check_pass_proceeds_to_probe() {
378 let server = MockServer::start().await;
379 Mock::given(any())
380 .and(path("/alice"))
381 .respond_with(ResponseTemplate::new(200))
382 .mount(&server)
383 .await;
384 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
385 site.regex_check = Some("^[a-z]{3,}$".into());
387 let outcome = build_client().check(&site, &user()).await;
388 assert_eq!(outcome.kind, MatchKind::Found);
389 }
390
391 #[tokio::test]
392 async fn status_signal_reports_found_on_match() {
393 let server = MockServer::start().await;
394 Mock::given(any())
395 .and(path("/alice"))
396 .respond_with(ResponseTemplate::new(200))
397 .mount(&server)
398 .await;
399 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
400 let outcome = build_client().check(&site, &user()).await;
401 assert_eq!(outcome.kind, MatchKind::Found);
402 assert!(outcome.url.ends_with("/alice"));
403 assert!(outcome.reason.is_none());
404 assert_eq!(outcome.evidence, ["HTTP 200 (status_found)"]);
405 }
406
407 #[tokio::test]
408 async fn status_signal_pair_reports_not_found_on_404() {
409 let server = MockServer::start().await;
410 Mock::given(any())
411 .and(path("/alice"))
412 .respond_with(ResponseTemplate::new(404))
413 .mount(&server)
414 .await;
415 let site = site_with(
416 &server,
417 vec![
418 Signal::StatusFound { codes: vec![200] },
419 Signal::StatusNotFound { codes: vec![404] },
420 ],
421 );
422 let outcome = build_client().check(&site, &user()).await;
423 assert_eq!(outcome.kind, MatchKind::NotFound);
424 assert_eq!(outcome.evidence, ["HTTP 404 (status_not_found)"]);
426 }
427
428 #[tokio::test]
429 async fn body_absent_signal_detects_missing_account() {
430 let server = MockServer::start().await;
431 Mock::given(any())
432 .and(path("/alice"))
433 .respond_with(ResponseTemplate::new(200).set_body_string("<h1>Profile not found</h1>"))
434 .mount(&server)
435 .await;
436 let site = site_with(
437 &server,
438 vec![Signal::BodyAbsent {
439 text: "Profile not found".into(),
440 }],
441 );
442 let outcome = build_client().check(&site, &user()).await;
443 assert_eq!(outcome.kind, MatchKind::NotFound);
444 }
445
446 #[tokio::test]
447 async fn body_absent_alone_yields_uncertain_when_marker_missing() {
448 let server = MockServer::start().await;
451 Mock::given(any())
452 .and(path("/alice"))
453 .respond_with(ResponseTemplate::new(200).set_body_string("<h1>Welcome alice</h1>"))
454 .mount(&server)
455 .await;
456 let site = site_with(
457 &server,
458 vec![Signal::BodyAbsent {
459 text: "Profile not found".into(),
460 }],
461 );
462 let outcome = build_client().check(&site, &user()).await;
463 assert_eq!(outcome.kind, MatchKind::Uncertain);
464 }
465
466 #[tokio::test]
467 async fn body_present_plus_absent_resolve_to_found() {
468 let server = MockServer::start().await;
469 Mock::given(any())
470 .and(path("/alice"))
471 .respond_with(
472 ResponseTemplate::new(200)
473 .set_body_string(r#"<div class="profile-card">alice</div>"#),
474 )
475 .mount(&server)
476 .await;
477 let site = site_with(
478 &server,
479 vec![
480 Signal::BodyPresent {
481 text: "profile-card".into(),
482 },
483 Signal::BodyAbsent {
484 text: "Profile not found".into(),
485 },
486 ],
487 );
488 let outcome = build_client().check(&site, &user()).await;
489 assert_eq!(outcome.kind, MatchKind::Found);
490 }
491
492 #[tokio::test]
493 async fn redirect_absent_signal_detects_missing_account() {
494 let server = MockServer::start().await;
495 Mock::given(any())
496 .and(path("/alice"))
497 .respond_with(
498 ResponseTemplate::new(302).insert_header("location", "/login?next=/alice"),
499 )
500 .mount(&server)
501 .await;
502 Mock::given(any())
503 .and(path("/login"))
504 .respond_with(ResponseTemplate::new(200).set_body_string("login page"))
505 .mount(&server)
506 .await;
507 let site = site_with(
508 &server,
509 vec![Signal::RedirectAbsent {
510 fragment: "/login".into(),
511 }],
512 );
513 let outcome = build_client().check(&site, &user()).await;
514 assert_eq!(outcome.kind, MatchKind::NotFound);
515 }
516
517 #[tokio::test]
518 async fn negative_signal_wins_over_positive() {
519 let server = MockServer::start().await;
524 Mock::given(any())
525 .and(path("/alice"))
526 .respond_with(ResponseTemplate::new(200).set_body_string("Profile not found"))
527 .mount(&server)
528 .await;
529 let site = site_with(
530 &server,
531 vec![
532 Signal::StatusFound { codes: vec![200] },
533 Signal::BodyAbsent {
534 text: "Profile not found".into(),
535 },
536 ],
537 );
538 let outcome = build_client().check(&site, &user()).await;
539 assert_eq!(outcome.kind, MatchKind::NotFound);
540 }
541
542 #[tokio::test]
543 async fn network_failure_yields_uncertain() {
544 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
545 let port = listener.local_addr().unwrap().port();
546 drop(listener);
547
548 let site = Site {
549 name: "Dead".into(),
550 url: UrlTemplate::new(format!("http://127.0.0.1:{port}/{{username}}")).unwrap(),
551 signals: vec![Signal::StatusFound { codes: vec![200] }],
552 known_present: None,
553 known_absent: None,
554 extract: Vec::new(),
555 tags: Vec::new(),
556 request_headers: std::collections::BTreeMap::new(),
557 regex_check: None,
558 engine: None,
559 strip_bad_char: None,
560 request_method: crate::site::HttpMethod::Get,
561 request_body: None,
562 protection: Vec::new(),
563 disabled: false,
564 disabled_reason: None,
565 source: None,
566 popularity: None,
567 access: crate::AccessPolicy::default(),
568 };
569 let client = Client::builder()
570 .timeout(Duration::from_millis(500))
571 .connect_timeout(Duration::from_millis(500))
572 .max_retries(0)
573 .build()
574 .unwrap();
575 let outcome = client.check(&site, &user()).await;
576 assert_eq!(outcome.kind, MatchKind::Uncertain);
577 assert!(outcome.reason.is_some());
578 }
579
580 #[tokio::test]
581 async fn throttle_spaces_consecutive_calls_to_same_host() {
582 let server = MockServer::start().await;
583 Mock::given(any())
584 .and(path("/alice"))
585 .respond_with(ResponseTemplate::new(200))
586 .mount(&server)
587 .await;
588 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
589 let client = Client::builder()
594 .timeout(Duration::from_secs(2))
595 .min_request_interval(Duration::from_millis(300))
596 .build()
597 .unwrap();
598
599 client.check(&site, &user()).await;
600 let started = Instant::now();
601 client.check(&site, &user()).await;
602 let elapsed = started.elapsed();
603 assert!(
604 elapsed >= Duration::from_millis(200),
605 "second probe to the same host should wait ≥200 ms, got {elapsed:?}",
606 );
607 }
608
609 #[tokio::test]
610 async fn builder_overrides_user_agent() {
611 let server = MockServer::start().await;
612 Mock::given(any())
613 .and(path("/alice"))
614 .and(wiremock::matchers::header("user-agent", "adler-test/1.0"))
615 .respond_with(ResponseTemplate::new(200))
616 .mount(&server)
617 .await;
618 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
619 let client = Client::builder()
620 .user_agent("adler-test/1.0")
621 .build()
622 .unwrap();
623 let outcome = client.check(&site, &user()).await;
624 assert_eq!(outcome.kind, MatchKind::Found);
625 }
626
627 #[tokio::test]
628 async fn rate_limit_429_yields_uncertain_with_note() {
629 let server = MockServer::start().await;
630 Mock::given(any())
631 .and(path("/alice"))
632 .respond_with(ResponseTemplate::new(429))
633 .mount(&server)
634 .await;
635 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
636 let outcome = build_client().check(&site, &user()).await;
637 assert_eq!(outcome.kind, MatchKind::Uncertain);
638 assert_eq!(outcome.reason, Some(UncertainReason::RateLimited));
639 }
640
641 #[tokio::test]
642 async fn cloudflare_server_header_yields_uncertain() {
643 let server = MockServer::start().await;
644 Mock::given(any())
645 .and(path("/alice"))
646 .respond_with(ResponseTemplate::new(503).insert_header("server", "cloudflare"))
647 .mount(&server)
648 .await;
649 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
650 let outcome = build_client().check(&site, &user()).await;
651 assert_eq!(outcome.kind, MatchKind::Uncertain);
652 assert_eq!(outcome.reason, Some(UncertainReason::CloudflareChallenge));
653 }
654
655 #[tokio::test]
656 async fn cloudflare_interstitial_in_body_yields_uncertain() {
657 let server = MockServer::start().await;
660 Mock::given(any())
661 .and(path("/alice"))
662 .respond_with(
663 ResponseTemplate::new(200)
664 .set_body_string("<html><head><title>Just a moment...</title></head></html>"),
665 )
666 .mount(&server)
667 .await;
668 let site = site_with(
669 &server,
670 vec![Signal::BodyAbsent {
671 text: "Profile not found".into(),
672 }],
673 );
674 let outcome = build_client().check(&site, &user()).await;
675 assert_eq!(outcome.kind, MatchKind::Uncertain);
676 assert_eq!(outcome.reason, Some(UncertainReason::CloudflareChallenge));
677 }
678
679 #[tokio::test]
680 async fn ban_detection_does_not_fire_on_legitimate_403() {
681 let server = MockServer::start().await;
682 Mock::given(any())
683 .and(path("/alice"))
684 .respond_with(ResponseTemplate::new(403))
685 .mount(&server)
686 .await;
687 let site = site_with(
688 &server,
689 vec![
690 Signal::StatusFound { codes: vec![200] },
691 Signal::StatusNotFound { codes: vec![403] },
692 ],
693 );
694 let outcome = build_client().check(&site, &user()).await;
695 assert_eq!(outcome.kind, MatchKind::NotFound);
697 assert!(outcome.reason.is_none());
698 }
699
700 #[tokio::test]
701 async fn retry_recovers_after_transient_429() {
702 let server = MockServer::start().await;
703 Mock::given(any())
705 .and(path("/alice"))
706 .respond_with(ResponseTemplate::new(429))
707 .up_to_n_times(1)
708 .mount(&server)
709 .await;
710 Mock::given(any())
711 .and(path("/alice"))
712 .respond_with(ResponseTemplate::new(200))
713 .mount(&server)
714 .await;
715 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
716 let client = Client::builder()
717 .timeout(Duration::from_secs(2))
718 .min_request_interval(Duration::ZERO)
719 .max_retries(2)
720 .base_backoff_delay(Duration::from_millis(20))
721 .max_backoff_delay(Duration::from_millis(100))
722 .build()
723 .unwrap();
724 let outcome = client.check(&site, &user()).await;
725 assert_eq!(outcome.kind, MatchKind::Found);
726 assert!(outcome.reason.is_none());
727 }
728
729 #[tokio::test]
730 async fn retry_exhausts_and_returns_uncertain() {
731 let server = MockServer::start().await;
732 Mock::given(any())
733 .and(path("/alice"))
734 .respond_with(ResponseTemplate::new(429))
735 .mount(&server)
736 .await;
737 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
738 let client = Client::builder()
739 .timeout(Duration::from_secs(2))
740 .min_request_interval(Duration::ZERO)
741 .max_retries(2)
742 .base_backoff_delay(Duration::from_millis(10))
743 .max_backoff_delay(Duration::from_millis(50))
744 .build()
745 .unwrap();
746 let outcome = client.check(&site, &user()).await;
747 assert_eq!(outcome.kind, MatchKind::Uncertain);
748 assert_eq!(outcome.reason, Some(UncertainReason::RateLimited));
749 }
750
751 #[tokio::test]
752 async fn retry_does_not_fire_on_network_error() {
753 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
757 let port = listener.local_addr().unwrap().port();
758 drop(listener);
759 let site = Site {
760 name: "Dead".into(),
761 url: UrlTemplate::new(format!("http://127.0.0.1:{port}/{{username}}")).unwrap(),
762 signals: vec![Signal::StatusFound { codes: vec![200] }],
763 known_present: None,
764 known_absent: None,
765 extract: Vec::new(),
766 tags: Vec::new(),
767 request_headers: std::collections::BTreeMap::new(),
768 regex_check: None,
769 engine: None,
770 strip_bad_char: None,
771 request_method: crate::site::HttpMethod::Get,
772 request_body: None,
773 protection: Vec::new(),
774 disabled: false,
775 disabled_reason: None,
776 source: None,
777 popularity: None,
778 access: crate::AccessPolicy::default(),
779 };
780 let client = Client::builder()
781 .timeout(Duration::from_millis(500))
782 .connect_timeout(Duration::from_millis(500))
783 .min_request_interval(Duration::ZERO)
784 .max_retries(3)
785 .base_backoff_delay(Duration::from_secs(60))
786 .build()
787 .unwrap();
788 let started = Instant::now();
789 let outcome = client.check(&site, &user()).await;
790 assert!(started.elapsed() < Duration::from_secs(5));
793 assert_eq!(outcome.kind, MatchKind::Uncertain);
794 assert!(
795 matches!(outcome.reason, Some(UncertainReason::Network(_))),
796 "got {:?}",
797 outcome.reason,
798 );
799 }
800
801 #[tokio::test]
802 async fn rotates_user_agent_per_request() {
803 let server = MockServer::start().await;
807 Mock::given(any())
808 .and(path("/alice"))
809 .and(wiremock::matchers::header("user-agent", "RotatorUA/9.9"))
810 .respond_with(ResponseTemplate::new(200))
811 .mount(&server)
812 .await;
813 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
814 let client = Client::builder()
815 .min_request_interval(Duration::ZERO)
816 .max_retries(0)
817 .rotate_user_agents(vec!["RotatorUA/9.9".into()])
818 .build()
819 .unwrap();
820 let outcome = client.check(&site, &user()).await;
821 assert_eq!(outcome.kind, MatchKind::Found);
822 }
823
824 #[test]
825 fn invalid_proxy_url_fails_build() {
826 let err = Client::builder().proxy("not a url").build().unwrap_err();
827 assert!(matches!(err, Error::HttpSetup { .. }));
828 }
829
830 #[test]
831 fn schemeless_proxy_is_rejected_up_front() {
832 let err = Client::builder().proxy("not-a-url").build().unwrap_err();
834 let Error::HttpSetup { message } = err else {
835 panic!("expected HttpSetup, got {err:?}");
836 };
837 assert!(message.contains("must start with"), "{message}");
838 }
839
840 #[test]
841 fn socks5_proxy_scheme_is_accepted() {
842 assert!(
844 Client::builder()
845 .proxy("socks5://127.0.0.1:9050")
846 .build()
847 .is_ok()
848 );
849 }
850
851 #[tokio::test]
852 async fn global_rps_cap_spaces_requests_across_hosts() {
853 let server = MockServer::start().await;
856 Mock::given(any())
857 .respond_with(ResponseTemplate::new(200))
858 .mount(&server)
859 .await;
860 let site_a = Site {
861 name: "A".into(),
862 url: UrlTemplate::new(format!("{}/a/{{username}}", server.uri())).unwrap(),
863 signals: vec![Signal::StatusFound { codes: vec![200] }],
864 known_present: None,
865 known_absent: None,
866 extract: Vec::new(),
867 tags: Vec::new(),
868 request_headers: std::collections::BTreeMap::new(),
869 regex_check: None,
870 engine: None,
871 strip_bad_char: None,
872 request_method: crate::site::HttpMethod::Get,
873 request_body: None,
874 protection: Vec::new(),
875 disabled: false,
876 disabled_reason: None,
877 source: None,
878 popularity: None,
879 access: crate::AccessPolicy::default(),
880 };
881 let site_b = Site {
882 name: "B".into(),
883 url: UrlTemplate::new(format!("{}/b/{{username}}", server.uri())).unwrap(),
884 signals: vec![Signal::StatusFound { codes: vec![200] }],
885 known_present: None,
886 known_absent: None,
887 extract: Vec::new(),
888 tags: Vec::new(),
889 request_headers: std::collections::BTreeMap::new(),
890 regex_check: None,
891 engine: None,
892 strip_bad_char: None,
893 request_method: crate::site::HttpMethod::Get,
894 request_body: None,
895 protection: Vec::new(),
896 disabled: false,
897 disabled_reason: None,
898 source: None,
899 popularity: None,
900 access: crate::AccessPolicy::default(),
901 };
902 let client = Client::builder()
907 .min_request_interval(Duration::ZERO)
908 .max_retries(0)
909 .max_rps(std::num::NonZeroU32::new(2).unwrap())
910 .build()
911 .unwrap();
912 client.check(&site_a, &user()).await;
915 let started = Instant::now();
916 client.check(&site_b, &user()).await;
917 assert!(
918 started.elapsed() >= Duration::from_millis(350),
919 "global cap should space cross-host requests, got {:?}",
920 started.elapsed(),
921 );
922 }
923
924 #[tokio::test]
925 async fn respect_robots_skips_disallowed_paths() {
926 let server = MockServer::start().await;
927 Mock::given(any())
928 .and(path("/robots.txt"))
929 .respond_with(
930 ResponseTemplate::new(200).set_body_string("User-agent: *\nDisallow: /no"),
931 )
932 .mount(&server)
933 .await;
934 Mock::given(any())
935 .and(path("/no/alice"))
936 .respond_with(ResponseTemplate::new(200))
937 .mount(&server)
938 .await;
939 Mock::given(any())
940 .and(path("/yes/alice"))
941 .respond_with(ResponseTemplate::new(200))
942 .mount(&server)
943 .await;
944 let client = Client::builder()
945 .min_request_interval(Duration::ZERO)
946 .max_retries(0)
947 .respect_robots(true)
948 .build()
949 .unwrap();
950
951 let disallowed = Site {
952 name: "No".into(),
953 url: UrlTemplate::new(format!("{}/no/{{username}}", server.uri())).unwrap(),
954 signals: vec![Signal::StatusFound { codes: vec![200] }],
955 known_present: None,
956 known_absent: None,
957 extract: Vec::new(),
958 tags: Vec::new(),
959 request_headers: std::collections::BTreeMap::new(),
960 regex_check: None,
961 engine: None,
962 strip_bad_char: None,
963 request_method: crate::site::HttpMethod::Get,
964 request_body: None,
965 protection: Vec::new(),
966 disabled: false,
967 disabled_reason: None,
968 source: None,
969 popularity: None,
970 access: crate::AccessPolicy::default(),
971 };
972 let allowed = Site {
973 name: "Yes".into(),
974 url: UrlTemplate::new(format!("{}/yes/{{username}}", server.uri())).unwrap(),
975 signals: vec![Signal::StatusFound { codes: vec![200] }],
976 known_present: None,
977 known_absent: None,
978 extract: Vec::new(),
979 tags: Vec::new(),
980 request_headers: std::collections::BTreeMap::new(),
981 regex_check: None,
982 engine: None,
983 strip_bad_char: None,
984 request_method: crate::site::HttpMethod::Get,
985 request_body: None,
986 protection: Vec::new(),
987 disabled: false,
988 disabled_reason: None,
989 source: None,
990 popularity: None,
991 access: crate::AccessPolicy::default(),
992 };
993
994 let no = client.check(&disallowed, &user()).await;
995 assert_eq!(no.kind, MatchKind::Uncertain);
996 assert_eq!(no.reason, Some(UncertainReason::RobotsDisallowed));
997
998 let yes = client.check(&allowed, &user()).await;
999 assert_eq!(yes.kind, MatchKind::Found);
1000 }
1001
1002 #[tokio::test]
1003 async fn body_read_skipped_when_no_body_signal_needed() {
1004 let server = MockServer::start().await;
1007 Mock::given(any())
1008 .and(path("/alice"))
1009 .respond_with(ResponseTemplate::new(200).set_body_string("Profile not found"))
1010 .mount(&server)
1011 .await;
1012 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1013 let outcome = build_client().check(&site, &user()).await;
1014 assert_eq!(outcome.kind, MatchKind::Found);
1015 }
1016
1017 #[derive(Debug)]
1023 struct RecordingBackend {
1024 page: RenderedPage,
1025 calls: std::sync::atomic::AtomicUsize,
1026 }
1027
1028 impl RecordingBackend {
1029 fn with_page(page: RenderedPage) -> Self {
1030 Self {
1031 page,
1032 calls: std::sync::atomic::AtomicUsize::new(0),
1033 }
1034 }
1035 fn call_count(&self) -> usize {
1036 self.calls.load(std::sync::atomic::Ordering::SeqCst)
1037 }
1038 }
1039
1040 #[async_trait::async_trait]
1041 impl BrowserBackend for RecordingBackend {
1042 async fn fetch(
1043 &self,
1044 _url: &url::Url,
1045 _headers: &std::collections::BTreeMap<String, String>,
1046 _timeout: Duration,
1047 ) -> Result<RenderedPage> {
1048 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1049 Ok(self.page.clone())
1050 }
1051 }
1052
1053 fn site_bot_protected(server: &MockServer) -> Site {
1054 let mut s = site_with(server, vec![Signal::StatusFound { codes: vec![200] }]);
1055 s.tags = vec![BOT_PROTECTED_TAG.into()];
1056 s
1057 }
1058
1059 #[tokio::test]
1060 async fn browser_routes_bot_protected_sites() {
1061 let server = MockServer::start().await;
1064 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1065 status: 200,
1066 final_url: url::Url::parse("https://example.com/alice").unwrap(),
1067 body: "<html></html>".into(),
1068 elapsed_ms: 42,
1069 }));
1070 let client = Client::builder()
1071 .min_request_interval(Duration::ZERO)
1072 .max_retries(0)
1073 .browser(backend.clone())
1074 .build()
1075 .unwrap();
1076 let outcome = client.check(&site_bot_protected(&server), &user()).await;
1077 assert_eq!(outcome.kind, MatchKind::Found);
1078 assert_eq!(backend.call_count(), 1, "browser invoked exactly once");
1079 }
1080
1081 #[tokio::test]
1082 async fn non_bot_protected_sites_skip_browser() {
1083 let server = MockServer::start().await;
1084 Mock::given(any())
1085 .and(path("/alice"))
1086 .respond_with(ResponseTemplate::new(200))
1087 .mount(&server)
1088 .await;
1089 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1090 status: 500, final_url: url::Url::parse("https://x/").unwrap(),
1092 body: String::new(),
1093 elapsed_ms: 0,
1094 }));
1095 let client = Client::builder()
1096 .min_request_interval(Duration::ZERO)
1097 .max_retries(0)
1098 .browser(backend.clone())
1099 .build()
1100 .unwrap();
1101 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1103 let outcome = client.check(&site, &user()).await;
1104 assert_eq!(outcome.kind, MatchKind::Found);
1105 assert_eq!(backend.call_count(), 0, "browser must not be touched");
1106 }
1107
1108 #[tokio::test]
1109 async fn browser_budget_exhaust_yields_uncertain() {
1110 let server = MockServer::start().await;
1111 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1112 status: 200,
1113 final_url: url::Url::parse("https://x/").unwrap(),
1114 body: String::new(),
1115 elapsed_ms: 0,
1116 }));
1117 let client = Client::builder()
1118 .min_request_interval(Duration::ZERO)
1119 .max_retries(0)
1120 .browser(backend.clone())
1121 .browser_budget(1)
1122 .build()
1123 .unwrap();
1124 let site = site_bot_protected(&server);
1125 let first = client.check(&site, &user()).await;
1127 assert_eq!(first.kind, MatchKind::Found);
1128 let second = client.check(&site, &user()).await;
1130 assert_eq!(second.kind, MatchKind::Uncertain);
1131 assert!(matches!(
1132 second.reason,
1133 Some(UncertainReason::BrowserBudget)
1134 ));
1135 assert_eq!(
1136 backend.call_count(),
1137 1,
1138 "second call must not invoke backend"
1139 );
1140 }
1141
1142 #[tokio::test]
1143 async fn browser_failure_surfaces_as_uncertain_browser_failed() {
1144 struct FailingBackend;
1145 #[async_trait::async_trait]
1146 impl BrowserBackend for FailingBackend {
1147 async fn fetch(
1148 &self,
1149 _url: &url::Url,
1150 _headers: &std::collections::BTreeMap<String, String>,
1151 _timeout: Duration,
1152 ) -> Result<RenderedPage> {
1153 Err(Error::BrowserSetup {
1154 message: "simulated crash".into(),
1155 })
1156 }
1157 }
1158 impl std::fmt::Debug for FailingBackend {
1159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1160 f.write_str("FailingBackend")
1161 }
1162 }
1163
1164 let server = MockServer::start().await;
1165 let client = Client::builder()
1166 .min_request_interval(Duration::ZERO)
1167 .max_retries(0)
1168 .browser(Arc::new(FailingBackend))
1169 .build()
1170 .unwrap();
1171 let outcome = client.check(&site_bot_protected(&server), &user()).await;
1172 assert_eq!(outcome.kind, MatchKind::Uncertain);
1173 match outcome.reason {
1174 Some(UncertainReason::BrowserFailed(msg)) => {
1175 assert!(msg.contains("simulated crash"), "got: {msg}");
1176 }
1177 other => panic!("expected BrowserFailed, got {other:?}"),
1178 }
1179 }
1180
1181 #[tokio::test]
1182 async fn status_only_site_uses_head_request() {
1183 let server = MockServer::start().await;
1187 Mock::given(method("HEAD"))
1188 .and(path("/alice"))
1189 .respond_with(ResponseTemplate::new(200))
1190 .mount(&server)
1191 .await;
1192 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1193 let outcome = build_client().check(&site, &user()).await;
1194 assert_eq!(outcome.kind, MatchKind::Found);
1195 let recvd = server.received_requests().await.unwrap_or_default();
1196 assert_eq!(recvd.len(), 1);
1197 assert_eq!(recvd[0].method.as_str(), "HEAD");
1198 }
1199
1200 #[tokio::test]
1201 async fn body_signal_site_uses_get_request() {
1202 let server = MockServer::start().await;
1205 Mock::given(any())
1206 .and(path("/alice"))
1207 .respond_with(ResponseTemplate::new(200).set_body_string("hello alice"))
1208 .mount(&server)
1209 .await;
1210 let site = site_with(
1211 &server,
1212 vec![Signal::BodyPresent {
1213 text: "hello".into(),
1214 }],
1215 );
1216 let outcome = build_client().check(&site, &user()).await;
1217 assert_eq!(outcome.kind, MatchKind::Found);
1218 let recvd = server.received_requests().await.unwrap_or_default();
1219 assert_eq!(recvd[0].method.as_str(), "GET");
1220 }
1221
1222 #[tokio::test]
1223 async fn protection_field_routes_through_browser_like_bot_protected_tag() {
1224 let server = MockServer::start().await;
1229 Mock::given(any())
1230 .respond_with(ResponseTemplate::new(200))
1231 .mount(&server)
1232 .await;
1233 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1234 site.protection = vec![crate::site::ProtectionKind::Cloudflare];
1235 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1237 status: 200,
1238 final_url: url::Url::parse(&format!("{}/alice", server.uri())).unwrap(),
1239 body: String::new(),
1240 elapsed_ms: 0,
1241 }));
1242 let client = Client::builder()
1243 .min_request_interval(Duration::ZERO)
1244 .max_retries(0)
1245 .browser(backend)
1246 .build()
1247 .unwrap();
1248 let outcome = client.check(&site, &user()).await;
1249 assert_eq!(outcome.kind, MatchKind::Found);
1252 let recvd = server.received_requests().await.unwrap_or_default();
1254 assert_eq!(
1255 recvd.len(),
1256 0,
1257 "structured protection must skip the raw HTTP path"
1258 );
1259 }
1260
1261 #[tokio::test]
1262 async fn user_auth_protection_alone_uses_http_session_path() {
1263 let server = MockServer::start().await;
1264 Mock::given(any())
1265 .and(path("/alice"))
1266 .respond_with(ResponseTemplate::new(200))
1267 .mount(&server)
1268 .await;
1269 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1270 status: 500,
1271 final_url: url::Url::parse("https://x/").unwrap(),
1272 body: String::new(),
1273 elapsed_ms: 0,
1274 }));
1275 let client = Client::builder()
1276 .min_request_interval(Duration::ZERO)
1277 .max_retries(0)
1278 .browser(backend.clone())
1279 .build()
1280 .unwrap();
1281 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1282 site.protection = vec![ProtectionKind::UserAuth];
1283
1284 let outcome = client.check(&site, &user()).await;
1285
1286 assert_eq!(outcome.kind, MatchKind::Found);
1287 assert_eq!(
1288 backend.call_count(),
1289 0,
1290 "user-auth alone must not invoke browser"
1291 );
1292 let recvd = server.received_requests().await.unwrap_or_default();
1293 assert_eq!(recvd.len(), 1, "user-auth alone should use raw HTTP");
1294 }
1295
1296 #[tokio::test]
1297 async fn post_method_sends_body_with_username_substituted() {
1298 let server = MockServer::start().await;
1302 Mock::given(method("POST"))
1303 .and(path("/api"))
1304 .respond_with(ResponseTemplate::new(200))
1305 .mount(&server)
1306 .await;
1307 let site = Site {
1312 name: "ApiPost".into(),
1313 url: UrlTemplate::new(format!("{}/api?_={{username}}", server.uri())).unwrap(),
1314 signals: vec![Signal::StatusFound { codes: vec![200] }],
1315 known_present: None,
1316 known_absent: None,
1317 extract: Vec::new(),
1318 tags: Vec::new(),
1319 request_headers: std::collections::BTreeMap::new(),
1320 regex_check: None,
1321 engine: None,
1322 strip_bad_char: None,
1323 request_method: HttpMethod::Post,
1324 request_body: Some(r#"{"name":"{username}"}"#.into()),
1325 protection: Vec::new(),
1326 disabled: false,
1327 disabled_reason: None,
1328 source: None,
1329 popularity: None,
1330 access: crate::AccessPolicy::default(),
1331 };
1332 let outcome = build_client().check(&site, &user()).await;
1333 assert_eq!(outcome.kind, MatchKind::Found);
1334 let recvd = server.received_requests().await.unwrap_or_default();
1335 assert_eq!(recvd.len(), 1);
1336 assert_eq!(recvd[0].method.as_str(), "POST");
1337 let body = String::from_utf8_lossy(&recvd[0].body).to_string();
1338 assert!(body.contains("\"name\":\"alice\""), "body was: {body}");
1339 }
1340
1341 #[tokio::test]
1342 async fn head_405_falls_back_to_get() {
1343 let server = MockServer::start().await;
1346 Mock::given(method("HEAD"))
1347 .and(path("/alice"))
1348 .respond_with(ResponseTemplate::new(405))
1349 .mount(&server)
1350 .await;
1351 Mock::given(any())
1352 .and(path("/alice"))
1353 .respond_with(ResponseTemplate::new(200))
1354 .mount(&server)
1355 .await;
1356 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1357 let outcome = build_client().check(&site, &user()).await;
1358 assert_eq!(outcome.kind, MatchKind::Found);
1359 let recvd = server.received_requests().await.unwrap_or_default();
1360 assert_eq!(recvd.len(), 2);
1361 assert_eq!(recvd[0].method.as_str(), "HEAD");
1362 assert_eq!(recvd[1].method.as_str(), "GET");
1363 }
1364
1365 async fn cloudflare_503_server() -> MockServer {
1374 let server = MockServer::start().await;
1375 Mock::given(any())
1376 .respond_with(ResponseTemplate::new(503).insert_header("server", "cloudflare"))
1377 .mount(&server)
1378 .await;
1379 server
1380 }
1381
1382 #[tokio::test]
1383 async fn http_success_stamps_http_transport_no_escalations() {
1384 let server = MockServer::start().await;
1385 Mock::given(any())
1386 .respond_with(ResponseTemplate::new(200))
1387 .mount(&server)
1388 .await;
1389 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1390 let outcome = build_client().check(&site, &user()).await;
1391 assert_eq!(outcome.kind, MatchKind::Found);
1392 assert_eq!(
1393 outcome.transport,
1394 Some(crate::escalation::TransportTier::Http),
1395 "successful HTTP probe must stamp Http transport"
1396 );
1397 assert_eq!(outcome.escalations, 0, "no escalation on the happy path");
1398 }
1399
1400 #[tokio::test]
1401 async fn escalates_cloudflare_uncertain_to_browser_and_stamps_one() {
1402 let server = cloudflare_503_server().await;
1403 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1405 status: 200,
1406 final_url: url::Url::parse(&format!("{}/alice", server.uri())).unwrap(),
1407 body: String::new(),
1408 elapsed_ms: 5,
1409 }));
1410 let client = Client::builder()
1411 .min_request_interval(Duration::ZERO)
1412 .max_retries(0)
1413 .browser(Arc::clone(&backend) as Arc<dyn BrowserBackend>)
1414 .build()
1415 .unwrap();
1416 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1419 let outcome = client.check(&site, &user()).await;
1420 assert_eq!(
1421 outcome.kind,
1422 MatchKind::Found,
1423 "escalation should flip CF challenge to Found via browser (reason {:?})",
1424 outcome.reason
1425 );
1426 assert_eq!(
1427 outcome.transport,
1428 Some(crate::escalation::TransportTier::Browser),
1429 "escalated outcome must be stamped Browser"
1430 );
1431 assert_eq!(
1432 outcome.escalations, 1,
1433 "exactly one escalation should have fired"
1434 );
1435 assert_eq!(backend.call_count(), 1, "browser invoked exactly once");
1436 }
1437
1438 #[tokio::test]
1439 async fn disable_escalation_leaves_cloudflare_uncertain_untouched() {
1440 let server = cloudflare_503_server().await;
1441 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1442 status: 200,
1443 final_url: url::Url::parse(&format!("{}/alice", server.uri())).unwrap(),
1444 body: String::new(),
1445 elapsed_ms: 0,
1446 }));
1447 let client = Client::builder()
1448 .min_request_interval(Duration::ZERO)
1449 .max_retries(0)
1450 .browser(Arc::clone(&backend) as Arc<dyn BrowserBackend>)
1451 .disable_escalation()
1452 .build()
1453 .unwrap();
1454 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1455 let outcome = client.check(&site, &user()).await;
1456 assert_eq!(outcome.kind, MatchKind::Uncertain);
1457 assert!(matches!(
1458 outcome.reason,
1459 Some(UncertainReason::CloudflareChallenge)
1460 ));
1461 assert_eq!(
1462 outcome.transport,
1463 Some(crate::escalation::TransportTier::Http),
1464 "primary transport must still be stamped"
1465 );
1466 assert_eq!(outcome.escalations, 0);
1467 assert_eq!(
1468 backend.call_count(),
1469 0,
1470 "browser must not be touched when --no-escalation"
1471 );
1472 }
1473
1474 #[tokio::test]
1475 async fn escalation_budget_zero_keeps_browser_untouched() {
1476 let server = cloudflare_503_server().await;
1477 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1478 status: 200,
1479 final_url: url::Url::parse(&format!("{}/alice", server.uri())).unwrap(),
1480 body: String::new(),
1481 elapsed_ms: 0,
1482 }));
1483 let client = Client::builder()
1484 .min_request_interval(Duration::ZERO)
1485 .max_retries(0)
1486 .browser(Arc::clone(&backend) as Arc<dyn BrowserBackend>)
1487 .escalation_budget(0)
1488 .build()
1489 .unwrap();
1490 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1491 let outcome = client.check(&site, &user()).await;
1492 assert_eq!(outcome.kind, MatchKind::Uncertain);
1493 assert!(matches!(
1494 outcome.reason,
1495 Some(UncertainReason::CloudflareChallenge)
1496 ));
1497 assert_eq!(outcome.escalations, 0);
1498 assert_eq!(
1499 backend.call_count(),
1500 0,
1501 "zero budget must deny every escalation"
1502 );
1503 }
1504
1505 #[tokio::test]
1506 async fn escalation_consumes_budget_then_stops() {
1507 let server = cloudflare_503_server().await;
1508 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1509 status: 200,
1510 final_url: url::Url::parse(&format!("{}/alice", server.uri())).unwrap(),
1511 body: String::new(),
1512 elapsed_ms: 0,
1513 }));
1514 let client = Client::builder()
1515 .min_request_interval(Duration::ZERO)
1516 .max_retries(0)
1517 .browser(Arc::clone(&backend) as Arc<dyn BrowserBackend>)
1518 .escalation_budget(1)
1519 .build()
1520 .unwrap();
1521 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1522 let first = client.check(&site, &user()).await;
1524 assert_eq!(first.kind, MatchKind::Found);
1525 assert_eq!(first.escalations, 1);
1526 let second = client.check(&site, &user()).await;
1528 assert_eq!(second.kind, MatchKind::Uncertain);
1529 assert!(matches!(
1530 second.reason,
1531 Some(UncertainReason::CloudflareChallenge)
1532 ));
1533 assert_eq!(second.escalations, 0);
1534 assert_eq!(backend.call_count(), 1, "browser called exactly once total");
1535 }
1536}