ics-simlab-config-gen-claude/tests/test_network_validation.py

245 lines
8.6 KiB
Python

#!/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