425 lines
14 KiB
Rust
425 lines
14 KiB
Rust
use super::*;
|
|
#[path = "http/choice.rs"]
|
|
mod choice;
|
|
use std::{
|
|
io::{Read, Write},
|
|
net::{TcpListener, TcpStream},
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
pub(super) struct Server(pub(super) std::process::Child);
|
|
impl Drop for Server {
|
|
fn drop(&mut self) {
|
|
let _ = self.0.kill();
|
|
let _ = self.0.wait();
|
|
}
|
|
}
|
|
|
|
pub(super) fn request(
|
|
port: u16,
|
|
method: &str,
|
|
path: &str,
|
|
host: &str,
|
|
headers: &str,
|
|
body: &str,
|
|
) -> (u16, String) {
|
|
let (status, _, body) = request_head(port, method, path, host, headers, body);
|
|
(status, body)
|
|
}
|
|
|
|
pub(super) fn request_head(
|
|
port: u16,
|
|
method: &str,
|
|
path: &str,
|
|
host: &str,
|
|
headers: &str,
|
|
body: &str,
|
|
) -> (u16, String, String) {
|
|
let mut socket = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
|
socket
|
|
.set_read_timeout(Some(Duration::from_secs(3)))
|
|
.unwrap();
|
|
write!(socket, "{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Length: {}\r\n{headers}\r\n{body}", body.len()).unwrap();
|
|
let mut response = String::new();
|
|
socket.read_to_string(&mut response).unwrap();
|
|
let (head, body) = response.split_once("\r\n\r\n").unwrap();
|
|
(
|
|
head.split_whitespace().nth(1).unwrap().parse().unwrap(),
|
|
head.into(),
|
|
body.into(),
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn json_cmd_api_budget_keeps_input_boundaries_and_raw_spelling() {
|
|
let directory = Directory::new();
|
|
let root = directory.0.join("module/demo/input");
|
|
std::fs::create_dir_all(&root).unwrap();
|
|
std::fs::write(
|
|
root.join("app.dever"),
|
|
r#"type Packet { payload: Json
|
|
note: Text?
|
|
count: Int
|
|
extra: Json? }
|
|
|
|
accept(value: Packet) (answer: Int) { answer = value.count }
|
|
|
|
echo(value: Json) (answer: Json) { answer = value }
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
let mut api = "".to_owned();
|
|
for (method, visibility) in [("cmd", ""), ("post", "public ")] {
|
|
for name in ["accept", "echo"] {
|
|
api.push_str(&format!("{visibility}{method} {name} = app.{name}\n"));
|
|
}
|
|
}
|
|
std::fs::write(root.join("api.dever"), api).unwrap();
|
|
let project = Project::load(&directory.0).unwrap();
|
|
let program = project.check().unwrap();
|
|
let binary =
|
|
build::build(&program, &project.sources, "main", &directory.0.join("app")).unwrap();
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
let config = directory.0.join("setting.json");
|
|
std::fs::write(&config, format!("{{\"http\":{{\"host\":\"127.0.0.1\",\"port\":{port}}},\"sites\":{{\"public\":{{\"path\":\"\",\"hosts\":[\"fixture.test\"]}}}}}}" )).unwrap();
|
|
let raw = r#"{ "values": [1e9999,-0,1.00e+2,"\u96ea"] }"#;
|
|
let input = format!("{{\"value\":{raw}}}");
|
|
let expected = format!("{{\"code\":0,\"message\":\"ok\",\"data\":{raw}}}");
|
|
let command = Command::new(&binary)
|
|
.args([
|
|
"--config",
|
|
config.to_str().unwrap(),
|
|
"demo.input.echo",
|
|
&input,
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert!(command.status.success(), "{:?}", command.stderr);
|
|
assert_eq!(
|
|
String::from_utf8(command.stdout).unwrap(),
|
|
format!("{expected}\n")
|
|
);
|
|
let mut server = Server(
|
|
Command::new(&binary)
|
|
.args(["--config", config.to_str().unwrap()])
|
|
.stdout(std::process::Stdio::null())
|
|
.spawn()
|
|
.unwrap(),
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while TcpStream::connect(("127.0.0.1", port)).is_err() {
|
|
assert!(server.0.try_wait().unwrap().is_none());
|
|
assert!(Instant::now() < deadline);
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
let call = |name: &str, input: &str| {
|
|
request(
|
|
port,
|
|
"POST",
|
|
&format!("/demo/input/{name}"),
|
|
"fixture.test",
|
|
"Content-Type: application/json\r\n",
|
|
input,
|
|
)
|
|
};
|
|
assert_eq!(call("echo", &input), (200, expected));
|
|
for input in [
|
|
r#"[{"payload":null,"count":0}]"#,
|
|
r#"{"value":[null,null,0,null]}"#,
|
|
r#"{"value":{"payload":null,"note":null,"note":null,"count":0}}"#,
|
|
r#"{"value":{"payload":{"a":0,"\u0061":1},"count":0}}"#,
|
|
r#"{"value":{"payload":"\ud800","count":0}}"#,
|
|
r#"{"value":{"payload":null,"count":0,"unknown":0}}"#,
|
|
r#"{"value":{"payload":null,"count":0,"extra":null,"extra":null}}"#,
|
|
r#"{"value":{"payload":null,"count":0}} {}"#,
|
|
] {
|
|
assert_eq!(call("accept", input).0, 400, "{input}");
|
|
let command = Command::new(&binary)
|
|
.args([
|
|
"--config",
|
|
config.to_str().unwrap(),
|
|
"demo.input.accept",
|
|
input,
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert!(!command.status.success());
|
|
assert!(String::from_utf8_lossy(&command.stderr).contains("invalid command input"));
|
|
}
|
|
// 总值数边界的正文超过 Linux 单个 argv 的限制,通过 HTTP 验证同一 codec。
|
|
let limit = dever_runtime::wire::document::MAX_VALUES;
|
|
for (count, extra) in [(limit - 4, ""), (limit - 6, ",\"extra\":[0]")] {
|
|
let payload = vec!["0"; count].join(",");
|
|
for (note, status) in [("", 200), (",\"note\":null", 400)] {
|
|
let input =
|
|
format!("{{\"value\":{{\"payload\":[{payload}],\"count\":0{extra}{note}}}}}");
|
|
assert_eq!(call("accept", &input).0, status);
|
|
}
|
|
}
|
|
for (depth, status) in [(62, 200), (63, 400)] {
|
|
let payload = "[".repeat(depth) + &"]".repeat(depth);
|
|
let input = format!("{{\"value\":{{\"payload\":{payload},\"count\":0}}}}");
|
|
assert_eq!(call("accept", &input).0, status);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn automatic_http_runs_dever_app_strict_inputs_sites_faults_cmd_and_shutdown() {
|
|
application(true);
|
|
application(false);
|
|
}
|
|
|
|
fn application(commands: bool) {
|
|
let directory = Directory::new();
|
|
let mut project = Project::load(&fixture().with_file_name("http-application")).unwrap();
|
|
if !commands {
|
|
let source = project
|
|
.sources
|
|
.iter_mut()
|
|
.find(|source| source.path.ends_with("greeting/api.dever"))
|
|
.unwrap();
|
|
*source = dever::syntax::source::Source::new(
|
|
source.id,
|
|
&source.path,
|
|
source.text.split("cmd ").next().unwrap().into(),
|
|
);
|
|
}
|
|
if commands {
|
|
let source = project
|
|
.sources
|
|
.iter_mut()
|
|
.find(|source| source.path.ends_with("greeting/api.dever"))
|
|
.unwrap();
|
|
*source = dever::syntax::source::Source::new(
|
|
source.id,
|
|
&source.path,
|
|
format!("{}\npublic post greet = app.greet\n", source.text),
|
|
);
|
|
}
|
|
let program = project.check().unwrap();
|
|
let binary =
|
|
build::build(&program, &project.sources, "main", &directory.0.join("app")).unwrap();
|
|
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
drop(listener);
|
|
let config = directory.0.join("setting.json");
|
|
std::fs::write(&config, format!("{{\"http\":{{\"host\":\"127.0.0.1\",\"port\":{port}}},\"sites\":{{\"public\":{{\"path\":\"\",\"hosts\":[\"fixture.test\"]}}}}}}" )).unwrap();
|
|
let fragment = serde_json::json!({"source": " \n{\"a\": [1e9999,-0]}\t "}).to_string();
|
|
let expected_fragment = "{\"code\":0,\"message\":\"ok\",\"data\":{\"value\":{\"a\": [1e9999,-0]},\"label\":\"雪\\\"\"}}";
|
|
if commands {
|
|
let command = Command::new(&binary)
|
|
.args([
|
|
"--config",
|
|
config.to_str().unwrap(),
|
|
"demo.greeting.greet",
|
|
"{\"name\":\"CMD\"}",
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
command.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&command.stderr)
|
|
);
|
|
assert!(String::from_utf8_lossy(&command.stdout).contains("Hello, CMD"));
|
|
let command = Command::new(&binary)
|
|
.args([
|
|
"--config",
|
|
config.to_str().unwrap(),
|
|
"demo.greeting.fragment",
|
|
&fragment,
|
|
])
|
|
.output()
|
|
.unwrap();
|
|
assert!(
|
|
command.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&command.stderr)
|
|
);
|
|
assert_eq!(
|
|
String::from_utf8(command.stdout).unwrap(),
|
|
format!("{expected_fragment}\n")
|
|
);
|
|
}
|
|
let mut server = Server(
|
|
Command::new(&binary)
|
|
.args(["--config", config.to_str().unwrap()])
|
|
.stdout(std::process::Stdio::null())
|
|
.spawn()
|
|
.unwrap(),
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
while TcpStream::connect(("127.0.0.1", port)).is_err() {
|
|
assert!(
|
|
server.0.try_wait().unwrap().is_none(),
|
|
"server exited during startup"
|
|
);
|
|
assert!(Instant::now() < deadline, "server did not bind");
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
let (status, body) = request(
|
|
port,
|
|
"POST",
|
|
"/demo/greeting/fragment",
|
|
"fixture.test",
|
|
"Content-Type: application/json\r\n",
|
|
&fragment,
|
|
);
|
|
assert_eq!(status, 200, "{body}");
|
|
assert_eq!(body, expected_fragment);
|
|
let (status, head, body) =
|
|
request_head(port, "PUT", "/demo/greeting/greet", "fixture.test", "", "");
|
|
assert_eq!(status, 405);
|
|
assert_eq!(
|
|
body,
|
|
"{\"code\":405,\"message\":\"method not allowed\",\"data\":null}"
|
|
);
|
|
let allowed = head
|
|
.lines()
|
|
.filter_map(|line| line.split_once(':'))
|
|
.find(|(name, _)| name.eq_ignore_ascii_case("allow"))
|
|
.unwrap()
|
|
.1
|
|
.split(',')
|
|
.map(str::trim)
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
assert_eq!(
|
|
allowed,
|
|
if commands {
|
|
vec!["GET", "POST"]
|
|
} else {
|
|
vec!["GET"]
|
|
}
|
|
.into_iter()
|
|
.collect()
|
|
);
|
|
for (path, host) in [
|
|
("/demo/greeting/greet", "wrong.test"),
|
|
("/demo/greeting/absent", "fixture.test"),
|
|
] {
|
|
let (status, head, _) = request_head(port, "PUT", path, host, "", "");
|
|
assert_eq!(status, 404);
|
|
assert!(!head.to_ascii_lowercase().contains("\r\nallow:"));
|
|
}
|
|
for expected in [400, 401, 403, 404, 409, 429, 500] {
|
|
let (status, body) = request(
|
|
port,
|
|
"GET",
|
|
&format!("/demo/greeting/reject?status={expected}"),
|
|
"fixture.test",
|
|
"",
|
|
"",
|
|
);
|
|
assert_eq!(status, expected, "{body}");
|
|
let body: serde_json::Value = serde_json::from_str(&body).unwrap();
|
|
assert_eq!(body["code"], expected);
|
|
assert!(body["data"].is_null());
|
|
assert!(!body.to_string().contains("Problem"));
|
|
}
|
|
let (status, body) = request(
|
|
port,
|
|
"GET",
|
|
"/demo/greeting/greet?name=%E9%9B%AA",
|
|
"fixture.test",
|
|
"Cookie: stale=ignored\r\n",
|
|
"",
|
|
);
|
|
assert_eq!(status, 200);
|
|
assert_eq!(
|
|
body,
|
|
"{\"code\":0,\"message\":\"ok\",\"data\":\"Hello, 雪\"}"
|
|
);
|
|
for path in [
|
|
"/demo/greeting/greet",
|
|
"/demo/greeting/greet?name=a&name=b",
|
|
"/demo/greeting/greet?name=a&%6Eame=b",
|
|
"/demo/greeting/greet?name=%ZZ",
|
|
"/demo/greeting/greet?name=%",
|
|
"/demo/greeting/greet?name=%FF",
|
|
"/demo/greeting/greet?name",
|
|
"/demo/greeting/greet?name=a&extra=b",
|
|
] {
|
|
assert_eq!(request(port, "GET", path, "fixture.test", "", "").0, 400);
|
|
}
|
|
assert_eq!(
|
|
request(
|
|
port,
|
|
"GET",
|
|
"/demo/greeting/greet?name=a",
|
|
"wrong.test",
|
|
"",
|
|
""
|
|
)
|
|
.0,
|
|
404
|
|
);
|
|
assert_eq!(
|
|
request(
|
|
port,
|
|
"GET",
|
|
"/demo/greeting/greet?name=a",
|
|
"fixture.test",
|
|
"Authorization: Bearer invalid\r\n",
|
|
""
|
|
)
|
|
.0,
|
|
401
|
|
);
|
|
let json = "Content-Type: application/json\r\n";
|
|
for (body, status) in [
|
|
("{\"value\":41}", 200),
|
|
("{\"value\":1,\"value\":2}", 400),
|
|
("{\"value\":1.2}", 400),
|
|
("{\"value\":1,\"extra\":2}", 400),
|
|
("[1]", 400),
|
|
("{\"value\":null}", 400),
|
|
("{\"value\":9223372036854775807}", 500),
|
|
] {
|
|
let (actual, response) = request(
|
|
port,
|
|
"POST",
|
|
"/demo/greeting/pair",
|
|
"fixture.test",
|
|
json,
|
|
body,
|
|
);
|
|
assert_eq!(actual, status, "{response}");
|
|
assert!(!response.contains("arithmetic fault"));
|
|
}
|
|
assert_eq!(
|
|
request(
|
|
port,
|
|
"POST",
|
|
"/demo/greeting/pair",
|
|
"fixture.test",
|
|
"",
|
|
"{\"value\":1}"
|
|
)
|
|
.0,
|
|
415
|
|
);
|
|
assert!(
|
|
Command::new("sh")
|
|
.args([
|
|
"-c",
|
|
"kill -TERM \"$1\"",
|
|
"dever-test",
|
|
&server.0.id().to_string()
|
|
])
|
|
.status()
|
|
.unwrap()
|
|
.success()
|
|
);
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
loop {
|
|
if let Some(status) = server.0.try_wait().unwrap() {
|
|
assert!(status.success());
|
|
break;
|
|
}
|
|
assert!(Instant::now() < deadline, "shutdown did not release server");
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
}
|