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::confidence::ConfidenceReason;
193 use crate::error::{Error, Result};
194 use crate::profile::{EvidenceOrigin, ProfileEvidenceKind};
195 use crate::site::{Extractor, HttpMethod, ProtectionKind, Signal, Site, UrlTemplate};
196 use crate::username::Username;
197 use std::time::Instant;
198 use wiremock::matchers::{any, method, path};
199 use wiremock::{Mock, MockServer, ResponseTemplate};
200
201 use crate::test_fixtures::{default_site, test_client};
202
203 fn build_client() -> Client {
204 test_client()
205 }
206
207 fn site_with(server: &MockServer, signals: Vec<Signal>) -> Site {
208 let mut s = default_site("Mock", &format!("{}/{{username}}", server.uri()));
209 s.signals = signals;
210 s
211 }
212
213 fn tiktok_oembed_site(server: &MockServer) -> Site {
214 let mut site = default_site(
215 "TikTok",
216 &format!(
217 "{}/oembed?url=https://www.tiktok.com/@{{username}}",
218 server.uri()
219 ),
220 );
221 site.signals = vec![
222 Signal::StatusFound { codes: vec![200] },
223 Signal::BodyPresent {
224 text: r#""author_url":"#.into(),
225 },
226 Signal::BodyUsername {
227 text: r#""embed_product_id":"{username}""#.into(),
228 },
229 Signal::StatusNotFound { codes: vec![400] },
230 Signal::BodyAbsent {
231 text: r#""code":400"#.into(),
232 },
233 ];
234 site
235 }
236
237 fn pinterest_oembed_site(server: &MockServer) -> Site {
238 let mut site = default_site(
239 "Pinterest",
240 &format!(
241 "{}/oembed.json?url=https://www.pinterest.com/{{username}}/",
242 server.uri()
243 ),
244 );
245 site.signals = vec![
246 Signal::StatusFound { codes: vec![200] },
247 Signal::BodyUsername {
248 text: r#""author_url":"https://www.pinterest.com/{username}/""#.into(),
249 },
250 Signal::StatusNotFound { codes: vec![404] },
251 ];
252 site
253 }
254
255 fn reddit_oauth_site(server: &MockServer) -> Site {
256 let mut site = default_site(
257 "Reddit",
258 &format!("{}/user/{{username}}/about", server.uri()),
259 );
260 site.signals = vec![
261 Signal::StatusFound { codes: vec![200] },
262 Signal::JsonUsername {
263 pointer: "/data/name".into(),
264 },
265 Signal::StatusNotFound { codes: vec![404] },
266 ];
267 site.access.session = Some("reddit".to_owned());
268 site
269 }
270
271 fn user() -> Username {
272 Username::new("alice").unwrap()
273 }
274
275 #[tokio::test]
276 async fn regex_check_short_circuits_before_any_request() {
277 let server = MockServer::start().await;
281 Mock::given(any())
282 .respond_with(ResponseTemplate::new(200))
283 .mount(&server)
284 .await;
285 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
286 site.regex_check = Some("^[A-Za-z]{8,}$".into());
288 let outcome = build_client().check(&site, &user()).await;
289 assert_eq!(outcome.kind, MatchKind::Uncertain);
290 assert!(
291 matches!(outcome.reason, Some(UncertainReason::UsernameNotAllowed)),
292 "expected UsernameNotAllowed, got {:?}",
293 outcome.reason,
294 );
295 let recvd = server.received_requests().await.unwrap_or_default();
298 assert_eq!(
299 recvd.len(),
300 0,
301 "regex_check mismatch must skip the HTTP request entirely"
302 );
303 }
304
305 #[tokio::test]
306 async fn geo_constrained_site_with_no_egress_is_geo_unavailable() {
307 let server = MockServer::start().await;
310 Mock::given(any())
311 .respond_with(ResponseTemplate::new(200))
312 .mount(&server)
313 .await;
314 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
315 site.access = crate::access::AccessPolicy {
318 geo: vec![crate::access::CountryCode::new("pl").unwrap()],
319 ..crate::access::AccessPolicy::default()
320 };
321 let outcome = build_client().check(&site, &user()).await;
322 assert_eq!(outcome.kind, MatchKind::Uncertain);
323 assert!(
324 matches!(outcome.reason, Some(UncertainReason::GeoUnavailable)),
325 "expected GeoUnavailable, got {:?}",
326 outcome.reason,
327 );
328 let recvd = server.received_requests().await.unwrap_or_default();
331 assert_eq!(
332 recvd.len(),
333 0,
334 "geo-unavailable must skip the HTTP request entirely"
335 );
336 }
337
338 #[tokio::test]
339 async fn session_headers_are_sent_on_probe() {
340 let server = MockServer::start().await;
343 Mock::given(any())
344 .and(wiremock::matchers::header("cookie", "sessionid=real"))
345 .respond_with(ResponseTemplate::new(200))
346 .mount(&server)
347 .await;
348 let mut headers = std::collections::BTreeMap::new();
349 headers.insert("Cookie".to_string(), "sessionid=real".to_string());
350 let mut store = SessionStore::new();
351 store.insert("acct", crate::access::Session::from_headers(headers));
352 let client = Client::builder()
353 .timeout(Duration::from_secs(2))
354 .min_request_interval(Duration::ZERO)
355 .max_retries(0)
356 .sessions(store)
357 .build()
358 .expect("client builds");
359 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
360 site.access.session = Some("acct".to_string());
361 let outcome = client.check(&site, &user()).await;
362 assert_eq!(
363 outcome.kind,
364 MatchKind::Found,
365 "session cookie should unlock the 200 (got {:?})",
366 outcome.reason,
367 );
368 }
369
370 #[tokio::test]
371 async fn live_enriched_result_stamps_evidence_access_metadata() {
372 let server = MockServer::start().await;
373 Mock::given(any())
374 .respond_with(
375 ResponseTemplate::new(200)
376 .set_body_string(r#"<html><h1 class="name">Alice Example</h1></html>"#),
377 )
378 .mount(&server)
379 .await;
380 let client = Client::builder()
381 .timeout(Duration::from_secs(2))
382 .min_request_interval(Duration::ZERO)
383 .max_retries(0)
384 .enrich(true)
385 .build()
386 .expect("client builds");
387 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
388 site.extract = vec![Extractor {
389 field: "name".to_owned(),
390 selector: "h1.name".to_owned(),
391 attr: None,
392 }];
393
394 let outcome = client.check(&site, &user()).await;
395
396 assert_eq!(outcome.kind, MatchKind::Found);
397 assert_eq!(outcome.profile_evidence.len(), 1);
398 let source = &outcome.profile_evidence[0].source;
399 assert!(source.observed_at_ms.is_some());
400 let access = source.access_path.as_ref().expect("access metadata");
401 assert_eq!(access.transport, crate::escalation::TransportTier::Http);
402 assert!(!access.escalated);
403 assert!(!access.authenticated);
404 assert!(!access.session_required);
405 }
406
407 #[tokio::test]
408 async fn authenticated_enriched_result_marks_authenticated_without_session_name() {
409 let server = MockServer::start().await;
410 Mock::given(any())
411 .and(wiremock::matchers::header("cookie", "sessionid=real"))
412 .respond_with(
413 ResponseTemplate::new(200)
414 .set_body_string(r#"<html><h1 class="name">Alice Example</h1></html>"#),
415 )
416 .mount(&server)
417 .await;
418 let mut headers = std::collections::BTreeMap::new();
419 headers.insert("Cookie".to_string(), "sessionid=real".to_string());
420 let mut store = SessionStore::new();
421 store.insert("acct", crate::access::Session::from_headers(headers));
422 let client = Client::builder()
423 .timeout(Duration::from_secs(2))
424 .min_request_interval(Duration::ZERO)
425 .max_retries(0)
426 .sessions(store)
427 .enrich(true)
428 .build()
429 .expect("client builds");
430 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
431 site.access.session = Some("acct".to_owned());
432 site.extract = vec![Extractor {
433 field: "name".to_owned(),
434 selector: "h1.name".to_owned(),
435 attr: None,
436 }];
437
438 let outcome = client.check(&site, &user()).await;
439
440 assert_eq!(outcome.kind, MatchKind::Found);
441 let evidence = outcome.profile_evidence.first().expect("profile evidence");
442 let access = evidence
443 .source
444 .access_path
445 .as_ref()
446 .expect("access metadata");
447 assert!(access.authenticated);
448 let encoded = serde_json::to_string(evidence).unwrap();
449 assert!(!encoded.contains("acct"));
450 assert!(!encoded.contains("sessionid=real"));
451 }
452
453 #[tokio::test]
454 async fn missing_named_session_is_session_required() {
455 let server = MockServer::start().await;
456 Mock::given(any())
457 .respond_with(ResponseTemplate::new(200))
458 .mount(&server)
459 .await;
460 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
461 site.access.session = Some("not-configured".to_string());
463 let outcome = build_client().check(&site, &user()).await;
464 assert_eq!(outcome.kind, MatchKind::Uncertain);
465 assert!(
466 matches!(outcome.reason, Some(UncertainReason::SessionRequired)),
467 "expected SessionRequired, got {:?}",
468 outcome.reason,
469 );
470 let recvd = server.received_requests().await.unwrap_or_default();
471 assert_eq!(
472 recvd.len(),
473 0,
474 "a missing session must skip the request, not probe unauthenticated"
475 );
476 }
477
478 #[cfg(feature = "impersonate")]
479 #[tokio::test]
480 async fn impersonate_routes_pure_tls_fingerprint_site() {
481 let server = MockServer::start().await;
482 Mock::given(any())
483 .respond_with(ResponseTemplate::new(200))
484 .mount(&server)
485 .await;
486 let client = Client::builder()
487 .timeout(Duration::from_secs(2))
488 .min_request_interval(Duration::ZERO)
489 .max_retries(0)
490 .build()
491 .expect("client builds with impersonate");
492 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
493 site.protection = vec![crate::site::ProtectionKind::TlsFingerprint];
496 let outcome = client.check(&site, &user()).await;
497 assert_eq!(
498 outcome.kind,
499 MatchKind::Found,
500 "expected Found (reason {:?})",
501 outcome.reason,
502 );
503 let recvd = server.received_requests().await.expect("received requests");
507 assert_eq!(recvd.len(), 1, "expected exactly one request");
508 let ua = recvd[0]
509 .headers
510 .get("user-agent")
511 .and_then(|v| v.to_str().ok())
512 .unwrap_or("");
513 assert!(
514 ua.contains("Chrome/"),
515 "expected Chrome-shaped UA from wreq, got {ua:?}"
516 );
517 }
518
519 #[tokio::test]
520 async fn regex_check_pass_proceeds_to_probe() {
521 let server = MockServer::start().await;
522 Mock::given(any())
523 .and(path("/alice"))
524 .respond_with(ResponseTemplate::new(200))
525 .mount(&server)
526 .await;
527 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
528 site.regex_check = Some("^[a-z]{3,}$".into());
530 let outcome = build_client().check(&site, &user()).await;
531 assert_eq!(outcome.kind, MatchKind::Found);
532 }
533
534 #[tokio::test]
535 async fn status_signal_reports_found_on_match() {
536 let server = MockServer::start().await;
537 Mock::given(any())
538 .and(path("/alice"))
539 .respond_with(ResponseTemplate::new(200))
540 .mount(&server)
541 .await;
542 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
543 let outcome = build_client().check(&site, &user()).await;
544 assert_eq!(outcome.kind, MatchKind::Found);
545 assert!(outcome.url.ends_with("/alice"));
546 assert!(outcome.reason.is_none());
547 assert_eq!(outcome.evidence, ["HTTP 200 (status_found)"]);
548 }
549
550 #[tokio::test]
551 async fn body_username_signal_creates_exact_username_evidence_without_enrich() {
552 let server = MockServer::start().await;
553 Mock::given(any())
554 .and(path("/johndoe"))
555 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"handle":"johndoe"}"#))
556 .mount(&server)
557 .await;
558 let mut site = site_with(
559 &server,
560 vec![Signal::BodyUsername {
561 text: r#""handle":"{username}""#.into(),
562 }],
563 );
564 site.strip_bad_char = Some(".".into());
565
566 let outcome = build_client()
567 .check(&site, &Username::new("john.doe").unwrap())
568 .await;
569
570 assert_eq!(outcome.kind, MatchKind::Found);
571 assert!(outcome.enrichment.is_empty());
572 assert_eq!(outcome.profile_evidence.len(), 1);
573 let evidence = &outcome.profile_evidence[0];
574 assert_eq!(evidence.kind, ProfileEvidenceKind::Username);
575 assert_eq!(evidence.field, None);
576 assert_eq!(evidence.value, "johndoe");
577 assert_eq!(evidence.source.origin, EvidenceOrigin::Signal);
578 assert!(evidence.source.observed_at_ms.is_some());
579 assert!(
580 evidence
581 .source
582 .access_path
583 .as_ref()
584 .is_some_and(|path| path.transport == crate::TransportTier::Http)
585 );
586 assert!(
587 outcome
588 .confidence
589 .reasons
590 .iter()
591 .any(|reason| matches!(reason, ConfidenceReason::ExactUsernameMatch { count: 1 }))
592 );
593 }
594
595 #[tokio::test]
596 async fn tiktok_oembed_found_emits_exact_username_evidence() {
597 let server = MockServer::start().await;
598 Mock::given(method("GET"))
599 .and(path("/oembed"))
600 .respond_with(ResponseTemplate::new(200).set_body_string(
601 r#"{"author_url":"https://www.tiktok.com/@alice","embed_product_id":"alice"}"#,
602 ))
603 .mount(&server)
604 .await;
605
606 let outcome = build_client()
607 .check(&tiktok_oembed_site(&server), &user())
608 .await;
609
610 assert_eq!(outcome.kind, MatchKind::Found);
611 assert_eq!(outcome.transport, Some(crate::TransportTier::Http));
612 assert_eq!(
613 outcome.evidence,
614 [
615 "HTTP 200 (status_found)",
616 "body contains \"\\\"author_url\\\":\" (body_present)",
617 "body contains \"\\\"embed_product_id\\\":\\\"alice\\\"\" (body_username)"
618 ]
619 );
620 assert_eq!(outcome.profile_evidence.len(), 1);
621 let evidence = &outcome.profile_evidence[0];
622 assert_eq!(evidence.kind, ProfileEvidenceKind::Username);
623 assert_eq!(evidence.value, "alice");
624 assert_eq!(evidence.source.origin, EvidenceOrigin::Signal);
625 assert!(
626 evidence
627 .source
628 .access_path
629 .as_ref()
630 .is_some_and(|path| path.transport == crate::TransportTier::Http)
631 );
632 assert!(
633 outcome
634 .confidence
635 .reasons
636 .iter()
637 .any(|reason| matches!(reason, ConfidenceReason::ExactUsernameMatch { count: 1 }))
638 );
639 }
640
641 #[tokio::test]
642 async fn tiktok_oembed_400_is_not_found_even_with_body_signals_enabled() {
643 let server = MockServer::start().await;
644 Mock::given(method("GET"))
645 .and(path("/oembed"))
646 .respond_with(
647 ResponseTemplate::new(400)
648 .set_body_string(r#"{"code":400,"message":"Cannot get user info"}"#),
649 )
650 .mount(&server)
651 .await;
652
653 let outcome = build_client()
654 .check(&tiktok_oembed_site(&server), &user())
655 .await;
656
657 assert_eq!(outcome.kind, MatchKind::NotFound);
658 assert_eq!(
659 outcome.evidence,
660 [
661 "HTTP 400 (status_not_found)",
662 "body contains \"\\\"code\\\":400\" (body_absent)"
663 ]
664 );
665 assert!(
666 outcome.profile_evidence.is_empty(),
667 "NotFound oEmbed responses must not emit username evidence"
668 );
669 }
670
671 #[tokio::test]
672 async fn pinterest_oembed_found_emits_exact_username_evidence() {
673 let server = MockServer::start().await;
674 Mock::given(method("GET"))
675 .and(path("/oembed.json"))
676 .respond_with(ResponseTemplate::new(200).set_body_string(
677 r#"{"provider_name":"Pinterest","title":"Alice","author_name":"Alice","author_url":"https://www.pinterest.com/alice/"}"#,
678 ))
679 .mount(&server)
680 .await;
681
682 let outcome = build_client()
683 .check(&pinterest_oembed_site(&server), &user())
684 .await;
685
686 assert_eq!(outcome.kind, MatchKind::Found);
687 assert_eq!(outcome.transport, Some(crate::TransportTier::Http));
688 assert_eq!(
689 outcome.evidence,
690 [
691 "HTTP 200 (status_found)",
692 "body contains \"\\\"author_url\\\":\\\"https://www.pinterest.com/alice/\\\"\" (body_username)"
693 ]
694 );
695 assert_eq!(outcome.profile_evidence.len(), 1);
696 let evidence = &outcome.profile_evidence[0];
697 assert_eq!(evidence.kind, ProfileEvidenceKind::Username);
698 assert_eq!(evidence.value, "alice");
699 assert_eq!(evidence.source.origin, EvidenceOrigin::Signal);
700 assert!(
701 evidence
702 .source
703 .access_path
704 .as_ref()
705 .is_some_and(|path| path.transport == crate::TransportTier::Http)
706 );
707 assert!(
708 outcome
709 .confidence
710 .reasons
711 .iter()
712 .any(|reason| matches!(reason, ConfidenceReason::ExactUsernameMatch { count: 1 }))
713 );
714 }
715
716 #[tokio::test]
717 async fn pinterest_oembed_404_is_not_found_without_username_evidence() {
718 let server = MockServer::start().await;
719 Mock::given(method("GET"))
720 .and(path("/oembed.json"))
721 .respond_with(ResponseTemplate::new(404).set_body_string(r#"{"message":"Not found"}"#))
722 .mount(&server)
723 .await;
724
725 let outcome = build_client()
726 .check(&pinterest_oembed_site(&server), &user())
727 .await;
728
729 assert_eq!(outcome.kind, MatchKind::NotFound);
730 assert_eq!(outcome.evidence, ["HTTP 404 (status_not_found)"]);
731 assert!(
732 outcome.profile_evidence.is_empty(),
733 "NotFound oEmbed responses must not emit username evidence"
734 );
735 }
736
737 #[tokio::test]
738 async fn reddit_oauth_json_username_emits_authenticated_exact_evidence() {
739 let server = MockServer::start().await;
740 Mock::given(method("GET"))
741 .and(path("/user/alice/about"))
742 .and(wiremock::matchers::header("authorization", "Bearer token"))
743 .respond_with(
744 ResponseTemplate::new(200)
745 .set_body_string(r#"{"kind":"t2","data":{"name":"alice"}}"#),
746 )
747 .mount(&server)
748 .await;
749 let mut headers = std::collections::BTreeMap::new();
750 headers.insert("Authorization".to_owned(), "Bearer token".to_owned());
751 let mut store = SessionStore::new();
752 store.insert("reddit", crate::access::Session::from_headers(headers));
753 let client = Client::builder()
754 .timeout(std::time::Duration::from_secs(2))
755 .min_request_interval(std::time::Duration::ZERO)
756 .max_retries(0)
757 .sessions(store)
758 .build()
759 .expect("client builds");
760
761 let outcome = client.check(&reddit_oauth_site(&server), &user()).await;
762
763 assert_eq!(outcome.kind, MatchKind::Found);
764 assert_eq!(
765 outcome.evidence,
766 [
767 "HTTP 200 (status_found)",
768 "json pointer \"/data/name\" equals \"alice\" (json_username)"
769 ]
770 );
771 assert_eq!(outcome.profile_evidence.len(), 1);
772 let evidence = &outcome.profile_evidence[0];
773 assert_eq!(evidence.kind, ProfileEvidenceKind::Username);
774 assert_eq!(evidence.value, "alice");
775 assert_eq!(evidence.source.origin, EvidenceOrigin::Signal);
776 let access = evidence
777 .source
778 .access_path
779 .as_ref()
780 .expect("access metadata");
781 assert_eq!(access.transport, crate::TransportTier::Http);
782 assert!(access.authenticated);
783 assert!(!access.session_required);
784 assert!(
785 outcome
786 .confidence
787 .reasons
788 .iter()
789 .any(|reason| matches!(reason, ConfidenceReason::ExactUsernameMatch { count: 1 }))
790 );
791 assert!(
792 outcome
793 .confidence
794 .reasons
795 .iter()
796 .any(|reason| matches!(reason, ConfidenceReason::AuthenticatedAccess))
797 );
798 }
799
800 #[tokio::test]
801 async fn reddit_oauth_404_is_not_found_without_username_evidence() {
802 let server = MockServer::start().await;
803 Mock::given(method("GET"))
804 .and(path("/user/alice/about"))
805 .and(wiremock::matchers::header("authorization", "Bearer token"))
806 .respond_with(ResponseTemplate::new(404).set_body_string(r#"{"message":"Not Found"}"#))
807 .mount(&server)
808 .await;
809 let mut headers = std::collections::BTreeMap::new();
810 headers.insert("Authorization".to_owned(), "Bearer token".to_owned());
811 let mut store = SessionStore::new();
812 store.insert("reddit", crate::access::Session::from_headers(headers));
813 let client = Client::builder()
814 .timeout(std::time::Duration::from_secs(2))
815 .min_request_interval(std::time::Duration::ZERO)
816 .max_retries(0)
817 .sessions(store)
818 .build()
819 .expect("client builds");
820
821 let outcome = client.check(&reddit_oauth_site(&server), &user()).await;
822
823 assert_eq!(outcome.kind, MatchKind::NotFound);
824 assert_eq!(outcome.evidence, ["HTTP 404 (status_not_found)"]);
825 assert!(
826 outcome.profile_evidence.is_empty(),
827 "NotFound OAuth responses must not emit username evidence"
828 );
829 }
830
831 #[tokio::test]
832 async fn generic_body_present_does_not_create_username_evidence() {
833 let server = MockServer::start().await;
834 Mock::given(any())
835 .and(path("/alice"))
836 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"username":"alice"}"#))
837 .mount(&server)
838 .await;
839 let site = site_with(
840 &server,
841 vec![Signal::BodyPresent {
842 text: "username".into(),
843 }],
844 );
845
846 let outcome = build_client().check(&site, &user()).await;
847
848 assert_eq!(outcome.kind, MatchKind::Found);
849 assert!(outcome.profile_evidence.is_empty());
850 assert!(
851 !outcome
852 .confidence
853 .reasons
854 .iter()
855 .any(|reason| matches!(reason, ConfidenceReason::ExactUsernameMatch { .. }))
856 );
857 }
858
859 #[tokio::test]
860 async fn status_signal_pair_reports_not_found_on_404() {
861 let server = MockServer::start().await;
862 Mock::given(any())
863 .and(path("/alice"))
864 .respond_with(ResponseTemplate::new(404))
865 .mount(&server)
866 .await;
867 let site = site_with(
868 &server,
869 vec![
870 Signal::StatusFound { codes: vec![200] },
871 Signal::StatusNotFound { codes: vec![404] },
872 ],
873 );
874 let outcome = build_client().check(&site, &user()).await;
875 assert_eq!(outcome.kind, MatchKind::NotFound);
876 assert_eq!(outcome.evidence, ["HTTP 404 (status_not_found)"]);
878 }
879
880 #[tokio::test]
881 async fn conflicting_not_found_does_not_attach_username_evidence() {
882 let server = MockServer::start().await;
883 Mock::given(any())
884 .and(path("/alice"))
885 .respond_with(
886 ResponseTemplate::new(200)
887 .set_body_string(r#"{"username":"alice","error":"missing"}"#),
888 )
889 .mount(&server)
890 .await;
891 let site = site_with(
892 &server,
893 vec![
894 Signal::BodyUsername {
895 text: r#""username":"{username}""#.into(),
896 },
897 Signal::BodyAbsent {
898 text: r#""error":"missing""#.into(),
899 },
900 ],
901 );
902
903 let outcome = build_client().check(&site, &user()).await;
904
905 assert_eq!(outcome.kind, MatchKind::NotFound);
906 assert!(outcome.profile_evidence.is_empty());
907 }
908
909 #[tokio::test]
910 async fn body_absent_signal_detects_missing_account() {
911 let server = MockServer::start().await;
912 Mock::given(any())
913 .and(path("/alice"))
914 .respond_with(ResponseTemplate::new(200).set_body_string("<h1>Profile not found</h1>"))
915 .mount(&server)
916 .await;
917 let site = site_with(
918 &server,
919 vec![Signal::BodyAbsent {
920 text: "Profile not found".into(),
921 }],
922 );
923 let outcome = build_client().check(&site, &user()).await;
924 assert_eq!(outcome.kind, MatchKind::NotFound);
925 }
926
927 #[tokio::test]
928 async fn body_absent_alone_yields_uncertain_when_marker_missing() {
929 let server = MockServer::start().await;
932 Mock::given(any())
933 .and(path("/alice"))
934 .respond_with(ResponseTemplate::new(200).set_body_string("<h1>Welcome alice</h1>"))
935 .mount(&server)
936 .await;
937 let site = site_with(
938 &server,
939 vec![Signal::BodyAbsent {
940 text: "Profile not found".into(),
941 }],
942 );
943 let outcome = build_client().check(&site, &user()).await;
944 assert_eq!(outcome.kind, MatchKind::Uncertain);
945 }
946
947 #[tokio::test]
948 async fn body_present_plus_absent_resolve_to_found() {
949 let server = MockServer::start().await;
950 Mock::given(any())
951 .and(path("/alice"))
952 .respond_with(
953 ResponseTemplate::new(200)
954 .set_body_string(r#"<div class="profile-card">alice</div>"#),
955 )
956 .mount(&server)
957 .await;
958 let site = site_with(
959 &server,
960 vec![
961 Signal::BodyPresent {
962 text: "profile-card".into(),
963 },
964 Signal::BodyAbsent {
965 text: "Profile not found".into(),
966 },
967 ],
968 );
969 let outcome = build_client().check(&site, &user()).await;
970 assert_eq!(outcome.kind, MatchKind::Found);
971 }
972
973 #[tokio::test]
974 async fn redirect_absent_signal_detects_missing_account() {
975 let server = MockServer::start().await;
976 Mock::given(any())
977 .and(path("/alice"))
978 .respond_with(
979 ResponseTemplate::new(302).insert_header("location", "/login?next=/alice"),
980 )
981 .mount(&server)
982 .await;
983 Mock::given(any())
984 .and(path("/login"))
985 .respond_with(ResponseTemplate::new(200).set_body_string("login page"))
986 .mount(&server)
987 .await;
988 let site = site_with(
989 &server,
990 vec![Signal::RedirectAbsent {
991 fragment: "/login".into(),
992 }],
993 );
994 let outcome = build_client().check(&site, &user()).await;
995 assert_eq!(outcome.kind, MatchKind::NotFound);
996 }
997
998 #[tokio::test]
999 async fn negative_signal_wins_over_positive() {
1000 let server = MockServer::start().await;
1005 Mock::given(any())
1006 .and(path("/alice"))
1007 .respond_with(ResponseTemplate::new(200).set_body_string("Profile not found"))
1008 .mount(&server)
1009 .await;
1010 let site = site_with(
1011 &server,
1012 vec![
1013 Signal::StatusFound { codes: vec![200] },
1014 Signal::BodyAbsent {
1015 text: "Profile not found".into(),
1016 },
1017 ],
1018 );
1019 let outcome = build_client().check(&site, &user()).await;
1020 assert_eq!(outcome.kind, MatchKind::NotFound);
1021 }
1022
1023 #[tokio::test]
1024 async fn network_failure_yields_uncertain() {
1025 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1026 let port = listener.local_addr().unwrap().port();
1027 drop(listener);
1028
1029 let site = Site {
1030 name: "Dead".into(),
1031 url: UrlTemplate::new(format!("http://127.0.0.1:{port}/{{username}}")).unwrap(),
1032 signals: vec![Signal::StatusFound { codes: vec![200] }],
1033 known_present: None,
1034 known_absent: None,
1035 extract: Vec::new(),
1036 tags: Vec::new(),
1037 request_headers: std::collections::BTreeMap::new(),
1038 regex_check: None,
1039 engine: None,
1040 strip_bad_char: None,
1041 request_method: crate::site::HttpMethod::Get,
1042 request_body: None,
1043 protection: Vec::new(),
1044 disabled: false,
1045 disabled_reason: None,
1046 source: None,
1047 popularity: None,
1048 access: crate::AccessPolicy::default(),
1049 };
1050 let client = Client::builder()
1051 .timeout(Duration::from_millis(500))
1052 .connect_timeout(Duration::from_millis(500))
1053 .max_retries(0)
1054 .build()
1055 .unwrap();
1056 let outcome = client.check(&site, &user()).await;
1057 assert_eq!(outcome.kind, MatchKind::Uncertain);
1058 assert!(outcome.reason.is_some());
1059 }
1060
1061 #[tokio::test]
1062 async fn throttle_spaces_consecutive_calls_to_same_host() {
1063 let server = MockServer::start().await;
1064 Mock::given(any())
1065 .and(path("/alice"))
1066 .respond_with(ResponseTemplate::new(200))
1067 .mount(&server)
1068 .await;
1069 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1070 let client = Client::builder()
1075 .timeout(Duration::from_secs(2))
1076 .min_request_interval(Duration::from_millis(300))
1077 .build()
1078 .unwrap();
1079
1080 client.check(&site, &user()).await;
1081 let started = Instant::now();
1082 client.check(&site, &user()).await;
1083 let elapsed = started.elapsed();
1084 assert!(
1085 elapsed >= Duration::from_millis(200),
1086 "second probe to the same host should wait ≥200 ms, got {elapsed:?}",
1087 );
1088 }
1089
1090 #[tokio::test]
1091 async fn builder_overrides_user_agent() {
1092 let server = MockServer::start().await;
1093 Mock::given(any())
1094 .and(path("/alice"))
1095 .and(wiremock::matchers::header("user-agent", "adler-test/1.0"))
1096 .respond_with(ResponseTemplate::new(200))
1097 .mount(&server)
1098 .await;
1099 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1100 let client = Client::builder()
1101 .user_agent("adler-test/1.0")
1102 .build()
1103 .unwrap();
1104 let outcome = client.check(&site, &user()).await;
1105 assert_eq!(outcome.kind, MatchKind::Found);
1106 }
1107
1108 #[tokio::test]
1109 async fn rate_limit_429_yields_uncertain_with_note() {
1110 let server = MockServer::start().await;
1111 Mock::given(any())
1112 .and(path("/alice"))
1113 .respond_with(ResponseTemplate::new(429))
1114 .mount(&server)
1115 .await;
1116 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1117 let outcome = build_client().check(&site, &user()).await;
1118 assert_eq!(outcome.kind, MatchKind::Uncertain);
1119 assert_eq!(outcome.reason, Some(UncertainReason::RateLimited));
1120 }
1121
1122 #[tokio::test]
1123 async fn cloudflare_server_header_yields_uncertain() {
1124 let server = MockServer::start().await;
1125 Mock::given(any())
1126 .and(path("/alice"))
1127 .respond_with(ResponseTemplate::new(503).insert_header("server", "cloudflare"))
1128 .mount(&server)
1129 .await;
1130 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1131 let outcome = build_client().check(&site, &user()).await;
1132 assert_eq!(outcome.kind, MatchKind::Uncertain);
1133 assert_eq!(outcome.reason, Some(UncertainReason::CloudflareChallenge));
1134 }
1135
1136 #[tokio::test]
1137 async fn cloudflare_interstitial_in_body_yields_uncertain() {
1138 let server = MockServer::start().await;
1141 Mock::given(any())
1142 .and(path("/alice"))
1143 .respond_with(
1144 ResponseTemplate::new(200)
1145 .set_body_string("<html><head><title>Just a moment...</title></head></html>"),
1146 )
1147 .mount(&server)
1148 .await;
1149 let site = site_with(
1150 &server,
1151 vec![Signal::BodyAbsent {
1152 text: "Profile not found".into(),
1153 }],
1154 );
1155 let outcome = build_client().check(&site, &user()).await;
1156 assert_eq!(outcome.kind, MatchKind::Uncertain);
1157 assert_eq!(outcome.reason, Some(UncertainReason::CloudflareChallenge));
1158 }
1159
1160 #[tokio::test]
1161 async fn ban_detection_does_not_fire_on_legitimate_403() {
1162 let server = MockServer::start().await;
1163 Mock::given(any())
1164 .and(path("/alice"))
1165 .respond_with(ResponseTemplate::new(403))
1166 .mount(&server)
1167 .await;
1168 let site = site_with(
1169 &server,
1170 vec![
1171 Signal::StatusFound { codes: vec![200] },
1172 Signal::StatusNotFound { codes: vec![403] },
1173 ],
1174 );
1175 let outcome = build_client().check(&site, &user()).await;
1176 assert_eq!(outcome.kind, MatchKind::NotFound);
1178 assert!(outcome.reason.is_none());
1179 }
1180
1181 #[tokio::test]
1182 async fn retry_recovers_after_transient_429() {
1183 let server = MockServer::start().await;
1184 Mock::given(any())
1186 .and(path("/alice"))
1187 .respond_with(ResponseTemplate::new(429))
1188 .up_to_n_times(1)
1189 .mount(&server)
1190 .await;
1191 Mock::given(any())
1192 .and(path("/alice"))
1193 .respond_with(ResponseTemplate::new(200))
1194 .mount(&server)
1195 .await;
1196 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1197 let client = Client::builder()
1198 .timeout(Duration::from_secs(2))
1199 .min_request_interval(Duration::ZERO)
1200 .max_retries(2)
1201 .base_backoff_delay(Duration::from_millis(20))
1202 .max_backoff_delay(Duration::from_millis(100))
1203 .build()
1204 .unwrap();
1205 let outcome = client.check(&site, &user()).await;
1206 assert_eq!(outcome.kind, MatchKind::Found);
1207 assert!(outcome.reason.is_none());
1208 }
1209
1210 #[tokio::test]
1211 async fn retry_exhausts_and_returns_uncertain() {
1212 let server = MockServer::start().await;
1213 Mock::given(any())
1214 .and(path("/alice"))
1215 .respond_with(ResponseTemplate::new(429))
1216 .mount(&server)
1217 .await;
1218 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1219 let client = Client::builder()
1220 .timeout(Duration::from_secs(2))
1221 .min_request_interval(Duration::ZERO)
1222 .max_retries(2)
1223 .base_backoff_delay(Duration::from_millis(10))
1224 .max_backoff_delay(Duration::from_millis(50))
1225 .build()
1226 .unwrap();
1227 let outcome = client.check(&site, &user()).await;
1228 assert_eq!(outcome.kind, MatchKind::Uncertain);
1229 assert_eq!(outcome.reason, Some(UncertainReason::RateLimited));
1230 }
1231
1232 #[tokio::test]
1233 async fn retry_does_not_fire_on_network_error() {
1234 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1238 let port = listener.local_addr().unwrap().port();
1239 drop(listener);
1240 let site = Site {
1241 name: "Dead".into(),
1242 url: UrlTemplate::new(format!("http://127.0.0.1:{port}/{{username}}")).unwrap(),
1243 signals: vec![Signal::StatusFound { codes: vec![200] }],
1244 known_present: None,
1245 known_absent: None,
1246 extract: Vec::new(),
1247 tags: Vec::new(),
1248 request_headers: std::collections::BTreeMap::new(),
1249 regex_check: None,
1250 engine: None,
1251 strip_bad_char: None,
1252 request_method: crate::site::HttpMethod::Get,
1253 request_body: None,
1254 protection: Vec::new(),
1255 disabled: false,
1256 disabled_reason: None,
1257 source: None,
1258 popularity: None,
1259 access: crate::AccessPolicy::default(),
1260 };
1261 let client = Client::builder()
1262 .timeout(Duration::from_millis(500))
1263 .connect_timeout(Duration::from_millis(500))
1264 .min_request_interval(Duration::ZERO)
1265 .max_retries(3)
1266 .base_backoff_delay(Duration::from_secs(60))
1267 .build()
1268 .unwrap();
1269 let started = Instant::now();
1270 let outcome = client.check(&site, &user()).await;
1271 assert!(started.elapsed() < Duration::from_secs(5));
1274 assert_eq!(outcome.kind, MatchKind::Uncertain);
1275 assert!(
1276 matches!(outcome.reason, Some(UncertainReason::Network(_))),
1277 "got {:?}",
1278 outcome.reason,
1279 );
1280 }
1281
1282 #[tokio::test]
1283 async fn rotates_user_agent_per_request() {
1284 let server = MockServer::start().await;
1288 Mock::given(any())
1289 .and(path("/alice"))
1290 .and(wiremock::matchers::header("user-agent", "RotatorUA/9.9"))
1291 .respond_with(ResponseTemplate::new(200))
1292 .mount(&server)
1293 .await;
1294 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1295 let client = Client::builder()
1296 .min_request_interval(Duration::ZERO)
1297 .max_retries(0)
1298 .rotate_user_agents(vec!["RotatorUA/9.9".into()])
1299 .build()
1300 .unwrap();
1301 let outcome = client.check(&site, &user()).await;
1302 assert_eq!(outcome.kind, MatchKind::Found);
1303 }
1304
1305 #[test]
1306 fn invalid_proxy_url_fails_build() {
1307 let err = Client::builder().proxy("not a url").build().unwrap_err();
1308 assert!(matches!(err, Error::HttpSetup { .. }));
1309 }
1310
1311 #[test]
1312 fn schemeless_proxy_is_rejected_up_front() {
1313 let err = Client::builder().proxy("not-a-url").build().unwrap_err();
1315 let Error::HttpSetup { message } = err else {
1316 panic!("expected HttpSetup, got {err:?}");
1317 };
1318 assert!(message.contains("must start with"), "{message}");
1319 }
1320
1321 #[test]
1322 fn socks5_proxy_scheme_is_accepted() {
1323 assert!(
1325 Client::builder()
1326 .proxy("socks5://127.0.0.1:9050")
1327 .build()
1328 .is_ok()
1329 );
1330 }
1331
1332 #[tokio::test]
1333 async fn global_rps_cap_spaces_requests_across_hosts() {
1334 let server = MockServer::start().await;
1337 Mock::given(any())
1338 .respond_with(ResponseTemplate::new(200))
1339 .mount(&server)
1340 .await;
1341 let site_a = Site {
1342 name: "A".into(),
1343 url: UrlTemplate::new(format!("{}/a/{{username}}", server.uri())).unwrap(),
1344 signals: vec![Signal::StatusFound { codes: vec![200] }],
1345 known_present: None,
1346 known_absent: None,
1347 extract: Vec::new(),
1348 tags: Vec::new(),
1349 request_headers: std::collections::BTreeMap::new(),
1350 regex_check: None,
1351 engine: None,
1352 strip_bad_char: None,
1353 request_method: crate::site::HttpMethod::Get,
1354 request_body: None,
1355 protection: Vec::new(),
1356 disabled: false,
1357 disabled_reason: None,
1358 source: None,
1359 popularity: None,
1360 access: crate::AccessPolicy::default(),
1361 };
1362 let site_b = Site {
1363 name: "B".into(),
1364 url: UrlTemplate::new(format!("{}/b/{{username}}", server.uri())).unwrap(),
1365 signals: vec![Signal::StatusFound { codes: vec![200] }],
1366 known_present: None,
1367 known_absent: None,
1368 extract: Vec::new(),
1369 tags: Vec::new(),
1370 request_headers: std::collections::BTreeMap::new(),
1371 regex_check: None,
1372 engine: None,
1373 strip_bad_char: None,
1374 request_method: crate::site::HttpMethod::Get,
1375 request_body: None,
1376 protection: Vec::new(),
1377 disabled: false,
1378 disabled_reason: None,
1379 source: None,
1380 popularity: None,
1381 access: crate::AccessPolicy::default(),
1382 };
1383 let client = Client::builder()
1388 .min_request_interval(Duration::ZERO)
1389 .max_retries(0)
1390 .max_rps(std::num::NonZeroU32::new(2).unwrap())
1391 .build()
1392 .unwrap();
1393 client.check(&site_a, &user()).await;
1396 let started = Instant::now();
1397 client.check(&site_b, &user()).await;
1398 assert!(
1399 started.elapsed() >= Duration::from_millis(350),
1400 "global cap should space cross-host requests, got {:?}",
1401 started.elapsed(),
1402 );
1403 }
1404
1405 #[tokio::test]
1406 async fn respect_robots_skips_disallowed_paths() {
1407 let server = MockServer::start().await;
1408 Mock::given(any())
1409 .and(path("/robots.txt"))
1410 .respond_with(
1411 ResponseTemplate::new(200).set_body_string("User-agent: *\nDisallow: /no"),
1412 )
1413 .mount(&server)
1414 .await;
1415 Mock::given(any())
1416 .and(path("/no/alice"))
1417 .respond_with(ResponseTemplate::new(200))
1418 .mount(&server)
1419 .await;
1420 Mock::given(any())
1421 .and(path("/yes/alice"))
1422 .respond_with(ResponseTemplate::new(200))
1423 .mount(&server)
1424 .await;
1425 let client = Client::builder()
1426 .min_request_interval(Duration::ZERO)
1427 .max_retries(0)
1428 .respect_robots(true)
1429 .build()
1430 .unwrap();
1431
1432 let disallowed = Site {
1433 name: "No".into(),
1434 url: UrlTemplate::new(format!("{}/no/{{username}}", server.uri())).unwrap(),
1435 signals: vec![Signal::StatusFound { codes: vec![200] }],
1436 known_present: None,
1437 known_absent: None,
1438 extract: Vec::new(),
1439 tags: Vec::new(),
1440 request_headers: std::collections::BTreeMap::new(),
1441 regex_check: None,
1442 engine: None,
1443 strip_bad_char: None,
1444 request_method: crate::site::HttpMethod::Get,
1445 request_body: None,
1446 protection: Vec::new(),
1447 disabled: false,
1448 disabled_reason: None,
1449 source: None,
1450 popularity: None,
1451 access: crate::AccessPolicy::default(),
1452 };
1453 let allowed = Site {
1454 name: "Yes".into(),
1455 url: UrlTemplate::new(format!("{}/yes/{{username}}", server.uri())).unwrap(),
1456 signals: vec![Signal::StatusFound { codes: vec![200] }],
1457 known_present: None,
1458 known_absent: None,
1459 extract: Vec::new(),
1460 tags: Vec::new(),
1461 request_headers: std::collections::BTreeMap::new(),
1462 regex_check: None,
1463 engine: None,
1464 strip_bad_char: None,
1465 request_method: crate::site::HttpMethod::Get,
1466 request_body: None,
1467 protection: Vec::new(),
1468 disabled: false,
1469 disabled_reason: None,
1470 source: None,
1471 popularity: None,
1472 access: crate::AccessPolicy::default(),
1473 };
1474
1475 let no = client.check(&disallowed, &user()).await;
1476 assert_eq!(no.kind, MatchKind::Uncertain);
1477 assert_eq!(no.reason, Some(UncertainReason::RobotsDisallowed));
1478
1479 let yes = client.check(&allowed, &user()).await;
1480 assert_eq!(yes.kind, MatchKind::Found);
1481 }
1482
1483 #[tokio::test]
1484 async fn body_read_skipped_when_no_body_signal_needed() {
1485 let server = MockServer::start().await;
1488 Mock::given(any())
1489 .and(path("/alice"))
1490 .respond_with(ResponseTemplate::new(200).set_body_string("Profile not found"))
1491 .mount(&server)
1492 .await;
1493 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1494 let outcome = build_client().check(&site, &user()).await;
1495 assert_eq!(outcome.kind, MatchKind::Found);
1496 }
1497
1498 #[derive(Debug)]
1504 struct RecordingBackend {
1505 page: RenderedPage,
1506 calls: std::sync::atomic::AtomicUsize,
1507 }
1508
1509 impl RecordingBackend {
1510 fn with_page(page: RenderedPage) -> Self {
1511 Self {
1512 page,
1513 calls: std::sync::atomic::AtomicUsize::new(0),
1514 }
1515 }
1516 fn call_count(&self) -> usize {
1517 self.calls.load(std::sync::atomic::Ordering::SeqCst)
1518 }
1519 }
1520
1521 #[async_trait::async_trait]
1522 impl BrowserBackend for RecordingBackend {
1523 async fn fetch(
1524 &self,
1525 _url: &url::Url,
1526 _headers: &std::collections::BTreeMap<String, String>,
1527 _timeout: Duration,
1528 ) -> Result<RenderedPage> {
1529 self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1530 Ok(self.page.clone())
1531 }
1532 }
1533
1534 fn site_bot_protected(server: &MockServer) -> Site {
1535 let mut s = site_with(server, vec![Signal::StatusFound { codes: vec![200] }]);
1536 s.tags = vec![BOT_PROTECTED_TAG.into()];
1537 s
1538 }
1539
1540 #[tokio::test]
1541 async fn browser_routes_bot_protected_sites() {
1542 let server = MockServer::start().await;
1545 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1546 status: 200,
1547 final_url: url::Url::parse("https://example.com/alice").unwrap(),
1548 body: "<html></html>".into(),
1549 elapsed_ms: 42,
1550 }));
1551 let client = Client::builder()
1552 .min_request_interval(Duration::ZERO)
1553 .max_retries(0)
1554 .browser(backend.clone())
1555 .build()
1556 .unwrap();
1557 let outcome = client.check(&site_bot_protected(&server), &user()).await;
1558 assert_eq!(outcome.kind, MatchKind::Found);
1559 assert_eq!(backend.call_count(), 1, "browser invoked exactly once");
1560 }
1561
1562 #[tokio::test]
1563 async fn non_bot_protected_sites_skip_browser() {
1564 let server = MockServer::start().await;
1565 Mock::given(any())
1566 .and(path("/alice"))
1567 .respond_with(ResponseTemplate::new(200))
1568 .mount(&server)
1569 .await;
1570 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1571 status: 500, final_url: url::Url::parse("https://x/").unwrap(),
1573 body: String::new(),
1574 elapsed_ms: 0,
1575 }));
1576 let client = Client::builder()
1577 .min_request_interval(Duration::ZERO)
1578 .max_retries(0)
1579 .browser(backend.clone())
1580 .build()
1581 .unwrap();
1582 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1584 let outcome = client.check(&site, &user()).await;
1585 assert_eq!(outcome.kind, MatchKind::Found);
1586 assert_eq!(backend.call_count(), 0, "browser must not be touched");
1587 }
1588
1589 #[tokio::test]
1590 async fn browser_budget_exhaust_yields_uncertain() {
1591 let server = MockServer::start().await;
1592 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1593 status: 200,
1594 final_url: url::Url::parse("https://x/").unwrap(),
1595 body: String::new(),
1596 elapsed_ms: 0,
1597 }));
1598 let client = Client::builder()
1599 .min_request_interval(Duration::ZERO)
1600 .max_retries(0)
1601 .browser(backend.clone())
1602 .browser_budget(1)
1603 .build()
1604 .unwrap();
1605 let site = site_bot_protected(&server);
1606 let first = client.check(&site, &user()).await;
1608 assert_eq!(first.kind, MatchKind::Found);
1609 let second = client.check(&site, &user()).await;
1611 assert_eq!(second.kind, MatchKind::Uncertain);
1612 assert!(matches!(
1613 second.reason,
1614 Some(UncertainReason::BrowserBudget)
1615 ));
1616 assert_eq!(
1617 backend.call_count(),
1618 1,
1619 "second call must not invoke backend"
1620 );
1621 }
1622
1623 #[tokio::test]
1624 async fn browser_failure_surfaces_as_uncertain_browser_failed() {
1625 struct FailingBackend;
1626 #[async_trait::async_trait]
1627 impl BrowserBackend for FailingBackend {
1628 async fn fetch(
1629 &self,
1630 _url: &url::Url,
1631 _headers: &std::collections::BTreeMap<String, String>,
1632 _timeout: Duration,
1633 ) -> Result<RenderedPage> {
1634 Err(Error::BrowserSetup {
1635 message: "simulated crash".into(),
1636 })
1637 }
1638 }
1639 impl std::fmt::Debug for FailingBackend {
1640 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1641 f.write_str("FailingBackend")
1642 }
1643 }
1644
1645 let server = MockServer::start().await;
1646 let client = Client::builder()
1647 .min_request_interval(Duration::ZERO)
1648 .max_retries(0)
1649 .browser(Arc::new(FailingBackend))
1650 .build()
1651 .unwrap();
1652 let outcome = client.check(&site_bot_protected(&server), &user()).await;
1653 assert_eq!(outcome.kind, MatchKind::Uncertain);
1654 match outcome.reason {
1655 Some(UncertainReason::BrowserFailed(msg)) => {
1656 assert!(msg.contains("simulated crash"), "got: {msg}");
1657 }
1658 other => panic!("expected BrowserFailed, got {other:?}"),
1659 }
1660 }
1661
1662 #[tokio::test]
1663 async fn status_only_site_uses_head_request() {
1664 let server = MockServer::start().await;
1668 Mock::given(method("HEAD"))
1669 .and(path("/alice"))
1670 .respond_with(ResponseTemplate::new(200))
1671 .mount(&server)
1672 .await;
1673 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1674 let outcome = build_client().check(&site, &user()).await;
1675 assert_eq!(outcome.kind, MatchKind::Found);
1676 let recvd = server.received_requests().await.unwrap_or_default();
1677 assert_eq!(recvd.len(), 1);
1678 assert_eq!(recvd[0].method.as_str(), "HEAD");
1679 }
1680
1681 #[tokio::test]
1682 async fn body_signal_site_uses_get_request() {
1683 let server = MockServer::start().await;
1686 Mock::given(any())
1687 .and(path("/alice"))
1688 .respond_with(ResponseTemplate::new(200).set_body_string("hello alice"))
1689 .mount(&server)
1690 .await;
1691 let site = site_with(
1692 &server,
1693 vec![Signal::BodyPresent {
1694 text: "hello".into(),
1695 }],
1696 );
1697 let outcome = build_client().check(&site, &user()).await;
1698 assert_eq!(outcome.kind, MatchKind::Found);
1699 let recvd = server.received_requests().await.unwrap_or_default();
1700 assert_eq!(recvd[0].method.as_str(), "GET");
1701 }
1702
1703 #[tokio::test]
1704 async fn protection_field_routes_through_browser_like_bot_protected_tag() {
1705 let server = MockServer::start().await;
1710 Mock::given(any())
1711 .respond_with(ResponseTemplate::new(200))
1712 .mount(&server)
1713 .await;
1714 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1715 site.protection = vec![crate::site::ProtectionKind::Cloudflare];
1716 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1718 status: 200,
1719 final_url: url::Url::parse(&format!("{}/alice", server.uri())).unwrap(),
1720 body: String::new(),
1721 elapsed_ms: 0,
1722 }));
1723 let client = Client::builder()
1724 .min_request_interval(Duration::ZERO)
1725 .max_retries(0)
1726 .browser(backend)
1727 .build()
1728 .unwrap();
1729 let outcome = client.check(&site, &user()).await;
1730 assert_eq!(outcome.kind, MatchKind::Found);
1733 let recvd = server.received_requests().await.unwrap_or_default();
1735 assert_eq!(
1736 recvd.len(),
1737 0,
1738 "structured protection must skip the raw HTTP path"
1739 );
1740 }
1741
1742 #[tokio::test]
1743 async fn user_auth_protection_alone_uses_http_session_path() {
1744 let server = MockServer::start().await;
1745 Mock::given(any())
1746 .and(path("/alice"))
1747 .respond_with(ResponseTemplate::new(200))
1748 .mount(&server)
1749 .await;
1750 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1751 status: 500,
1752 final_url: url::Url::parse("https://x/").unwrap(),
1753 body: String::new(),
1754 elapsed_ms: 0,
1755 }));
1756 let client = Client::builder()
1757 .min_request_interval(Duration::ZERO)
1758 .max_retries(0)
1759 .browser(backend.clone())
1760 .build()
1761 .unwrap();
1762 let mut site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1763 site.protection = vec![ProtectionKind::UserAuth];
1764
1765 let outcome = client.check(&site, &user()).await;
1766
1767 assert_eq!(outcome.kind, MatchKind::Found);
1768 assert_eq!(
1769 backend.call_count(),
1770 0,
1771 "user-auth alone must not invoke browser"
1772 );
1773 let recvd = server.received_requests().await.unwrap_or_default();
1774 assert_eq!(recvd.len(), 1, "user-auth alone should use raw HTTP");
1775 }
1776
1777 #[tokio::test]
1778 async fn post_method_sends_body_with_username_substituted() {
1779 let server = MockServer::start().await;
1783 Mock::given(method("POST"))
1784 .and(path("/api"))
1785 .respond_with(ResponseTemplate::new(200))
1786 .mount(&server)
1787 .await;
1788 let site = Site {
1793 name: "ApiPost".into(),
1794 url: UrlTemplate::new(format!("{}/api?_={{username}}", server.uri())).unwrap(),
1795 signals: vec![Signal::StatusFound { codes: vec![200] }],
1796 known_present: None,
1797 known_absent: None,
1798 extract: Vec::new(),
1799 tags: Vec::new(),
1800 request_headers: std::collections::BTreeMap::new(),
1801 regex_check: None,
1802 engine: None,
1803 strip_bad_char: None,
1804 request_method: HttpMethod::Post,
1805 request_body: Some(r#"{"name":"{username}"}"#.into()),
1806 protection: Vec::new(),
1807 disabled: false,
1808 disabled_reason: None,
1809 source: None,
1810 popularity: None,
1811 access: crate::AccessPolicy::default(),
1812 };
1813 let outcome = build_client().check(&site, &user()).await;
1814 assert_eq!(outcome.kind, MatchKind::Found);
1815 let recvd = server.received_requests().await.unwrap_or_default();
1816 assert_eq!(recvd.len(), 1);
1817 assert_eq!(recvd[0].method.as_str(), "POST");
1818 let body = String::from_utf8_lossy(&recvd[0].body).to_string();
1819 assert!(body.contains("\"name\":\"alice\""), "body was: {body}");
1820 }
1821
1822 #[tokio::test]
1823 async fn head_405_falls_back_to_get() {
1824 let server = MockServer::start().await;
1827 Mock::given(method("HEAD"))
1828 .and(path("/alice"))
1829 .respond_with(ResponseTemplate::new(405))
1830 .mount(&server)
1831 .await;
1832 Mock::given(any())
1833 .and(path("/alice"))
1834 .respond_with(ResponseTemplate::new(200))
1835 .mount(&server)
1836 .await;
1837 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1838 let outcome = build_client().check(&site, &user()).await;
1839 assert_eq!(outcome.kind, MatchKind::Found);
1840 let recvd = server.received_requests().await.unwrap_or_default();
1841 assert_eq!(recvd.len(), 2);
1842 assert_eq!(recvd[0].method.as_str(), "HEAD");
1843 assert_eq!(recvd[1].method.as_str(), "GET");
1844 }
1845
1846 async fn cloudflare_503_server() -> MockServer {
1855 let server = MockServer::start().await;
1856 Mock::given(any())
1857 .respond_with(ResponseTemplate::new(503).insert_header("server", "cloudflare"))
1858 .mount(&server)
1859 .await;
1860 server
1861 }
1862
1863 #[tokio::test]
1864 async fn http_success_stamps_http_transport_no_escalations() {
1865 let server = MockServer::start().await;
1866 Mock::given(any())
1867 .respond_with(ResponseTemplate::new(200))
1868 .mount(&server)
1869 .await;
1870 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1871 let outcome = build_client().check(&site, &user()).await;
1872 assert_eq!(outcome.kind, MatchKind::Found);
1873 assert_eq!(
1874 outcome.transport,
1875 Some(crate::escalation::TransportTier::Http),
1876 "successful HTTP probe must stamp Http transport"
1877 );
1878 assert_eq!(outcome.escalations, 0, "no escalation on the happy path");
1879 }
1880
1881 #[tokio::test]
1882 async fn escalates_cloudflare_uncertain_to_browser_and_stamps_one() {
1883 let server = cloudflare_503_server().await;
1884 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1886 status: 200,
1887 final_url: url::Url::parse(&format!("{}/alice", server.uri())).unwrap(),
1888 body: String::new(),
1889 elapsed_ms: 5,
1890 }));
1891 let client = Client::builder()
1892 .min_request_interval(Duration::ZERO)
1893 .max_retries(0)
1894 .browser(Arc::clone(&backend) as Arc<dyn BrowserBackend>)
1895 .build()
1896 .unwrap();
1897 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1900 let outcome = client.check(&site, &user()).await;
1901 assert_eq!(
1902 outcome.kind,
1903 MatchKind::Found,
1904 "escalation should flip CF challenge to Found via browser (reason {:?})",
1905 outcome.reason
1906 );
1907 assert_eq!(
1908 outcome.transport,
1909 Some(crate::escalation::TransportTier::Browser),
1910 "escalated outcome must be stamped Browser"
1911 );
1912 assert_eq!(
1913 outcome.escalations, 1,
1914 "exactly one escalation should have fired"
1915 );
1916 assert_eq!(backend.call_count(), 1, "browser invoked exactly once");
1917 }
1918
1919 #[tokio::test]
1920 async fn disable_escalation_leaves_cloudflare_uncertain_untouched() {
1921 let server = cloudflare_503_server().await;
1922 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1923 status: 200,
1924 final_url: url::Url::parse(&format!("{}/alice", server.uri())).unwrap(),
1925 body: String::new(),
1926 elapsed_ms: 0,
1927 }));
1928 let client = Client::builder()
1929 .min_request_interval(Duration::ZERO)
1930 .max_retries(0)
1931 .browser(Arc::clone(&backend) as Arc<dyn BrowserBackend>)
1932 .disable_escalation()
1933 .build()
1934 .unwrap();
1935 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1936 let outcome = client.check(&site, &user()).await;
1937 assert_eq!(outcome.kind, MatchKind::Uncertain);
1938 assert!(matches!(
1939 outcome.reason,
1940 Some(UncertainReason::CloudflareChallenge)
1941 ));
1942 assert_eq!(
1943 outcome.transport,
1944 Some(crate::escalation::TransportTier::Http),
1945 "primary transport must still be stamped"
1946 );
1947 assert_eq!(outcome.escalations, 0);
1948 assert_eq!(
1949 backend.call_count(),
1950 0,
1951 "browser must not be touched when --no-escalation"
1952 );
1953 }
1954
1955 #[tokio::test]
1956 async fn escalation_budget_zero_keeps_browser_untouched() {
1957 let server = cloudflare_503_server().await;
1958 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1959 status: 200,
1960 final_url: url::Url::parse(&format!("{}/alice", server.uri())).unwrap(),
1961 body: String::new(),
1962 elapsed_ms: 0,
1963 }));
1964 let client = Client::builder()
1965 .min_request_interval(Duration::ZERO)
1966 .max_retries(0)
1967 .browser(Arc::clone(&backend) as Arc<dyn BrowserBackend>)
1968 .escalation_budget(0)
1969 .build()
1970 .unwrap();
1971 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
1972 let outcome = client.check(&site, &user()).await;
1973 assert_eq!(outcome.kind, MatchKind::Uncertain);
1974 assert!(matches!(
1975 outcome.reason,
1976 Some(UncertainReason::CloudflareChallenge)
1977 ));
1978 assert_eq!(outcome.escalations, 0);
1979 assert_eq!(
1980 backend.call_count(),
1981 0,
1982 "zero budget must deny every escalation"
1983 );
1984 }
1985
1986 #[tokio::test]
1987 async fn escalation_consumes_budget_then_stops() {
1988 let server = cloudflare_503_server().await;
1989 let backend = Arc::new(RecordingBackend::with_page(RenderedPage {
1990 status: 200,
1991 final_url: url::Url::parse(&format!("{}/alice", server.uri())).unwrap(),
1992 body: String::new(),
1993 elapsed_ms: 0,
1994 }));
1995 let client = Client::builder()
1996 .min_request_interval(Duration::ZERO)
1997 .max_retries(0)
1998 .browser(Arc::clone(&backend) as Arc<dyn BrowserBackend>)
1999 .escalation_budget(1)
2000 .build()
2001 .unwrap();
2002 let site = site_with(&server, vec![Signal::StatusFound { codes: vec![200] }]);
2003 let first = client.check(&site, &user()).await;
2005 assert_eq!(first.kind, MatchKind::Found);
2006 assert_eq!(first.escalations, 1);
2007 let second = client.check(&site, &user()).await;
2009 assert_eq!(second.kind, MatchKind::Uncertain);
2010 assert!(matches!(
2011 second.reason,
2012 Some(UncertainReason::CloudflareChallenge)
2013 ));
2014 assert_eq!(second.escalations, 0);
2015 assert_eq!(backend.call_count(), 1, "browser called exactly once total");
2016 }
2017}