//! The roster host held against the Python oracle's measured behaviour. //! //! Response bytes are only half of it. The redirector cost two live gate //! attempts to a divergence no response comparison could see — a request body //! left unread, which turns the close into an RST — so the oracle's lifecycle //! was measured with a probe and every property is asserted here: //! //! ```text //! dwell after responding 0 ms //! request body drained before the response is written //! close immediately after, cleanly //! keep-alive none: one request per connection //! ``` //! //! Where a property is observable both from the client and from inside the //! host, it is asserted **inside the host** via `ConnOutcome`. That distinction //! is not pedantry: the first attempt to test body-draining asserted that the //! client still received its response, and that test passed against a host that //! did not drain at all — the client reads the buffered response and sees //! `close_notify` before any reset. use std::io::{Read, Write}; use std::net::TcpStream; use std::time::{Duration, Instant}; use openfut_adapter_fifa17::roster; use openfut_roster_host::{bind, BodyRead, RosterConfig}; const PATH: &str = "/fifa17/fut/rosterupdate.xml"; const BODY: &[u8] = b"probe=1&pad=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; fn cert_pair() -> (String, String) { let base = concat!(env!("CARGO_MANIFEST_DIR"), "/../fifa17-recon/tools"); ( format!("{base}/redir_cert.pem"), format!("{base}/redir_key.pem"), ) } struct Harness { addr: std::net::SocketAddr, outcomes: openfut_roster_host::Outcomes, } fn start() -> Harness { start_with(|_| {}) } fn start_with(tweak: impl FnOnce(&mut RosterConfig)) -> Harness { let (c, k) = cert_pair(); let mut cfg = RosterConfig::for_test(&c, &k); tweak(&mut cfg); let server = bind(cfg).expect("bind"); let addr = server.local_addr; let outcomes = server.outcomes(); std::thread::spawn(move || { let _ = server.run(); }); Harness { addr, outcomes } } /// A client that behaves like FIFA: legacy suites, no certificate checking. fn connect(addr: std::net::SocketAddr) -> openfut_tls::SslStream { use openfut_tls::{SslConnector, SslMethod, SslVerifyMode}; let mut b = SslConnector::builder(SslMethod::tls()).expect("connector"); b.set_cipher_list(openfut_adapter_fifa17::tls::OBSERVED_CLIENT_SUITES) .expect("ciphers"); b.set_verify(SslVerifyMode::NONE); let sock = TcpStream::connect(addr).expect("connect"); sock.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); let ssl = b .build() .configure() .and_then(|c| c.verify_hostname(false).into_ssl("roster-test")) .expect("ssl"); let mut s = openfut_tls::SslStream::new(ssl, sock).expect("stream"); s.connect().expect("handshake"); s } fn request(method: &str, body: Option<&[u8]>) -> Vec { let mut r = format!("{method} {PATH} HTTP/1.1\r\nHost: roster-test\r\nAccept: */*\r\n"); if let Some(b) = body { r.push_str(&format!("Content-Length: {}\r\n", b.len())); } r.push_str("\r\n"); let mut out = r.into_bytes(); if let Some(b) = body { out.extend_from_slice(b); } out } fn read_to_eof(s: &mut openfut_tls::SslStream) -> Vec { let mut out = Vec::new(); let mut chunk = [0u8; 4096]; loop { match s.read(&mut chunk) { Ok(0) | Err(_) => break, Ok(n) => out.extend_from_slice(&chunk[..n]), } } out } fn exchange(addr: std::net::SocketAddr, method: &str, body: Option<&[u8]>) -> Vec { let mut s = connect(addr); s.write_all(&request(method, body)).expect("write"); s.flush().ok(); read_to_eof(&mut s) } fn mask(raw: &[u8]) -> String { String::from_utf8_lossy(raw) .split("\r\n") .map(|l| { if l.starts_with("Date: ") { "Date: ".to_string() } else if l.starts_with("Server: ") { "Server: ".to_string() } else { l.to_string() } }) .collect::>() .join("\r\n") } /// The bytes the adapter says the oracle sends, masked the same way. fn expected(method: roster::Method) -> String { mask(&roster::roster_response( method, roster::ORACLE_SERVER, "IRRELEVANT", )) } #[test] fn get_returns_the_oracles_bytes() { let h = start(); let got = exchange(h.addr, "GET", None); assert_eq!(mask(&got), expected(roster::Method::Get)); assert!(String::from_utf8_lossy(&got).starts_with("HTTP/1.0 200 OK\r\n")); } #[test] fn head_and_post_are_headers_only_but_still_advertise_the_body() { let h = start(); for (m, adapter_m) in [ ("HEAD", roster::Method::Head), ("POST", roster::Method::Post), ] { let body = if m == "POST" { Some(BODY) } else { None }; let got = exchange(h.addr, m, body); assert_eq!(mask(&got), expected(adapter_m), "{m} differs"); let text = String::from_utf8_lossy(&got); assert!(text.contains("Content-Length: 67"), "{m}: {text}"); assert!(text.ends_with("\r\n\r\n"), "{m} must send no body"); } } /// Asserted on the HOST's record, not on what the client received. /// /// A client-side assertion cannot distinguish "drained" from "not drained": /// it reads the buffered response either way. This is the exact mistake that /// let a mutation survive on the redirector. #[test] fn the_request_body_is_drained_before_the_response_is_written() { let h = start(); let _ = exchange(h.addr, "POST", Some(BODY)); let rec = h.outcomes.lock().unwrap(); let last = rec.last().expect("a connection was recorded"); assert_eq!( last.body, BodyRead::Complete(BODY.len()), "the whole body must be read before answering, or the close becomes an RST" ); assert!(last.response_bytes > 0); } /// The oracle answers a POST only after the body arrives. Sending the head, /// pausing, then sending the body proves the host waits rather than replying /// early and leaving the socket dirty. #[test] fn a_late_body_is_waited_for_rather_than_answered_early() { let h = start(); let mut s = connect(h.addr); let head = format!( "POST {PATH} HTTP/1.1\r\nHost: roster-test\r\nContent-Length: {}\r\n\r\n", BODY.len() ); s.write_all(head.as_bytes()).unwrap(); s.flush().ok(); s.get_ref() .set_read_timeout(Some(Duration::from_millis(600))) .unwrap(); let mut early = [0u8; 64]; let early_n = s.read(&mut early).unwrap_or(0); assert_eq!( early_n, 0, "answered before the body arrived: {:?}", String::from_utf8_lossy(&early[..early_n]) ); s.get_ref() .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); s.write_all(BODY).unwrap(); s.flush().ok(); let late = read_to_eof(&mut s); assert!(!late.is_empty(), "no response after the body arrived"); assert_eq!(mask(&late), expected(roster::Method::Post)); } /// One request per connection: the response says `Connection: close` and the /// oracle closes. Measured, not assumed — a keep-alive loop would be a /// behaviour change invisible in any single response. #[test] fn the_connection_closes_after_one_request() { let h = start(); let mut s = connect(h.addr); s.write_all(&request("GET", None)).unwrap(); s.flush().ok(); let first = read_to_eof(&mut s); assert!(!first.is_empty()); // read_to_eof already ran to EOF; a second request must not be answered. let _ = s.write_all(&request("GET", None)); let mut buf = [0u8; 32]; let n = s.read(&mut buf).unwrap_or(0); assert_eq!(n, 0, "a second request was answered on the same connection"); } /// The oracle has no post-response sleep — unlike the redirector's 300ms. /// Inheriting the wrong dwell would add delay to a poll that runs every few /// seconds. #[test] fn the_connection_is_not_held_open_after_responding() { let h = start(); let mut s = connect(h.addr); s.write_all(&request("GET", None)).unwrap(); s.flush().ok(); let start = Instant::now(); let got = read_to_eof(&mut s); let elapsed = start.elapsed(); assert!(!got.is_empty()); assert!( elapsed < Duration::from_millis(150), "closed after {elapsed:?}; the roster oracle closes immediately" ); } /// The dwell stays configurable so the causal experiment — set it and confirm /// the delay appears — can run without a rebuild. Guards against the knob /// being quietly ignored. #[test] fn the_dwell_is_overridable() { let h = start_with(|c| c.close_dwell = Duration::from_millis(400)); let mut s = connect(h.addr); s.write_all(&request("GET", None)).unwrap(); s.flush().ok(); let start = Instant::now(); let _ = read_to_eof(&mut s); assert!( start.elapsed() >= Duration::from_millis(350), "dwell override had no effect" ); } /// The oracle never routes on the path — it logs it and answers the same way. /// A host that started 404ing unknown paths would be a behaviour change. #[test] fn any_path_gets_the_same_answer() { let h = start(); let mut s = connect(h.addr); s.write_all(b"GET /something/else HTTP/1.1\r\nHost: x\r\n\r\n") .unwrap(); s.flush().ok(); let got = read_to_eof(&mut s); assert_eq!(mask(&got), expected(roster::Method::Get)); } /// Masking must not be able to hide a real difference. #[test] fn masking_does_not_hide_a_missing_header() { let good = roster::roster_response(roster::Method::Get, roster::ORACLE_SERVER, "X"); let without: Vec = String::from_utf8_lossy(&good) .split("\r\n") .filter(|l| !l.starts_with("Date: ")) .collect::>() .join("\r\n") .into_bytes(); assert_ne!(mask(&good), mask(&without)); }