""" 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'