//! RCON integration tests using in-process mock servers. //! //! Real OS sockets on ephemeral ports — no mocking framework. Each test //! binds a listener, spawns a task that speaks the expected protocol, then //! exercises `rcon::send_command` and asserts on the result. Tests are //! unix-only because the musl cross-compile target and the CI runner are both //! Linux; the production use case is also Linux-only (game servers don't run //! on macOS or Windows in production). //! //! We use `#[cfg(unix)]` to keep parity with the supervisor integration tests. #![cfg(unix)] use corrosion_host_agent::rcon::{RconConfig, RconKind}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; // --------------------------------------------------------------------------- // Source RCON helpers — duplicate the wire-format encode/decode locally so // the tests own the mock server without depending on the production code path. // --------------------------------------------------------------------------- /// Build a Source RCON packet: [size(4LE) | id(4LE) | type(4LE) | body | 0x00 0x00] fn encode_packet(id: i32, ptype: i32, body: &[u8]) -> Vec { let size = (4 + 4 + body.len() + 2) as i32; let mut out = Vec::with_capacity(4 + size as usize); out.extend_from_slice(&size.to_le_bytes()); out.extend_from_slice(&id.to_le_bytes()); out.extend_from_slice(&ptype.to_le_bytes()); out.extend_from_slice(body); out.push(0x00); out.push(0x00); out } /// Read one Source RCON packet from a TcpStream. async fn read_packet(stream: &mut TcpStream) -> (i32, i32, Vec) { let mut size_buf = [0u8; 4]; stream.read_exact(&mut size_buf).await.unwrap(); let size = i32::from_le_bytes(size_buf) as usize; let mut payload = vec![0u8; size]; stream.read_exact(&mut payload).await.unwrap(); let id = i32::from_le_bytes(payload[0..4].try_into().unwrap()); let ptype = i32::from_le_bytes(payload[4..8].try_into().unwrap()); let body_end = size.saturating_sub(2); let body = payload[8..body_end].to_vec(); (id, ptype, body) } const SOURCE_TYPE_AUTH: i32 = 3; const SOURCE_TYPE_AUTH_RESPONSE: i32 = 2; const SOURCE_TYPE_EXECCOMMAND: i32 = 2; const SOURCE_TYPE_RESPONSE_VALUE: i32 = 0; // --------------------------------------------------------------------------- // Mock Source RCON server // --------------------------------------------------------------------------- /// Run a Source RCON server that accepts password "goodpw", rejects others, /// and responds to the first EXECCOMMAND with `response_body`. /// /// If `split_at` is Some(n) the body is split: the first `n` bytes arrive in /// one RESPONSE_VALUE packet and the remainder in a second — testing multi- /// packet reassembly. async fn run_source_mock( mut stream: TcpStream, accept_password: &str, command_response: &[u8], split_at: Option, ) { // --- Auth phase --- let (auth_id, ptype, body) = read_packet(&mut stream).await; assert_eq!(ptype, SOURCE_TYPE_AUTH, "expected AUTH packet"); let password = String::from_utf8_lossy(&body); if password != accept_password { // Send empty RESPONSE_VALUE then AUTH_RESPONSE with id = -1 (failure). let empty = encode_packet(auth_id, SOURCE_TYPE_RESPONSE_VALUE, b""); stream.write_all(&empty).await.unwrap(); let fail = encode_packet(-1, SOURCE_TYPE_AUTH_RESPONSE, b""); stream.write_all(&fail).await.unwrap(); return; } // Success: empty RESPONSE_VALUE then AUTH_RESPONSE with the auth id. let empty = encode_packet(auth_id, SOURCE_TYPE_RESPONSE_VALUE, b""); stream.write_all(&empty).await.unwrap(); let ok = encode_packet(auth_id, SOURCE_TYPE_AUTH_RESPONSE, b""); stream.write_all(&ok).await.unwrap(); // --- Command phase --- let (cmd_id, cmd_ptype, _cmd_body) = read_packet(&mut stream).await; assert_eq!(cmd_ptype, SOURCE_TYPE_EXECCOMMAND, "expected EXECCOMMAND"); // Read the probe packet (empty RESPONSE_VALUE with a different id). let (probe_id, probe_ptype, _) = read_packet(&mut stream).await; assert_eq!(probe_ptype, SOURCE_TYPE_RESPONSE_VALUE, "expected probe packet"); // Send the command response, optionally split across two packets. if let Some(n) = split_at { let (part1, part2) = command_response.split_at(n.min(command_response.len())); let p1 = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, part1); stream.write_all(&p1).await.unwrap(); let p2 = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, part2); stream.write_all(&p2).await.unwrap(); } else { let p = encode_packet(cmd_id, SOURCE_TYPE_RESPONSE_VALUE, command_response); stream.write_all(&p).await.unwrap(); } // Echo the probe to signal end-of-response. let probe_echo = encode_packet(probe_id, SOURCE_TYPE_RESPONSE_VALUE, b""); stream.write_all(&probe_echo).await.unwrap(); } // --------------------------------------------------------------------------- // Source RCON tests // --------------------------------------------------------------------------- #[tokio::test] async fn source_rcon_auth_and_exec_returns_response() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); run_source_mock(stream, "goodpw", b"Hello from server", None).await; }); let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() }; let result = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status") .await .expect("command should succeed"); assert_eq!(result, "Hello from server"); } #[tokio::test] async fn source_rcon_wrong_password_returns_auth_error() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); run_source_mock(stream, "goodpw", b"should not see this", None).await; }); let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "wrongpw".to_string() }; let err = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status") .await .expect_err("wrong password should fail"); assert!( err.to_string().to_lowercase().contains("auth"), "error should mention auth failure, got: {err}" ); } #[tokio::test] async fn source_rcon_multi_packet_response_concatenated() { // Build a body large enough to split meaningfully across two packets. // Use repeating ASCII so the result is valid UTF-8 and easy to verify. // 200 'A's then 200 'B's = 400 bytes, split at 200. let body: Vec = std::iter::repeat_n(b'A', 200) .chain(std::iter::repeat_n(b'B', 200)) .collect(); let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); let body_clone = body.clone(); tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); run_source_mock(stream, "goodpw", &body_clone, Some(200)).await; }); let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() }; let result = corrosion_host_agent::rcon::send_command(&cfg, "soulmask", "showplayers") .await .expect("multi-packet command should succeed"); let expected = String::from_utf8(body).unwrap(); assert_eq!(result, expected, "full body should be concatenated across both packets"); } #[tokio::test] async fn source_rcon_connect_timeout_to_unreachable_port() { // Bind a listener but never accept — the connection will time out during // the RCON auth phase because nothing is reading from the socket. // We use a port that is bound (so TCP connect itself succeeds) but then // the mock simply drops the stream, forcing a read error, which should // surface as an error (not a panic or hang). let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); // Accept the TCP connection but immediately drop it — simulates a port // that accepts but never speaks RCON. tokio::spawn(async move { let (_stream, _) = listener.accept().await.unwrap(); // _stream dropped here — EOF on the client's read }); let cfg = RconConfig { kind: Some(RconKind::Source), port, password: "goodpw".to_string() }; let err = corrosion_host_agent::rcon::send_command(&cfg, "conan", "status") .await .expect_err("closed connection should fail"); // We just need it to fail and not hang; error message varies by OS. let _ = err; } // --------------------------------------------------------------------------- // WebRCON mock server // --------------------------------------------------------------------------- /// Run a WebRCON mock: send one noise frame (Identifier 0), then respond to /// the first real request with the given output. async fn run_webrcon_mock(stream: tokio::net::TcpStream, output: &str) { use futures::{SinkExt, StreamExt}; use tokio_tungstenite::accept_async; use tokio_tungstenite::tungstenite::Message as WsMsg; let mut ws = accept_async(stream).await.expect("WS handshake failed"); // Send noise (chat frame, Identifier 0) before the real request arrives. let noise = serde_json::json!({ "Identifier": 0, "Message": "Player X joined", "Name": "Server", "Type": "Chat" }); ws.send(WsMsg::Text(noise.to_string())) .await .unwrap(); // Read the command request. let msg = ws.next().await.unwrap().unwrap(); let text = match msg { WsMsg::Text(t) => t, other => panic!("expected Text frame, got {other:?}"), }; let req: serde_json::Value = serde_json::from_str(&text).unwrap(); let req_id = req["Identifier"].as_i64().unwrap() as i32; // Reply with the same Identifier so the client correlates correctly. let reply = serde_json::json!({ "Identifier": req_id, "Message": output, "Type": "Generic", }); ws.send(WsMsg::Text(reply.to_string())).await.unwrap(); } // --------------------------------------------------------------------------- // WebRCON tests // --------------------------------------------------------------------------- #[tokio::test] async fn webrcon_skips_noise_and_returns_correct_message() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let port = listener.local_addr().unwrap().port(); tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); run_webrcon_mock(stream, "Players: 42/100").await; }); // Password is embedded in the URL path — any non-empty string works with // our mock. let cfg = RconConfig { kind: Some(RconKind::WebRcon), port, password: "testpw".to_string(), }; let result = corrosion_host_agent::rcon::send_command(&cfg, "rust", "playercount") .await .expect("WebRCON command should succeed"); assert_eq!(result, "Players: 42/100"); } // --------------------------------------------------------------------------- // TOML parsing test — pins [[instance]] + [instance.rcon] sub-table syntax // --------------------------------------------------------------------------- #[test] fn toml_instance_with_rcon_parses_correctly() { let toml = r#" [agent] license_id = "test-license" nats_url = "nats://localhost:4222" [[instance]] id = "rust-main" game = "rust" root = "/opt/rustserver" [instance.rcon] port = 28016 password = "secretpassword" kind = "webrcon" "#; let cfg: corrosion_host_agent::config::ConfigFile = toml::from_str(toml).expect("TOML should parse"); assert_eq!(cfg.instances.len(), 1); let inst = &cfg.instances[0]; assert_eq!(inst.id, "rust-main"); let rcon = inst.rcon.as_ref().expect("rcon should be present"); assert_eq!(rcon.port, 28016); assert_eq!(rcon.password, "secretpassword"); assert_eq!(rcon.kind, Some(corrosion_host_agent::rcon::RconKind::WebRcon)); } #[test] fn toml_instance_without_rcon_defaults_to_none() { let toml = r#" [agent] license_id = "test-license" nats_url = "nats://localhost:4222" [[instance]] id = "conan-main" game = "conan" root = "/opt/conan" "#; let cfg: corrosion_host_agent::config::ConfigFile = toml::from_str(toml).expect("TOML should parse"); assert!(cfg.instances[0].rcon.is_none(), "absent rcon should be None"); } #[test] fn resolved_kind_infers_from_game_name() { use corrosion_host_agent::rcon::{RconConfig, RconKind}; let cfg_no_kind = RconConfig { kind: None, port: 28016, password: "x".to_string() }; assert_eq!(cfg_no_kind.resolved_kind("rust"), RconKind::WebRcon); assert_eq!(cfg_no_kind.resolved_kind("conan"), RconKind::Source); assert_eq!(cfg_no_kind.resolved_kind("soulmask"), RconKind::Source); assert_eq!(cfg_no_kind.resolved_kind("dune"), RconKind::WebRcon); // fallback // Explicit kind always wins. let cfg_source = RconConfig { kind: Some(RconKind::Source), ..cfg_no_kind.clone() }; assert_eq!(cfg_source.resolved_kind("rust"), RconKind::Source); let cfg_webrcon = RconConfig { kind: Some(RconKind::WebRcon), ..cfg_no_kind }; assert_eq!(cfg_webrcon.resolved_kind("conan"), RconKind::WebRcon); }