xh/tests/cli.rs
Jan Verbeek 300203338f Improve rustls error messages for invalid certificates
After a recent release rustls provides better error messages for
invalid certificates. For example:

```
invalid peer certificate: certificate not valid for name "wrong.host.badssl.com"; certificate is only valid for DnsName("*.badssl.com") or DnsName("badssl.com")
```

The message for expired certificates still isn't too readable but the
error now contains timestamps so we enhance it ourselves:

```
xh: error: error sending request for url (https://expired.badssl.com/)

Caused by:
    0: client error (Connect)
    1: invalid peer certificate: certificate expired: verification time 1742381579 (UNIX), but certificate is not valid after 1428883199 (313498380 seconds ago)

Certificate not valid after 2015-04-12 23:59:59.0 +00:00:00 (9years 11months 6days 8h 43m 24s ago).
```
2025-03-19 12:29:17 +01:00

3807 lines
102 KiB
Rust
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![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<String, String>;
fn body(self) -> Pin<Box<dyn Future<Output = Vec<u8>> + Send>>;
fn body_as_string(self) -> Pin<Box<dyn Future<Output = String> + Send>>;
}
impl<T> RequestExt for hyper::Request<T>
where
T: hyper::body::Body + Send + 'static,
T::Data: Send,
T::Error: std::fmt::Debug,
{
fn query_params(&self) -> HashMap<String, String> {
form_urlencoded::parse(self.uri().query().unwrap().as_bytes())
.into_owned()
.collect::<HashMap<String, String>>()
}
fn body(self) -> Pin<Box<dyn Future<Output = Vec<u8>> + Send>> {
let fut = async { self.collect().await.unwrap().to_bytes().to_vec() };
Box::pin(fut)
}
fn body_as_string(self) -> Pin<Box<dyn Future<Output = String> + 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<String> {
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: 你好å<C2BD><C3A5> (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äº<C3A4> (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 <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::<std::path::PathBuf>(
[
"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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(
&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::<serde_json::Value>(&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::<std::path::PathBuf>(
[
"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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<Vec<_>>(),
[
&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::<Vec<_>>(),
[&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::<serde_json::Value>(&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
"#});
}