506 lines
19 KiB
Python
506 lines
19 KiB
Python
"""
|
|
Unit tests for Config Service
|
|
|
|
Tests the ConfigService class which manages scan configuration files.
|
|
"""
|
|
|
|
import pytest
|
|
import os
|
|
import yaml
|
|
import tempfile
|
|
import shutil
|
|
from web.services.config_service import ConfigService
|
|
|
|
|
|
class TestConfigService:
|
|
"""Test suite for ConfigService"""
|
|
|
|
@pytest.fixture
|
|
def temp_configs_dir(self):
|
|
"""Create a temporary directory for config files"""
|
|
temp_dir = tempfile.mkdtemp()
|
|
yield temp_dir
|
|
shutil.rmtree(temp_dir)
|
|
|
|
@pytest.fixture
|
|
def service(self, temp_configs_dir):
|
|
"""Create a ConfigService instance with temp directory"""
|
|
return ConfigService(configs_dir=temp_configs_dir)
|
|
|
|
@pytest.fixture
|
|
def sample_yaml_config(self):
|
|
"""Sample YAML config content"""
|
|
return """title: Test Scan
|
|
sites:
|
|
- name: Web Servers
|
|
ips:
|
|
- address: 10.10.20.4
|
|
expected:
|
|
ping: true
|
|
tcp_ports: [22, 80, 443]
|
|
udp_ports: [53]
|
|
services: [ssh, http, https]
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def sample_csv_content(self):
|
|
"""Sample CSV content"""
|
|
return """scan_title,site_name,ip_address,ping_expected,tcp_ports,udp_ports,services
|
|
Test Scan,Web Servers,10.10.20.4,true,"22,80,443",53,"ssh,http,https"
|
|
Test Scan,Web Servers,10.10.20.5,true,22,,"ssh"
|
|
"""
|
|
|
|
def test_list_configs_empty_directory(self, service):
|
|
"""Test listing configs when directory is empty"""
|
|
configs = service.list_configs()
|
|
assert configs == []
|
|
|
|
def test_list_configs_with_files(self, service, temp_configs_dir, sample_yaml_config):
|
|
"""Test listing configs with existing files"""
|
|
# Create a config file
|
|
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
|
with open(config_path, 'w') as f:
|
|
f.write(sample_yaml_config)
|
|
|
|
configs = service.list_configs()
|
|
assert len(configs) == 1
|
|
assert configs[0]['filename'] == 'test-scan.yaml'
|
|
assert configs[0]['title'] == 'Test Scan'
|
|
assert 'created_at' in configs[0]
|
|
assert 'size_bytes' in configs[0]
|
|
assert 'used_by_schedules' in configs[0]
|
|
|
|
def test_list_configs_ignores_non_yaml_files(self, service, temp_configs_dir):
|
|
"""Test that non-YAML files are ignored"""
|
|
# Create non-YAML files
|
|
with open(os.path.join(temp_configs_dir, 'test.txt'), 'w') as f:
|
|
f.write('not a yaml file')
|
|
with open(os.path.join(temp_configs_dir, 'readme.md'), 'w') as f:
|
|
f.write('# README')
|
|
|
|
configs = service.list_configs()
|
|
assert len(configs) == 0
|
|
|
|
def test_get_config_valid(self, service, temp_configs_dir, sample_yaml_config):
|
|
"""Test getting a valid config file"""
|
|
# Create a config file
|
|
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
|
with open(config_path, 'w') as f:
|
|
f.write(sample_yaml_config)
|
|
|
|
result = service.get_config('test-scan.yaml')
|
|
assert result['filename'] == 'test-scan.yaml'
|
|
assert 'content' in result
|
|
assert 'parsed' in result
|
|
assert result['parsed']['title'] == 'Test Scan'
|
|
assert len(result['parsed']['sites']) == 1
|
|
|
|
def test_get_config_not_found(self, service):
|
|
"""Test getting a non-existent config"""
|
|
with pytest.raises(FileNotFoundError, match="not found"):
|
|
service.get_config('nonexistent.yaml')
|
|
|
|
def test_get_config_invalid_yaml(self, service, temp_configs_dir):
|
|
"""Test getting a config with invalid YAML syntax"""
|
|
# Create invalid YAML file
|
|
config_path = os.path.join(temp_configs_dir, 'invalid.yaml')
|
|
with open(config_path, 'w') as f:
|
|
f.write("invalid: yaml: syntax: [")
|
|
|
|
with pytest.raises(ValueError, match="Invalid YAML syntax"):
|
|
service.get_config('invalid.yaml')
|
|
|
|
def test_create_from_yaml_valid(self, service, sample_yaml_config):
|
|
"""Test creating config from valid YAML"""
|
|
filename = service.create_from_yaml('test-scan.yaml', sample_yaml_config)
|
|
assert filename == 'test-scan.yaml'
|
|
assert service.config_exists('test-scan.yaml')
|
|
|
|
# Verify content
|
|
result = service.get_config('test-scan.yaml')
|
|
assert result['parsed']['title'] == 'Test Scan'
|
|
|
|
def test_create_from_yaml_adds_extension(self, service, sample_yaml_config):
|
|
"""Test that .yaml extension is added if missing"""
|
|
filename = service.create_from_yaml('test-scan', sample_yaml_config)
|
|
assert filename == 'test-scan.yaml'
|
|
assert service.config_exists('test-scan.yaml')
|
|
|
|
def test_create_from_yaml_sanitizes_filename(self, service, sample_yaml_config):
|
|
"""Test that filename is sanitized"""
|
|
filename = service.create_from_yaml('../../../etc/test.yaml', sample_yaml_config)
|
|
# secure_filename should remove path traversal
|
|
assert '..' not in filename
|
|
assert '/' not in filename
|
|
|
|
def test_create_from_yaml_duplicate_filename(self, service, temp_configs_dir, sample_yaml_config):
|
|
"""Test creating config with duplicate filename"""
|
|
# Create first config
|
|
service.create_from_yaml('test-scan.yaml', sample_yaml_config)
|
|
|
|
# Try to create duplicate
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
service.create_from_yaml('test-scan.yaml', sample_yaml_config)
|
|
|
|
def test_create_from_yaml_invalid_syntax(self, service):
|
|
"""Test creating config with invalid YAML syntax"""
|
|
invalid_yaml = "invalid: yaml: syntax: ["
|
|
|
|
with pytest.raises(ValueError, match="Invalid YAML syntax"):
|
|
service.create_from_yaml('test.yaml', invalid_yaml)
|
|
|
|
def test_create_from_yaml_invalid_structure(self, service):
|
|
"""Test creating config with invalid structure (missing title)"""
|
|
invalid_config = """sites:
|
|
- name: Test
|
|
ips:
|
|
- address: 10.0.0.1
|
|
expected:
|
|
ping: true
|
|
"""
|
|
|
|
with pytest.raises(ValueError, match="Missing required field: 'title'"):
|
|
service.create_from_yaml('test.yaml', invalid_config)
|
|
|
|
def test_create_from_csv_valid(self, service, sample_csv_content):
|
|
"""Test creating config from valid CSV"""
|
|
filename, yaml_content = service.create_from_csv(sample_csv_content)
|
|
assert filename == 'test-scan.yaml'
|
|
assert service.config_exists(filename)
|
|
|
|
# Verify YAML was created correctly
|
|
result = service.get_config(filename)
|
|
assert result['parsed']['title'] == 'Test Scan'
|
|
assert len(result['parsed']['sites']) == 1
|
|
assert len(result['parsed']['sites'][0]['ips']) == 2
|
|
|
|
def test_create_from_csv_with_suggested_filename(self, service, sample_csv_content):
|
|
"""Test creating config with suggested filename"""
|
|
filename, yaml_content = service.create_from_csv(sample_csv_content, 'custom-name.yaml')
|
|
assert filename == 'custom-name.yaml'
|
|
assert service.config_exists(filename)
|
|
|
|
def test_create_from_csv_invalid(self, service):
|
|
"""Test creating config from invalid CSV"""
|
|
invalid_csv = """scan_title,site_name,ip_address
|
|
Missing,Columns,Here
|
|
"""
|
|
|
|
with pytest.raises(ValueError, match="CSV parsing failed"):
|
|
service.create_from_csv(invalid_csv)
|
|
|
|
def test_create_from_csv_duplicate_filename(self, service, sample_csv_content):
|
|
"""Test creating CSV config with duplicate filename"""
|
|
# Create first config
|
|
service.create_from_csv(sample_csv_content)
|
|
|
|
# Try to create duplicate (same title generates same filename)
|
|
with pytest.raises(ValueError, match="already exists"):
|
|
service.create_from_csv(sample_csv_content)
|
|
|
|
def test_delete_config_valid(self, service, temp_configs_dir, sample_yaml_config):
|
|
"""Test deleting a config file"""
|
|
# Create a config file
|
|
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
|
with open(config_path, 'w') as f:
|
|
f.write(sample_yaml_config)
|
|
|
|
assert service.config_exists('test-scan.yaml')
|
|
service.delete_config('test-scan.yaml')
|
|
assert not service.config_exists('test-scan.yaml')
|
|
|
|
def test_delete_config_not_found(self, service):
|
|
"""Test deleting non-existent config"""
|
|
with pytest.raises(FileNotFoundError, match="not found"):
|
|
service.delete_config('nonexistent.yaml')
|
|
|
|
def test_delete_config_used_by_schedule(self, service, temp_configs_dir, sample_yaml_config, monkeypatch):
|
|
"""Test deleting config that is used by schedules"""
|
|
# Create a config file
|
|
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
|
with open(config_path, 'w') as f:
|
|
f.write(sample_yaml_config)
|
|
|
|
# Mock get_schedules_using_config to return schedules
|
|
def mock_get_schedules(filename):
|
|
return ['Daily Scan', 'Weekly Audit']
|
|
|
|
monkeypatch.setattr(service, 'get_schedules_using_config', mock_get_schedules)
|
|
|
|
with pytest.raises(ValueError, match="used by the following schedules"):
|
|
service.delete_config('test-scan.yaml')
|
|
|
|
# Config should still exist
|
|
assert service.config_exists('test-scan.yaml')
|
|
|
|
def test_validate_config_content_valid(self, service):
|
|
"""Test validating valid config content"""
|
|
valid_config = {
|
|
'title': 'Test Scan',
|
|
'sites': [
|
|
{
|
|
'name': 'Web Servers',
|
|
'ips': [
|
|
{
|
|
'address': '10.10.20.4',
|
|
'expected': {
|
|
'ping': True,
|
|
'tcp_ports': [22, 80, 443],
|
|
'udp_ports': [53]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(valid_config)
|
|
assert is_valid is True
|
|
assert error == ""
|
|
|
|
def test_validate_config_content_not_dict(self, service):
|
|
"""Test validating non-dict content"""
|
|
is_valid, error = service.validate_config_content(['not', 'a', 'dict'])
|
|
assert is_valid is False
|
|
assert 'must be a dictionary' in error
|
|
|
|
def test_validate_config_content_missing_title(self, service):
|
|
"""Test validating config without title"""
|
|
config = {
|
|
'sites': []
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(config)
|
|
assert is_valid is False
|
|
assert "Missing required field: 'title'" in error
|
|
|
|
def test_validate_config_content_missing_sites(self, service):
|
|
"""Test validating config without sites"""
|
|
config = {
|
|
'title': 'Test'
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(config)
|
|
assert is_valid is False
|
|
assert "Missing required field: 'sites'" in error
|
|
|
|
def test_validate_config_content_empty_title(self, service):
|
|
"""Test validating config with empty title"""
|
|
config = {
|
|
'title': '',
|
|
'sites': []
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(config)
|
|
assert is_valid is False
|
|
assert "non-empty string" in error
|
|
|
|
def test_validate_config_content_sites_not_list(self, service):
|
|
"""Test validating config with sites as non-list"""
|
|
config = {
|
|
'title': 'Test',
|
|
'sites': 'not a list'
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(config)
|
|
assert is_valid is False
|
|
assert "must be a list" in error
|
|
|
|
def test_validate_config_content_no_sites(self, service):
|
|
"""Test validating config with empty sites list"""
|
|
config = {
|
|
'title': 'Test',
|
|
'sites': []
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(config)
|
|
assert is_valid is False
|
|
assert "at least one site" in error
|
|
|
|
def test_validate_config_content_site_missing_name(self, service):
|
|
"""Test validating site without name"""
|
|
config = {
|
|
'title': 'Test',
|
|
'sites': [
|
|
{
|
|
'ips': []
|
|
}
|
|
]
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(config)
|
|
assert is_valid is False
|
|
assert "missing required field: 'name'" in error
|
|
|
|
def test_validate_config_content_site_missing_ips(self, service):
|
|
"""Test validating site without ips"""
|
|
config = {
|
|
'title': 'Test',
|
|
'sites': [
|
|
{
|
|
'name': 'Test Site'
|
|
}
|
|
]
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(config)
|
|
assert is_valid is False
|
|
assert "missing required field: 'ips'" in error
|
|
|
|
def test_validate_config_content_site_no_ips(self, service):
|
|
"""Test validating site with empty ips list"""
|
|
config = {
|
|
'title': 'Test',
|
|
'sites': [
|
|
{
|
|
'name': 'Test Site',
|
|
'ips': []
|
|
}
|
|
]
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(config)
|
|
assert is_valid is False
|
|
assert "at least one IP" in error
|
|
|
|
def test_validate_config_content_ip_missing_address(self, service):
|
|
"""Test validating IP without address"""
|
|
config = {
|
|
'title': 'Test',
|
|
'sites': [
|
|
{
|
|
'name': 'Test Site',
|
|
'ips': [
|
|
{
|
|
'expected': {}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(config)
|
|
assert is_valid is False
|
|
assert "missing required field: 'address'" in error
|
|
|
|
def test_validate_config_content_ip_missing_expected(self, service):
|
|
"""Test validating IP without expected"""
|
|
config = {
|
|
'title': 'Test',
|
|
'sites': [
|
|
{
|
|
'name': 'Test Site',
|
|
'ips': [
|
|
{
|
|
'address': '10.0.0.1'
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
is_valid, error = service.validate_config_content(config)
|
|
assert is_valid is False
|
|
assert "missing required field: 'expected'" in error
|
|
|
|
def test_generate_filename_from_title_simple(self, service):
|
|
"""Test generating filename from simple title"""
|
|
filename = service.generate_filename_from_title('Production Scan')
|
|
assert filename == 'production-scan.yaml'
|
|
|
|
def test_generate_filename_from_title_special_chars(self, service):
|
|
"""Test generating filename with special characters"""
|
|
filename = service.generate_filename_from_title('Prod Scan (2025)!')
|
|
assert filename == 'prod-scan-2025.yaml'
|
|
assert '(' not in filename
|
|
assert ')' not in filename
|
|
assert '!' not in filename
|
|
|
|
def test_generate_filename_from_title_multiple_spaces(self, service):
|
|
"""Test generating filename with multiple spaces"""
|
|
filename = service.generate_filename_from_title('Test Multiple Spaces')
|
|
assert filename == 'test-multiple-spaces.yaml'
|
|
# Should not have consecutive hyphens
|
|
assert '--' not in filename
|
|
|
|
def test_generate_filename_from_title_leading_trailing_spaces(self, service):
|
|
"""Test generating filename with leading/trailing spaces"""
|
|
filename = service.generate_filename_from_title(' Test Scan ')
|
|
assert filename == 'test-scan.yaml'
|
|
assert not filename.startswith('-')
|
|
assert not filename.endswith('-.yaml')
|
|
|
|
def test_generate_filename_from_title_long(self, service):
|
|
"""Test generating filename from long title"""
|
|
long_title = 'A' * 300
|
|
filename = service.generate_filename_from_title(long_title)
|
|
# Should be limited to 200 chars (195 + .yaml)
|
|
assert len(filename) <= 200
|
|
|
|
def test_generate_filename_from_title_empty(self, service):
|
|
"""Test generating filename from empty title"""
|
|
filename = service.generate_filename_from_title('')
|
|
assert filename == 'config.yaml'
|
|
|
|
def test_generate_filename_from_title_only_special_chars(self, service):
|
|
"""Test generating filename from title with only special characters"""
|
|
filename = service.generate_filename_from_title('!@#$%^&*()')
|
|
assert filename == 'config.yaml'
|
|
|
|
def test_get_config_path(self, service, temp_configs_dir):
|
|
"""Test getting config path"""
|
|
path = service.get_config_path('test.yaml')
|
|
assert path == os.path.join(temp_configs_dir, 'test.yaml')
|
|
|
|
def test_config_exists_true(self, service, temp_configs_dir, sample_yaml_config):
|
|
"""Test config_exists returns True for existing file"""
|
|
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
|
|
with open(config_path, 'w') as f:
|
|
f.write(sample_yaml_config)
|
|
|
|
assert service.config_exists('test-scan.yaml') is True
|
|
|
|
def test_config_exists_false(self, service):
|
|
"""Test config_exists returns False for non-existent file"""
|
|
assert service.config_exists('nonexistent.yaml') is False
|
|
|
|
def test_get_schedules_using_config_none(self, service):
|
|
"""Test getting schedules when none use the config"""
|
|
schedules = service.get_schedules_using_config('test.yaml')
|
|
# Should return empty list (ScheduleService might not exist in test env)
|
|
assert isinstance(schedules, list)
|
|
|
|
def test_list_configs_sorted_by_date(self, service, temp_configs_dir, sample_yaml_config):
|
|
"""Test that configs are sorted by creation date (most recent first)"""
|
|
import time
|
|
|
|
# Create first config
|
|
config1_path = os.path.join(temp_configs_dir, 'config1.yaml')
|
|
with open(config1_path, 'w') as f:
|
|
f.write(sample_yaml_config)
|
|
|
|
time.sleep(0.1) # Ensure different timestamps
|
|
|
|
# Create second config
|
|
config2_path = os.path.join(temp_configs_dir, 'config2.yaml')
|
|
with open(config2_path, 'w') as f:
|
|
f.write(sample_yaml_config)
|
|
|
|
configs = service.list_configs()
|
|
assert len(configs) == 2
|
|
# Most recent should be first
|
|
assert configs[0]['filename'] == 'config2.yaml'
|
|
assert configs[1]['filename'] == 'config1.yaml'
|
|
|
|
def test_list_configs_handles_parse_errors(self, service, temp_configs_dir):
|
|
"""Test that list_configs handles files that can't be parsed"""
|
|
# Create invalid YAML file
|
|
config_path = os.path.join(temp_configs_dir, 'invalid.yaml')
|
|
with open(config_path, 'w') as f:
|
|
f.write("invalid: yaml: [")
|
|
|
|
# Should not raise error, just use filename as title
|
|
configs = service.list_configs()
|
|
assert len(configs) == 1
|
|
assert configs[0]['filename'] == 'invalid.yaml'
|