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::>(); 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)); } }