#![allow(clippy::bool_assert_comparison)] mod cases; mod server; use std::collections::{HashMap, HashSet}; use std::fs::{self, File, OpenOptions}; use std::future::Future; use std::io::Write; use std::iter::FromIterator; use std::net::IpAddr; use std::pin::Pin; use std::str::FromStr; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use assert_cmd::cmd::Command; use http_body_util::BodyExt; use indoc::indoc; use predicates::function::function; use predicates::str::contains; use reqwest::header::HeaderValue; use serde_json::Value; use tempfile::{tempdir, NamedTempFile, TempDir}; pub trait RequestExt { fn query_params(&self) -> HashMap; fn body(self) -> Pin> + Send>>; fn body_as_string(self) -> Pin + Send>>; } impl RequestExt for hyper::Request where T: hyper::body::Body + Send + 'static, T::Data: Send, T::Error: std::fmt::Debug, { fn query_params(&self) -> HashMap { form_urlencoded::parse(self.uri().query().unwrap().as_bytes()) .into_owned() .collect::>() } fn body(self) -> Pin> + Send>> { let fut = async { self.collect().await.unwrap().to_bytes().to_vec() }; Box::pin(fut) } fn body_as_string(self) -> Pin + Send>> { let fut = async { String::from_utf8(self.body().await).unwrap() }; Box::pin(fut) } } fn random_string() -> String { use rand::Rng; rand::thread_rng() .sample_iter(&rand::distributions::Alphanumeric) .take(10) .map(char::from) .collect() } /// Cargo-cross for ARM runs tests using qemu. /// /// It sets an environment variable like this: /// CARGO_TARGET_ARM_UNKNOWN_LINUX_GNUEABIHF_RUNNER=qemu-arm fn find_runner() -> Option { for (key, value) in std::env::vars() { if key.starts_with("CARGO_TARGET_") && key.ends_with("_RUNNER") && !value.is_empty() { return Some(value); } } None } fn get_base_command() -> Command { let mut cmd; let path = assert_cmd::cargo::cargo_bin("xh"); if let Some(runner) = find_runner() { let mut runner = runner.split_whitespace(); cmd = Command::new(runner.next().unwrap()); for arg in runner { cmd.arg(arg); } cmd.arg(path); } else { cmd = Command::new(path); } cmd.env("HOME", ""); cmd.env("NETRC", ""); cmd.env("XH_CONFIG_DIR", ""); #[cfg(target_os = "windows")] cmd.env("XH_TEST_MODE_WIN_HOME_DIR", ""); cmd.env("RUST_BACKTRACE", "0"); cmd } /// Sensible default command to test with. use [`get_base_command`] if this /// setup doesn't apply. fn get_command() -> Command { let mut cmd = get_base_command(); cmd.env("XH_TEST_MODE", "1"); cmd.env("XH_TEST_MODE_TERM", "1"); cmd } /// Do not pretend the output goes to a terminal. fn redirecting_command() -> Command { let mut cmd = get_base_command(); cmd.env("XH_TEST_MODE", "1"); cmd } /// Color output (with ANSI colors) by default. fn color_command() -> Command { let mut cmd = get_command(); cmd.env("XH_TEST_MODE_COLOR", "1"); cmd } const BINARY_SUPPRESSOR: &str = concat!( "+-----------------------------------------+\n", "| NOTE: binary data not shown in terminal |\n", "+-----------------------------------------+\n", "\n" ); #[allow(unused)] mod prelude { pub(crate) use super::color_command; pub(crate) use super::get_base_command; pub(crate) use super::get_command; pub(crate) use super::random_string; pub(crate) use super::redirecting_command; pub(crate) use super::server; pub(crate) use super::RequestExt; pub(crate) use super::BINARY_SUPPRESSOR; } #[test] fn basic_json_post() { let server = server::http(|req| async move { assert_eq!(req.method(), "POST"); assert_eq!(req.headers()["Content-Type"], "application/json"); assert_eq!(req.body_as_string().await, "{\"name\":\"ali\"}"); hyper::Response::builder() .header(hyper::header::CONTENT_TYPE, "application/json") .body(r#"{"got":"name","status":"ok"}"#.into()) .unwrap() }); get_command() .arg("--print=b") .arg("--pretty=format") .arg("post") .arg(server.base_url()) .arg("name=ali") .assert() .stdout(indoc! {r#" { "got": "name", "status": "ok" } "#}); } #[test] fn full_json_response_utf8_decode() { let server = server::http(|_| async move { hyper::Response::builder() .header(hyper::header::CONTENT_TYPE, "application/json") .body(r#"{"hello": "\u4f60\u597d"}"#.into()) .unwrap() }); get_command() .arg("--print=b") .arg("-S") .arg("--pretty=format") .arg("post") .arg(server.base_url()) .assert() .stdout(indoc! {r#" { "hello": "\u4f60\u597d" } "#}); get_command() .arg("--print=b") .arg("--pretty=format") .arg("post") .arg(server.base_url()) .assert() .stdout(indoc! {r#" { "hello": "你好" } "#}); } #[test] fn basic_get() { let server = server::http(|req| async move { assert_eq!(req.method(), "GET"); hyper::Response::builder().body("foobar\n".into()).unwrap() }); get_command() .args(["--print=b", "get", &server.base_url()]) .assert() .stdout("foobar\n\n"); } #[test] fn basic_head() { let server = server::http(|req| async move { assert_eq!(req.method(), "HEAD"); hyper::Response::default() }); get_command() .args(["head", &server.base_url()]) .assert() .success(); } #[test] fn basic_options() { let server = server::http(|req| async move { assert_eq!(req.method(), "OPTIONS"); hyper::Response::builder() .header("Allow", "GET, HEAD, OPTIONS") .body("".into()) .unwrap() }); get_command() .args(["-h", "options", &server.base_url()]) .assert() .stdout(contains("HTTP/1.1 200 OK")) .stdout(contains("Allow:")); } #[test] fn multiline_value() { let server = server::http(|req| async move { assert_eq!(req.method(), "POST"); assert_eq!(req.body_as_string().await, "foo=bar%0Abaz"); hyper::Response::default() }); get_command() .args(["--form", "post", &server.base_url(), "foo=bar\nbaz"]) .assert() .success(); } #[test] fn post_empty_body() { let server = server::http(|req| async move { assert_eq!(req.method(), "POST"); assert_eq!(req.headers().get(reqwest::header::TRANSFER_ENCODING), None); assert_eq!(req.body_as_string().await, ""); hyper::Response::default() }); get_command() .args(["post", &server.base_url()]) .assert() .success(); } #[test] fn nested_json() { let server = server::http(|req| async move { assert_eq!( req.body_as_string().await, r#"{"shallow":"value","object":{"key":"value"},"array":[1,2,3],"wow":{"such":{"deep":[null,null,null,{"much":{"power":{"!":"Amaze"}}}]}}}"# ); hyper::Response::default() }); get_command() .args(["post", &server.base_url()]) .arg("shallow=value") .arg("object[key]=value") .arg("array[]:=1") .arg("array[1]:=2") .arg("array[2]:=3") .arg("wow[such][deep][3][much][power][!]=Amaze") .assert() .success(); } #[test] fn json_path_with_escaped_characters() { get_command() .arg("--print=B") .arg("--offline") .arg(":") .arg(r"f\=\:\;oo\[\\[\@]=b\:\:\:ar") .assert() .stdout(indoc! {r#" { "f=:;oo[\\": { "@": "b:::ar" } } "#}); } #[test] fn nested_json_type_error() { get_command() .arg("--print=B") .arg("--offline") .arg(":") .arg("x[x][2]=5") .arg("x[x][x]=2") .assert() .failure() .stderr(indoc! {r#" xh: error: Can't perform 'key' based access on 'x[x]' which has a type of 'array' but this operation requires a type of 'object'. x[x][x] ^^^ "#}); get_command() .arg("--print=B") .arg("--offline") .arg(":") .arg("foo[x]=5") .arg("[][x]=2") .assert() .failure() .stderr(indoc! {r#" xh: error: Can't perform 'append' based access on '' which has a type of 'object' but this operation requires a type of 'array'. [][x] ^^ "#}); } #[test] fn json_path_special_chars_not_escaped_in_form() { get_command() .arg("--print=B") .arg("--offline") .arg("--form") .arg(":") .arg(r"\]=a") .assert() .stdout(indoc! {r#" %5C%5D=a "#}); } #[test] fn header() { let server = server::http(|req| async move { assert_eq!(req.headers()["X-Foo"], "Bar"); hyper::Response::default() }); get_command() .args([&server.base_url(), "x-foo:Bar"]) .assert() .success(); } #[test] fn multiple_headers_with_same_key() { let server = server::http(|req| async move { let mut hello_header = req.headers().get_all("hello").iter(); assert_eq!(hello_header.next().unwrap(), &"world"); assert_eq!(hello_header.next().unwrap(), &"people"); hyper::Response::default() }); get_command() .args([&server.base_url(), "hello:world", "hello:people"]) .assert() .success(); } #[test] fn query_param() { let server = server::http(|req| async move { assert_eq!(req.query_params()["foo"], "bar"); hyper::Response::default() }); get_command() .args([&server.base_url(), "foo==bar"]) .assert() .success(); } #[test] fn json_param() { let server = server::http(|req| async move { assert_eq!(req.body_as_string().await, "{\"foo\":[1,2,3]}"); hyper::Response::default() }); get_command() .args([&server.base_url(), "foo:=[1,2,3]"]) .assert() .success(); } #[test] fn verbose() { let server = server::http(|req| async move { assert_eq!(req.headers()["Connection"], "keep-alive"); assert_eq!(req.headers()["Content-Type"], "application/json"); assert_eq!(req.headers()["Content-Length"], "9"); assert_eq!(req.headers()["User-Agent"], "xh/0.0.0 (test mode)"); assert_eq!(req.body_as_string().await, "{\"x\":\"y\"}"); hyper::Response::builder() .header("X-Foo", "Bar") .header("Date", "N/A") .body("a body".into()) .unwrap() }); get_command() .args(["--verbose", &server.base_url(), "x=y"]) .assert() .stdout(indoc! {r#" POST / HTTP/1.1 Accept: application/json, */*;q=0.5 Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Content-Length: 9 Content-Type: application/json Host: http.mock User-Agent: xh/0.0.0 (test mode) { "x": "y" } HTTP/1.1 200 OK Content-Length: 6 Date: N/A X-Foo: Bar a body "#}); } #[test] fn decode() { let server = server::http(|_req| async move { hyper::Response::builder() .header("Content-Type", "text/plain; charset=latin1") .body(b"\xe9".as_ref().into()) .unwrap() }); get_command() .args(["--print=b", &server.base_url()]) .assert() .stdout("é\n"); } #[test] fn streaming_decode() { let server = server::http(|_req| async move { hyper::Response::builder() .header("Content-Type", "text/plain; charset=latin1") .body(b"\xe9".as_ref().into()) .unwrap() }); get_command() .args(["--print=b", "--stream", &server.base_url()]) .assert() .stdout("é\n"); } #[test] fn only_decode_for_terminal() { let server = server::http(|_req| async move { hyper::Response::builder() .header("Content-Type", "text/plain; charset=latin1") .body(b"\xe9".as_ref().into()) .unwrap() }); let output = redirecting_command() .arg(server.base_url()) .assert() .get_output() .stdout .clone(); assert_eq!(&output, b"\xe9"); // .stdout() doesn't support byte slices } #[test] fn do_decode_if_formatted() { let server = server::http(|_req| async move { hyper::Response::builder() .header("Content-Type", "text/plain; charset=latin1") .body(b"\xe9".as_ref().into()) .unwrap() }); redirecting_command() .args(["--pretty=all", &server.base_url()]) .assert() .stdout("é"); } #[test] fn never_decode_if_binary() { let server = server::http(|_req| async move { hyper::Response::builder() // this mimetype with a charset may actually be incoherent .header("Content-Type", "application/octet-stream; charset=latin1") .body(b"\xe9".as_ref().into()) .unwrap() }); let output = redirecting_command() .args(["--pretty=all", &server.base_url()]) .assert() .get_output() .stdout .clone(); assert_eq!(&output, b"\xe9"); } #[test] fn binary_detection() { let server = server::http(|_req| async move { hyper::Response::builder() .body(b"foo\0bar".as_ref().into()) .unwrap() }); get_command() .args(["--print=b", &server.base_url()]) .assert() .stdout(BINARY_SUPPRESSOR); } #[test] fn streaming_binary_detection() { let server = server::http(|_req| async move { hyper::Response::builder() .body(b"foo\0bar".as_ref().into()) .unwrap() }); get_command() .args(["--print=b", "--stream", &server.base_url()]) .assert() .stdout(BINARY_SUPPRESSOR); } #[test] fn request_binary_detection() { redirecting_command() .args(["--print=B", "--offline", ":"]) .write_stdin(b"foo\0bar".as_ref()) .assert() .stdout(indoc! {r#" +-----------------------------------------+ | NOTE: binary data not shown in terminal | +-----------------------------------------+ "#}); } #[test] fn timeout() { let mut server = server::http(|_req| async move { tokio::time::sleep(Duration::from_secs_f32(0.5)).await; hyper::Response::default() }); server.disable_hit_checks(); get_command() .args(["--timeout=0.1", &server.base_url()]) .assert() .code(2) .stderr(contains("operation timed out")); } #[test] fn timeout_no_limit() { let server = server::http(|_req| async move { tokio::time::sleep(Duration::from_secs_f32(0.5)).await; hyper::Response::default() }); get_command() .args(["--timeout=0", &server.base_url()]) .assert() .success(); } #[test] fn timeout_invalid() { get_command() .args(["--timeout=-0.01", "--offline", ":"]) .assert() .failure() .stderr(contains("Connection timeout is negative")); get_command() .args(["--timeout=18446744073709552000", "--offline", ":"]) .assert() .failure() .stderr(contains("Connection timeout is too big")); get_command() .args(["--timeout=inf", "--offline", ":"]) .assert() .failure() .stderr(contains("Connection timeout is too big")); get_command() .args(["--timeout=NaN", "--offline", ":"]) .assert() .failure() .stderr(contains("Connection timeout is not a valid number")); get_command() .args(["--timeout=SEC", "--offline", ":"]) .assert() .failure() .stderr(contains("Connection timeout is not a valid number")); } #[test] fn check_status() { let server = server::http(|_req| async move { hyper::Response::builder() .status(404) .body("".into()) .unwrap() }); get_command() .args(["--check-status", &server.base_url()]) .assert() .code(4) .stderr(""); } #[test] fn check_status_warning() { let server = server::http(|_req| async move { hyper::Response::builder() .status(501) .body("".into()) .unwrap() }); redirecting_command() .args(["--check-status", &server.base_url()]) .assert() .code(5) .stderr("xh: warning: HTTP 501 Not Implemented\n"); } #[test] fn check_status_is_implied() { let server = server::http(|_req| async move { hyper::Response::builder() .status(404) .body("".into()) .unwrap() }); get_command() .arg(server.base_url()) .assert() .code(4) .stderr(""); } #[test] fn check_status_is_not_implied_in_compat_mode() { let server = server::http(|_req| async move { hyper::Response::builder() .status(404) .body("".into()) .unwrap() }); get_command() .env("XH_HTTPIE_COMPAT_MODE", "") .arg(server.base_url()) .assert() .code(0); } #[test] fn user_password_auth() { let server = server::http(|req| async move { assert_eq!(req.headers()["Authorization"], "Basic dXNlcjpwYXNz"); hyper::Response::default() }); get_command() .args(["--auth=user:pass", &server.base_url()]) .assert() .success(); } #[test] fn user_auth() { let server = server::http(|req| async move { assert_eq!(req.headers()["Authorization"], "Basic dXNlcjo="); hyper::Response::default() }); get_command() .args(["--auth=user:", &server.base_url()]) .assert() .success(); } #[test] fn bearer_auth() { let server = server::http(|req| async move { assert_eq!(req.headers()["Authorization"], "Bearer SomeToken"); hyper::Response::default() }); get_command() .args(["--bearer=SomeToken", &server.base_url()]) .assert() .success(); } #[test] fn digest_auth() { let server = server::http(|req| async move { if req.headers().get("Authorization").is_none() { hyper::Response::builder() .status(401) .header("WWW-Authenticate", r#"Digest realm="me@xh.com", nonce="e5051361f053723a807674177fc7022f", qop="auth, auth-int", opaque="9dcf562038f1ec1c8d02f218ef0e7a4b", algorithm=MD5, stale=FALSE"#) .body("".into()) .unwrap() } else { hyper::Response::builder() .body("authenticated".into()) .unwrap() } }); get_command() .arg("--auth-type=digest") .arg("--auth=ahmed:12345") .arg(server.base_url()) .assert() .stdout(contains("HTTP/1.1 200 OK")); server.assert_hits(2); } #[cfg(feature = "online-tests")] #[test] fn successful_digest_auth() { get_command() .arg("--auth-type=digest") .arg("--auth=ahmed:12345") .arg("httpbingo.org/digest-auth/auth/ahmed/12345") .assert() .stdout(contains("HTTP/1.1 200 OK")); } #[cfg(feature = "online-tests")] #[test] fn compress_request_body_online() { get_command() .arg("https://postman-echo.com/post") .args(["--body", "-f", &format!("a={}", "1".repeat(1000))]) .assert() .stdout(function(|body: &str| { let json: Value = serde_json::from_str(body).unwrap(); assert_eq!(json["json"]["a"], Value::String("1".repeat(1000))); let length: i32 = json["headers"]["content-length"] .as_str() .unwrap() .parse() .unwrap(); assert_eq!(length, 1002); true })); get_command() .arg("https://postman-echo.com/post") .args(["-x", "--body", "-f", &format!("a={}", "1".repeat(1000))]) .assert() .stdout(function(|body: &str| { let json: Value = serde_json::from_str(body).unwrap(); assert_eq!(json["json"]["a"], Value::String("1".repeat(1000))); let length: i32 = json["headers"]["content-length"] .as_str() .unwrap() .parse() .unwrap(); assert!(length < 1000); true })); } #[cfg(feature = "online-tests")] #[test] fn unsuccessful_digest_auth() { get_command() .arg("--auth-type=digest") .arg("--auth=ahmed:wrongpass") .arg("httpbingo.org/digest-auth/auth/ahmed/12345") .assert() .stdout(contains("HTTP/1.1 401 Unauthorized")); } #[test] fn digest_auth_with_redirection() { let server = server::http(|req| async move { match req.uri().path() { "/login_page" => { if req.headers().get("Authorization").is_none() { hyper::Response::builder() .status(401) .header("WWW-Authenticate", r#"Digest realm="me@xh.com", nonce="e5051361f053723a807674177fc7022f", qop="auth, auth-int", opaque="9dcf562038f1ec1c8d02f218ef0e7a4b", algorithm=MD5, stale=FALSE"#) .header("date", "N/A") .body("".into()) .unwrap() } else { hyper::Response::builder() .status(302) .header("location", "/admin_page") .header("date", "N/A") .body("authentication successful, redirecting...".into()) .unwrap() } } "/admin_page" => { if req.headers().get("Authorization").is_none() { hyper::Response::builder() .header("date", "N/A") .body("admin page".into()) .unwrap() } else { hyper::Response::builder() .status(401) .body("unauthorized".into()) .unwrap() } } _ => panic!("unknown path"), } }); get_command() .env("XH_TEST_DIGEST_AUTH_CNONCE", "f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ") .arg("--auth-type=digest") .arg("--auth=ahmed:12345") .arg("--follow") .arg("--verbose") .arg(server.url("/login_page")) .assert() .stdout(indoc! {r#" GET /login_page HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Host: http.mock User-Agent: xh/0.0.0 (test mode) HTTP/1.1 401 Unauthorized Content-Length: 0 Date: N/A Www-Authenticate: Digest realm="me@xh.com", nonce="e5051361f053723a807674177fc7022f", qop="auth, auth-int", opaque="9dcf562038f1ec1c8d02f218ef0e7a4b", algorithm=MD5, stale=FALSE GET /login_page HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Authorization: Digest username="ahmed", realm="me@xh.com", nonce="e5051361f053723a807674177fc7022f", uri="/login_page", qop=auth, nc=00000001, cnonce="f2/wE4q74E6zIJEtWaHKaf5wv/H5QzzpXusqGemxURZJ", response="894fd5ee1dcc702df7e4a6abed37fd56", opaque="9dcf562038f1ec1c8d02f218ef0e7a4b", algorithm=MD5 Connection: keep-alive Host: http.mock User-Agent: xh/0.0.0 (test mode) HTTP/1.1 302 Found Content-Length: 41 Date: N/A Location: /admin_page authentication successful, redirecting... GET /admin_page HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Host: http.mock User-Agent: xh/0.0.0 (test mode) HTTP/1.1 200 OK Content-Length: 10 Date: N/A admin page "#}); server.assert_hits(3); } #[test] fn netrc_env_user_password_auth() { let server = server::http(|req| async move { assert_eq!(req.headers()["Authorization"], "Basic dXNlcjpwYXNz"); hyper::Response::default() }); let mut netrc = NamedTempFile::new().unwrap(); writeln!( netrc, "machine {}\nlogin user\npassword pass", server.host() ) .unwrap(); get_command() .env("NETRC", netrc.path()) .arg(server.base_url()) .assert() .success(); } #[test] fn netrc_env_no_bearer_auth_unless_specified() { // Test that we don't pass an authorization header if the .netrc contains no username, // and the --auth-type=bearer flag isn't explicitly specified. let server = server::http(|req| async move { assert!(req.headers().get("Authorization").is_none()); hyper::Response::default() }); let mut netrc = NamedTempFile::new().unwrap(); writeln!(netrc, "machine {}\npassword pass", server.host()).unwrap(); get_command() .env("NETRC", netrc.path()) .arg(server.base_url()) .assert() .success(); } #[test] fn netrc_env_auth_type_bearer() { // If we're using --auth-type=bearer, test that it's properly sent with a .netrc that // contains only a password and no username. let server = server::http(|req| async move { assert_eq!(req.headers()["Authorization"], "Bearer pass"); hyper::Response::default() }); let mut netrc = NamedTempFile::new().unwrap(); writeln!(netrc, "machine {}\npassword pass", server.host()).unwrap(); get_command() .env("NETRC", netrc.path()) .arg(server.base_url()) .arg("--auth-type=bearer") .assert() .success(); } #[test] fn netrc_file_user_password_auth() { for netrc_file in [".netrc", "_netrc"] { let server = server::http(|req| async move { assert_eq!(req.headers()["Authorization"], "Basic dXNlcjpwYXNz"); hyper::Response::default() }); let homedir = TempDir::new().unwrap(); let netrc_path = homedir.path().join(netrc_file); let mut netrc = File::create(netrc_path).unwrap(); writeln!( netrc, "machine {}\nlogin user\npassword pass", server.host() ) .unwrap(); netrc.flush().unwrap(); get_command() .env("HOME", homedir.path()) .env("XH_TEST_MODE_WIN_HOME_DIR", homedir.path()) .env_remove("NETRC") .arg(server.base_url()) .assert() .success(); drop(netrc); homedir.close().unwrap(); } } fn get_proxy_command( protocol_to_request: &str, protocol_to_proxy: &str, proxy_url: &str, ) -> Command { let mut cmd = get_command(); cmd.arg("--check-status") .arg(format!("--proxy={}:{}", protocol_to_proxy, proxy_url)) .arg("GET") .arg(format!("{}://example.test/get", protocol_to_request)); cmd } #[test] fn proxy_http_proxy() { let server = server::http(|req| async move { assert_eq!(req.method(), "GET"); assert_eq!(req.headers()["host"], "example.test"); hyper::Response::default() }); get_proxy_command("http", "http", &server.base_url()) .assert() .success(); } #[test] fn proxy_https_proxy() { let server = server::http(|req| async move { assert_eq!(req.method(), "CONNECT"); hyper::Response::builder() .status(502) .body("".into()) .unwrap() }); get_proxy_command("https", "https", &server.base_url()) .assert() .stderr(contains("unsuccessful tunnel")) .failure(); } #[test] fn proxy_http_all_proxy() { let server = server::http(|req| async move { assert_eq!(req.method(), "GET"); hyper::Response::builder() .status(502) .body("".into()) .unwrap() }); get_proxy_command("http", "all", &server.base_url()) .assert() .stdout(contains("HTTP/1.1 502 Bad Gateway")) .failure(); } #[test] fn proxy_https_all_proxy() { let server = server::http(|req| async move { assert_eq!(req.method(), "CONNECT"); hyper::Response::builder() .status(502) .body("".into()) .unwrap() }); get_proxy_command("https", "all", &server.base_url()) .assert() .stderr(contains("unsuccessful tunnel")) .failure(); } #[test] fn last_supplied_proxy_wins() { let mut first_server = server::http(|req| async move { assert_eq!(req.headers()["host"], "example.test"); hyper::Response::builder() .status(500) .body("".into()) .unwrap() }); let second_server = server::http(|req| async move { assert_eq!(req.headers()["host"], "example.test"); hyper::Response::builder() .status(200) .body("".into()) .unwrap() }); let mut cmd = get_command(); cmd.args([ format!("--proxy=http:{}", first_server.base_url()).as_str(), format!("--proxy=http:{}", second_server.base_url()).as_str(), "GET", "http://example.test", ]) .assert() .success(); first_server.disable_hit_checks(); first_server.assert_hits(0); second_server.assert_hits(1); } #[test] fn proxy_multiple_valid_proxies() { let mut cmd = get_command(); cmd.arg("--offline") .arg("--proxy=http:https://127.0.0.1:8000") .arg("--proxy=https:socks5://127.0.0.1:8000") .arg("--proxy=all:http://127.0.0.1:8000") .arg("GET") .arg("http://httpbingo.org/get"); cmd.assert().success(); } // temporarily disabled for builds not using rustls #[cfg(all(feature = "online-tests", feature = "rustls"))] #[test] fn verify_default_yes() { use predicates::boolean::PredicateBooleanExt; get_command() .args(["-v", "https://self-signed.badssl.com"]) .assert() .failure() .stdout(contains("GET / HTTP/1.1")) // rustls or native-tls .stderr(contains("UnknownIssuer").or(contains("self signed certificate"))); } // temporarily disabled for builds not using rustls #[cfg(all(feature = "online-tests", feature = "rustls"))] #[test] fn verify_explicit_yes() { use predicates::boolean::PredicateBooleanExt; get_command() .args(["-v", "--verify=yes", "https://self-signed.badssl.com"]) .assert() .failure() .stdout(contains("GET / HTTP/1.1")) // rustls or native-tls .stderr(contains("UnknownIssuer").or(contains("self signed certificate"))); } #[cfg(feature = "online-tests")] #[test] fn verify_no() { get_command() .args(["-v", "--verify=no", "https://self-signed.badssl.com"]) .assert() .stdout(contains("GET / HTTP/1.1")) .stdout(contains("HTTP/1.1 200 OK")) .stderr(predicates::str::is_empty()); } #[cfg(all(feature = "rustls", feature = "online-tests"))] #[test] fn verify_valid_file() { get_command() .arg("-v") .arg("--verify=tests/fixtures/certs/wildcard-self-signed.pem") .arg("https://self-signed.badssl.com") .assert() .stdout(contains("GET / HTTP/1.1")) .stdout(contains("HTTP/1.1 200 OK")) .stderr(predicates::str::is_empty()); } // This test may fail if https://github.com/seanmonstar/reqwest/issues/1260 is fixed // If that happens make sure to remove the warning, not just this test #[cfg(all(feature = "native-tls", feature = "online-tests"))] #[test] fn verify_valid_file_native_tls() { get_command() .arg("--native-tls") .arg("--verify=tests/fixtures/certs/wildcard-self-signed.pem") .arg("https://self-signed.badssl.com") .assert() .stderr(contains("Custom CA bundles with native-tls are broken")); } #[cfg(feature = "online-tests")] #[test] fn cert_without_key() { get_command() .args(["-v", "https://client.badssl.com"]) .assert() .stdout(contains("400 No required SSL certificate was sent")) .stderr(predicates::str::is_empty()); } #[cfg(all(feature = "rustls", feature = "online-tests"))] #[test] fn formatted_certificate_expired_message() { get_command() .arg("https://expired.badssl.com") .assert() .failure() .stderr(contains("Certificate not valid after 2015-04-12")); } #[test] fn override_dns_resolution() { let server = server::http(|req| async move { let host = req.headers()["host"].to_str().unwrap(); assert!(host.starts_with("example.com")); hyper::Response::builder() .header("X-Foo", "Bar") .header("Date", "N/A") .header("Content-Type", "application/json") .body(r#"{"hello":"world"}"#.into()) .unwrap() }); get_command() .arg("--body") .arg("--resolve=example.com:127.0.0.1") .arg(format!("http://example.com:{}", server.port())) .assert() .stdout(indoc! {r#" { "hello": "world" } "#}); } #[cfg(feature = "online-tests")] #[test] fn use_ipv4() { get_command() .args(["https://api64.ipify.org", "--body", "--ipv4"]) .assert() .stdout(function(|output: &str| { IpAddr::from_str(output.trim()).unwrap().is_ipv4() })) .stderr(predicates::str::is_empty()); } // real use ipv6 #[cfg(all(feature = "ipv6-tests", feature = "online-tests"))] #[test] fn use_ipv6() { get_command() .args(["https://api64.ipify.org", "--body", "--ipv6"]) .assert() .stdout(function(|output: &str| { IpAddr::from_str(output.trim()).unwrap().is_ipv6() })) .stderr(predicates::str::is_empty()); } #[cfg(feature = "online-tests")] #[ignore = "certificate expired (I think)"] #[test] fn cert_with_key() { get_command() .arg("-v") .arg("--cert=tests/fixtures/certs/client.badssl.com.crt") .arg("--cert-key=tests/fixtures/certs/client.badssl.com.key") .arg("https://client.badssl.com") .assert() .stdout(contains("HTTP/1.1 200 OK")) .stdout(contains("client-authenticated")) .stderr(predicates::str::is_empty()); } #[cfg(all(feature = "native-tls", feature = "online-tests"))] #[test] fn cert_with_key_native_tls() { get_command() .arg("--native-tls") .arg("--cert=tests/fixtures/certs/client.badssl.com.crt") .arg("--cert-key=tests/fixtures/certs/client.badssl.com.key") .arg("https://client.badssl.com") .assert() .failure() .stderr(contains( "Client certificates are not supported for native-tls", )); } #[cfg(not(feature = "native-tls"))] #[test] fn native_tls_flag_disabled() { get_command() .args(["--native-tls", ":"]) .assert() .failure() .stderr(contains("built without native-tls support")); } #[cfg(all(feature = "native-tls", feature = "online-tests"))] #[test] fn native_tls_works() { get_command() .args(["--native-tls", "https://example.org"]) .assert() .success(); } #[cfg(feature = "online-tests")] #[test] fn good_tls_version() { get_command() .arg("--ssl=tls1.2") .arg("https://tls-v1-2.badssl.com:1012/") .assert() .success(); } #[cfg(all(feature = "native-tls", feature = "online-tests"))] #[test] fn good_tls_version_nativetls() { get_command() .arg("--ssl=tls1.2") .arg("--native-tls") .arg("https://tls-v1-2.badssl.com:1012/") .assert() .success(); } #[test] fn bad_tls_version() { get_command() .arg("--ssl=tls1.3") .arg("https://tls-v1-2.badssl.com:1012/") .assert() .failure(); } #[cfg(feature = "native-tls")] #[test] fn bad_tls_version_nativetls() { get_command() .arg("--ssl=tls1.1") .arg("--native-tls") .arg("https://tls-v1-2.badssl.com:1012/") .assert() .failure(); } #[cfg(feature = "native-tls")] #[test] fn unsupported_tls_version_nativetls() { get_command() .arg("--ssl=tls1.3") .arg("--native-tls") .arg("https://example.org") .assert() .failure() .stderr(contains("invalid minimum TLS version")) .stderr(contains("running without the --native-tls")); } #[cfg(feature = "rustls")] #[test] fn unsupported_tls_version_rustls() { #[cfg(feature = "native-tls")] const MSG: &str = "native-tls will be enabled"; #[cfg(not(feature = "native-tls"))] const MSG: &str = "Consider building with the `native-tls` feature enabled"; get_command() .arg("--offline") .arg("--ssl=tls1.1") .arg(":") .assert() .stderr(contains("rustls does not support older TLS versions")) .stderr(contains(MSG)); } #[test] fn forced_json() { let server = server::http(|req| async move { assert_eq!(req.headers()["content-type"], "application/json"); assert_eq!(req.headers()["accept"], "application/json, */*;q=0.5"); hyper::Response::default() }); get_command() .args(["--json", &server.base_url()]) .assert() .success(); } #[test] fn forced_form() { let server = server::http(|req| async move { assert_eq!( req.headers()["content-type"], "application/x-www-form-urlencoded" ); hyper::Response::default() }); get_command() .args(["--form", &server.base_url()]) .assert() .success(); } #[test] fn forced_multipart() { let server = server::http(|req| async move { assert_eq!(req.method(), "POST"); assert_eq!(req.headers().get("content-type").is_some(), true); assert_eq!(req.body_as_string().await, ""); hyper::Response::default() }); get_command() .args(["--multipart", &server.base_url()]) .assert() .success(); } #[test] fn formatted_json_output() { let server = server::http(|_req| async move { hyper::Response::builder() .header("content-type", "application/json") .body(r#"{"":0}"#.into()) .unwrap() }); get_command() .args(["--print=b", &server.base_url()]) .assert() .stdout(indoc! {r#" { "": 0 } "#}); } #[test] fn inferred_json_output() { let server = server::http(|_req| async move { hyper::Response::builder() .header("content-type", "text/plain") .body(r#"{"":0}"#.into()) .unwrap() }); get_command() .args(["--print=b", &server.base_url()]) .assert() .stdout(indoc! {r#" { "": 0 } "#}); } #[test] fn inferred_json_javascript_output() { let server = server::http(|_req| async move { hyper::Response::builder() .header("content-type", "application/javascript") .body(r#"{"":0}"#.into()) .unwrap() }); get_command() .args(["--print=b", &server.base_url()]) .assert() .stdout(indoc! {r#" { "": 0 } "#}); } #[test] fn inferred_nonjson_output() { let server = server::http(|_req| async move { hyper::Response::builder() .header("content-type", "text/plain") // Trailing comma makes it invalid JSON, though formatting would still work .body(r#"{"":0,}"#.into()) .unwrap() }); get_command() .args(["--print=b", &server.base_url()]) .assert() .stdout(indoc! {r#" {"":0,} "#}); } #[test] fn noninferred_json_output() { let server = server::http(|_req| async move { hyper::Response::builder() // Valid JSON, but not declared as text .header("content-type", "application/octet-stream") .body(r#"{"":0}"#.into()) .unwrap() }); get_command() .args(["--print=b", &server.base_url()]) .assert() .stdout(indoc! {r#" {"":0} "#}); } #[test] fn empty_body_defaults_to_get() { let server = server::http(|req| async move { assert_eq!(req.method(), "GET"); assert_eq!(req.body_as_string().await, ""); hyper::Response::default() }); get_command().arg(server.base_url()).assert().success(); } #[test] fn non_empty_body_defaults_to_post() { let server = server::http(|req| async move { assert_eq!(req.method(), "POST"); assert_eq!(req.body_as_string().await, "{\"x\":4}"); hyper::Response::default() }); get_command() .args([&server.base_url(), "x:=4"]) .assert() .success(); } #[test] fn empty_raw_body_defaults_to_post() { let server = server::http(|req| async move { assert_eq!(req.method(), "POST"); assert_eq!(req.body_as_string().await, ""); hyper::Response::default() }); redirecting_command() .arg(server.base_url()) .write_stdin("") .assert() .success(); } #[test] fn body_from_stdin() { let server = server::http(|req| async move { assert_eq!(req.body_as_string().await, "body from stdin"); hyper::Response::default() }); redirecting_command() .arg(server.base_url()) .write_stdin("body from stdin") .assert() .success(); } #[test] fn body_from_raw() { let server = server::http(|req| async move { assert_eq!(req.body_as_string().await, "body from raw"); hyper::Response::default() }); get_command() .args(["--raw=body from raw", &server.base_url()]) .assert() .success(); } #[test] fn support_utf8_header_value() { let server = server::http(|req| async move { assert_eq!(req.headers()["hello"].as_bytes(), "你好".as_bytes()); hyper::Response::builder() .header("hello", "你好呀") .header("Date", "N/A") .body("".into()) .unwrap() }); get_command() .args([&server.base_url(), "hello:你好"]) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Length: 0 Date: N/A Hello: 你好å�� (UTF-8: 你好呀) "#}) .success(); } #[test] fn support_latin1_header_value() { let server = server::http(|_req| async move { hyper::Response::builder() .header("hello", HeaderValue::from_bytes(b"R\xF3dos").unwrap()) .header("Date", "N/A") .body("".into()) .unwrap() }); get_command() .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Length: 0 Date: N/A Hello: Ródos "#}) .success(); } #[test] fn redirect_support_utf8_location() { let server = server::http(|req| async move { match req.uri().path() { "/first_page" => hyper::Response::builder() .status(302) .header("Date", "N/A") .header("Location", "/page二") .body("redirecting...".into()) .unwrap(), "/page%E4%BA%8C" => hyper::Response::builder() .header("Date", "N/A") .body("final destination".into()) .unwrap(), _ => panic!("unknown path"), } }); get_command() .args([&server.url("/first_page"), "--follow", "--verbose", "--all"]) .assert() .stdout(indoc! {r#" GET /first_page HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Host: http.mock User-Agent: xh/0.0.0 (test mode) HTTP/1.1 302 Found Content-Length: 14 Date: N/A Location: /pageäº� (UTF-8: /page二) redirecting... GET /page%E4%BA%8C HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Host: http.mock User-Agent: xh/0.0.0 (test mode) HTTP/1.1 200 OK Content-Length: 17 Date: N/A final destination "#}); } #[test] fn mixed_stdin_request_items() { redirecting_command() .args(["--offline", ":", "x=3"]) .write_stdin("") .assert() .failure() .stderr(contains( "Request body (from stdin) and request data (key=value) cannot be mixed", )); } #[test] fn mixed_stdin_raw() { redirecting_command() .args(["--offline", "--raw=hello", ":"]) .write_stdin("") .assert() .failure() .stderr(contains( "Request body from stdin and --raw cannot be mixed", )); } #[test] fn mixed_raw_request_items() { get_command() .args(["--offline", "--raw=hello", ":", "x=3"]) .assert() .failure() .stderr(contains( "Request body (from --raw) and request data (key=value) cannot be mixed", )); } #[test] fn multipart_stdin() { redirecting_command() .args(["--offline", "--multipart", ":"]) .write_stdin("") .assert() .failure() .stderr(contains("Cannot build a multipart request body from stdin")); } #[test] fn multipart_raw() { get_command() .args(["--offline", "--raw=hello", "--multipart", ":"]) .assert() .failure() .stderr(contains("'--raw ' cannot be used with '--multipart'")); } #[test] fn default_json_for_raw_body() { let server = server::http(|req| async move { assert_eq!(req.headers()["content-type"], "application/json"); hyper::Response::default() }); redirecting_command() .arg(server.base_url()) .write_stdin("") .assert() .success(); } #[test] fn multipart_file_upload() { let server = server::http(|req| async move { // This test may be fragile, it's conceivable that the headers will become // lowercase in the future // (so if this breaks all of a sudden, check that first) let body = req.body_as_string().await; assert!(body.contains("Hello world")); assert!(body.contains(concat!( "Content-Disposition: form-data; name=\"x\"; filename=\"input.txt\"\r\n", "\r\n", "Hello world\n" ))); assert!(body.contains(concat!( "Content-Disposition: form-data; name=\"y\"; filename=\"foobar.htm\"\r\n", "Content-Type: text/html\r\n", "\r\n", "Hello world\n", ))); hyper::Response::default() }); let dir = tempfile::tempdir().unwrap(); let filename = dir.path().join("input.txt"); OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&filename) .unwrap() .write_all(b"Hello world\n") .unwrap(); get_command() .arg("--form") .arg(server.base_url()) .arg(format!("x@{}", filename.to_string_lossy())) .arg(format!( "y@{};type=text/html;filename=foobar.htm", filename.to_string_lossy() )) .assert() .success(); } #[test] fn warn_for_filename_tag_on_body() { let dir = tempfile::tempdir().unwrap(); let filename = dir.path().join("input"); OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&filename) .unwrap() .write_all(b"Hello world\n") .unwrap(); get_command() .arg("--offline") .arg(":") .arg(format!( "@{};filename=hello.txt", filename.to_string_lossy() )) .assert() .success() .stderr( "xh: warning: Ignoring ;filename= tag for single-file body. Consider --multipart.\n", ); } #[test] fn body_from_file() { let server = server::http(|req| async move { assert_eq!(req.headers()["content-type"], "text/plain"); assert_eq!(req.body_as_string().await, "Hello world\n"); hyper::Response::default() }); let dir = tempfile::tempdir().unwrap(); let filename = dir.path().join("input.txt"); OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&filename) .unwrap() .write_all(b"Hello world\n") .unwrap(); get_command() .arg(server.base_url()) .arg(format!("@{}", filename.to_string_lossy())) .assert() .success(); } #[test] fn body_from_file_with_explicit_mimetype() { let server = server::http(|req| async move { assert_eq!(req.headers()["content-type"], "image/png"); assert_eq!(req.body_as_string().await, "Hello world\n"); hyper::Response::default() }); let dir = tempfile::tempdir().unwrap(); let filename = dir.path().join("input.txt"); OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&filename) .unwrap() .write_all(b"Hello world\n") .unwrap(); get_command() .arg(server.base_url()) .arg(format!("@{};type=image/png", filename.to_string_lossy())) .assert() .success(); } #[test] fn body_from_file_with_fallback_mimetype() { let server = server::http(|req| async move { assert_eq!(req.headers()["content-type"], "application/json"); assert_eq!(req.body_as_string().await, "Hello world\n"); hyper::Response::default() }); let dir = tempfile::tempdir().unwrap(); let filename = dir.path().join("input"); OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&filename) .unwrap() .write_all(b"Hello world\n") .unwrap(); get_command() .arg(server.base_url()) .arg(format!("@{}", filename.to_string_lossy())) .assert() .success(); } #[test] fn no_double_file_body() { get_command() .args([":", "@foo", "@bar"]) .assert() .failure() .stderr(contains("Can't read request from multiple files")); } #[test] fn print_body_from_file() { let dir = tempfile::tempdir().unwrap(); let filename = dir.path().join("input"); OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&filename) .unwrap() .write_all(b"Hello world\n") .unwrap(); get_command() .arg("--offline") .arg(":") .arg(format!("@{}", filename.to_string_lossy())) .assert() .success() .stdout(contains("Hello world")); } #[test] fn colored_headers() { color_command() .args(["--offline", ":"]) .assert() .success() // Color .stdout(contains("\x1b[4m")) // Reset .stdout(contains("\x1b[0m")); } #[test] fn colored_body() { color_command() .args(["--offline", ":", "x:=3"]) .assert() .success() .stdout(contains("\x1b[34m3\x1b[0m")); } #[test] fn force_color_pipe() { redirecting_command() .arg("--ignore-stdin") .arg("--offline") .arg("--pretty=colors") .arg(":") .arg("x:=3") .assert() .success() .stdout(contains("\x1b[34m3\x1b[0m")); } #[test] fn request_json_keys_order_is_preserved() { let server = server::http(|req| async move { assert_eq!(req.body_as_string().await, r#"{"name":"ali","age":24}"#); hyper::Response::default() }); get_command() .args(["get", &server.base_url(), "name=ali", "age:=24"]) .assert() .success(); } #[test] fn data_field_from_file() { let server = server::http(|req| async move { assert_eq!(req.body_as_string().await, r#"{"ids":"[1,2,3]"}"#); hyper::Response::default() }); let mut text_file = NamedTempFile::new().unwrap(); write!(text_file, "[1,2,3]").unwrap(); get_command() .arg(server.base_url()) .arg(format!("ids=@{}", text_file.path().to_string_lossy())) .assert() .success(); } #[test] fn data_field_from_file_in_form_mode() { let server = server::http(|req| async move { assert_eq!(req.body_as_string().await, r#"message=hello+world"#); hyper::Response::default() }); let mut text_file = NamedTempFile::new().unwrap(); write!(text_file, "hello world").unwrap(); get_command() .arg(server.base_url()) .arg("--form") .arg(format!("message=@{}", text_file.path().to_string_lossy())) .assert() .success(); } #[test] fn json_field_from_file() { let server = server::http(|req| async move { assert_eq!(req.body_as_string().await, r#"{"ids":[1,2,3]}"#); hyper::Response::default() }); let mut json_file = NamedTempFile::new().unwrap(); writeln!(json_file, "[1,2,3]").unwrap(); get_command() .arg(server.base_url()) .arg(format!("ids:=@{}", json_file.path().to_string_lossy())) .assert() .success(); } #[test] fn header_from_file() { let server = server::http(|req| async move { assert_eq!(req.headers()["x-api-key"], "hello1234"); hyper::Response::default() }); let mut text_file = NamedTempFile::new().unwrap(); writeln!(text_file, "hello1234").unwrap(); get_command() .arg(server.base_url()) .arg(format!("x-api-key:@{}", text_file.path().to_string_lossy())) .assert() .success(); } #[test] fn query_param_from_file() { let server = server::http(|req| async move { assert_eq!(req.query_params()["foo"], "bar+baz\n"); hyper::Response::default() }); let mut text_file = NamedTempFile::new().unwrap(); writeln!(text_file, "bar+baz").unwrap(); get_command() .arg(server.base_url()) .arg(format!("foo==@{}", text_file.path().to_string_lossy())) .assert() .success(); } #[test] fn can_unset_default_headers() { get_command() .args([":", "user-agent:", "--offline"]) .assert() .stdout(indoc! {r#" GET / HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Host: http.mock "#}); } #[test] fn can_unset_headers() { get_command() .args([":", "hello:world", "goodbye:world", "goodbye:", "--offline"]) .assert() .stdout(indoc! {r#" GET / HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Hello: world Host: http.mock User-Agent: xh/0.0.0 (test mode) "#}); } #[test] fn can_set_unset_header() { get_command() .args([":", "hello:", "hello:world", "--offline"]) .assert() .stdout(indoc! {r#" GET / HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Hello: world Host: http.mock User-Agent: xh/0.0.0 (test mode) "#}); } #[test] fn named_sessions() { let server = server::http(|_req| async move { hyper::Response::builder() .header("set-cookie", "cook1=one; Path=/") .body("".into()) .unwrap() }); let config_dir = tempdir().unwrap(); let random_name = random_string(); get_command() .env("XH_CONFIG_DIR", config_dir.path()) .arg(server.base_url()) .arg(format!("--session={}", random_name)) .arg("--bearer=hello") .arg("cookie:lang=en") .assert() .success(); server.assert_hits(1); let path_to_session = config_dir.path().join::( [ "sessions", &format!("127.0.0.1_{}", server.port()), &format!("{}.json", random_name), ] .iter() .collect(), ); let session_content = fs::read_to_string(path_to_session).unwrap(); assert_eq!( serde_json::from_str::(&session_content).unwrap(), serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": "bearer", "raw_auth": "hello" }, "cookies": [ { "name": "lang", "value": "en", "path": "/", "domain": "127.0.0.1" }, { "name": "cook1", "value": "one", "path": "/", "domain": "127.0.0.1" } ], "headers": [] }) ); } #[test] fn anonymous_sessions() { let server = server::http(|_req| async move { hyper::Response::builder() .header("set-cookie", "cook1=one") .body("".into()) .unwrap() }); let mut path_to_session = std::env::temp_dir(); let file_name = random_string(); path_to_session.push(file_name); get_command() .arg(server.base_url()) .arg(format!("--session={}", path_to_session.to_string_lossy())) .arg("--auth=me:pass") .arg("hello:world") .assert() .success(); server.assert_hits(1); let session_content = fs::read_to_string(path_to_session).unwrap(); assert_eq!( serde_json::from_str::(&session_content).unwrap(), serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": "basic", "raw_auth": "me:pass" }, "cookies": [ { "name": "cook1", "value": "one", "domain": "127.0.0.1", "path": "/" } ], "headers": [ { "name": "hello", "value": "world" } ] }) ); } #[test] fn anonymous_read_only_session() { let server = server::http(|_req| async move { hyper::Response::builder() .header("set-cookie", "lang=en") .body("".into()) .unwrap() }); let session_file = NamedTempFile::new().unwrap(); let old_session_content = serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [ { "name": "cookie1", "value": "one" } ], "headers": [ { "name": "hello", "value": "world" } ] }); std::fs::write(&session_file, old_session_content.to_string()).unwrap(); get_command() .arg(server.base_url()) .arg("goodbye:world") .arg(format!( "--session-read-only={}", session_file.path().to_string_lossy() )) .assert() .success(); assert_eq!( serde_json::from_str::( &fs::read_to_string(session_file.path()).unwrap() ) .unwrap(), old_session_content ); } #[test] fn session_files_are_created_in_read_only_mode() { let server = server::http(|_req| async move { hyper::Response::builder() .header("set-cookie", "lang=ar") .body("".into()) .unwrap() }); let mut path_to_session = std::env::temp_dir(); let file_name = random_string(); path_to_session.push(file_name); assert_eq!(path_to_session.exists(), false); get_command() .arg(server.base_url()) .arg("hello:world") .arg(format!( "--session-read-only={}", path_to_session.to_string_lossy() )) .assert() .success(); let session_content = fs::read_to_string(path_to_session).unwrap(); assert_eq!( serde_json::from_str::(&session_content).unwrap(), serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [ { "name": "lang", "value": "ar", "domain": "127.0.0.1", "path": "/" } ], "headers": [ { "name": "hello", "value": "world" } ] }) ); } #[test] fn named_read_only_session() { let server = server::http(|_req| async move { hyper::Response::builder() .header("set-cookie", "lang=en") .body("".into()) .unwrap() }); let config_dir = tempdir().unwrap(); let random_name = random_string(); let path_to_session = config_dir.path().join::( [ "xh", "sessions", &format!("127.0.0.1_{}", server.port()), &format!("{}.json", random_name), ] .iter() .collect(), ); let old_session_content = serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [ { "name": "cookie1", "value": "one" } ], "headers": [ { "name": "hello", "value": "world" } ] }); fs::create_dir_all(path_to_session.parent().unwrap()).unwrap(); File::create(&path_to_session).unwrap(); std::fs::write(&path_to_session, old_session_content.to_string()).unwrap(); get_command() .env("XH_CONFIG_DIR", config_dir.path()) .arg(server.base_url()) .arg("goodbye:world") .arg(format!("--session-read-only={}", random_name)) .assert() .success(); assert_eq!( serde_json::from_str::(&fs::read_to_string(path_to_session).unwrap()) .unwrap(), old_session_content ); } #[test] fn expired_cookies_are_removed_from_session() { let future_timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() + 1000; let past_timestamp = 1_114_425_967; // 2005-04-25 let session_file = NamedTempFile::new().unwrap(); std::fs::write( &session_file, serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [ { "name": "expired_cookie", "value": "random_string", "expires": past_timestamp, "domain": "127.0.0.1" }, { "name": "unexpired_cookie", "value": "random_string", "expires": future_timestamp, "domain": "127.0.0.1" }, { "name": "with_out_expiry", "value": "random_string", "domain": "127.0.0.1" } ], "headers": [] }) .to_string(), ) .unwrap(); get_command() .arg("127.0.0.1") .arg(format!( "--session={}", session_file.path().to_string_lossy() )) .arg("--offline") .assert() .success(); let session_content = fs::read_to_string(session_file.path()).unwrap(); assert_eq!( serde_json::from_str::(&session_content).unwrap(), serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [ { "name": "unexpired_cookie", "value": "random_string", "expires": future_timestamp, "domain": "127.0.0.1", "path": "/" }, { "name": "with_out_expiry", "value": "random_string", "domain": "127.0.0.1", "path": "/" } ], "headers": [] }) ); } fn cookies_are_equal(c1: &str, c2: &str) -> bool { HashSet::<_>::from_iter(c1.split(';').map(str::trim)) == HashSet::<_>::from_iter(c2.split(';').map(str::trim)) } #[test] fn cookies_override_each_other_in_the_correct_order() { // Cookies storage priority is: Server response > Command line request > Session file // See https://httpie.io/docs#cookie-storage-behaviour let server = server::http(|req| async move { assert!(cookies_are_equal( req.headers()["cookie"].to_str().unwrap(), "lang=fr; cook1=two; cook2=two" )); hyper::Response::builder() .header("set-cookie", "lang=en") .header("set-cookie", "cook1=one") .body("".into()) .unwrap() }); let session_file = NamedTempFile::new().unwrap(); std::fs::write( &session_file, serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [ { "name": "lang", "value": "fr", "domain": "127.0.0.1" }, { "name": "cook2", "value": "three", "domain": "127.0.0.1" } ], "headers": [] }) .to_string(), ) .unwrap(); get_command() .arg(server.base_url()) .arg("cookie:cook1=two;cook2=two") .arg(format!( "--session={}", session_file.path().to_string_lossy() )) .arg("--no-check-status") .assert() .success(); server.assert_hits(1); let session_content = fs::read_to_string(session_file.path()).unwrap(); assert_eq!( serde_json::from_str::(&session_content).unwrap(), serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [ { "name": "lang", "value": "en", "domain": "127.0.0.1", "path": "/" }, { "name": "cook2", "value": "two", "domain": "127.0.0.1", "path": "/" }, { "name": "cook1", "value": "one", "domain": "127.0.0.1", "path": "/" }, ], "headers": [] }) ); } #[test] fn cookies_are_segmented_by_domain() { let session_file = NamedTempFile::new().unwrap(); std::fs::write( &session_file, serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [ // will be overwritten by set-cookie header from example.com { "name": "lang", "value": "fi", "domain": "example.com" }, // will not be overwritten { "name": "lang", "value": "fr", "domain": "example.org" }, ], "headers": [] }) .to_string(), ) .unwrap(); let server = server::http(|req| async move { match req.uri().host() { Some("example.com") => { assert_eq!(req.headers()["cookie"].to_str().unwrap(), "lang=fi"); hyper::Response::builder() .header("set-cookie", "lang=en") .body("".into()) .unwrap() } Some("example.net") => { assert!(req.headers().get("cookie").is_none()); hyper::Response::builder() .header("set-cookie", "lang=ar") .body("".into()) .unwrap() } _ => panic!("unknown path"), } }); for url in ["http://example.com", "http://example.net"] { get_command() .arg(url) .arg(format!("--proxy=all:{}", server.base_url())) .arg(format!( "--session={}", session_file.path().to_string_lossy() )) .assert() .success(); } let session_content = fs::read_to_string(session_file.path()).unwrap(); assert_eq!( serde_json::from_str::(&session_content).unwrap(), serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [ { "name": "lang", "value": "en", "domain": "example.com", "path": "/" }, { "name": "lang", "value": "fr", "domain": "example.org", "path": "/" }, { "name": "lang", "value": "ar", "domain": "example.net", "path": "/" } ], "headers": [] }) ); } /// According to [RFC-6265: HTTP State Management /// Mechanism](https://httpwg.org/specs/rfc6265.html#cookie-path), cookies without an explicit path /// attribute must be interpreted to have a default path. If we don't store that default path, xh /// may erroneously send cookies in requests where it shouldn't have. #[test] fn cookies_are_stored_with_default_path() { let server = server::http(|_req| async move { hyper::Response::builder() .header("set-cookie", "cook1=one") .body("".into()) .unwrap() }); let mut path_to_session = std::env::temp_dir(); let file_name = random_string(); path_to_session.push(file_name); get_command() .arg(format!("{}{}", server.base_url(), "/some/path/file")) .arg(format!("--session={}", path_to_session.to_string_lossy())) .arg("--auth=me:pass") .arg("hello:world") .assert() .success(); server.assert_hits(1); let session_content = fs::read_to_string(path_to_session).unwrap(); assert_eq!( serde_json::from_str::(&session_content).unwrap(), serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": "basic", "raw_auth": "me:pass" }, "cookies": [ { "name": "cook1", "value": "one", "domain": "127.0.0.1", "path": "/some/path" } ], "headers": [ { "name": "hello", "value": "world" } ] }) ); } #[test] fn basic_auth_from_session_is_used() { let server = server::http(|req| async move { assert_eq!(req.headers()["authorization"], "Basic dXNlcjpwYXNz"); hyper::Response::default() }); let session_file = NamedTempFile::new().unwrap(); std::fs::write( &session_file, serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": "basic", "raw_auth": "user:pass" }, "cookies": [], "headers": [] }) .to_string(), ) .unwrap(); get_command() .arg(server.base_url()) .arg(format!( "--session={}", session_file.path().to_string_lossy() )) .arg("--no-check-status") .assert() .success(); } #[test] fn bearer_auth_from_session_is_used() { let server = server::http(|req| async move { assert_eq!(req.headers()["authorization"], "Bearer secret-token"); hyper::Response::default() }); let session_file = NamedTempFile::new().unwrap(); std::fs::write( &session_file, serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": "bearer", "raw_auth": "secret-token" }, "cookies": [], "headers": [] }) .to_string(), ) .unwrap(); get_command() .arg(server.base_url()) .arg(format!( "--session={}", session_file.path().to_string_lossy() )) .arg("--no-check-status") .assert() .success(); } #[test] fn auth_netrc_is_not_persisted_in_session() { let server = server::http(|req| async move { assert_eq!(req.headers()["authorization"], "Basic dXNlcjpwYXNz"); hyper::Response::default() }); let mut path_to_session = std::env::temp_dir(); let file_name = random_string(); path_to_session.push(file_name); assert_eq!(path_to_session.exists(), false); let mut netrc = NamedTempFile::new().unwrap(); writeln!( netrc, "machine {}\nlogin user\npassword pass", server.host() ) .unwrap(); get_command() .env("NETRC", netrc.path()) .arg(server.base_url()) .arg("hello:world") .arg(format!("--session={}", path_to_session.to_string_lossy())) .assert() .success(); server.assert_hits(1); let session_content = fs::read_to_string(path_to_session).unwrap(); assert_eq!( serde_json::from_str::(&session_content).unwrap(), serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [], "headers": [ { "name": "hello", "value": "world" } ] }) ); } #[test] fn multiple_headers_with_same_key_in_session() { let server = server::http(|req| async move { use reqwest::header::HeaderValue; assert_eq!( req.headers() .get_all("hello") .into_iter() .collect::>(), [ &HeaderValue::from_static("world"), &HeaderValue::from_static("people") ] ); hyper::Response::default() }); let session_file = NamedTempFile::new().unwrap(); std::fs::write( &session_file, serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": {}, "cookies": [], "headers": [ { "name": "hello", "value": "world" }, { "name": "hello", "value": "people" }, ] }) .to_string(), ) .unwrap(); get_command() .arg(server.base_url()) .arg(format!( "--session={}", session_file.path().to_string_lossy() )) .arg("--no-check-status") .assert() .success(); } #[test] fn headers_from_session_are_overwritten() { let server = server::http(|req| async move { use reqwest::header::HeaderValue; assert_eq!( req.headers() .get_all("hello") .into_iter() .collect::>(), [&HeaderValue::from_static("people")] ); hyper::Response::default() }); let session_file = NamedTempFile::new().unwrap(); std::fs::write( &session_file, serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": {}, "cookies": [], "headers": [ { "name": "hello", "value": "world" }, ] }) .to_string(), ) .unwrap(); get_command() .arg(server.base_url()) .arg(format!( "--session={}", session_file.path().to_string_lossy() )) .arg("--no-check-status") .arg("hello:people") .assert() .success(); } #[test] fn old_session_format_is_automatically_migrated() { let server = server::http(|req| async move { assert_eq!(req.headers()["hello"], "world"); hyper::Response::default() }); let session_file = NamedTempFile::new().unwrap(); let future_timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs() + 1000; std::fs::write( &session_file, serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": {}, "cookies": { "lang": { "value": "en", "expires": future_timestamp, "path": "/", "secure": false }, }, "headers": { "hello": "world" } }) .to_string(), ) .unwrap(); get_command() .arg(server.base_url()) .arg(format!( "--session={}", session_file.path().to_string_lossy() )) .assert() .success(); let session_content = fs::read_to_string(session_file).unwrap(); assert_eq!( serde_json::from_str::(&session_content).unwrap(), serde_json::json!({ "__meta__": { "about": "xh session file", "xh": "0.0.0" }, "auth": { "type": null, "raw_auth": null }, "cookies": [ { "name": "lang", "value": "en", "expires": future_timestamp, "path": "/", "secure": false, "domain": "127.0.0.1" }, ], "headers": [ { "name": "hello", "value": "world" } ] }) ); } #[test] fn print_intermediate_requests_and_responses() { let server = server::http(|req| async move { match req.uri().path() { "/first_page" => hyper::Response::builder() .status(302) .header("Date", "N/A") .header("Location", "/second_page") .body("redirecting...".into()) .unwrap(), "/second_page" => hyper::Response::builder() .header("Date", "N/A") .body("final destination".into()) .unwrap(), _ => panic!("unknown path"), } }); get_command() .args([&server.url("/first_page"), "--follow", "--verbose", "--all"]) .assert() .stdout(indoc! {r#" GET /first_page HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Host: http.mock User-Agent: xh/0.0.0 (test mode) HTTP/1.1 302 Found Content-Length: 14 Date: N/A Location: /second_page redirecting... GET /second_page HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Host: http.mock User-Agent: xh/0.0.0 (test mode) HTTP/1.1 200 OK Content-Length: 17 Date: N/A final destination "#}); } #[test] fn history_print() { let server = server::http(|req| async move { match req.uri().path() { "/first_page" => hyper::Response::builder() .status(302) .header("Date", "N/A") .header("Location", "/second_page") .body("redirecting...".into()) .unwrap(), "/second_page" => hyper::Response::builder() .header("Date", "N/A") .body("final destination".into()) .unwrap(), _ => panic!("unknown path"), } }); get_command() .arg(server.url("/first_page")) .arg("--follow") .arg("--print=HhBb") .arg("--history-print=Hh") .arg("--all") .assert() .stdout(indoc! {r#" GET /first_page HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Host: http.mock User-Agent: xh/0.0.0 (test mode) HTTP/1.1 302 Found Content-Length: 14 Date: N/A Location: /second_page GET /second_page HTTP/1.1 Accept: */* Accept-Encoding: gzip, deflate, br, zstd Connection: keep-alive Host: http.mock User-Agent: xh/0.0.0 (test mode) HTTP/1.1 200 OK Content-Length: 17 Date: N/A final destination "#}); } #[test] fn max_redirects_is_enforced() { let server = server::http(|_req| async move { hyper::Response::builder() .status(302) .header("Date", "N/A") .header("Location", "/") // infinite redirect loop .body("redirecting...".into()) .unwrap() }); get_command() .args([&server.base_url(), "--follow", "--max-redirects=5"]) .assert() .stderr(contains("Too many redirects (--max-redirects=5)")) .code(6); } #[test] fn method_is_changed_when_following_302_redirect() { let server = server::http(|req| async move { match req.uri().path() { "/first_page" => { assert_eq!(req.method(), "POST"); assert!(req.headers().get("Content-Length").is_some()); assert_eq!(req.body_as_string().await, r#"{"name":"ali"}"#); hyper::Response::builder() .status(302) .header("Location", "/second_page") .body("redirecting...".into()) .unwrap() } "/second_page" => { assert_eq!(req.method(), "GET"); assert!(req.headers().get("Content-Length").is_none()); hyper::Response::builder() .body("final destination".into()) .unwrap() } _ => panic!("unknown path"), } }); get_command() .args([ "post", &server.url("/first_page"), "--verbose", "--follow", "name=ali", ]) .assert() .success() .stdout(contains("POST /first_page HTTP/1.1")) .stdout(contains("GET /second_page HTTP/1.1")); server.assert_hits(2); } #[test] fn method_is_not_changed_when_following_307_redirect() { let server = server::http(|req| async move { match req.uri().path() { "/first_page" => { assert_eq!(req.method(), "POST"); assert_eq!(req.body_as_string().await, r#"{"name":"ali"}"#); hyper::Response::builder() .status(307) .header("Location", "/second_page") .body("redirecting...".into()) .unwrap() } "/second_page" => { assert_eq!(req.method(), "POST"); assert_eq!(req.body_as_string().await, r#"{"name":"ali"}"#); hyper::Response::builder() .body("final destination".into()) .unwrap() } _ => panic!("unknown path"), } }); get_command() .args([ "post", &server.url("/first_page"), "--verbose", "--follow", "name=ali", ]) .assert() .success() .stdout(contains("POST /first_page HTTP/1.1")) .stdout(contains("POST /second_page HTTP/1.1")); server.assert_hits(2); } #[test] fn sensitive_headers_are_removed_after_cross_domain_redirect() { let server1 = server::http(|req| async move { assert!(req.headers().get("Authorization").is_none()); assert!(req.headers().get("Hello").is_some()); hyper::Response::builder() .header("Date", "N/A") .body("final destination".into()) .unwrap() }); let server1_base_url = server1.base_url(); let server2 = server::http(move |req| { let server1_base_url = server1_base_url.clone(); async move { assert!(req.headers().get("Authorization").is_some()); assert!(req.headers().get("Hello").is_some()); hyper::Response::builder() .status(302) .header("Location", server1_base_url) .body("redirecting...".into()) .unwrap() } }); get_command() .arg(server2.base_url()) .arg("--follow") .arg("--auth=user:pass") .arg("hello:world") .assert() .success(); server1.assert_hits(1); server2.assert_hits(1); } #[test] fn request_body_is_buffered_for_307_redirect() { let server = server::http(|req| async move { match req.uri().path() { "/first_page" => hyper::Response::builder() .status(307) .header("Location", "/second_page") .body("redirecting...".into()) .unwrap(), "/second_page" => { assert_eq!(req.body_as_string().await, "hello world\n"); hyper::Response::builder() .body("final destination".into()) .unwrap() } _ => panic!("unknown path"), } }); let mut file = NamedTempFile::new().unwrap(); writeln!(file, "hello world").unwrap(); get_command() .arg(server.url("/first_page")) .arg("--follow") .arg("--all") .arg("--print=Hh") // prevent Printer from buffering the request body by not using --verbose .arg(format!("@{}", file.path().to_string_lossy())) .assert() .success() .stdout(contains("POST /second_page HTTP/1.1")); server.assert_hits(2); } #[test] fn read_args_from_config() { let config_dir = tempdir().unwrap(); File::create(config_dir.path().join("config.json")).unwrap(); std::fs::write( config_dir.path().join("config.json"), serde_json::json!({"default_options": ["--form", "--print=hbHB"]}).to_string(), ) .unwrap(); get_command() .env("XH_CONFIG_DIR", config_dir.path()) .arg(":") .arg("--offline") .arg("--print=B") // this should overwrite the value from config.json .arg("sort=asc") .arg("limit=100") .assert() .stdout("sort=asc&limit=100\n\n") .success(); } #[test] fn warns_if_config_is_invalid() { let config_dir = tempdir().unwrap(); File::create(config_dir.path().join("config.json")).unwrap(); std::fs::write( config_dir.path().join("config.json"), serde_json::json!({"default_options": "--form"}).to_string(), ) .unwrap(); get_command() .env("XH_CONFIG_DIR", config_dir.path()) .args([":", "--offline"]) .assert() .stderr(contains("Unable to parse config file")) .success(); } #[cfg(feature = "online-tests")] #[test] fn http1_0() { get_command() .args(["--print=hH", "--http-version=1.0", "https://example.com"]) .assert() .success() .stdout(contains("GET / HTTP/1.0")) // Some servers i.e nginx respond with HTTP/1.1 to HTTP/1.0 requests, see https://serverfault.com/questions/442960/nginx-ignoring-clients-http-1-0-request-and-respond-by-http-1-1 // Fortunately, https://example.com is not one of those. .stdout(contains("HTTP/1.0 200 OK")); } #[cfg(feature = "online-tests")] #[test] fn http1_1() { get_command() .args(["--print=hH", "--http-version=1.1", "https://example.com"]) .assert() .success() .stdout(contains("GET / HTTP/1.1")) .stdout(contains("HTTP/1.1 200 OK")); } #[cfg(feature = "online-tests")] #[test] fn http2() { get_command() .args(["--print=hH", "--http-version=2", "https://example.com"]) .assert() .success() .stdout(contains("GET / HTTP/2.0")) .stdout(contains("HTTP/2.0 200 OK")); } #[test] fn http2_prior_knowledge() { let server = server::http(|_req| async move { hyper::Response::builder() .body("Hello HTTP/2.0".into()) .unwrap() }); get_command() .arg("-v") .arg("--http-version=2") .arg(server.base_url()) .assert() .failure() .stderr(contains("UserUnsupportedVersion")); get_command() .arg("-v") .arg("--http-version=2-prior-knowledge") .arg(server.base_url()) .assert() .success() .stdout(contains("GET / HTTP/2.0")) .stdout(contains("HTTP/2.0 200")) .stdout(contains("Hello HTTP/2.0")); } #[test] fn override_response_charset() { let server = server::http(|_req| async move { hyper::Response::builder() .header("Content-Type", "text/plain; charset=utf-8") .body(b"\xe9".as_ref().into()) .unwrap() }); get_command() .arg("--print=b") .arg("--response-charset=latin1") .arg(server.base_url()) .assert() .stdout("é\n"); } #[test] fn override_response_mime() { let server = server::http(|_req| async move { hyper::Response::builder() .header("Content-Type", "text/html; charset=utf-8") .body("{\"status\": \"ok\"}".into()) .unwrap() }); get_command() .arg("--print=b") .arg("--response-mime=application/json") .arg(server.base_url()) .assert() .stdout(indoc! {r#" { "status": "ok" } "#}); } #[test] fn omit_response_body() { let server = server::http(|_req| async move { hyper::Response::builder() .header("date", "N/A") .body("Hello!".into()) .unwrap() }); get_command() .arg("--print=h") .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Length: 6 Date: N/A "#}); } #[test] fn encoding_detection() { fn case( content_type: &'static str, body: &'static (impl AsRef<[u8]> + ?Sized), output: &'static str, ) { let body = body.as_ref(); let server = server::http(move |_| async move { hyper::Response::builder() .header("Content-Type", content_type) .body(body.into()) .unwrap() }); get_command() .arg("--print=b") .arg(server.base_url()) .assert() .stdout(output); get_command() .arg("--print=b") .arg("--stream") .arg(server.base_url()) .assert() .stdout(output); server.assert_hits(2); } // UTF-8 is a typical fallback case("text/plain", "é", "é\n"); // But headers take precedence case("text/html; charset=latin1", "é", "é\n"); // As do BOMs case("text/html", b"\xFF\xFEa\0b\0", "ab\n"); // windows-1252 is another common fallback case("text/plain", b"\xFF", "ÿ\n"); // BOMs are stripped case("text/plain", b"\xFF\xFEa\0b\0", "ab\n"); case("text/plain; charset=UTF-16", b"\xFF\xFEa\0b\0", "ab\n"); case("text/plain; charset=UTF-16LE", b"\xFF\xFEa\0b\0", "ab\n"); case("text/plain", b"\xFE\xFF\0a\0b", "ab\n"); case("text/plain; charset=UTF-16BE", b"\xFE\xFF\0a\0b", "ab\n"); // ...unless they're for a different encoding case( "text/plain; charset=UTF-16LE", b"\xFE\xFFa\0b\0", "\u{FFFE}ab\n", ); case( "text/plain; charset=UTF-16BE", b"\xFF\xFE\0a\0b", "\u{FFFE}ab\n", ); // Binary content is detected case("application/octet-stream", "foo\0bar", BINARY_SUPPRESSOR); // (even for non-ASCII-compatible encodings) case("text/plain; charset=UTF-16", "\0\0", BINARY_SUPPRESSOR); } #[test] fn tilde_expanded_in_request_items() { let homedir = TempDir::new().unwrap(); std::fs::write(homedir.path().join("secret_key.txt"), "sxemfalm.....").unwrap(); get_command() .env("HOME", homedir.path()) .env("XH_TEST_MODE_WIN_HOME_DIR", homedir.path()) .args(["--offline", ":", "key=@~/secret_key.txt"]) .assert() .stdout(contains("sxemfalm.....")) .success(); std::fs::write(homedir.path().join("ids.json"), "[102,111,164]").unwrap(); get_command() .env("HOME", homedir.path()) .env("XH_TEST_MODE_WIN_HOME_DIR", homedir.path()) .args(["--offline", "--pretty=none", ":", "ids:=@~/ids.json"]) .assert() .stdout(contains("[102,111,164]")) .success(); std::fs::write(homedir.path().join("moby-dick.txt"), "Call me Ishmael.").unwrap(); get_command() .env("HOME", homedir.path()) .env("XH_TEST_MODE_WIN_HOME_DIR", homedir.path()) .args(["--offline", "--form", ":", "content@~/moby-dick.txt"]) .assert() .stdout(contains("Call me Ishmael.")) .success(); std::fs::write(homedir.path().join("random_file"), "random data").unwrap(); get_command() .env("HOME", homedir.path()) .env("XH_TEST_MODE_WIN_HOME_DIR", homedir.path()) .args(["--offline", ":", "@~/random_file"]) .assert() .stdout(contains("random data")) .success(); } #[test] fn gzip() { let server = server::http(|_req| async move { let compressed_bytes = fs::read("./tests/fixtures/responses/hello_world.gz").unwrap(); hyper::Response::builder() .header("date", "N/A") .header("content-encoding", "gzip") .body(compressed_bytes.into()) .unwrap() }); get_command() .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Encoding: gzip Content-Length: 48 Date: N/A Hello world "#}); } #[test] fn deflate() { let server = server::http(|_req| async move { let compressed_bytes = fs::read("./tests/fixtures/responses/hello_world.zz").unwrap(); hyper::Response::builder() .header("date", "N/A") .header("content-encoding", "deflate") .body(compressed_bytes.into()) .unwrap() }); get_command() .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Encoding: deflate Content-Length: 20 Date: N/A Hello world "#}); } #[test] fn brotli() { let server = server::http(|_req| async move { let compressed_bytes = fs::read("./tests/fixtures/responses/hello_world.br").unwrap(); hyper::Response::builder() .header("date", "N/A") .header("content-encoding", "br") .body(compressed_bytes.into()) .unwrap() }); get_command() .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Encoding: br Content-Length: 17 Date: N/A Hello world "#}); } #[test] fn zstd() { let server = server::http(|_req| async move { let compressed_bytes = fs::read("./tests/fixtures/responses/hello_world.zst").unwrap(); hyper::Response::builder() .header("date", "N/A") .header("content-encoding", "zstd") .body(compressed_bytes.into()) .unwrap() }); get_command() .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Encoding: zstd Content-Length: 25 Date: N/A Hello world "#}); } #[test] fn empty_response_with_content_encoding() { let server = server::http(|_req| async move { hyper::Response::builder() .header("date", "N/A") .header("content-encoding", "gzip") .body("".into()) .unwrap() }); get_command() .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Encoding: gzip Content-Length: 0 Date: N/A "#}); } #[test] fn empty_response_with_content_encoding_and_content_length() { let server = server::http(|_req| async move { hyper::Response::builder() .header("date", "N/A") .header("content-encoding", "gzip") .header("content-length", "100") .body("".into()) .unwrap() }); get_command() .arg("head") .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Encoding: gzip Content-Length: 100 Date: N/A "#}); } /// Regression test: this used to crash because ZstdDecoder::new() is fallible #[test] fn empty_zstd_response_with_content_encoding_and_content_length() { let server = server::http(|_req| async move { hyper::Response::builder() .header("date", "N/A") .header("content-encoding", "zstd") .header("content-length", "100") .body("".into()) .unwrap() }); get_command() .arg("head") .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Encoding: zstd Content-Length: 100 Date: N/A "#}); } /// After an initial fix this scenario still crashed #[test] fn streaming_empty_zstd_response_with_content_encoding_and_content_length() { let server = server::http(|_req| async move { hyper::Response::builder() .header("date", "N/A") .header("content-encoding", "zstd") .header("content-length", "100") .body("".into()) .unwrap() }); get_command() .arg("--stream") .arg("head") .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK Content-Encoding: zstd Content-Length: 100 Date: N/A "#}); } #[test] fn response_meta() { let server = server::http(|_req| async move { hyper::Response::builder() .header("date", "N/A") .body("Hello!".into()) .unwrap() }); get_command() .arg("--print=m") .arg(server.base_url()) .assert() .stdout(contains("Elapsed time: ")) .stdout(contains("Remote address: ")); } #[test] fn redirect_with_response_meta() { let server = server::http(|req| async move { match req.uri().path() { "/first_page" => hyper::Response::builder() .status(302) .header("Date", "N/A") .header("Location", "/second_page") .body("redirecting...".into()) .unwrap(), "/second_page" => hyper::Response::builder() .header("Date", "N/A") .body("final destination".into()) .unwrap(), _ => panic!("unknown path"), } }); get_command() .arg(server.url("/first_page")) .arg("--follow") .arg("-vv") .assert() .stdout(contains("Elapsed time: ").count(2)) .stdout(contains("Remote address: ").count(2)); get_command() .arg(server.url("/first_page")) .arg("--follow") .arg("--meta") .assert() .stdout(contains("Elapsed time: ").count(1)) .stdout(contains("Remote address: ").count(1)); } #[cfg(feature = "online-tests")] #[test] fn digest_auth_with_response_meta() { get_command() .arg("--auth-type=digest") .arg("--auth=ahmed:12345") .arg("-vv") .arg("httpbingo.org/digest-auth/auth/ahmed/12345") .assert() .stdout(contains("Elapsed time: ").count(2)) .stdout(contains("Remote address: ").count(2)); } #[test] fn non_get_redirect_translation_warning() { get_command() .args(["--follow", "--curl", "POST", "http://example.com"]) .assert() .stderr(contains("Using a combination of -X/--request and -L/--location which may cause unintended side effects.")); } #[test] fn custom_json_indent_level() { let server = server::http(|_req| async move { hyper::Response::builder() .header("content-type", "application/json") .body(r#"{"hello":"world"}"#.into()) .unwrap() }); get_command() .args([ "--print=b", "--format-options=json.indent:2", &server.base_url(), ]) .assert() .stdout(indoc! {r#" { "hello": "world" } "#}); } #[test] fn unsorted_headers() { let server = server::http(|_req| async move { hyper::Response::builder() .header("X-Foo", "Bar") .header("Date", "N/A") .header("Content-Type", "application/json") .body(r#"{"hello":"world"}"#.into()) .unwrap() }); get_command() .args(["--format-options=headers.sort:false", &server.base_url()]) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK X-Foo: Bar Date: N/A Content-Type: application/json Content-Length: 17 { "hello": "world" } "#}); } #[test] fn multiple_format_options_are_merged() { let server = server::http(|_req| async move { hyper::Response::builder() .header("X-Foo", "Bar") .header("Date", "N/A") .header("Content-Type", "application/json") .body(r#"{"hello":"world"}"#.into()) .unwrap() }); get_command() .arg("--format-options=json.indent:2,json.indent:8") .arg("--format-options=headers.sort:false") .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 OK X-Foo: Bar Date: N/A Content-Type: application/json Content-Length: 17 { "hello": "world" } "#}); } #[test] fn reason_phrase_is_preserved() { let server = server::http(|_req| async move { let mut response = hyper::Response::builder(); response .extensions_mut() .unwrap() .insert(hyper::ext::ReasonPhrase::from_static(b"Wonderful")); response.header("Date", "N/A").body("".into()).unwrap() }); get_command() .arg(server.base_url()) .assert() .stdout(indoc! {r#" HTTP/1.1 200 Wonderful Content-Length: 0 Date: N/A "#}); }