245 lines
8.6 KiB
Python
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
|