captchaforge 0.2.36

[DO NOT USE — UNDER ACTIVE DEVELOPMENT, NOT PRODUCTION-READY] Captcha solver scaffolding for chromiumoxide-driven browsers. The architecture is in place (vendor solvers, retry-loop iframe walking, VLM provider abstraction, real-WAF bench harness) but the live-vendor success rate is still 0% — Cloudflare Turnstile / hCaptcha / reCAPTCHA detect us at a TLS / CDP fingerprint layer that no flag-based stealth has cleared. Watch the repo; do not depend on this for any real workload.
Documentation
//! Integration tests for the production wirings shipped in
//! rounds 32 (H-series). Each test verifies that the chain
//! actually invokes the new module along its production code
//! path — separate from the per-module unit tests that prove the
//! module works in isolation.
//!
//! These tests are CRITICAL — a regression that disconnects the
//! wiring from the chain looks identical to a single-module test
//! failure but breaks the user-visible product silently. Keep the
//! suite minimal + fast (<100ms each) so it runs in every PR.

use std::sync::Arc;

use captchaforge::detect::DetectedCaptcha;
use captchaforge::solver::token_shapes::{for_vendor, TokenShape};
use captchaforge::solver::CaptchaSolverChain;
use captchaforge::telemetry::{MetricsTelemetry, SolverTelemetry};
use captchaforge::training_corpus::TrainingCorpus;
use tempfile::tempdir;

// ─── H1 — token-shape oracle wired into chain ─────────────────

#[test]
fn token_oracle_classifies_decoy_as_decoy() {
    // Direct contract test of the oracle the chain consults.
    // If this returned Plausible we'd be silently passing decoy
    // tokens through the chain post-success path.
    let oracle = for_vendor("cloudflare-turnstile").expect("CF Turnstile oracle is wired");
    assert_eq!(oracle.classify("FAILED"), TokenShape::Decoy);
    assert_eq!(oracle.classify(""), TokenShape::Decoy);
}

#[test]
fn token_oracle_resolves_for_every_built_in_vendor() {
    // Chain calls `for_vendor(canonical_name)` for these. If
    // any returns None, the chain skips the decoy check + lets
    // the soft-failure decoy through.
    for vendor in [
        "cloudflare-turnstile",
        "recaptcha-v2",
        "recaptcha-v3",
        "recaptcha-enterprise",
        "hcaptcha",
    ] {
        assert!(
            for_vendor(vendor).is_some(),
            "{vendor} must have a wired token oracle (chain depends on it)"
        );
    }
}

#[test]
fn token_oracle_classifies_realistic_synthetic_token_as_plausible() {
    // ~250-char base64url string starting with the CF revision
    // prefix. The shape the chain expects to see when the solver
    // succeeds for real.
    let oracle = for_vendor("cloudflare-turnstile").unwrap();
    let mut tok = String::from("0.");
    for i in 0..248u32 {
        const ALPH: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
        tok.push(ALPH[(i * 17 % 64) as usize] as char);
    }
    assert_eq!(oracle.classify(&tok), TokenShape::Plausible);
}

// ─── H2 — TrainingCorpus wiring on the chain builder ──────────

#[test]
fn chain_with_training_corpus_builds_without_panic() {
    // Wiring contract: with_training_corpus returns Self, builder
    // chain compiles. Catches accidental signature breakage.
    let tmp = tempdir().unwrap();
    let corpus = Arc::new(TrainingCorpus::open(tmp.path()).unwrap());
    let _chain = CaptchaSolverChain::default_chain().with_training_corpus(corpus);
    // Smoke: just that it compiled + constructed.
}

#[test]
fn training_corpus_round_trips_a_failure_sample() {
    // Direct corpus contract — separate from the chain wiring.
    use captchaforge::training_corpus::TrainingSample;
    let tmp = tempdir().unwrap();
    let corpus = TrainingCorpus::open(tmp.path()).unwrap();

    let sample = TrainingSample {
        solver: "(chain-terminal)".into(),
        vendor: "cloudflare-turnstile".into(),
        detected_kind: "Turnstile".into(),
        url: "https://example.com".into(),
        outcome: "failure".into(),
        confidence: None,
        time_ms: 1234,
        screenshot_b64: None,
        dom_snapshot: None,
        verified_outcome: None,
        captured_at_unix: 1_700_000_000_i64,
    };
    corpus.append(&sample).unwrap();

    let back = corpus.load_vendor("cloudflare-turnstile").unwrap();
    assert_eq!(back.len(), 1);
    assert_eq!(back[0].outcome, "failure");
    assert_eq!(back[0].solver, "(chain-terminal)");
}

#[test]
fn training_corpus_aggregates_per_vendor_counts() {
    // The CLI `corpus stats` subcommand depends on this aggregator.
    use captchaforge::training_corpus::TrainingSample;
    let tmp = tempdir().unwrap();
    let corpus = TrainingCorpus::open(tmp.path()).unwrap();

    fn sample(vendor: &str) -> TrainingSample {
        TrainingSample {
            solver: "x".into(),
            vendor: vendor.into(),
            detected_kind: "x".into(),
            url: "x".into(),
            outcome: "x".into(),
            confidence: None,
            time_ms: 0,
            screenshot_b64: None,
            dom_snapshot: None,
            verified_outcome: None,
            captured_at_unix: 0,
        }
    }
    for _ in 0..3 {
        corpus.append(&sample("cf")).unwrap();
    }
    for _ in 0..7 {
        corpus.append(&sample("hcaptcha")).unwrap();
    }
    let counts = corpus.vendor_counts().unwrap();
    let map: std::collections::HashMap<_, _> = counts.into_iter().collect();
    assert_eq!(map.get("cf"), Some(&3));
    assert_eq!(map.get("hcaptcha"), Some(&7));
}

// ─── H3 — hot-reload registry contract ────────────────────────

#[tokio::test]
async fn hot_reload_registry_loads_initial_extras_from_dir() {
    use captchaforge::rule_watcher::HotReloadRegistry;
    let tmp = tempdir().unwrap();
    let path = tmp.path().to_path_buf();
    std::fs::write(
        path.join("vendor_x.toml"),
        r#"
[[provider]]
name = "vendor_x_test"
priority = 200
[provider.triggers]
selectors = [".vendor-x"]
"#,
    )
    .unwrap();
    let watcher = HotReloadRegistry::start(&path, std::time::Duration::from_secs(60)).unwrap();
    let registry = watcher.current();
    let names: Vec<&'static str> = registry.providers().iter().map(|p| p.name()).collect();
    assert!(
        names.contains(&"vendor_x_test"),
        "initial load missed vendor_x_test: {names:?}"
    );
}

#[tokio::test]
async fn hot_reload_registry_atomic_swap_when_new_file_lands() {
    use captchaforge::rule_watcher::HotReloadRegistry;
    let tmp = tempdir().unwrap();
    let path = tmp.path().to_path_buf();
    std::fs::write(
        path.join("v1.toml"),
        r#"
[[provider]]
name = "wired_v1"
priority = 200
[provider.triggers]
selectors = [".v1"]
"#,
    )
    .unwrap();
    let watcher = HotReloadRegistry::start(&path, std::time::Duration::from_secs(60)).unwrap();
    assert_eq!(watcher.reload_count(), 0);

    // Sleep > 1s so mtime second-resolution filesystems detect change.
    tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
    std::fs::write(
        path.join("v2.toml"),
        r#"
[[provider]]
name = "wired_v2"
priority = 201
[provider.triggers]
selectors = [".v2"]
"#,
    )
    .unwrap();

    assert!(watcher.poll_once(), "poll must detect new file");
    assert_eq!(watcher.reload_count(), 1);

    let names: Vec<&'static str> = watcher
        .current()
        .providers()
        .iter()
        .map(|p| p.name())
        .collect();
    assert!(
        names.contains(&"wired_v1"),
        "v1 should still be present after reload"
    );
    assert!(names.contains(&"wired_v2"), "v2 should appear after reload");
}

// ─── G1 / H5 — telemetry wiring + metrics exposition ──────────

#[test]
fn metrics_telemetry_records_via_dyn_solver_telemetry() {
    let metrics = Arc::new(MetricsTelemetry::new());

    // The chain installs telemetry as Arc<dyn SolverTelemetry>.
    // Invoke through the trait object form — the same path the
    // production chain takes — to confirm dispatch works.
    let dyn_t: Arc<dyn SolverTelemetry> = metrics.clone();
    let kind = DetectedCaptcha::Turnstile;
    let captcha_type = captchaforge::solver::CaptchaType::CloudflareTurnstile;
    let method = captchaforge::solver::SolveMethod::BehavioralBypass;
    dyn_t.record(&captchaforge::telemetry::SolveEvent::new(
        "TestSolver",
        &captcha_type,
        &kind,
        "ex.com",
        captchaforge::telemetry::SolveOutcome::Success,
        100,
        Some(0.9),
        &method,
    ));

    let snap = metrics.snapshot();
    assert_eq!(
        snap.counts
            .iter()
            .find(|c| c.solver == "TestSolver" && c.outcome == "success")
            .map(|c| c.count),
        Some(1)
    );
}

#[test]
fn metrics_snapshot_renders_valid_prometheus_format() {
    // The `serve /metrics` endpoint serves this exact bytes.
    // Prometheus scrapers reject responses missing # HELP / # TYPE
    // lines; this regression-tests the format invariant.
    let metrics = MetricsTelemetry::new();
    let kind = DetectedCaptcha::Turnstile;
    let captcha_type = captchaforge::solver::CaptchaType::CloudflareTurnstile;
    let method = captchaforge::solver::SolveMethod::BehavioralBypass;
    metrics.record(&captchaforge::telemetry::SolveEvent::new(
        "TestSolver",
        &captcha_type,
        &kind,
        "ex.com",
        captchaforge::telemetry::SolveOutcome::Success,
        100,
        Some(0.9),
        &method,
    ));
    let prom = metrics.snapshot().to_prometheus();
    assert!(prom.contains("# HELP captchaforge_solve_total"));
    assert!(prom.contains("# TYPE captchaforge_solve_total counter"));
    assert!(prom.contains("# TYPE captchaforge_solve_duration_ms histogram"));
    // Every line must end with newline so scrapers don't lose the last sample.
    assert!(prom.ends_with('\n'));
}

// ─── E3 — multi-hop planner contract ─────────────────────────

#[test]
fn planner_outcome_helpers_match_terminal_state() {
    use captchaforge::solver::planner::{HopOutcome, PlannerOutcome, PlannerTerminal};
    use captchaforge::solver::{CaptchaSolveResult, SolveMethod};
    use std::time::Duration;

    let success_outcome = PlannerOutcome {
        hops: vec![HopOutcome {
            hop_index: 1,
            detected: DetectedCaptcha::Turnstile,
            solve: CaptchaSolveResult {
                success: true,
                confidence: 1.0,
                method: SolveMethod::BehavioralBypass,
                time_ms: 100,
                solution: "ok".into(),
                screenshot: None,
                cookies: Vec::new(),
                verified_outcome: None,
            },
        }],
        terminal: PlannerTerminal::PageCaptchaFree,
        elapsed: Duration::from_millis(100),
    };
    assert!(success_outcome.fully_succeeded());
    assert!(success_outcome.any_solve_succeeded());
}

// ─── E2 — frame_graph contract ───────────────────────────────

#[test]
fn frame_graph_is_constructable_for_solver_consumers() {
    // Solvers traversing the page tree depend on the graph
    // building + queryable shape. Empty default + bfs() returns
    // empty for a fresh graph.
    use captchaforge::frame_graph::FrameGraph;
    let g = FrameGraph::default();
    assert!(g.bfs().is_empty());
    assert!(!g.any_captcha_marker());
}