leyline-http 0.1.1

HTTP client that mimics browsers on the wire
Documentation
use super::{Error, Kind};
use crate::h2::H2Error;
use crate::h2::error::ErrorCode;
use crate::tls::TlsError;

fn io(kind: std::io::ErrorKind) -> Error {
    Error::new(Kind::Io).with_source(std::io::Error::new(kind, "io"))
}

fn tls(err: TlsError) -> Error {
    Error::new(Kind::Tls).with_source(err)
}

#[test]
fn timeout_kind_is_timeout_not_connect() {
    let err = Error::new(Kind::Timeout);
    assert!(err.is_timeout());
    assert!(!err.is_connect());
    assert!(!err.is_status());
    assert_eq!(err.kind(), Kind::Timeout);
}

#[test]
fn timed_out_io_is_timeout_not_connect() {
    let err = io(std::io::ErrorKind::TimedOut);
    assert!(err.is_timeout());
    assert!(!err.is_connect());
}

#[test]
fn body_io_is_not_connect() {
    let err = io(std::io::ErrorKind::UnexpectedEof);
    assert!(!err.is_connect());
    assert!(!err.is_timeout());
    assert!(err.is_connection_closed());
}

#[test]
fn file_io_is_not_connect() {
    assert!(!io(std::io::ErrorKind::NotFound).is_connect());
}

#[test]
fn refused_io_is_connect() {
    let err = io(std::io::ErrorKind::ConnectionRefused);
    assert!(err.is_connect());
    assert!(!err.is_timeout());
}

#[test]
fn connect_kind_is_connect() {
    assert!(Error::new(Kind::Connect).is_connect());
}

#[test]
fn tls_handshake_is_connect_and_connection_closed() {
    let err = tls(TlsError::Handshake("alert".into()));
    assert!(err.is_connect());
    assert!(err.is_connection_closed());
}

#[test]
fn cert_mismatch_is_not_connect() {
    let err = tls(TlsError::Hostname("wrong host".into()));
    assert!(!err.is_connect());
    assert!(!err.is_connection_closed());
}

#[test]
fn dns_is_connect_not_connection_closed() {
    let err = tls(TlsError::Dns(std::io::Error::other("nxdomain")));
    assert!(err.is_connect());
    assert!(!err.is_connection_closed());
}

#[test]
fn tls_io_timeout_is_timeout() {
    let err = tls(TlsError::TcpConnect(std::io::Error::new(
        std::io::ErrorKind::TimedOut,
        "slow",
    )));
    assert!(err.is_timeout());
    assert!(err.is_connect());
}

#[test]
fn h2_goaway_and_refused_stream_are_connection_closed() {
    let goaway = Error::new(Kind::Http2).with_source(H2Error::Connection {
        code: ErrorCode::NoError,
        reason: String::new(),
    });
    assert!(goaway.is_connection_closed());
    let refused = Error::new(Kind::Http2).with_source(H2Error::Stream {
        stream_id: 1,
        code: ErrorCode::RefusedStream,
    });
    assert!(refused.is_connection_closed());
}

#[test]
fn status_kind_carries_status() {
    let err = Error::new(Kind::Status).with_status(http::StatusCode::FORBIDDEN);
    assert!(err.is_status());
    assert_eq!(err.status().map(|s| s.as_u16()), Some(403));
    assert!(!err.is_timeout());
    assert!(!err.is_connect());
}

#[test]
fn predicates_are_kind_scoped() {
    for kind in [Kind::Request, Kind::Config, Kind::Proxy] {
        let err = Error::new(kind);
        assert!(!err.is_timeout(), "{kind} must not be a timeout");
        assert!(!err.is_connect(), "{kind} must not be a connect failure");
        assert!(!err.is_status(), "{kind} must not carry a status");
    }
}

#[test]
fn source_returns_the_wrapped_error() {
    use std::error::Error as _;
    let connect = tls(TlsError::TcpConnect(std::io::Error::new(
        std::io::ErrorKind::ConnectionRefused,
        "refused",
    )));
    assert!(connect.source().is_some());
    assert!(connect.tls().is_some());

    let plain = io(std::io::ErrorKind::BrokenPipe);
    assert!(plain.source().is_some());
    assert!(plain.io().is_some());

    let url = Error::from_url_parse(url::Url::parse("::").expect_err("not a url"));
    assert_eq!(url.kind(), Kind::Url);
    assert!(url.source().is_some());

    let json = Error::from_json(serde_json::from_str::<u32>("nope").expect_err("not json"));
    assert_eq!(json.kind(), Kind::Json);
    assert!(json.source().is_some());

    assert!(Error::new(Kind::Timeout).source().is_none());
}

#[test]
fn display_names_the_kind_and_appends_the_url() {
    let err = Error::new(Kind::Config).with_message("no host in URL");
    assert_eq!(err.to_string(), "config: no host in URL");

    let with_url = Error::new(Kind::Status)
        .with_status(http::StatusCode::NOT_FOUND)
        .with_url("https://example.test/a".parse().expect("url"));
    assert_eq!(
        with_url.to_string(),
        "status 404 for https://example.test/a"
    );

    let sourced = tls(TlsError::Hostname("wrong host".into()));
    assert_eq!(sourced.to_string(), "tls");
}

#[test]
fn without_url_drops_the_url_from_display() {
    let err = Error::new(Kind::Request)
        .with_message("boom")
        .with_url("https://example.test/a".parse().expect("url"));
    assert!(err.to_string().contains("example.test"));
    let bare = err.without_url();
    assert!(bare.url().is_none());
    assert_eq!(bare.to_string(), "request: boom");
}

#[test]
fn debug_redacts_userinfo() {
    let err = Error::new(Kind::Request)
        .with_url("https://user:pass@example.test/a".parse().expect("url"));
    let text = format!("{err:?}");
    assert!(!text.contains("user:pass"), "userinfo leaked: {text}");
    assert!(
        text.contains("***@example.test"),
        "unexpected debug: {text}"
    );
    assert!(
        !err.to_string().contains("pass"),
        "userinfo leaked in Display"
    );
}

impl Error {
    fn without_url(mut self) -> Self {
        self.inner.url = None;
        self
    }
}