From 93459a8b2c18f00fd8ef1a502141c617c6100df1 Mon Sep 17 00:00:00 2001 From: cuteolaf Date: Fri, 9 Jan 2026 10:27:17 +0100 Subject: [PATCH 1/2] test(secure-runtime): achieve comprehensive test coverage for container runtime Note: Lower coverage on client.rs, ws_client.rs, ws_transport.rs, and broker.rs is expected as these contain async I/O code (Unix sockets, WebSocket networking, Docker API calls) that requires infrastructure for integration testing. All synchronous, unit-testable code has comprehensive coverage. --- crates/secure-container-runtime/src/client.rs | 128 +++++++++ crates/secure-container-runtime/src/policy.rs | 196 ++++++++++++++ .../secure-container-runtime/src/protocol.rs | 152 +++++++++++ crates/secure-container-runtime/src/types.rs | 248 ++++++++++++++++++ .../secure-container-runtime/src/ws_client.rs | 92 ++++++- .../src/ws_transport.rs | 72 +++++ 6 files changed, 887 insertions(+), 1 deletion(-) diff --git a/crates/secure-container-runtime/src/client.rs b/crates/secure-container-runtime/src/client.rs index 763939ab0..1f7f15d26 100644 --- a/crates/secure-container-runtime/src/client.rs +++ b/crates/secure-container-runtime/src/client.rs @@ -677,4 +677,132 @@ mod tests { assert_eq!(config.resources.memory_bytes, 2 * 1024 * 1024 * 1024); assert_eq!(config.network.ports.get(&8080), Some(&0)); } + + #[test] + fn test_config_builder_all_methods() { + let mut env_vars = HashMap::new(); + env_vars.insert("VAR1".into(), "val1".into()); + env_vars.insert("VAR2".into(), "val2".into()); + + let config = ContainerConfigBuilder::new("alpine:latest", "ch1", "owner1") + .name("test") + .cmd(vec!["sh".into(), "-c".into(), "echo hello".into()]) + .env("KEY", "value") + .envs(env_vars) + .working_dir("/app") + .memory(1024 * 1024 * 1024) // 1GB + .cpu(0.5) + .pids(128) + .network_mode(NetworkMode::Bridge) + .port(8080, 8080) + .allow_internet(true) + .mount("/tmp/data", "/data", false) + .mount_readonly("/tmp/config", "/config") + .label("version", "1.0") + .build(); + + assert_eq!(config.name, Some("test".into())); + assert_eq!(config.cmd, Some(vec!["sh".into(), "-c".into(), "echo hello".into()])); + assert_eq!(config.env.get("KEY"), Some(&"value".into())); + assert_eq!(config.env.get("VAR1"), Some(&"val1".into())); + assert_eq!(config.working_dir, Some("/app".into())); + assert_eq!(config.resources.memory_bytes, 1024 * 1024 * 1024); + assert_eq!(config.resources.cpu_cores, 0.5); + assert_eq!(config.resources.pids_limit, 128); + assert_eq!(config.network.mode, NetworkMode::Bridge); + assert_eq!(config.network.ports.get(&8080), Some(&8080)); + assert_eq!(config.network.allow_internet, true); + assert_eq!(config.mounts.len(), 2); + assert_eq!(config.mounts[0].read_only, false); + assert_eq!(config.mounts[1].read_only, true); + assert_eq!(config.labels.get("version"), Some(&"1.0".into())); + } + + #[test] + fn test_config_builder_memory_gb() { + let config = ContainerConfigBuilder::new("test:1", "ch1", "owner1") + .memory_gb(4.5) + .build(); + + assert_eq!(config.resources.memory_bytes, (4.5 * 1024.0 * 1024.0 * 1024.0) as i64); + } + + #[test] + fn test_secure_container_client_new() { + let client = SecureContainerClient::new("/custom/path.sock"); + assert_eq!(client.socket_path, "/custom/path.sock"); + } + + #[test] + fn test_secure_container_client_default_path() { + let client = SecureContainerClient::default_path(); + assert_eq!(client.socket_path, "/var/run/platform/container-broker.sock"); + } + + #[test] + fn test_cleanup_result_success() { + let result = CleanupResult { + total: 5, + stopped: 5, + removed: 5, + errors: vec![], + }; + assert!(result.success()); + + let result_with_errors = CleanupResult { + total: 5, + stopped: 4, + removed: 4, + errors: vec!["Failed to stop container".into()], + }; + assert!(!result_with_errors.success()); + } + + #[test] + fn test_oneshot_result_fields() { + let result = OneshotResult { + success: true, + logs: "test output".into(), + duration_secs: 1.5, + timed_out: false, + }; + + assert!(result.success); + assert_eq!(result.logs, "test output"); + assert_eq!(result.duration_secs, 1.5); + assert!(!result.timed_out); + } + + #[test] + fn test_challenge_stats_fields() { + let stats = ChallengeStats { + challenge_id: "challenge-123".into(), + total_containers: 10, + running_containers: 7, + stopped_containers: 3, + container_ids: vec!["c1".into(), "c2".into()], + }; + + assert_eq!(stats.challenge_id, "challenge-123"); + assert_eq!(stats.total_containers, 10); + assert_eq!(stats.running_containers, 7); + assert_eq!(stats.stopped_containers, 3); + assert_eq!(stats.container_ids.len(), 2); + } + + #[test] + fn test_container_start_result_fields() { + let mut ports = HashMap::new(); + ports.insert(8080, 38080); + + let result = ContainerStartResult { + container_id: "container-123".into(), + ports: ports.clone(), + endpoint: Some("http://test-container:8080".into()), + }; + + assert_eq!(result.container_id, "container-123"); + assert_eq!(result.ports, ports); + assert_eq!(result.endpoint, Some("http://test-container:8080".into())); + } } diff --git a/crates/secure-container-runtime/src/policy.rs b/crates/secure-container-runtime/src/policy.rs index 402a7fc14..73824d1e7 100644 --- a/crates/secure-container-runtime/src/policy.rs +++ b/crates/secure-container-runtime/src/policy.rs @@ -291,6 +291,7 @@ impl SecurityPolicy { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; #[test] fn test_policy_default() { @@ -388,4 +389,199 @@ mod tests { assert!(policy.validate(&config).is_err()); } + + #[test] + fn test_strict_policy() { + let policy = SecurityPolicy::strict(); + assert_eq!(policy.allowed_image_prefixes.len(), 1); + assert_eq!(policy.allowed_image_prefixes[0], "ghcr.io/platformnetwork/"); + assert!(policy.validate_image("ghcr.io/platformnetwork/test:latest").is_ok()); + assert!(policy.validate_image("docker.io/test:latest").is_err()); + } + + #[test] + fn test_development_policy() { + let policy = SecurityPolicy::development(); + assert!(policy.allowed_mount_prefixes.contains(&"/workspace/".to_string())); + } + + #[test] + fn test_allow_all_images() { + let mut policy = SecurityPolicy::strict(); + assert!(!policy.allowed_image_prefixes.is_empty()); + + policy = policy.allow_all_images(); + assert!(policy.allowed_image_prefixes.is_empty()); + assert!(policy.validate_image("any:image").is_ok()); + } + + #[test] + fn test_with_image_prefix() { + let policy = SecurityPolicy::default() + .with_image_prefix("docker.io/myorg/"); + + assert!(policy.allowed_image_prefixes.contains(&"docker.io/myorg/".to_string())); + } + + #[test] + fn test_validate_missing_owner_id() { + let policy = SecurityPolicy::default(); + let config = ContainerConfig { + image: "test:latest".to_string(), + challenge_id: "challenge-1".to_string(), + owner_id: "".to_string(), + ..Default::default() + }; + + assert!(policy.validate(&config).is_err()); + } + + #[test] + fn test_validate_resources_excessive_cpu() { + let policy = SecurityPolicy::default(); + let resources = ResourceLimits { + memory_bytes: 1024 * 1024 * 1024, + cpu_cores: 10.0, // Exceeds max_cpu_cores (4.0) + pids_limit: 100, + disk_quota_bytes: 0, + }; + + assert!(policy.validate_resources(&resources).is_err()); + } + + #[test] + fn test_validate_resources_excessive_pids() { + let policy = SecurityPolicy::default(); + let resources = ResourceLimits { + memory_bytes: 1024 * 1024 * 1024, + cpu_cores: 1.0, + pids_limit: 10000, // Exceeds max_pids (512) + disk_quota_bytes: 0, + }; + + assert!(policy.validate_resources(&resources).is_err()); + } + + #[test] + fn test_validate_mounts_run_docker_sock() { + let policy = SecurityPolicy::default(); + let mounts = vec![MountConfig { + source: "/run/docker.sock".to_string(), + target: "/var/run/docker.sock".to_string(), + read_only: true, + }]; + + assert!(policy.validate_mounts(&mounts).is_err()); + } + + #[test] + fn test_validate_mounts_forbidden_paths() { + let policy = SecurityPolicy::default(); + + let forbidden_mounts = vec![ + "/etc/passwd", + "/etc/shadow", + "/etc/sudoers", + "/root", + "/home", + "/proc", + "/sys", + "/dev", + ]; + + for path in forbidden_mounts { + let mounts = vec![MountConfig { + source: path.to_string(), + target: "/mnt/test".to_string(), + read_only: true, + }]; + assert!(policy.validate_mounts(&mounts).is_err(), "Should block mount: {}", path); + } + } + + #[test] + fn test_validate_mounts_allowed_prefixes() { + let policy = SecurityPolicy::default(); + + let allowed_mounts = vec![ + "/tmp/data", + "/var/lib/platform/challenge-1", + "/var/lib/docker/volumes/vol1", + ]; + + for path in allowed_mounts { + let mounts = vec![MountConfig { + source: path.to_string(), + target: "/mnt/test".to_string(), + read_only: true, + }]; + assert!(policy.validate_mounts(&mounts).is_ok(), "Should allow mount: {}", path); + } + } + + #[test] + fn test_validate_mounts_docker_sock_in_path() { + let policy = SecurityPolicy::default(); + let mounts = vec![MountConfig { + source: "/tmp/docker.sock".to_string(), + target: "/app/docker.sock".to_string(), + read_only: true, + }]; + + // Should be blocked due to containing "docker.sock" + assert!(policy.validate_mounts(&mounts).is_err()); + } + + #[test] + fn test_validate_mounts_empty_list() { + let policy = SecurityPolicy::default(); + assert!(policy.validate_mounts(&vec![]).is_ok()); + } + + #[test] + fn test_validate_network() { + let policy = SecurityPolicy::default(); + + let network = NetworkConfig { + mode: NetworkMode::Isolated, + ports: HashMap::new(), + allow_internet: false, + }; + assert!(policy.validate_network(&network).is_ok()); + + let network_with_internet = NetworkConfig { + mode: NetworkMode::Bridge, + ports: HashMap::new(), + allow_internet: true, + }; + assert!(policy.validate_network(&network_with_internet).is_ok()); + } + + #[test] + fn test_check_container_limit() { + let policy = SecurityPolicy::default(); + + assert!(policy.check_container_limit("challenge-1", 50).is_ok()); + assert!(policy.check_container_limit("challenge-1", 99).is_ok()); + assert!(policy.check_container_limit("challenge-1", 100).is_err()); + assert!(policy.check_container_limit("challenge-1", 150).is_err()); + } + + #[test] + fn test_check_owner_limit() { + let policy = SecurityPolicy::default(); + + assert!(policy.check_owner_limit("owner-1", 100).is_ok()); + assert!(policy.check_owner_limit("owner-1", 199).is_ok()); + assert!(policy.check_owner_limit("owner-1", 200).is_err()); + assert!(policy.check_owner_limit("owner-1", 300).is_err()); + } + + #[test] + fn test_forbidden_mount_paths_default() { + let policy = SecurityPolicy::default(); + assert!(policy.forbidden_mount_paths.contains("/var/run/docker.sock")); + assert!(policy.forbidden_mount_paths.contains("/run/docker.sock")); + assert!(policy.forbidden_mount_paths.contains("/etc/passwd")); + } } diff --git a/crates/secure-container-runtime/src/protocol.rs b/crates/secure-container-runtime/src/protocol.rs index 95f6de26d..42731a216 100644 --- a/crates/secure-container-runtime/src/protocol.rs +++ b/crates/secure-container-runtime/src/protocol.rs @@ -300,6 +300,7 @@ pub fn decode_response(data: &str) -> Result { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; #[test] fn test_request_serialization() { @@ -344,4 +345,155 @@ mod tests { assert!(json.contains("challenge-1")); assert!(json.contains("create")); } + + #[test] + fn test_request_type_all_variants() { + let config = ContainerConfig::default(); + + assert_eq!(Request::Create { config: config.clone(), request_id: "1".into() }.request_type(), "create"); + assert_eq!(Request::Start { container_id: "c1".into(), request_id: "1".into() }.request_type(), "start"); + assert_eq!(Request::Stop { container_id: "c1".into(), timeout_secs: 10, request_id: "1".into() }.request_type(), "stop"); + assert_eq!(Request::Remove { container_id: "c1".into(), force: false, request_id: "1".into() }.request_type(), "remove"); + assert_eq!(Request::Exec { container_id: "c1".into(), command: vec![], working_dir: None, timeout_secs: 30, request_id: "1".into() }.request_type(), "exec"); + assert_eq!(Request::Inspect { container_id: "c1".into(), request_id: "1".into() }.request_type(), "inspect"); + assert_eq!(Request::List { challenge_id: None, owner_id: None, request_id: "1".into() }.request_type(), "list"); + assert_eq!(Request::Logs { container_id: "c1".into(), tail: 100, request_id: "1".into() }.request_type(), "logs"); + assert_eq!(Request::Pull { image: "alpine".into(), request_id: "1".into() }.request_type(), "pull"); + assert_eq!(Request::Build { tag: "test:1".into(), dockerfile: "".into(), context: None, request_id: "1".into() }.request_type(), "build"); + assert_eq!(Request::Ping { request_id: "1".into() }.request_type(), "ping"); + assert_eq!(Request::CopyFrom { container_id: "c1".into(), path: "/file".into(), request_id: "1".into() }.request_type(), "copy_from"); + assert_eq!(Request::CopyTo { container_id: "c1".into(), path: "/file".into(), data: "".into(), request_id: "1".into() }.request_type(), "copy_to"); + } + + #[test] + fn test_request_challenge_id() { + let config = ContainerConfig { + challenge_id: "challenge-123".into(), + ..Default::default() + }; + + let create_req = Request::Create { config, request_id: "1".into() }; + assert_eq!(create_req.challenge_id(), Some("challenge-123")); + + let ping_req = Request::Ping { request_id: "1".into() }; + assert_eq!(ping_req.challenge_id(), None); + + let list_req = Request::List { challenge_id: Some("ch-456".into()), owner_id: None, request_id: "1".into() }; + assert_eq!(list_req.challenge_id(), Some("ch-456")); + } + + #[test] + fn test_request_owner_id() { + let config = ContainerConfig { + owner_id: "owner-123".into(), + ..Default::default() + }; + + let create_req = Request::Create { config, request_id: "1".into() }; + assert_eq!(create_req.owner_id(), Some("owner-123")); + + let ping_req = Request::Ping { request_id: "1".into() }; + assert_eq!(ping_req.owner_id(), None); + + let list_req = Request::List { challenge_id: None, owner_id: Some("owner-456".into()), request_id: "1".into() }; + assert_eq!(list_req.owner_id(), Some("owner-456")); + } + + #[test] + fn test_response_request_id_all_variants() { + assert_eq!(Response::Created { container_id: "c1".into(), container_name: "name".into(), request_id: "r1".into() }.request_id(), "r1"); + assert_eq!(Response::Started { container_id: "c1".into(), ports: HashMap::new(), endpoint: None, request_id: "r2".into() }.request_id(), "r2"); + assert_eq!(Response::Stopped { container_id: "c1".into(), request_id: "r3".into() }.request_id(), "r3"); + assert_eq!(Response::Removed { container_id: "c1".into(), request_id: "r4".into() }.request_id(), "r4"); + + let exec_result = ExecResult { stdout: "".into(), stderr: "".into(), exit_code: 0, duration_ms: 100, timed_out: false }; + assert_eq!(Response::ExecResult { result: exec_result, request_id: "r5".into() }.request_id(), "r5"); + } + + #[test] + fn test_response_is_error() { + let error_resp = Response::Error { + error: ContainerError::InvalidRequest("test".into()), + request_id: "1".into() + }; + assert!(error_resp.is_error()); + + let success_resp = Response::Pong { version: "1.0".into(), request_id: "1".into() }; + assert!(!success_resp.is_error()); + } + + #[test] + fn test_response_error_constructor() { + let resp = Response::error("req-123".into(), ContainerError::ContainerNotFound("c1".into())); + assert!(resp.is_error()); + assert_eq!(resp.request_id(), "req-123"); + } + + #[test] + fn test_all_request_variants_serialization() { + let requests = vec![ + Request::Start { container_id: "c1".into(), request_id: "1".into() }, + Request::Stop { container_id: "c1".into(), timeout_secs: 10, request_id: "2".into() }, + Request::Remove { container_id: "c1".into(), force: true, request_id: "3".into() }, + Request::Exec { container_id: "c1".into(), command: vec!["ls".into()], working_dir: Some("/tmp".into()), timeout_secs: 30, request_id: "4".into() }, + Request::Inspect { container_id: "c1".into(), request_id: "5".into() }, + Request::List { challenge_id: Some("ch1".into()), owner_id: Some("owner1".into()), request_id: "6".into() }, + Request::Logs { container_id: "c1".into(), tail: 50, request_id: "7".into() }, + Request::Pull { image: "alpine:latest".into(), request_id: "8".into() }, + Request::Build { tag: "test:1".into(), dockerfile: "FROM alpine".into(), context: Some("data".into()), request_id: "9".into() }, + Request::CopyFrom { container_id: "c1".into(), path: "/app/file.txt".into(), request_id: "10".into() }, + Request::CopyTo { container_id: "c1".into(), path: "/app/data.txt".into(), data: "base64data".into(), request_id: "11".into() }, + ]; + + for request in requests { + let json = encode_request(&request); + let decoded = decode_request(&json).unwrap(); + assert_eq!(decoded.request_id(), request.request_id()); + } + } + + #[test] + fn test_all_response_variants_serialization() { + let responses = vec![ + Response::Pulled { image: "alpine".into(), request_id: "1".into() }, + Response::Built { image_id: "sha256:abc".into(), logs: "build logs".into(), request_id: "2".into() }, + Response::LogsResult { logs: "container logs".into(), request_id: "3".into() }, + Response::CopyFromResult { data: "base64".into(), size: 1024, request_id: "4".into() }, + Response::CopyToResult { request_id: "5".into() }, + ]; + + for response in responses { + let json = encode_response(&response); + let decoded = decode_response(&json).unwrap(); + assert_eq!(decoded.request_id(), response.request_id()); + } + } + + #[test] + fn test_response_info_and_list() { + let info = ContainerInfo { + id: "c1".into(), + name: "test".into(), + challenge_id: "ch1".into(), + owner_id: "owner1".into(), + image: "alpine".into(), + state: ContainerState::Running, + created_at: chrono::Utc::now(), + ports: HashMap::new(), + endpoint: None, + labels: HashMap::new(), + }; + + let resp = Response::Info { info: info.clone(), request_id: "r1".into() }; + assert_eq!(resp.request_id(), "r1"); + + let list_resp = Response::ContainerList { containers: vec![info], request_id: "r2".into() }; + assert_eq!(list_resp.request_id(), "r2"); + } + + #[test] + fn test_decode_invalid_json() { + assert!(decode_request("invalid json").is_err()); + assert!(decode_response("{\"invalid\": true}").is_err()); + } } diff --git a/crates/secure-container-runtime/src/types.rs b/crates/secure-container-runtime/src/types.rs index bd1357f39..ed45fcc83 100644 --- a/crates/secure-container-runtime/src/types.rs +++ b/crates/secure-container-runtime/src/types.rs @@ -269,3 +269,251 @@ pub mod labels { pub const BROKER_VERSION: &str = "platform.broker.version"; pub const MANAGED: &str = "platform.managed"; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_container_config_default() { + let config = ContainerConfig::default(); + assert!(config.image.is_empty()); + assert!(config.challenge_id.is_empty()); + assert!(config.owner_id.is_empty()); + assert!(config.name.is_none()); + assert!(config.cmd.is_none()); + assert!(config.env.is_empty()); + assert!(config.mounts.is_empty()); + assert!(config.labels.is_empty()); + } + + #[test] + fn test_resource_limits_default() { + let limits = ResourceLimits::default(); + assert_eq!(limits.memory_bytes, 2 * 1024 * 1024 * 1024); + assert_eq!(limits.cpu_cores, 1.0); + assert_eq!(limits.pids_limit, 256); + assert_eq!(limits.disk_quota_bytes, 0); + } + + #[test] + fn test_network_config_default() { + let network = NetworkConfig::default(); + assert_eq!(network.mode, NetworkMode::Isolated); + assert!(network.ports.is_empty()); + assert!(!network.allow_internet); + } + + #[test] + fn test_network_mode_serialization() { + let modes = vec![ + NetworkMode::None, + NetworkMode::Bridge, + NetworkMode::Isolated, + ]; + + for mode in modes { + let json = serde_json::to_string(&mode).unwrap(); + let decoded: NetworkMode = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, mode); + } + } + + #[test] + fn test_container_state_serialization() { + let states = vec![ + ContainerState::Creating, + ContainerState::Running, + ContainerState::Stopped, + ContainerState::Paused, + ContainerState::Removing, + ContainerState::Dead, + ContainerState::Unknown, + ]; + + for state in states { + let json = serde_json::to_string(&state).unwrap(); + let decoded: ContainerState = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, state); + } + } + + #[test] + fn test_mount_config_serialization() { + let mount = MountConfig { + source: "/host/path".into(), + target: "/container/path".into(), + read_only: true, + }; + + let json = serde_json::to_string(&mount).unwrap(); + let decoded: MountConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.source, "/host/path"); + assert_eq!(decoded.target, "/container/path"); + assert!(decoded.read_only); + } + + #[test] + fn test_exec_result_fields() { + let result = ExecResult { + stdout: "output".into(), + stderr: "error".into(), + exit_code: 0, + duration_ms: 1500, + timed_out: false, + }; + + assert_eq!(result.stdout, "output"); + assert_eq!(result.stderr, "error"); + assert_eq!(result.exit_code, 0); + assert_eq!(result.duration_ms, 1500); + assert!(!result.timed_out); + } + + #[test] + fn test_audit_action_serialization() { + let actions = vec![ + AuditAction::ContainerCreate, + AuditAction::ContainerStart, + AuditAction::ContainerStop, + AuditAction::ContainerRemove, + AuditAction::ContainerExec, + AuditAction::ImagePull, + AuditAction::ImageBuild, + AuditAction::PolicyViolation, + ]; + + for action in actions { + let json = serde_json::to_string(&action).unwrap(); + let decoded: AuditAction = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded, action); + } + } + + #[test] + fn test_audit_entry_serialization() { + let mut details = HashMap::new(); + details.insert("key".into(), "value".into()); + + let entry = AuditEntry { + timestamp: chrono::Utc::now(), + action: AuditAction::ContainerCreate, + challenge_id: "ch1".into(), + owner_id: "owner1".into(), + container_id: Some("container1".into()), + success: true, + error: None, + details, + }; + + let json = serde_json::to_string(&entry).unwrap(); + let decoded: AuditEntry = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.action, AuditAction::ContainerCreate); + assert_eq!(decoded.challenge_id, "ch1"); + assert!(decoded.success); + } + + #[test] + fn test_container_error_display() { + let errors = vec![ + ContainerError::ImageNotWhitelisted("test:latest".into()), + ContainerError::PolicyViolation("test".into()), + ContainerError::ContainerNotFound("c1".into()), + ContainerError::ResourceLimitExceeded("memory".into()), + ContainerError::PermissionDenied("test".into()), + ContainerError::InvalidConfig("test".into()), + ContainerError::DockerError("test".into()), + ContainerError::InternalError("test".into()), + ContainerError::Unauthorized("test".into()), + ContainerError::InvalidRequest("test".into()), + ]; + + for error in errors { + let msg = format!("{}", error); + assert!(!msg.is_empty()); + } + } + + #[test] + fn test_container_error_serialization() { + let error = ContainerError::PolicyViolation("docker socket blocked".into()); + let json = serde_json::to_string(&error).unwrap(); + let decoded: ContainerError = serde_json::from_str(&json).unwrap(); + + match decoded { + ContainerError::PolicyViolation(msg) => assert_eq!(msg, "docker socket blocked"), + _ => panic!("Wrong error type"), + } + } + + #[test] + fn test_container_info_serialization() { + let mut ports = HashMap::new(); + ports.insert(8080, 38080); + + let mut labels = HashMap::new(); + labels.insert("key".into(), "value".into()); + + let info = ContainerInfo { + id: "c1".into(), + name: "test-container".into(), + challenge_id: "ch1".into(), + owner_id: "owner1".into(), + image: "alpine:latest".into(), + state: ContainerState::Running, + created_at: chrono::Utc::now(), + ports, + endpoint: Some("http://test:8080".into()), + labels, + }; + + let json = serde_json::to_string(&info).unwrap(); + let decoded: ContainerInfo = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.id, "c1"); + assert_eq!(decoded.name, "test-container"); + assert_eq!(decoded.state, ContainerState::Running); + } + + #[test] + fn test_labels_constants() { + assert_eq!(labels::CHALLENGE_ID, "platform.challenge.id"); + assert_eq!(labels::OWNER_ID, "platform.owner.id"); + assert_eq!(labels::CREATED_BY, "platform.created-by"); + assert_eq!(labels::BROKER_VERSION, "platform.broker.version"); + assert_eq!(labels::MANAGED, "platform.managed"); + } + + #[test] + fn test_container_config_with_all_fields() { + let mut env = HashMap::new(); + env.insert("KEY".into(), "VALUE".into()); + + let mut labels = HashMap::new(); + labels.insert("label1".into(), "value1".into()); + + let config = ContainerConfig { + image: "test:latest".into(), + challenge_id: "ch1".into(), + owner_id: "owner1".into(), + name: Some("test".into()), + cmd: Some(vec!["sh".into()]), + env, + working_dir: Some("/app".into()), + resources: ResourceLimits::default(), + network: NetworkConfig::default(), + mounts: vec![], + labels, + user: Some("1000:1000".into()), + }; + + let json = serde_json::to_string(&config).unwrap(); + let decoded: ContainerConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(decoded.image, "test:latest"); + assert_eq!(decoded.challenge_id, "ch1"); + assert_eq!(decoded.user, Some("1000:1000".into())); + } +} diff --git a/crates/secure-container-runtime/src/ws_client.rs b/crates/secure-container-runtime/src/ws_client.rs index 72568822e..2160193dc 100644 --- a/crates/secure-container-runtime/src/ws_client.rs +++ b/crates/secure-container-runtime/src/ws_client.rs @@ -265,11 +265,101 @@ pub struct ContainerStartResult { mod tests { use super::*; + #[test] + fn test_new() { + let client = WsContainerClient::new( + "ws://localhost:8090", + "test-jwt-token", + "challenge-123", + "owner-456" + ); + + assert_eq!(client.ws_url, "ws://localhost:8090"); + assert_eq!(client.jwt_token, "test-jwt-token"); + assert_eq!(client.challenge_id, "challenge-123"); + assert_eq!(client.owner_id, "owner-456"); + } + #[test] fn test_from_env_missing() { - // Should return None when env vars are not set + // Clean up all env vars first std::env::remove_var("CONTAINER_BROKER_WS_URL"); std::env::remove_var("CONTAINER_BROKER_JWT"); + std::env::remove_var("CHALLENGE_UUID"); + std::env::remove_var("CHALLENGE_ID"); + std::env::remove_var("VALIDATOR_HOTKEY"); + + // Should return None when env vars are not set assert!(WsContainerClient::from_env().is_none()); } + + #[test] + fn test_from_env_with_vars() { + // Clean first + std::env::remove_var("CONTAINER_BROKER_WS_URL"); + std::env::remove_var("CONTAINER_BROKER_JWT"); + std::env::remove_var("CHALLENGE_UUID"); + std::env::remove_var("CHALLENGE_ID"); + std::env::remove_var("VALIDATOR_HOTKEY"); + + std::env::set_var("CONTAINER_BROKER_WS_URL", "ws://test:8090"); + std::env::set_var("CONTAINER_BROKER_JWT", "test-token"); + std::env::set_var("CHALLENGE_UUID", "uuid-123"); + std::env::set_var("VALIDATOR_HOTKEY", "hotkey-456"); + + let client = WsContainerClient::from_env().unwrap(); + assert_eq!(client.ws_url, "ws://test:8090"); + assert_eq!(client.jwt_token, "test-token"); + assert_eq!(client.challenge_id, "uuid-123"); + assert_eq!(client.owner_id, "hotkey-456"); + + // Cleanup + std::env::remove_var("CONTAINER_BROKER_WS_URL"); + std::env::remove_var("CONTAINER_BROKER_JWT"); + std::env::remove_var("CHALLENGE_UUID"); + std::env::remove_var("VALIDATOR_HOTKEY"); + } + + #[test] + fn test_from_env_with_fallback_challenge_id() { + // Clean first + std::env::remove_var("CONTAINER_BROKER_WS_URL"); + std::env::remove_var("CONTAINER_BROKER_JWT"); + std::env::remove_var("CHALLENGE_UUID"); + std::env::remove_var("CHALLENGE_ID"); + std::env::remove_var("VALIDATOR_HOTKEY"); + + std::env::set_var("CONTAINER_BROKER_WS_URL", "ws://test:8090"); + std::env::set_var("CONTAINER_BROKER_JWT", "test-token"); + std::env::set_var("CHALLENGE_ID", "challenge-name"); + + let client = WsContainerClient::from_env().unwrap(); + assert_eq!(client.challenge_id, "challenge-name"); + + // Cleanup + std::env::remove_var("CONTAINER_BROKER_WS_URL"); + std::env::remove_var("CONTAINER_BROKER_JWT"); + std::env::remove_var("CHALLENGE_ID"); + } + + #[test] + fn test_from_env_default_challenge_and_owner() { + // Clean first + std::env::remove_var("CONTAINER_BROKER_WS_URL"); + std::env::remove_var("CONTAINER_BROKER_JWT"); + std::env::remove_var("CHALLENGE_UUID"); + std::env::remove_var("CHALLENGE_ID"); + std::env::remove_var("VALIDATOR_HOTKEY"); + + std::env::set_var("CONTAINER_BROKER_WS_URL", "ws://test:8090"); + std::env::set_var("CONTAINER_BROKER_JWT", "test-token"); + + let client = WsContainerClient::from_env().unwrap(); + assert_eq!(client.challenge_id, "unknown-challenge"); + assert_eq!(client.owner_id, "unknown-owner"); + + // Cleanup + std::env::remove_var("CONTAINER_BROKER_WS_URL"); + std::env::remove_var("CONTAINER_BROKER_JWT"); + } } diff --git a/crates/secure-container-runtime/src/ws_transport.rs b/crates/secure-container-runtime/src/ws_transport.rs index a9d38799e..e4f9eb534 100644 --- a/crates/secure-container-runtime/src/ws_transport.rs +++ b/crates/secure-container-runtime/src/ws_transport.rs @@ -358,4 +358,76 @@ mod tests { assert_eq!(decoded.claims.challenge_id, "term-challenge"); assert_eq!(decoded.claims.owner_id, "validator-1"); } + + #[test] + fn test_generate_token_different_ttl() { + let secret = "secret-key"; + let token = generate_token("ch1", "owner1", secret, 7200).unwrap(); + + let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()); + let validation = jsonwebtoken::Validation::default(); + let decoded = jsonwebtoken::decode::(&token, &key, &validation).unwrap(); + + assert!(decoded.claims.exp > decoded.claims.iat); + assert_eq!(decoded.claims.exp - decoded.claims.iat, 7200); + } + + #[test] + fn test_ws_config_default() { + let config = WsConfig::default(); + assert_eq!(config.bind_addr, "0.0.0.0:8090"); + assert_eq!(config.max_connections_per_challenge, 10); + assert_eq!(config.allowed_challenges.len(), 0); + } + + #[test] + fn test_ws_config_from_env() { + std::env::set_var("BROKER_JWT_SECRET", "test-secret"); + let config = WsConfig::default(); + assert_eq!(config.jwt_secret, Some("test-secret".to_string())); + std::env::remove_var("BROKER_JWT_SECRET"); + } + + #[test] + fn test_ws_claims_fields() { + let claims = WsClaims { + challenge_id: "challenge-123".into(), + owner_id: "owner-456".into(), + iat: 1000, + exp: 2000, + }; + + assert_eq!(claims.challenge_id, "challenge-123"); + assert_eq!(claims.owner_id, "owner-456"); + assert_eq!(claims.iat, 1000); + assert_eq!(claims.exp, 2000); + } + + #[test] + fn test_auth_message_serialization() { + let auth_msg = AuthMessage { + token: "test-jwt-token".into(), + }; + + let json = serde_json::to_string(&auth_msg).unwrap(); + assert!(json.contains("test-jwt-token")); + + let decoded: AuthMessage = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.token, "test-jwt-token"); + } + + #[test] + fn test_ws_config_custom() { + let config = WsConfig { + bind_addr: "127.0.0.1:9000".into(), + jwt_secret: Some("my-secret".into()), + allowed_challenges: vec!["ch1".into(), "ch2".into()], + max_connections_per_challenge: 5, + }; + + assert_eq!(config.bind_addr, "127.0.0.1:9000"); + assert_eq!(config.jwt_secret, Some("my-secret".into())); + assert_eq!(config.allowed_challenges.len(), 2); + assert_eq!(config.max_connections_per_challenge, 5); + } } From fe2cbfdba2103430fa83bc364ad03cf4cc644d01 Mon Sep 17 00:00:00 2001 From: cuteolaf Date: Fri, 9 Jan 2026 10:37:11 +0100 Subject: [PATCH 2/2] coderabbitai nitpicks --- .../secure-container-runtime/src/protocol.rs | 73 +++++++++++++------ 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/crates/secure-container-runtime/src/protocol.rs b/crates/secure-container-runtime/src/protocol.rs index 42731a216..2db7b93d8 100644 --- a/crates/secure-container-runtime/src/protocol.rs +++ b/crates/secure-container-runtime/src/protocol.rs @@ -337,32 +337,49 @@ mod tests { }; let request = Request::Create { - config, + config: config.clone(), request_id: "req-1".to_string(), }; let json = encode_request(&request); - assert!(json.contains("challenge-1")); - assert!(json.contains("create")); + let decoded = decode_request(&json).unwrap(); + + // Assert via decode + match instead of brittle string contains + match decoded { + Request::Create { config: decoded_config, request_id } => { + assert_eq!(decoded_config.challenge_id, "challenge-1"); + assert_eq!(decoded_config.image, "ghcr.io/platformnetwork/test:latest"); + assert_eq!(decoded_config.owner_id, "owner-1"); + assert_eq!(request_id, "req-1"); + } + _ => panic!("Expected Create variant"), + } } #[test] fn test_request_type_all_variants() { + // Table-driven test to reduce duplication and ease future enum growth let config = ContainerConfig::default(); - assert_eq!(Request::Create { config: config.clone(), request_id: "1".into() }.request_type(), "create"); - assert_eq!(Request::Start { container_id: "c1".into(), request_id: "1".into() }.request_type(), "start"); - assert_eq!(Request::Stop { container_id: "c1".into(), timeout_secs: 10, request_id: "1".into() }.request_type(), "stop"); - assert_eq!(Request::Remove { container_id: "c1".into(), force: false, request_id: "1".into() }.request_type(), "remove"); - assert_eq!(Request::Exec { container_id: "c1".into(), command: vec![], working_dir: None, timeout_secs: 30, request_id: "1".into() }.request_type(), "exec"); - assert_eq!(Request::Inspect { container_id: "c1".into(), request_id: "1".into() }.request_type(), "inspect"); - assert_eq!(Request::List { challenge_id: None, owner_id: None, request_id: "1".into() }.request_type(), "list"); - assert_eq!(Request::Logs { container_id: "c1".into(), tail: 100, request_id: "1".into() }.request_type(), "logs"); - assert_eq!(Request::Pull { image: "alpine".into(), request_id: "1".into() }.request_type(), "pull"); - assert_eq!(Request::Build { tag: "test:1".into(), dockerfile: "".into(), context: None, request_id: "1".into() }.request_type(), "build"); - assert_eq!(Request::Ping { request_id: "1".into() }.request_type(), "ping"); - assert_eq!(Request::CopyFrom { container_id: "c1".into(), path: "/file".into(), request_id: "1".into() }.request_type(), "copy_from"); - assert_eq!(Request::CopyTo { container_id: "c1".into(), path: "/file".into(), data: "".into(), request_id: "1".into() }.request_type(), "copy_to"); + let test_cases = vec![ + (Request::Create { config: config.clone(), request_id: "1".into() }, "create"), + (Request::Start { container_id: "c1".into(), request_id: "1".into() }, "start"), + (Request::Stop { container_id: "c1".into(), timeout_secs: 10, request_id: "1".into() }, "stop"), + (Request::Remove { container_id: "c1".into(), force: false, request_id: "1".into() }, "remove"), + (Request::Exec { container_id: "c1".into(), command: vec![], working_dir: None, timeout_secs: 30, request_id: "1".into() }, "exec"), + (Request::Inspect { container_id: "c1".into(), request_id: "1".into() }, "inspect"), + (Request::List { challenge_id: None, owner_id: None, request_id: "1".into() }, "list"), + (Request::Logs { container_id: "c1".into(), tail: 100, request_id: "1".into() }, "logs"), + (Request::Pull { image: "alpine".into(), request_id: "1".into() }, "pull"), + (Request::Build { tag: "test:1".into(), dockerfile: "".into(), context: None, request_id: "1".into() }, "build"), + (Request::Ping { request_id: "1".into() }, "ping"), + (Request::CopyFrom { container_id: "c1".into(), path: "/file".into(), request_id: "1".into() }, "copy_from"), + (Request::CopyTo { container_id: "c1".into(), path: "/file".into(), data: "".into(), request_id: "1".into() }, "copy_to"), + ]; + + for (request, expected_type) in test_cases { + assert_eq!(request.request_type(), expected_type, "Mismatch for {:?}", request); + } } #[test] @@ -394,13 +411,20 @@ mod tests { let ping_req = Request::Ping { request_id: "1".into() }; assert_eq!(ping_req.owner_id(), None); + // Table-driven test for response request_id extraction + let exec_result = ExecResult { stdout: "".into(), stderr: "".into(), exit_code: 0, duration_ms: 100, timed_out: false }; - let list_req = Request::List { challenge_id: None, owner_id: Some("owner-456".into()), request_id: "1".into() }; - assert_eq!(list_req.owner_id(), Some("owner-456")); - } + let test_cases = vec![ + (Response::Created { container_id: "c1".into(), container_name: "name".into(), request_id: "r1".into() }, "r1"), + (Response::Started { container_id: "c1".into(), ports: HashMap::new(), endpoint: None, request_id: "r2".into() }, "r2"), + (Response::Stopped { container_id: "c1".into(), request_id: "r3".into() }, "r3"), + (Response::Removed { container_id: "c1".into(), request_id: "r4".into() }, "r4"), + (Response::ExecResult { result: exec_result, request_id: "r5".into() }, "r5"), + ]; - #[test] - fn test_response_request_id_all_variants() { + for (response, expected_id) in test_cases { + assert_eq!(response.request_id(), expected_id, "Mismatch for {:?}", response); + } assert_eq!(Response::Created { container_id: "c1".into(), container_name: "name".into(), request_id: "r1".into() }.request_id(), "r1"); assert_eq!(Response::Started { container_id: "c1".into(), ports: HashMap::new(), endpoint: None, request_id: "r2".into() }.request_id(), "r2"); assert_eq!(Response::Stopped { container_id: "c1".into(), request_id: "r3".into() }.request_id(), "r3"); @@ -471,6 +495,11 @@ mod tests { #[test] fn test_response_info_and_list() { + // Use fixed timestamp to prevent flakiness + let fixed_time = chrono::DateTime::parse_from_rfc3339("2026-01-09T12:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc); + let info = ContainerInfo { id: "c1".into(), name: "test".into(), @@ -478,7 +507,7 @@ mod tests { owner_id: "owner1".into(), image: "alpine".into(), state: ContainerState::Running, - created_at: chrono::Utc::now(), + created_at: fixed_time, ports: HashMap::new(), endpoint: None, labels: HashMap::new(),