leyline-http 0.1.5

HTTP client that impersonates Chrome, Firefox, and Safari on the wire: TLS (JA3/JA4), HTTP/2, and HTTP/3 fingerprints
Documentation
use leyline::Session;
use serde_json::Value;

use crate::http_support::httpbin_lite;

#[tokio::test]
async fn decompression_gzip() {
    let base = httpbin_lite::spawn().await;
    let session = Session::browser(leyline::Browser::default());
    let resp = session.get(format!("{base}/gzip")).await.unwrap();
    assert_eq!(resp.status(), 200);
    let json: Value =
        serde_json::from_str(&resp.text().await.unwrap()).expect("gzip-decoded body not JSON");
    assert_eq!(json["gzipped"], true);
}

#[tokio::test]
async fn decompression_brotli() {
    let base = httpbin_lite::spawn().await;
    let session = Session::browser(leyline::Browser::default());
    let resp = session.get(format!("{base}/brotli")).await.unwrap();
    assert_eq!(resp.status(), 200);
    let json: Value =
        serde_json::from_str(&resp.text().await.unwrap()).expect("brotli-decoded body not JSON");
    assert_eq!(json["brotli"], true);
}

#[tokio::test]
async fn decompression_deflate() {
    let base = httpbin_lite::spawn().await;
    let session = Session::browser(leyline::Browser::default());
    let resp = session.get(format!("{base}/deflate")).await.unwrap();
    assert_eq!(resp.status(), 200);
    let json: Value =
        serde_json::from_str(&resp.text().await.unwrap()).expect("deflate-decoded body not JSON");
    assert_eq!(json["deflated"], true);
}

#[tokio::test]
async fn cookies_set_then_sent() {
    let base = httpbin_lite::spawn().await;
    let session = Session::browser(leyline::Browser::default());
    let resp1 = session
        .get(format!("{base}/cookies/set?token=abc123"))
        .await
        .unwrap();
    assert_eq!(resp1.status(), 200);

    let resp2 = session.get(format!("{base}/cookies")).await.unwrap();
    assert_eq!(resp2.status(), 200);
    let json: Value = serde_json::from_str(&resp2.text().await.unwrap()).unwrap();
    assert_eq!(
        json["cookies"]["token"].as_str(),
        Some("abc123"),
        "cookie not sent on second request"
    );
}

#[tokio::test]
async fn response_cookies_use_the_rfc_parser_not_a_hand_parser() {
    let base = httpbin_lite::spawn().await;
    let session = Session::browser(leyline::Browser::default());
    let resp = session
        .get(format!("{base}/set-cookie-quoted"))
        .await
        .unwrap();
    assert_eq!(resp.status(), 200);
    assert_eq!(
        resp.cookies()
            .find(|c| c.name == "token")
            .map(|c| c.value)
            .as_deref(),
        Some("\"quoted value\""),
        "Response::cookies() must reflect the RFC parser (quotes kept)"
    );
    assert_eq!(
        session
            .cookies()
            .get_cookie(&url::Url::parse(&format!("{base}/")).unwrap(), "token")
            .as_deref(),
        Some("\"quoted value\""),
        "Response::cookies() and the jar must not diverge"
    );
}

#[tokio::test]
async fn redirect_follows_and_rewrites_url() {
    let base = httpbin_lite::spawn().await;
    let session = Session::browser(leyline::Browser::default());
    let resp = session.get(format!("{base}/redirect/3")).await.unwrap();
    assert_eq!(resp.status(), 200);
    assert_eq!(
        resp.redirect_chain().len(),
        3,
        "expected 3 redirects in chain"
    );
    assert!(
        resp.url().path().ends_with("/get"),
        "final URL wrong: {}",
        resp.url()
    );
}

#[tokio::test]
async fn redirect_preserves_auth_same_host() {
    let base = httpbin_lite::spawn().await;
    let session = Session::browser(leyline::Browser::default());
    let resp = session
        .request(
            http::Method::GET,
            format!("{base}/redirect-to?url={base}/headers"),
        )
        .bearer_auth("secret-token-xyz")
        .send()
        .await
        .unwrap();
    assert_eq!(resp.status(), 200);
    let json: Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap();
    assert_eq!(
        json["headers"]["Authorization"].as_str(),
        Some("Bearer secret-token-xyz"),
        "same-host redirect should preserve Authorization"
    );
}

#[tokio::test]
async fn redirect_307_308_replays_buffered_body() {
    for status in [307u16, 308] {
        let base = httpbin_lite::spawn().await;
        let session = Session::browser(leyline::Browser::default());
        let payload = "replay-me-please-i-am-a-request-body";
        let resp = session
            .post(format!("{base}/redirect-to?url=/post&status_code={status}"))
            .body(payload.as_bytes().to_vec())
            .send()
            .await
            .unwrap();
        assert_eq!(
            resp.status(),
            200,
            "{status}: should follow through to /post"
        );
        assert_eq!(
            resp.redirect_chain().len(),
            1,
            "{status}: exactly one redirect"
        );
        let json: Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap();
        assert_eq!(
            json["data"], payload,
            "{status} redirect dropped the request body"
        );
    }
}

#[tokio::test]
async fn redirect_to_non_http_scheme_is_refused() {
    let base = httpbin_lite::spawn().await;
    let session = Session::browser(leyline::Browser::default());
    let result = session
        .get(format!("{base}/redirect-to?url=file:///etc/passwd"))
        .await;
    assert!(result.is_err(), "a file:// redirect target must be refused");
    let msg = result.err().unwrap().to_string();
    assert!(
        msg.contains("non-http(s)") || msg.to_lowercase().contains("scheme"),
        "expected a redirect-scheme refusal, got: {msg}"
    );
}

#[tokio::test]
async fn post_json_body_roundtrip() {
    let base = httpbin_lite::spawn().await;
    let session = Session::browser(leyline::Browser::default());
    let body = serde_json::json!({"test": "leyline", "n": 42});
    let resp = session
        .post(format!("{base}/post"))
        .json(&body)
        .await
        .unwrap();
    assert_eq!(resp.status(), 200);
    let json: Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap();
    assert_eq!(json["json"]["test"], "leyline");
    assert_eq!(json["json"]["n"], 42);
}

#[tokio::test]
async fn post_form_body_roundtrip() {
    let base = httpbin_lite::spawn().await;
    let session = Session::browser(leyline::Browser::default());
    let resp = session
        .post(format!("{base}/post"))
        .form([("u", "alice"), ("p", "s3cret")])
        .await
        .unwrap();
    assert_eq!(resp.status(), 200);
    let json: Value = serde_json::from_str(&resp.text().await.unwrap()).unwrap();
    assert_eq!(json["form"]["u"], "alice");
    assert_eq!(json["form"]["p"], "s3cret");
}