mod common;
use common::Sandbox;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
const HOST: &str = "127.0.0.1";
fn pick_free_port() -> u16 {
let listener = std::net::TcpListener::bind(format!("{}:0", HOST)).expect("bind ephemeral");
listener.local_addr().expect("local_addr").port()
}
fn spawn_server(s: &Sandbox, port: u16) -> Child {
Command::new(env!("CARGO_BIN_EXE_req"))
.args([
"--file",
s.path().to_str().unwrap(),
"serve",
"--host",
HOST,
"--port",
&port.to_string(),
"--read-only",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn req serve")
}
fn wait_for_bind(port: u16, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if TcpStream::connect_timeout(
&format!("{}:{}", HOST, port).parse().unwrap(),
Duration::from_millis(200),
)
.is_ok()
{
return true;
}
std::thread::sleep(Duration::from_millis(75));
}
false
}
fn http_get(port: u16, path: &str) -> (u16, String) {
let mut stream = TcpStream::connect(format!("{}:{}", HOST, port)).expect("connect to server");
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
write!(
stream,
"GET {} HTTP/1.1\r\nHost: {}:{}\r\nConnection: close\r\n\r\n",
path, HOST, port
)
.expect("write request");
let mut buf = String::new();
stream.read_to_string(&mut buf).ok();
let mut lines = buf.splitn(2, "\r\n");
let status_line = lines.next().unwrap_or("");
let rest = lines.next().unwrap_or("");
let body = rest
.split_once("\r\n\r\n")
.map(|x| x.1)
.unwrap_or("")
.to_string();
let code = status_line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(0);
(code, body)
}
struct GuardedChild(Option<Child>);
impl Drop for GuardedChild {
fn drop(&mut self) {
if let Some(mut c) = self.0.take() {
let _ = c.kill();
let _ = c.wait();
}
}
}
fn fixture() -> (Sandbox, GuardedChild, u16) {
let s = Sandbox::new();
s.init("p");
let _ = s.run(&[
"add",
"--title",
"Hosted on the local web server for inspection",
"--statement",
"The system shall render this requirement at GET /r/REQ-0001.",
"--rationale",
"Fixture for serve smoke tests.",
"--kind",
"constraint",
"--priority",
"could",
]);
let port = pick_free_port();
let child = spawn_server(&s, port);
let bound = wait_for_bind(port, Duration::from_secs(10));
assert!(
bound,
"req serve did not bind to {}:{} within 10s",
HOST, port
);
(s, GuardedChild(Some(child)), port)
}
#[test]
fn req_0016_serve_root_returns_html_index() {
let (_s, _child, port) = fixture();
let (code, body) = http_get(port, "/");
assert_eq!(code, 200, "index should return 200, got {}", code);
assert!(
body.contains("<html"),
"body should be HTML: {}",
&body[..body.len().min(200)]
);
assert!(body.contains("REQ-0001"), "index should list REQ-0001");
}
#[test]
fn req_0016_serve_show_route_returns_html_detail() {
let (_s, _child, port) = fixture();
let (code, body) = http_get(port, "/r/REQ-0001");
assert_eq!(code, 200);
assert!(body.contains("REQ-0001"));
assert!(body.contains("Hosted on the local web server"));
assert!(body.contains("Statement") || body.contains("statement"));
}
#[test]
fn req_0016_serve_api_list_returns_json_array() {
let (_s, _child, port) = fixture();
let (code, body) = http_get(port, "/api/list");
assert_eq!(code, 200);
let v: serde_json::Value = serde_json::from_str(&body)
.unwrap_or_else(|_| panic!("/api/list should return JSON, got: {}", body));
let arr = v.as_array().expect("array of requirements");
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["id"].as_str().unwrap(), "REQ-0001");
}
#[test]
fn req_0016_serve_api_show_returns_json_object() {
let (_s, _child, port) = fixture();
let (code, body) = http_get(port, "/api/r/REQ-0001");
assert_eq!(code, 200);
let v: serde_json::Value = serde_json::from_str(&body).expect("json object");
assert_eq!(v["id"].as_str().unwrap(), "REQ-0001");
}
#[test]
fn req_0016_serve_unknown_id_returns_404() {
let (_s, _child, port) = fixture();
let bogus = format!("REQ-{:04}", 9999);
let url = format!("/api/r/{}", bogus);
let (code, _body) = http_get(port, &url);
assert_eq!(code, 404);
}
#[test]
fn req_0016_serve_html_escapes_user_supplied_strings() {
let s = Sandbox::new();
s.init("p");
let _ = s.run(&[
"add",
"--title",
"Has <script>tag and \"quotes\" in title",
"--statement",
"The system shall escape these characters on render.",
"--rationale",
"Fixture for HTML-escape behaviour in serve.",
"--kind",
"constraint",
"--priority",
"could",
]);
let port = pick_free_port();
let child = spawn_server(&s, port);
let _guard = GuardedChild(Some(child));
assert!(wait_for_bind(port, Duration::from_secs(10)));
let (code, body) = http_get(port, "/");
assert_eq!(code, 200);
assert!(
!body.contains("<script>tag"),
"raw < entity leaked through escape: {}",
&body[..body.len().min(400)]
);
assert!(
body.contains("<script>tag") || body.contains("<script>"),
"expected < entity in escaped output"
);
}
#[test]
fn req_0134_serve_safety_view_and_api() {
let s = Sandbox::new();
s.init("p");
s.enable_safety();
let _ = s.run(&["hazard", "add", "-t", "Hazardous mode", "--harm", "operator could be hurt", "-C", "C_C", "-F", "F_B", "-P", "P_B", "-W", "W3"]);
let _ = s.run(&["sf", "add", "-t", "Interlock", "--mitigates", "HAZ-0001"]);
let port = pick_free_port();
let child = spawn_server(&s, port);
assert!(wait_for_bind(port, Duration::from_secs(10)), "serve did not bind");
let _guard = GuardedChild(Some(child));
let (code, body) = http_get(port, "/");
assert_eq!(code, 200);
assert!(body.contains("/safety"), "index should link to /safety");
let (code, body) = http_get(port, "/safety");
assert_eq!(code, 200, "/safety should return 200");
assert!(body.contains("HAZ-0001"), "/safety should list the hazard");
assert!(body.contains("SIL3"), "/safety should show the derived SIL");
assert!(body.contains("not qualified per IEC 61508-3"), "/safety must carry the disclaimer");
let (code, body) = http_get(port, "/api/safety");
assert_eq!(code, 200, "/api/safety should return 200");
assert!(body.contains("\"hazards\""), "api should include hazards");
assert!(body.contains("HAZ-0001"));
}