Skip to main content

adler_core/client/
mod.rs

1//! HTTP client wrapping `reqwest`, plus the per-site probe entry point.
2//!
3//! The wrapper exists to keep `reqwest` out of Adler's public API surface.
4//! All knobs that future modules need (timeouts, redirect policy, user agent)
5//! are configured through [`ClientBuilder`]; per-request transient failures
6//! never bubble up as errors — they become
7//! [`MatchKind::Uncertain`](crate::MatchKind::Uncertain) on the returned
8//! outcome.
9
10use 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);
27/// Single fixed key for the global rate limiter (it gates all hosts).
28const GLOBAL_THROTTLE_KEY: &str = "*global*";
29
30/// HTTP client used to probe sites.
31///
32/// Cheap to clone — the underlying `reqwest::Client` is reference-counted
33/// internally, and the throttle is `Arc`-backed, so cloning is the
34/// recommended way to share a client between tasks. Cloned clients share
35/// throttle state, which is what you want: a fan-out scan must not
36/// accidentally exceed a per-host budget by spawning more clients.
37#[derive(Clone)]
38pub struct Client {
39    http: Arc<HttpFetcher>,
40    /// Geo / IP-type egress pool for sites whose `access` policy needs a
41    /// specific proxy. Empty by default → every site uses `http`.
42    egress: Arc<EgressPool>,
43    /// Operator-supplied sessions, keyed by the name a site references
44    /// via `access.session`. Empty by default.
45    sessions: Arc<SessionStore>,
46    throttle: HostThrottle,
47    /// Global RPS cap applied across all hosts. `None` → uncapped.
48    global_throttle: Option<HostThrottle>,
49    retry: RetryPolicy,
50    /// Optional rotation pool. Empty → use the client's fixed User-Agent.
51    /// `Arc<[String]>` so cloning a client per task stays cheap.
52    user_agents: Arc<[String]>,
53    /// Extract profile fields from `Found` pages that declare extractors.
54    enrich: bool,
55    /// When set, skip probes disallowed by the host's `robots.txt`.
56    robots: Option<RobotsCache>,
57    /// Browser backend used for `bot-protected` sites. `None` → those sites
58    /// stay on the raw HTTP path and typically end up `Uncertain`.
59    browser: Option<Arc<dyn BrowserBackend>>,
60    /// TLS-fingerprint-impersonating HTTP client (`wreq`). Built when
61    /// the `impersonate` Cargo feature is on; routes sites whose
62    /// `protection` is exactly `TlsFingerprint`.
63    #[cfg(feature = "impersonate")]
64    impersonate: Option<Arc<ImpersonateFetcher>>,
65    /// Per-scan cap on browser fetches. Shared across `Client::check` calls
66    /// for a single scan, so several tasks compete for the same budget.
67    browser_budget: Arc<BrowserBudget>,
68    /// Per-scan cap on *automatic escalations* from a cheap transport to
69    /// the browser when the cheap path returns
70    /// `Uncertain(CloudflareChallenge | RateLimited)`. Independent of
71    /// `browser_budget` so the pre-tagged `bot-protected` subset and the
72    /// long-tail escalation subset don't fight over the same number.
73    escalation_budget: Arc<crate::escalation::EscalationBudget>,
74    /// Whether automatic escalation runs at all. `false` keeps the cheap
75    /// transport's outcome verbatim — useful for benchmarking the raw
76    /// signals without the access-engine lift on top.
77    escalation_enabled: bool,
78}
79
80impl Client {
81    /// Start configuring a new client.
82    pub fn builder() -> ClientBuilder {
83        ClientBuilder::default()
84    }
85
86    /// Read-only view of the configured egress pool — `(country, kind)`
87    /// for every registered proxy, in the order they were declared.
88    /// Proxy URLs are not surfaced (they typically carry credentials),
89    /// so this is safe to serialise to a JSON response.
90    #[must_use]
91    pub fn egress_summary(&self) -> Vec<crate::access::EgressSummary> {
92        self.egress.summary()
93    }
94
95    /// Names of the configured sessions (sorted lexicographically),
96    /// without any header values. Useful for a UI listing which session
97    /// keys an operator can reference via `access.session` on a site.
98    #[must_use]
99    pub fn session_names(&self) -> Vec<String> {
100        self.sessions.names()
101    }
102
103    /// Names of the configured egresses (in registration order, only
104    /// those that supplied a name). Used by the server to validate
105    /// per-scan `egress_names` against the loaded pool.
106    #[must_use]
107    pub fn egress_names(&self) -> Vec<String> {
108        self.egress.names()
109    }
110
111    /// Returns a new client identical to this one except its egress
112    /// pool is restricted to entries whose `name` matches one of
113    /// `names`. An empty `names` slice is treated as "no filter" and
114    /// returns a clone of the full pool.
115    ///
116    /// Cheap to call repeatedly: all shared state (HTTP clients,
117    /// throttle, sessions, budgets, browser backend, …) is
118    /// `Arc`-cloned so the returned client shares the parent's
119    /// per-scan caps (browser budget, escalation budget, throttle
120    /// state) rather than each subset getting a fresh one. This is the
121    /// right behaviour for a single web-server instance handing out
122    /// per-request clients.
123    #[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/// Raw response data returned by [`Client::fetch`] for diagnostics.
146#[derive(Debug, Clone)]
147pub struct RawResponse {
148    /// HTTP status code.
149    pub status: u16,
150    /// Final URL after redirects.
151    pub final_url: String,
152    /// Decoded response body.
153    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
173/// Registry tag marking a site as bot-protected.
174///
175/// Set on sites behind Cloudflare, `PerimeterX`, datadome,
176/// `hCaptcha`, etc. The routing layer treats it as a hint that
177/// residential egress is likely required; the doctor and
178/// registry-summary surfaces use it to annotate honest-limit audits.
179/// Tags are compared with [`str::eq_ignore_ascii_case`].
180pub 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        // Stand up a mock that would 200 on *anything* — if probe_once
278        // failed to short-circuit on regex mismatch, the username
279        // "alice" (5 chars) would resolve to Found here.
280        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        // The site only accepts usernames of 8+ chars; "alice" is 5.
287        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        // No request should have hit the mock — assert by counting
296        // received_requests on the wiremock server.
297        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        // A mock that would 200 on anything — if the geo gate failed to
308        // short-circuit, "alice" would resolve to Found here.
309        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        // Require a Polish egress; the default client has no egress pool,
316        // so nothing can satisfy it.
317        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        // The site must NOT have been probed — an unreachable geo is not
329        // evidence of absence, and we don't fetch from the wrong location.
330        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        // Only respond 200 when the request carries the session cookie,
341        // so a Found verdict proves the header was actually applied.
342        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        // Names a session the (empty) store doesn't have.
462        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        // Pure TLS-fingerprint protection — exactly the shape that
494        // routes to the impersonate fetcher.
495        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        // wreq's Chrome-134 emulation sets a Chrome-shaped User-Agent —
504        // observable proof that the request came from the impersonate
505        // path and not the default `adler/<version>` HTTP fetcher.
506        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        // Pattern that matches "alice".
529        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        // Only the NotFound-voting signal is cited as evidence.
877        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        // Phase 2 semantics: absence of an absence-marker is not evidence
930        // of presence — it just means we have no signal that fired.
931        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        // StatusFound votes Found (200 matches); BodyAbsent votes NotFound
1001        // (error marker appears). Negative-priority aggregation → NotFound.
1002        // This is the canonical Sherlock "message" pattern: a site that
1003        // returns 200 for everyone and differentiates via an error string.
1004        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        // Interval is intentionally much larger than typical wiremock latency
1071        // (≤10 ms locally, can spike under heavy parallel test load). Any
1072        // value too close to HTTP latency would let the first request burn
1073        // through the throttle window and make the assertion flaky.
1074        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        // Body-based ban detection only runs when a signal already needs
1139        // the body — this site uses BodyAbsent so the body is read.
1140        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        // 403 is ambiguous for bans; site explicitly maps it to NotFound.
1177        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        // First request: 429. Subsequent: 200.
1185        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        // Connection refused → Uncertain note starts with "request:", not a
1235        // ban marker. We must NOT retry — otherwise a single dead site
1236        // burns the full backoff budget before reporting.
1237        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        // If retry fired, we'd be sleeping minutes; instead this returns
1272        // promptly with an Uncertain.
1273        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        // The mock only matches when the request carries one of the pooled
1285        // UAs; if rotation weren't applied, the default adler/x.y UA would
1286        // miss and the verdict would be NotFound.
1287        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        // reqwest would silently treat this as a host; we require a scheme.
1314        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        // Valid scheme + endpoint builds fine (no connection is attempted).
1324        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        // Two distinct host paths; per-host throttle is disabled, so any
1335        // spacing must come from the global RPS cap. 5 RPS → 200 ms apart.
1336        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        // 2 RPS → ~500 ms between requests. A large interval keeps the
1384        // assertion robust even when the first probe's own duration (which
1385        // eats into the measured gap) is inflated by test instrumentation
1386        // such as coverage tooling.
1387        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        // First request consumes the slot at t≈0; second waits ~500 ms even
1394        // though it targets a different host.
1395        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        // Mock returns body that would fail a body_absent check — but since
1486        // we only have a status signal, body is never read.
1487        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    // ===== Browser routing =====
1499
1500    /// Test backend that returns a canned page and counts calls. Lets the
1501    /// routing tests assert "Client did/did not invoke the browser" without
1502    /// involving a real Chrome process.
1503    #[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        // wiremock would *not* fire (raw HTTP path is skipped) — the backend
1543        // returns its canned page directly.
1544        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, // would make wiremock case fail if browser was taken
1572            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        // site WITHOUT bot-protected tag → must go via raw HTTP (wiremock).
1583        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        // First call consumes the only slot.
1607        let first = client.check(&site, &user()).await;
1608        assert_eq!(first.kind, MatchKind::Found);
1609        // Second call hits the cap → Uncertain(BrowserBudget), backend NOT invoked.
1610        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        // Site with only status signals (no body markers, no enrichment)
1665        // should be probed with HEAD — saves the body download on
1666        // ~30% of the registry.
1667        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        // Same baseline plus a body-marker signal — must still GET so
1684        // the body actually arrives for matching.
1685        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        // A site that declares `protection: [Cloudflare]` but doesn't
1706        // carry the legacy `bot-protected` tag should still route
1707        // through the browser backend — the new structured field is
1708        // an additional signal, not a tag replacement.
1709        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        // No bot-protected tag — pure structured-field test.
1717        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        // The recording backend always returns a synthetic 200, so
1731        // Found means we went through the browser path.
1732        assert_eq!(outcome.kind, MatchKind::Found);
1733        // No raw HTTP probe should have hit the mock server.
1734        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        // A POST-probed site (e.g. Anilist GraphQL) — the username
1780        // goes in the body, not the URL. Adler should substitute
1781        // `{username}` and send a POST with the rendered payload.
1782        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        // URL substitution still requires the `{username}` placeholder,
1789        // even for POST sites where the username also lives in the
1790        // body. Most real POST endpoints encode the username in both
1791        // (e.g. query string + body); we mirror that.
1792        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        // A server that rejects HEAD with 405 — Adler should silently
1825        // retry with GET so the optimisation can never cost accuracy.
1826        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    // ------------------------------------------------------------------
1847    // Phase 4 — automatic escalation when the cheap transport hits a
1848    // Cloudflare / rate-limit Uncertain that the browser could resolve.
1849    // ------------------------------------------------------------------
1850
1851    /// Mocked HTTP that always responds with a Cloudflare 503 (server
1852    /// header + 503 status — what the pre-body ban detector turns into
1853    /// `Uncertain(CloudflareChallenge)`).
1854    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        // Browser returns a 200 that the StatusFound signal turns into Found.
1885        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        // Non-bot-protected site — HTTP path runs first, hits Cloudflare,
1898        // escalation routes to the browser, browser's 200 → Found.
1899        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        // First call burns the only escalation slot.
2004        let first = client.check(&site, &user()).await;
2005        assert_eq!(first.kind, MatchKind::Found);
2006        assert_eq!(first.escalations, 1);
2007        // Second call's escalation is denied → cheap-path Uncertain survives.
2008        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}