Something went wrong. Try again.
Hardware-in-the-loop development platform for the Commodore 64.
Something went wrong. Try again.
15 kB · 503 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504//! 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 = 49152length = 256command_address = 49408screen_address = 1024color_address = 55296[vice]executable = "unavailable-server-test-vice"poll_interval_ms = 1[ultimate]poll_interval_ms = 1request_timeout_ms = 100[[tests]]name = "smoke"timeout_ms = 2000heartbeat_timeout_ms = 100expected_terminal = "pass"expected_screen = "ULTIMATE TOOL"startup_command = { name = "scenario", payload = "pass" }"#, ); (root, manifest)}
fn fs_write(path: impl AsRef<Path>, 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::<u16>().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<String> { 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<String>,}
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<String> { 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::<u16>().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<ExitStatus> { 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());}