//! Bounded process-level tests for the production `ultimate-tool serve` //! command. //! //! These tests spawn the real released binary over a temporary schema-11 //! manifest, read its readiness announcement, and drive it over loopback HTTP //! with only `std::net`. They never contact hardware: the manifest uses the //! deterministic fake target, `[build] command = ["true"]`, and no container //! image. The browser harness owns the separate real-Chromium coverage. use std::{ io::{BufRead, BufReader, Read, Write}, net::TcpStream, path::{Path, PathBuf}, process::{Child, Command, ExitStatus, Stdio}, sync::mpsc, thread, time::{Duration, Instant}, }; use tempfile::TempDir; /// Exact stdout prefix the production server prints once it has bound. const ANNOUNCE_PREFIX: &str = "Ultimate Tool dashboard: http://"; /// Bound on how long the readiness announcement may take to arrive. const READY_TIMEOUT: Duration = Duration::from_secs(20); /// Bound on any single loopback HTTP exchange. const IO_TIMEOUT: Duration = Duration::from_secs(10); /// Bound on waiting for a child that is expected to exit on its own. const EXIT_TIMEOUT: Duration = Duration::from_secs(10); fn fixture() -> (TempDir, PathBuf) { let root = tempfile::tempdir().unwrap(); fs_write(root.path().join("smoke.prg"), &[0x01, 0x08, 0xea]); let manifest = root.path().join("c64dev.toml"); fs_write( &manifest, br#" schema_version = 11 [project] name = "server-process-test" [build] command = ["true"] working_directory = "." artifact = "smoke.prg" toolchain = "server-test-only" [diagnostics] address = 49152 length = 256 command_address = 49408 screen_address = 1024 color_address = 55296 [vice] executable = "unavailable-server-test-vice" poll_interval_ms = 1 [ultimate] poll_interval_ms = 1 request_timeout_ms = 100 [[tests]] name = "smoke" timeout_ms = 2000 heartbeat_timeout_ms = 100 expected_terminal = "pass" expected_screen = "ULTIMATE TOOL" startup_command = { name = "scenario", payload = "pass" } "#, ); (root, manifest) } fn fs_write(path: impl AsRef, bytes: &[u8]) { std::fs::write(path, bytes).unwrap(); } /// One parsed HTTP response from the production server. struct HttpResponse { status: u16, body: String, } fn http_request( port: u16, method: &str, path: &str, host: &str, headers: &[(String, String)], body: Option<&str>, ) -> HttpResponse { let mut stream = TcpStream::connect(("127.0.0.1", port)).expect("connect to server"); stream.set_read_timeout(Some(IO_TIMEOUT)).unwrap(); stream.set_write_timeout(Some(IO_TIMEOUT)).unwrap(); let body = body.unwrap_or(""); let mut request = format!( "{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\nContent-Length: {}\r\n", body.len() ); if !body.is_empty() { request.push_str("Content-Type: application/json\r\n"); } for (name, value) in headers { request.push_str(&format!("{name}: {value}\r\n")); } request.push_str("\r\n"); request.push_str(body); stream .write_all(request.as_bytes()) .expect("write request to server"); let mut raw = String::new(); stream .read_to_string(&mut raw) .expect("read response from server"); parse_response(&raw) } fn parse_response(raw: &str) -> HttpResponse { let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw, "")); let status = head .lines() .next() .and_then(|line| line.split_whitespace().nth(1)) .and_then(|code| code.parse::().ok()) .unwrap_or(0); HttpResponse { status, body: body.to_string(), } } /// Minimal JSON string-field reader; the responses are small and flat, so a /// substring scan avoids pulling a JSON dependency into the CLI test target. fn json_string_field(body: &str, field: &str) -> Option { let key = format!("\"{field}\":\""); let start = body.find(&key)? + key.len(); let rest = &body[start..]; let end = rest.find('"')?; Some(rest[..end].to_string()) } /// A spawned `serve` child plus a reader for its stdout lines. struct ServeProcess { child: Child, lines: mpsc::Receiver, } impl ServeProcess { fn spawn(manifest: &Path) -> Self { let mut child = Command::new(env!("CARGO_BIN_EXE_ultimate-tool")) .args(["serve", "--manifest"]) .arg(manifest) .args(["--port", "0"]) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .expect("spawn ultimate-tool serve"); let stdout = child.stdout.take().expect("child stdout is piped"); let (sender, lines) = mpsc::channel(); thread::spawn(move || { for line in BufReader::new(stdout).lines() { match line { Ok(line) => { if sender.send(line).is_err() { break; } } Err(_) => break, } } }); ServeProcess { child, lines } } /// Read stdout until the readiness announcement arrives or the bound /// elapses. Returns the announced `127.0.0.1:PORT` authority. fn await_announcement(&mut self, timeout: Duration) -> Option { let deadline = Instant::now() + timeout; loop { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { return None; } match self.lines.recv_timeout(remaining) { Ok(line) => { if let Some(authority) = line.strip_prefix(ANNOUNCE_PREFIX) { return Some(authority.trim().to_string()); } } Err(mpsc::RecvTimeoutError::Timeout) => return None, Err(mpsc::RecvTimeoutError::Disconnected) => return None, } } } } impl Drop for ServeProcess { fn drop(&mut self) { // Bounded, best-effort teardown: SIGKILL then reap. let _ = self.child.kill(); let _ = self.child.wait(); } } /// A production server whose readiness line has been observed and whose /// per-process mutation token has been read from `/api/v1/session`. struct Server { /// Owned so the child is killed and reaped when the server is dropped. _process: ServeProcess, authority: String, port: u16, token: String, } impl Server { fn start(manifest: &Path) -> Self { let mut process = ServeProcess::spawn(manifest); let authority = process .await_announcement(READY_TIMEOUT) .expect("server must announce readiness within the bound"); let port = authority .rsplit(':') .next() .and_then(|port| port.parse::().ok()) .expect("announced authority ends with a port"); let mut server = Server { _process: process, authority, port, token: String::new(), }; let host = server.host(); let session = server.get("/api/v1/session", &host); assert_eq!(session.status, 200, "session body={}", session.body); server.token = json_string_field(&session.body, "mutation_token") .expect("session response carries a mutation token"); assert!(!server.token.is_empty(), "mutation token must not be empty"); server } fn host(&self) -> String { format!("127.0.0.1:{}", self.port) } fn origin(&self) -> String { format!("http://{}", self.host()) } fn request( &self, method: &str, path: &str, host: &str, headers: &[(String, String)], body: Option<&str>, ) -> HttpResponse { http_request(self.port, method, path, host, headers, body) } fn get(&self, path: &str, host: &str) -> HttpResponse { self.request("GET", path, host, &[], None) } } fn mutation_headers(token: &str, origin: &str) -> Vec<(String, String)> { vec![ ("x-ultimate-tool-token".to_string(), token.to_string()), ("Origin".to_string(), origin.to_string()), ] } const JOB_BODY: &str = r#"{"test":"smoke","targets":["fake"],"repeat":1}"#; fn wait_with_bound(child: &mut Child, timeout: Duration) -> Option { let deadline = Instant::now() + timeout; loop { match child.try_wait().unwrap() { Some(status) => return Some(status), None => { if Instant::now() >= deadline { return None; } thread::sleep(Duration::from_millis(25)); } } } } #[test] fn serve_announces_bound_origin_and_serves_versioned_routes() { let (_root, manifest) = fixture(); let server = Server::start(&manifest); assert_ne!( server.port, 0, "--port 0 must be replaced by the real bound port" ); assert_eq!(server.authority, server.host()); assert_eq!(server.origin(), format!("http://{}", server.authority)); let host = server.host(); let health = server.get("/api/v1/health", &host); assert_eq!(health.status, 200, "health body={}", health.body); assert!( health.body.contains("\"status\":\"ok\""), "body={}", health.body ); let project = server.get("/api/v1/project", &host); assert_eq!(project.status, 200, "project body={}", project.body); assert!( project.body.contains("server-process-test"), "body={}", project.body ); // The announced origin is the dashboard origin: a mutation carrying it and // the session token is authorized and accepted. let accepted = server.request( "POST", "/api/v1/jobs", &host, &mutation_headers(&server.token, &server.origin()), Some(JOB_BODY), ); assert_eq!(accepted.status, 202, "job body={}", accepted.body); } #[test] fn serve_rejects_mismatched_host_header() { let (_root, manifest) = fixture(); let server = Server::start(&manifest); for bad_host in ["127.0.0.1:0", "evil.example", "localhost"] { let denied = server.get("/api/v1/health", bad_host); assert_eq!(denied.status, 400, "host={bad_host} body={}", denied.body); assert!( denied.body.contains("invalid_host"), "host={bad_host} body={}", denied.body ); } } #[test] fn serve_requires_valid_token_and_origin_for_mutations() { let (_root, manifest) = fixture(); let server = Server::start(&manifest); let host = server.host(); // No token: rejected. let no_token = server.request( "POST", "/api/v1/jobs", &host, &[("Origin".to_string(), server.origin())], Some(JOB_BODY), ); assert_eq!(no_token.status, 403, "body={}", no_token.body); assert!( no_token.body.contains("invalid_mutation_token"), "body={}", no_token.body ); // Wrong token: rejected. let wrong_token = server.request( "POST", "/api/v1/jobs", &host, &mutation_headers("not-the-real-token", &server.origin()), Some(JOB_BODY), ); assert_eq!(wrong_token.status, 403, "body={}", wrong_token.body); assert!( wrong_token.body.contains("invalid_mutation_token"), "body={}", wrong_token.body ); // Correct token but foreign origin: rejected. let wrong_origin = server.request( "POST", "/api/v1/jobs", &host, &mutation_headers(&server.token, "http://127.0.0.1:1"), Some(JOB_BODY), ); assert_eq!(wrong_origin.status, 403, "body={}", wrong_origin.body); assert!( wrong_origin.body.contains("invalid_origin"), "body={}", wrong_origin.body ); // Correct token and matching origin: accepted. let accepted = server.request( "POST", "/api/v1/jobs", &host, &mutation_headers(&server.token, &server.origin()), Some(JOB_BODY), ); assert_eq!(accepted.status, 202, "body={}", accepted.body); } #[test] fn serve_exposes_no_test_seed_route() { let (_root, manifest) = fixture(); let server = Server::start(&manifest); let host = server.host(); for path in ["/api/v1/test/seed", "/api/v1/seed", "/api/v1/debug/seed"] { let response = server.get(path, &host); assert_eq!( response.status, 404, "unexpected route {path}: {}", response.body ); // A seed route must not be reachable as a mutation either. let posted = server.request( "POST", path, &host, &mutation_headers(&server.token, &server.origin()), Some(JOB_BODY), ); assert_eq!( posted.status, 404, "unexpected route {path}: {}", posted.body ); } } #[test] fn restarting_serve_rotates_the_mutation_token() { let (_root, manifest) = fixture(); let first = Server::start(&manifest); let old_token = first.token.clone(); assert!(!old_token.is_empty()); drop(first); // kill and reap the first server let second = Server::start(&manifest); let host = second.host(); let origin = second.origin(); assert_ne!( second.token, old_token, "a restarted server must mint a fresh per-process mutation token" ); // The old token is rejected before a job is accepted with the new token. let reused = second.request( "POST", "/api/v1/jobs", &host, &mutation_headers(&old_token, &origin), Some(JOB_BODY), ); assert_eq!(reused.status, 403, "body={}", reused.body); assert!( reused.body.contains("invalid_mutation_token"), "body={}", reused.body ); let accepted = second.request( "POST", "/api/v1/jobs", &host, &mutation_headers(&second.token, &origin), Some(JOB_BODY), ); assert_eq!(accepted.status, 202, "body={}", accepted.body); } #[test] fn serve_readiness_failure_does_not_leave_a_stranded_process() { let root = tempfile::tempdir().unwrap(); let missing = root.path().join("does-not-exist.toml"); let mut process = ServeProcess::spawn(&missing); let announced = process.await_announcement(Duration::from_secs(5)); assert!( announced.is_none(), "serve must not announce readiness for a missing manifest" ); // The child must exit on its own rather than lingering; Drop still reaps it. let status = wait_with_bound(&mut process.child, EXIT_TIMEOUT); assert!( status.is_some(), "serve should exit after a manifest load failure instead of lingering" ); assert!(!status.unwrap().success()); }