#!/usr/bin/env python3 """ Tests for network configuration validation. Validates that: 1. Duplicate IPs within same docker_network are detected 2. IPs outside declared subnet are detected 3. Unknown docker_network references are detected 4. Valid configs pass validation """ import json import subprocess import sys from pathlib import Path import pytest from models.ics_simlab_config_v2 import Config, set_strict_mode from tools.semantic_validation import validate_network_config FIXTURES_DIR = Path(__file__).parent / "fixtures" class TestDuplicateIPValidation: """Test duplicate IP detection.""" @pytest.fixture(autouse=True) def reset_strict_mode(self): """Reset strict mode before each test.""" set_strict_mode(False) yield set_strict_mode(False) def test_duplicate_ip_detected(self): """Duplicate IP on same docker_network should fail.""" fixture_path = FIXTURES_DIR / "config_duplicate_ip.json" raw = json.loads(fixture_path.read_text(encoding="utf-8")) config = Config.model_validate(raw) errors = validate_network_config(config) assert len(errors) >= 1 # Should detect ui and hmi1 both using 192.168.100.10 error_str = " ".join(str(e) for e in errors).lower() assert "duplicate" in error_str assert "192.168.100.10" in error_str assert "ui" in error_str assert "hmi1" in error_str def test_duplicate_ip_error_message_clarity(self): """Error message should list all colliding devices.""" fixture_path = FIXTURES_DIR / "config_duplicate_ip.json" raw = json.loads(fixture_path.read_text(encoding="utf-8")) config = Config.model_validate(raw) errors = validate_network_config(config) # Find the duplicate IP error dup_errors = [e for e in errors if "duplicate" in e.message.lower()] assert len(dup_errors) == 1 err = dup_errors[0] # Should mention both colliding devices assert "ui" in err.message.lower() assert "hmi1" in err.message.lower() class TestSubnetValidation: """Test subnet validation.""" @pytest.fixture(autouse=True) def reset_strict_mode(self): """Reset strict mode before each test.""" set_strict_mode(False) yield set_strict_mode(False) def test_out_of_subnet_detected(self): """IP outside declared subnet should fail.""" fixture_path = FIXTURES_DIR / "config_out_of_subnet_ip.json" raw = json.loads(fixture_path.read_text(encoding="utf-8")) config = Config.model_validate(raw) errors = validate_network_config(config) assert len(errors) >= 1 # Should detect plc1 with IP 10.0.0.50 outside 192.168.100.0/24 error_str = " ".join(str(e) for e in errors).lower() assert "subnet" in error_str or "not within" in error_str assert "10.0.0.50" in error_str assert "192.168.100.0/24" in error_str class TestDockerNetworkValidation: """Test docker_network reference validation.""" @pytest.fixture(autouse=True) def reset_strict_mode(self): """Reset strict mode before each test.""" set_strict_mode(False) yield set_strict_mode(False) def test_unknown_docker_network_detected(self): """Reference to nonexistent docker_network should fail.""" fixture_path = FIXTURES_DIR / "config_unknown_docker_network.json" raw = json.loads(fixture_path.read_text(encoding="utf-8")) config = Config.model_validate(raw) errors = validate_network_config(config) assert len(errors) >= 1 # Should detect plc1 referencing nonexistent_network error_str = " ".join(str(e) for e in errors).lower() assert "nonexistent_network" in error_str assert "not found" in error_str class TestValidNetworkConfig: """Test that valid configs pass validation.""" @pytest.fixture(autouse=True) def reset_strict_mode(self): """Reset strict mode before each test.""" set_strict_mode(False) yield set_strict_mode(False) def test_valid_config_passes(self): """Config with valid network settings should pass.""" fixture_path = FIXTURES_DIR / "valid_minimal.json" if not fixture_path.exists(): pytest.skip(f"Fixture not found: {fixture_path}") raw = json.loads(fixture_path.read_text(encoding="utf-8")) config = Config.model_validate(raw) errors = validate_network_config(config) assert len(errors) == 0, f"Unexpected errors: {errors}" def test_unique_ips_pass(self): """Config where all devices have unique IPs should pass.""" raw = { "ui": {"network": {"ip": "192.168.0.1", "port": 5000, "docker_network": "vlan1"}}, "hmis": [], "plcs": [{ "name": "plc1", "logic": "plc1.py", "network": {"ip": "192.168.0.21", "docker_network": "vlan1"}, "inbound_connections": [{"type": "tcp", "ip": "192.168.0.21", "port": 502}], "outbound_connections": [], "registers": {"coil": [], "discrete_input": [], "holding_register": [], "input_register": []}, "monitors": [], "controllers": [] }], "sensors": [], "actuators": [], "hils": [], "serial_networks": [], "ip_networks": [{"docker_name": "vlan1", "name": "vlan1", "subnet": "192.168.0.0/24"}] } config = Config.model_validate(raw) errors = validate_network_config(config) assert len(errors) == 0 class TestCheckNetworkingCLI: """Test the check_networking CLI tool.""" def test_cli_detects_duplicate_ip(self): """CLI should detect duplicate IPs and exit non-zero.""" fixture_path = FIXTURES_DIR / "config_duplicate_ip.json" result = subprocess.run( [sys.executable, "-m", "tools.check_networking", "--config", str(fixture_path)], capture_output=True, text=True ) assert result.returncode == 1 assert "duplicate" in result.stdout.lower() assert "192.168.100.10" in result.stdout def test_cli_detects_out_of_subnet(self): """CLI should detect out-of-subnet IPs and exit non-zero.""" fixture_path = FIXTURES_DIR / "config_out_of_subnet_ip.json" result = subprocess.run( [sys.executable, "-m", "tools.check_networking", "--config", str(fixture_path)], capture_output=True, text=True ) assert result.returncode == 1 assert "subnet" in result.stdout.lower() or "not within" in result.stdout.lower() def test_cli_json_output(self): """CLI should support JSON output format.""" fixture_path = FIXTURES_DIR / "config_duplicate_ip.json" result = subprocess.run( [sys.executable, "-m", "tools.check_networking", "--config", str(fixture_path), "--json"], capture_output=True, text=True ) assert result.returncode == 1 output = json.loads(result.stdout) assert output["status"] == "error" assert len(output["issues"]) >= 1 def test_cli_valid_config_passes(self): """CLI should exit zero for valid config.""" fixture_path = FIXTURES_DIR / "valid_minimal.json" if not fixture_path.exists(): pytest.skip(f"Fixture not found: {fixture_path}") result = subprocess.run( [sys.executable, "-m", "tools.check_networking", "--config", str(fixture_path)], capture_output=True, text=True ) assert result.returncode == 0 assert "ok" in result.stdout.lower() class TestIntegrationWithBuildConfig: """Test that network validation is wired into build_config.""" @pytest.fixture(autouse=True) def reset_strict_mode(self): """Reset strict mode before each test.""" set_strict_mode(False) yield set_strict_mode(False) def test_build_config_fails_on_duplicate_ip(self): """build_config should fail on duplicate IP (via validate_all_semantics).""" from tools.semantic_validation import validate_all_semantics fixture_path = FIXTURES_DIR / "config_duplicate_ip.json" raw = json.loads(fixture_path.read_text(encoding="utf-8")) config = Config.model_validate(raw) errors = validate_all_semantics(config) # Network errors should be in the combined output network_errors = [e for e in errors if "duplicate" in e.message.lower() or "network" in e.entity.lower()] assert len(network_errors) >= 1