parent
154f0b97bf
commit
ccdbf1307c
@ -1,4 +1,9 @@ |
||||
.PHONY: make |
||||
.PHONY: make update-psl |
||||
|
||||
make: |
||||
cargo build --release
|
||||
|
||||
# Refresh the vendored Public Suffix List used for cookie domain checks
|
||||
# (compiled into the binary by stdlib::http).
|
||||
update-psl: |
||||
curl -sfL https://publicsuffix.org/list/public_suffix_list.dat -o assets/public_suffix_list.dat
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,543 @@ |
||||
//! End-to-end tests for the http module against a local `tiny_http` server:
|
||||
//! request building (form/json/headers/auth), redirect behaviour, the session
|
||||
//! cookie jar, and jar persistence. Cookie *policy* (public suffix, Secure,
|
||||
//! expiry) is unit-tested in `stdlib::http`.
|
||||
|
||||
use std::sync::{Arc, Mutex}; |
||||
use std::thread::JoinHandle; |
||||
|
||||
use super::lua as sandboxed_lua; |
||||
|
||||
/// The stdlib Lua with networking and filesystem allowed (the default
|
||||
/// test policy is deny-all), plus a `BASE` global pointing at `server`.
|
||||
fn lua_net(server: &TestServer) -> mlua::Lua { |
||||
let lua = sandboxed_lua(); |
||||
lua.set_app_data(Arc::new(crate::stdlib::fs::FsPolicy::allow_all())); |
||||
lua.globals().set("BASE", server.base.clone()).unwrap(); |
||||
lua |
||||
} |
||||
|
||||
/// A request as the server saw it, for asserting on from the test thread
|
||||
/// (a panic inside the server thread would be silently swallowed).
|
||||
#[derive(Clone, Debug)] |
||||
struct Captured { |
||||
method: String, |
||||
url: String, |
||||
headers: Vec<(String, String)>, |
||||
body: String, |
||||
} |
||||
|
||||
impl Captured { |
||||
fn header(&self, name: &str) -> Option<&str> { |
||||
self.headers.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) |
||||
} |
||||
} |
||||
|
||||
struct TestServer { |
||||
base: String, |
||||
seen: Arc<Mutex<Vec<Captured>>>, |
||||
server: Arc<tiny_http::Server>, |
||||
thread: Option<JoinHandle<()>>, |
||||
} |
||||
|
||||
impl TestServer { |
||||
/// All requests observed so far, in arrival order.
|
||||
fn requests(&self) -> Vec<Captured> { |
||||
self.seen.lock().unwrap().clone() |
||||
} |
||||
} |
||||
|
||||
impl Drop for TestServer { |
||||
fn drop(&mut self) { |
||||
self.server.unblock(); |
||||
if let Some(t) = self.thread.take() { |
||||
let _ = t.join(); |
||||
} |
||||
} |
||||
} |
||||
|
||||
/// Spawn a local HTTP server; `handler` maps each captured request to a
|
||||
/// response. Every request is also recorded for later assertions.
|
||||
fn serve( |
||||
handler: impl Fn(&Captured) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> |
||||
+ Send |
||||
+ Sync |
||||
+ 'static, |
||||
) -> TestServer { |
||||
let server = Arc::new(tiny_http::Server::http("127.0.0.1:0").unwrap()); |
||||
let base = format!("http://{}", server.server_addr().to_ip().unwrap()); |
||||
let seen: Arc<Mutex<Vec<Captured>>> = Arc::default(); |
||||
|
||||
let thread = { |
||||
let server = server.clone(); |
||||
let seen = seen.clone(); |
||||
std::thread::spawn(move || { |
||||
for mut req in server.incoming_requests() { |
||||
let mut body = String::new(); |
||||
req.as_reader().read_to_string(&mut body).unwrap(); |
||||
let captured = Captured { |
||||
method: req.method().to_string(), |
||||
url: req.url().to_string(), |
||||
headers: req |
||||
.headers() |
||||
.iter() |
||||
.map(|h| (h.field.to_string().to_ascii_lowercase(), h.value.to_string())) |
||||
.collect(), |
||||
body, |
||||
}; |
||||
seen.lock().unwrap().push(captured.clone()); |
||||
let _ = req.respond(handler(&captured)); |
||||
} |
||||
}) |
||||
}; |
||||
|
||||
TestServer { base, seen, server, thread: Some(thread) } |
||||
} |
||||
|
||||
fn resp( |
||||
code: u16, |
||||
headers: &[(&str, &str)], |
||||
body: &str, |
||||
) -> tiny_http::Response<std::io::Cursor<Vec<u8>>> { |
||||
let mut r = tiny_http::Response::from_data(body.as_bytes().to_vec()).with_status_code(code); |
||||
for (k, v) in headers { |
||||
r.add_header(tiny_http::Header::from_bytes(k.as_bytes(), v.as_bytes()).unwrap()); |
||||
} |
||||
r |
||||
} |
||||
|
||||
fn path_of(c: &Captured) -> &str { |
||||
c.url.split('?').next().unwrap() |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_get_response_fields() { |
||||
let server = serve(|_| resp(200, &[("X-One", "1")], "hello")); |
||||
let lua = lua_net(&server); |
||||
|
||||
let (status, ok, body, x_one, url): (u16, bool, String, String, String) = lua |
||||
.load( |
||||
r#" |
||||
local r = http.get(BASE) |
||||
return r.status, r.ok, r.body, r.headers["x-one"], r.url |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
assert_eq!((status, ok), (200, true)); |
||||
assert_eq!(body, "hello"); |
||||
assert_eq!(x_one, "1"); |
||||
assert_eq!(url, format!("{}/", server.base)); |
||||
|
||||
// The default User-Agent goes out unless overridden.
|
||||
let reqs = server.requests(); |
||||
assert!(reqs[0].header("user-agent").unwrap().starts_with("a/"), "{reqs:?}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_form_body_is_urlencoded() { |
||||
let server = serve(|_| resp(200, &[], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load(r#"http.post(BASE, { form = { q = "a b&c=d", plain = "x", num = 5 } })"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
let req = &server.requests()[0]; |
||||
assert_eq!(req.method, "POST"); |
||||
assert_eq!(req.header("content-type"), Some("application/x-www-form-urlencoded")); |
||||
// Lua table order is unspecified; compare as a set of encoded pairs.
|
||||
let mut pairs: Vec<&str> = req.body.split('&').collect(); |
||||
pairs.sort(); |
||||
assert_eq!(pairs, ["num=5", "plain=x", "q=a+b%26c%3Dd"]); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_json_body_and_resp_json() { |
||||
let server = |
||||
serve(|c| resp(200, &[("Content-Type", "application/json")], &c.body.clone())); |
||||
let lua = lua_net(&server); |
||||
|
||||
let hello: String = lua |
||||
.load(r#"return http.postJSON(BASE, { hello = "world" }).json().hello"#) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(hello, "world"); |
||||
|
||||
let req = &server.requests()[0]; |
||||
assert_eq!(req.header("content-type"), Some("application/json")); |
||||
assert!(super::json_eq(&req.body, r#"{"hello":"world"}"#)); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_user_content_type_wins_over_implied() { |
||||
let server = serve(|_| resp(200, &[], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load( |
||||
r#" |
||||
http.post(BASE, { |
||||
form = { a = "1" }, |
||||
headers = { ["Content-Type"] = "text/plain" }, |
||||
}) |
||||
"#, |
||||
) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
let req = &server.requests()[0]; |
||||
let cts: Vec<&str> = |
||||
req.headers.iter().filter(|(k, _)| k == "content-type").map(|(_, v)| v.as_str()).collect(); |
||||
assert_eq!(cts, ["text/plain"], "exactly one Content-Type, the caller's"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_redirect_302_degrades_post_to_get() { |
||||
let server = serve(|c| match path_of(c) { |
||||
"/a" => resp(302, &[("Location", "/b")], ""), |
||||
"/b" => resp(200, &[], "final"), |
||||
p => resp(404, &[], p), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
let (status, body, url): (u16, String, String) = lua |
||||
.load( |
||||
r#" |
||||
local r = http.post(BASE .. "/a", { json = { x = 1 } }) |
||||
return r.status, r.body, r.url |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
assert_eq!((status, body.as_str()), (200, "final")); |
||||
assert!(url.ends_with("/b"), "{url}"); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs[1].method, "GET"); |
||||
assert_eq!(reqs[1].body, "", "302-degraded GET must not carry the POST body"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_redirect_307_preserves_method_and_body() { |
||||
let server = serve(|c| match path_of(c) { |
||||
"/a" => resp(307, &[("Location", "/b")], ""), |
||||
_ => resp(200, &[], ""), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load(r#"http.post(BASE .. "/a", { body = "payload" })"#).exec_async().await.unwrap(); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs[1].method, "POST"); |
||||
assert_eq!(reqs[1].body, "payload"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_too_many_redirects_errors() { |
||||
let server = serve(|_| resp(302, &[("Location", "/loop")], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
let err = |
||||
lua.load(r#"http.get(BASE .. "/loop")"#).exec_async().await.unwrap_err().to_string(); |
||||
assert!(err.contains("http:") && err.contains("redirect"), "{err}"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_session_captures_set_cookie_on_redirect() { |
||||
// The login pattern: Set-Cookie arrives on the 302 itself, and the
|
||||
// redirected hop must already send it back.
|
||||
let server = serve(|c| match path_of(c) { |
||||
"/login" => resp(302, &[("Location", "/dash"), ("Set-Cookie", "sid=abc; Path=/")], ""), |
||||
"/dash" => resp(200, &[], ""), |
||||
p => resp(404, &[], p), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
let (sid, n): (String, i64) = lua |
||||
.load( |
||||
r#" |
||||
local s = http.session() |
||||
s:get(BASE .. "/login") |
||||
local jar = s:cookies() |
||||
local domains = 0 |
||||
for _ in pairs(jar) do domains = domains + 1 end |
||||
return jar["127.0.0.1"].sid, domains |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(sid, "abc"); |
||||
assert_eq!(n, 1); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs[0].header("cookie"), None); |
||||
assert_eq!(reqs[1].header("cookie"), Some("sid=abc"), "jar cookie must reach /dash"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_session_clear_cookies() { |
||||
let server = serve(|c| match path_of(c) { |
||||
"/set" => resp(200, &[("Set-Cookie", "sid=abc")], ""), |
||||
_ => resp(200, &[], ""), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load( |
||||
r#" |
||||
local s = http.session() |
||||
s:get(BASE .. "/set") |
||||
s:clearCookies() |
||||
assert(next(s:cookies()) == nil, "jar must be empty") |
||||
s:get(BASE .. "/check") |
||||
"#, |
||||
) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs[1].header("cookie"), None); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_per_request_cookies_merge_with_jar() { |
||||
let server = serve(|c| match path_of(c) { |
||||
"/set" => resp(200, &[("Set-Cookie", "a=1"), ("Set-Cookie", "keep=yes")], ""), |
||||
_ => resp(200, &[], ""), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load( |
||||
r#" |
||||
local s = http.session() |
||||
s:get(BASE .. "/set") |
||||
s:get(BASE .. "/check", { cookies = { a = "2", b = "3" } }) |
||||
"#, |
||||
) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
let reqs = server.requests(); |
||||
let mut got: Vec<&str> = reqs[1].header("cookie").unwrap().split("; ").collect(); |
||||
got.sort(); |
||||
// Jar cookie `keep` rides along, per-request `a` wins over the jar's.
|
||||
assert_eq!(got, ["a=2", "b=3", "keep=yes"]); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_cookies_opt_conflicts_with_cookie_header() { |
||||
let server = serve(|_| resp(200, &[], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
let err = lua |
||||
.load(r#"http.get(BASE, { cookies = { a = "1" }, headers = { Cookie = "b=2" } })"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err() |
||||
.to_string(); |
||||
assert!(err.contains("not both"), "{err}"); |
||||
assert!(server.requests().is_empty(), "the request must not go out"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_session_save_load_roundtrip() { |
||||
let server = serve(|c| match path_of(c) { |
||||
"/set" => resp(200, &[("Set-Cookie", "sid=abc")], ""), |
||||
_ => resp(200, &[], ""), |
||||
}); |
||||
let lua = lua_net(&server); |
||||
let dir = tempfile::tempdir().unwrap(); |
||||
let jar_path = dir.path().join("jar.jsonl"); |
||||
lua.globals().set("JAR", jar_path.to_str().unwrap()).unwrap(); |
||||
|
||||
lua.load( |
||||
r#" |
||||
local s = http.session() |
||||
s:get(BASE .. "/set") |
||||
s:save(JAR) |
||||
|
||||
-- A fresh session preloaded from the file sends the cookie again. |
||||
local s2 = http.session(JAR) |
||||
assert(s2:cookies()["127.0.0.1"].sid == "abc", "cookie must survive save/load") |
||||
s2:get(BASE .. "/check") |
||||
"#, |
||||
) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs[1].header("cookie"), Some("sid=abc")); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_basic_auth_header() { |
||||
let server = serve(|_| resp(200, &[], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
lua.load(r#"http.get(BASE, { auth = { username = "alice", password = "secret" } })"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
// base64("alice:secret")
|
||||
let auth = server.requests()[0].header("authorization").unwrap().to_string(); |
||||
assert_eq!(auth, "Basic YWxpY2U6c2VjcmV0"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_digest_auth_answers_challenge() { |
||||
let server = serve(|c| { |
||||
if c.header("authorization").is_none() { |
||||
resp( |
||||
401, |
||||
&[("WWW-Authenticate", r#"Digest realm="test", nonce="abc123", qop="auth""#)], |
||||
"", |
||||
) |
||||
} else { |
||||
resp(200, &[], "in") |
||||
} |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
let (ok, body): (bool, String) = lua |
||||
.load( |
||||
r#" |
||||
local r = http.get(BASE .. "/p", { |
||||
auth = { username = "alice", password = "secret", scheme = "digest" }, |
||||
}) |
||||
return r.ok, r.body |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert!(ok); |
||||
assert_eq!(body, "in"); |
||||
|
||||
let reqs = server.requests(); |
||||
assert_eq!(reqs.len(), 2, "one challenge, one answer"); |
||||
let auth = reqs[1].header("authorization").unwrap(); |
||||
assert!(auth.starts_with("Digest "), "{auth}"); |
||||
for part in [r#"username="alice""#, r#"uri="/p""#, "response="] { |
||||
assert!(auth.contains(part), "{auth} missing {part}"); |
||||
} |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_cross_host_redirect_strips_credentials() { |
||||
// Two servers = two hosts (the port differs). Authorization and Cookie
|
||||
// must not follow the redirect off the original host; ordinary custom
|
||||
// headers do (matching browser/curl behaviour).
|
||||
let target = serve(|_| resp(200, &[], "landed")); |
||||
let origin = { |
||||
let to = format!("{}/land", target.base); |
||||
serve(move |_| resp(302, &[("Location", &to.clone())], "")) |
||||
}; |
||||
let lua = lua_net(&origin); |
||||
|
||||
let body: String = lua |
||||
.load( |
||||
r#" |
||||
local r = http.get(BASE .. "/go", { |
||||
auth = { username = "alice", password = "secret" }, |
||||
cookies = { sid = "abc" }, |
||||
headers = { ["X-Custom"] = "keep" }, |
||||
}) |
||||
return r.body |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
assert_eq!(body, "landed"); |
||||
|
||||
let first = &origin.requests()[0]; |
||||
assert!(first.header("authorization").is_some()); |
||||
assert!(first.header("cookie").is_some()); |
||||
|
||||
let landed = &target.requests()[0]; |
||||
assert_eq!(landed.header("authorization"), None, "auth leaked cross-host"); |
||||
assert_eq!(landed.header("cookie"), None, "cookies leaked cross-host"); |
||||
assert_eq!(landed.header("x-custom"), Some("keep")); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_multi_value_headers_and_set_cookies() { |
||||
let server = serve(|_| { |
||||
resp( |
||||
200, |
||||
&[ |
||||
("X-Multi", "one"), |
||||
("X-Multi", "two"), |
||||
("Set-Cookie", "a=1; Path=/"), |
||||
("Set-Cookie", "b=2; Path=/"), |
||||
], |
||||
"", |
||||
) |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
let (multi, first_sc, sc1, sc2): (String, String, String, String) = lua |
||||
.load( |
||||
r#" |
||||
local r = http.get(BASE) |
||||
assert(#r.setCookies == 2, "expected two Set-Cookie values") |
||||
return r.headers["x-multi"], r.headers["set-cookie"], r.setCookies[1], r.setCookies[2] |
||||
"#, |
||||
) |
||||
.eval_async() |
||||
.await |
||||
.unwrap(); |
||||
|
||||
assert_eq!(multi, "one, two"); |
||||
assert_eq!(first_sc, "a=1; Path=/"); |
||||
assert_eq!((sc1.as_str(), sc2.as_str()), ("a=1; Path=/", "b=2; Path=/")); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_timeout_errors() { |
||||
let server = serve(|_| { |
||||
std::thread::sleep(std::time::Duration::from_secs(2)); |
||||
resp(200, &[], "late") |
||||
}); |
||||
let lua = lua_net(&server); |
||||
|
||||
let start = std::time::Instant::now(); |
||||
let err = lua |
||||
.load(r#"http.get(BASE, { timeout = 0.1 })"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err() |
||||
.to_string(); |
||||
assert!(err.contains("http:"), "{err}"); |
||||
assert!(start.elapsed() < std::time::Duration::from_secs(1), "timeout did not apply"); |
||||
} |
||||
|
||||
#[tokio::test] |
||||
async fn test_invalid_url_and_method() { |
||||
let server = serve(|_| resp(200, &[], "")); |
||||
let lua = lua_net(&server); |
||||
|
||||
let err = lua.load(r#"http.get("not a url")"#).exec_async().await.unwrap_err().to_string(); |
||||
assert!(err.contains("invalid URL"), "{err}"); |
||||
|
||||
let err = lua |
||||
.load(r#"http.request("BAD METHOD", BASE)"#) |
||||
.exec_async() |
||||
.await |
||||
.unwrap_err() |
||||
.to_string(); |
||||
assert!(err.contains("invalid method"), "{err}"); |
||||
|
||||
assert!(server.requests().is_empty()); |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue