phase 4 complete

This commit is contained in:
2025-11-17 14:54:31 -06:00
parent 5301b07f37
commit 5f2314a532
21 changed files with 5046 additions and 509 deletions

483
tests/test_config_api.py Normal file
View File

@@ -0,0 +1,483 @@
"""
Integration tests for Config API endpoints.
Tests all config API endpoints including CSV/YAML upload, listing, downloading,
and deletion with schedule protection.
"""
import pytest
import os
import tempfile
import shutil
from web.app import create_app
from web.models import Base
from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker
@pytest.fixture
def app():
"""Create test application"""
# Create temporary database
test_db = tempfile.mktemp(suffix='.db')
# Create temporary configs directory
temp_configs_dir = tempfile.mkdtemp()
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{test_db}',
'SECRET_KEY': 'test-secret-key',
'WTF_CSRF_ENABLED': False,
})
# Override configs directory in ConfigService
os.environ['CONFIGS_DIR'] = temp_configs_dir
# Create tables
with app.app_context():
Base.metadata.create_all(bind=app.db_session.get_bind())
yield app
# Cleanup
os.unlink(test_db)
shutil.rmtree(temp_configs_dir)
@pytest.fixture
def client(app):
"""Create test client"""
return app.test_client()
@pytest.fixture
def auth_headers(client):
"""Get authentication headers"""
# First register and login a user
from web.auth.models import User
with client.application.app_context():
# Create test user
user = User(username='testuser')
user.set_password('testpass')
client.application.db_session.add(user)
client.application.db_session.commit()
# Login
response = client.post('/auth/login', data={
'username': 'testuser',
'password': 'testpass'
}, follow_redirects=True)
assert response.status_code == 200
# Return empty headers (session-based auth)
return {}
@pytest.fixture
def sample_csv():
"""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"
"""
@pytest.fixture
def sample_yaml():
"""Sample YAML 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]
"""
class TestListConfigs:
"""Tests for GET /api/configs"""
def test_list_configs_empty(self, client, auth_headers):
"""Test listing configs when none exist"""
response = client.get('/api/configs', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert 'configs' in data
assert data['configs'] == []
def test_list_configs_with_files(self, client, auth_headers, app, sample_yaml):
"""Test listing configs with existing files"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.get('/api/configs', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert len(data['configs']) == 1
assert data['configs'][0]['filename'] == 'test-scan.yaml'
assert data['configs'][0]['title'] == 'Test Scan'
assert 'created_at' in data['configs'][0]
assert 'size_bytes' in data['configs'][0]
assert 'used_by_schedules' in data['configs'][0]
def test_list_configs_requires_auth(self, client):
"""Test that listing configs requires authentication"""
response = client.get('/api/configs')
assert response.status_code in [401, 302] # Unauthorized or redirect
class TestGetConfig:
"""Tests for GET /api/configs/<filename>"""
def test_get_config_valid(self, client, auth_headers, app, sample_yaml):
"""Test getting a valid config file"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.get('/api/configs/test-scan.yaml', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data['filename'] == 'test-scan.yaml'
assert 'content' in data
assert 'parsed' in data
assert data['parsed']['title'] == 'Test Scan'
def test_get_config_not_found(self, client, auth_headers):
"""Test getting non-existent config"""
response = client.get('/api/configs/nonexistent.yaml', headers=auth_headers)
assert response.status_code == 404
data = response.get_json()
assert 'error' in data
def test_get_config_requires_auth(self, client):
"""Test that getting config requires authentication"""
response = client.get('/api/configs/test.yaml')
assert response.status_code in [401, 302]
class TestUploadCSV:
"""Tests for POST /api/configs/upload-csv"""
def test_upload_csv_valid(self, client, auth_headers, sample_csv):
"""Test uploading valid CSV"""
from io import BytesIO
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
result = response.get_json()
assert result['success'] is True
assert 'filename' in result
assert result['filename'].endswith('.yaml')
assert 'preview' in result
def test_upload_csv_no_file(self, client, auth_headers):
"""Test uploading without file"""
response = client.post('/api/configs/upload-csv', data={},
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
data = response.get_json()
assert 'error' in data
def test_upload_csv_invalid_format(self, client, auth_headers):
"""Test uploading invalid CSV"""
from io import BytesIO
invalid_csv = "not,a,valid,csv\nmissing,columns"
data = {
'file': (BytesIO(invalid_csv.encode('utf-8')), 'test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
result = response.get_json()
assert 'error' in result
def test_upload_csv_wrong_extension(self, client, auth_headers):
"""Test uploading file with wrong extension"""
from io import BytesIO
data = {
'file': (BytesIO(b'test'), 'test.txt')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_csv_duplicate_filename(self, client, auth_headers, sample_csv):
"""Test uploading CSV that generates duplicate filename"""
from io import BytesIO
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
}
# Upload first time
response1 = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response1.status_code == 200
# Upload second time (should fail)
response2 = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response2.status_code == 400
def test_upload_csv_requires_auth(self, client, sample_csv):
"""Test that uploading CSV requires authentication"""
from io import BytesIO
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
content_type='multipart/form-data')
assert response.status_code in [401, 302]
class TestUploadYAML:
"""Tests for POST /api/configs/upload-yaml"""
def test_upload_yaml_valid(self, client, auth_headers, sample_yaml):
"""Test uploading valid YAML"""
from io import BytesIO
data = {
'file': (BytesIO(sample_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
result = response.get_json()
assert result['success'] is True
assert 'filename' in result
def test_upload_yaml_no_file(self, client, auth_headers):
"""Test uploading without file"""
response = client.post('/api/configs/upload-yaml', data={},
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_invalid_syntax(self, client, auth_headers):
"""Test uploading YAML with invalid syntax"""
from io import BytesIO
invalid_yaml = "invalid: yaml: syntax: ["
data = {
'file': (BytesIO(invalid_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_missing_required_fields(self, client, auth_headers):
"""Test uploading YAML missing required fields"""
from io import BytesIO
invalid_yaml = """sites:
- name: Test
ips:
- address: 10.0.0.1
"""
data = {
'file': (BytesIO(invalid_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_wrong_extension(self, client, auth_headers):
"""Test uploading file with wrong extension"""
from io import BytesIO
data = {
'file': (BytesIO(b'test'), 'test.txt')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 400
def test_upload_yaml_requires_auth(self, client, sample_yaml):
"""Test that uploading YAML requires authentication"""
from io import BytesIO
data = {
'file': (BytesIO(sample_yaml.encode('utf-8')), 'test.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
content_type='multipart/form-data')
assert response.status_code in [401, 302]
class TestDownloadTemplate:
"""Tests for GET /api/configs/template"""
def test_download_template(self, client, auth_headers):
"""Test downloading CSV template"""
response = client.get('/api/configs/template', headers=auth_headers)
assert response.status_code == 200
assert response.content_type == 'text/csv; charset=utf-8'
assert b'scan_title,site_name,ip_address' in response.data
def test_download_template_requires_auth(self, client):
"""Test that downloading template requires authentication"""
response = client.get('/api/configs/template')
assert response.status_code in [401, 302]
class TestDownloadConfig:
"""Tests for GET /api/configs/<filename>/download"""
def test_download_config_valid(self, client, auth_headers, app, sample_yaml):
"""Test downloading existing config"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.get('/api/configs/test-scan.yaml/download', headers=auth_headers)
assert response.status_code == 200
assert response.content_type == 'application/x-yaml; charset=utf-8'
assert b'title: Test Scan' in response.data
def test_download_config_not_found(self, client, auth_headers):
"""Test downloading non-existent config"""
response = client.get('/api/configs/nonexistent.yaml/download', headers=auth_headers)
assert response.status_code == 404
def test_download_config_requires_auth(self, client):
"""Test that downloading config requires authentication"""
response = client.get('/api/configs/test.yaml/download')
assert response.status_code in [401, 302]
class TestDeleteConfig:
"""Tests for DELETE /api/configs/<filename>"""
def test_delete_config_valid(self, client, auth_headers, app, sample_yaml):
"""Test deleting a config file"""
# Create a config file
temp_configs_dir = os.environ.get('CONFIGS_DIR', '/app/configs')
config_path = os.path.join(temp_configs_dir, 'test-scan.yaml')
with open(config_path, 'w') as f:
f.write(sample_yaml)
response = client.delete('/api/configs/test-scan.yaml', headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data['success'] is True
# Verify file is deleted
assert not os.path.exists(config_path)
def test_delete_config_not_found(self, client, auth_headers):
"""Test deleting non-existent config"""
response = client.delete('/api/configs/nonexistent.yaml', headers=auth_headers)
assert response.status_code == 404
def test_delete_config_requires_auth(self, client):
"""Test that deleting config requires authentication"""
response = client.delete('/api/configs/test.yaml')
assert response.status_code in [401, 302]
class TestEndToEndWorkflow:
"""End-to-end workflow tests"""
def test_complete_csv_workflow(self, client, auth_headers, sample_csv):
"""Test complete CSV upload workflow"""
from io import BytesIO
# 1. Download template
response = client.get('/api/configs/template', headers=auth_headers)
assert response.status_code == 200
# 2. Upload CSV
data = {
'file': (BytesIO(sample_csv.encode('utf-8')), 'workflow-test.csv')
}
response = client.post('/api/configs/upload-csv', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
result = response.get_json()
filename = result['filename']
# 3. List configs (should include new one)
response = client.get('/api/configs', headers=auth_headers)
assert response.status_code == 200
configs = response.get_json()['configs']
assert any(c['filename'] == filename for c in configs)
# 4. Get config details
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 200
# 5. Download config
response = client.get(f'/api/configs/{filename}/download', headers=auth_headers)
assert response.status_code == 200
# 6. Delete config
response = client.delete(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 200
# 7. Verify deletion
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 404
def test_yaml_upload_workflow(self, client, auth_headers, sample_yaml):
"""Test YAML upload workflow"""
from io import BytesIO
# Upload YAML
data = {
'file': (BytesIO(sample_yaml.encode('utf-8')), 'yaml-workflow.yaml')
}
response = client.post('/api/configs/upload-yaml', data=data,
headers=auth_headers, content_type='multipart/form-data')
assert response.status_code == 200
filename = response.get_json()['filename']
# Verify it exists
response = client.get(f'/api/configs/{filename}', headers=auth_headers)
assert response.status_code == 200
# Clean up
client.delete(f'/api/configs/{filename}', headers=auth_headers)

View File

@@ -0,0 +1,505 @@
"""
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'