refactor to remove config_files in favor of db

This commit is contained in:
2025-11-19 20:29:14 -06:00
parent b2e6efb4b3
commit 41ba4c47b5
34 changed files with 463 additions and 536 deletions

View File

@@ -54,7 +54,7 @@ def init_default_alert_rules(session):
'webhook_enabled': False, 'webhook_enabled': False,
'severity': 'warning', 'severity': 'warning',
'filter_conditions': None, 'filter_conditions': None,
'config_file': None 'config_id': None
}, },
{ {
'name': 'Drift Detection', 'name': 'Drift Detection',
@@ -65,7 +65,7 @@ def init_default_alert_rules(session):
'webhook_enabled': False, 'webhook_enabled': False,
'severity': 'info', 'severity': 'info',
'filter_conditions': None, 'filter_conditions': None,
'config_file': None 'config_id': None
}, },
{ {
'name': 'Certificate Expiry Warning', 'name': 'Certificate Expiry Warning',
@@ -76,7 +76,7 @@ def init_default_alert_rules(session):
'webhook_enabled': False, 'webhook_enabled': False,
'severity': 'warning', 'severity': 'warning',
'filter_conditions': None, 'filter_conditions': None,
'config_file': None 'config_id': None
}, },
{ {
'name': 'Weak TLS Detection', 'name': 'Weak TLS Detection',
@@ -87,7 +87,7 @@ def init_default_alert_rules(session):
'webhook_enabled': False, 'webhook_enabled': False,
'severity': 'warning', 'severity': 'warning',
'filter_conditions': None, 'filter_conditions': None,
'config_file': None 'config_id': None
}, },
{ {
'name': 'Host Down Detection', 'name': 'Host Down Detection',

View File

@@ -78,7 +78,7 @@ class HTMLReportGenerator:
'title': self.report_data.get('title', 'SneakyScanner Report'), 'title': self.report_data.get('title', 'SneakyScanner Report'),
'scan_time': self.report_data.get('scan_time'), 'scan_time': self.report_data.get('scan_time'),
'scan_duration': self.report_data.get('scan_duration'), 'scan_duration': self.report_data.get('scan_duration'),
'config_file': self.report_data.get('config_file'), 'config_id': self.report_data.get('config_id'),
'sites': self.report_data.get('sites', []), 'sites': self.report_data.get('sites', []),
'summary_stats': summary_stats, 'summary_stats': summary_stats,
'drift_alerts': drift_alerts, 'drift_alerts': drift_alerts,

View File

@@ -948,7 +948,6 @@ class SneakyScanner:
'title': self.config['title'], 'title': self.config['title'],
'scan_time': datetime.utcnow().isoformat() + 'Z', 'scan_time': datetime.utcnow().isoformat() + 'Z',
'scan_duration': scan_duration, 'scan_duration': scan_duration,
'config_file': str(self.config_path) if self.config_path else None,
'config_id': self.config_id, 'config_id': self.config_id,
'sites': [] 'sites': []
} }

View File

@@ -490,8 +490,8 @@
<div class="header-meta"> <div class="header-meta">
<span>📅 <strong>Scan Time:</strong> {{ scan_time | format_date }}</span> <span>📅 <strong>Scan Time:</strong> {{ scan_time | format_date }}</span>
<span>⏱️ <strong>Duration:</strong> {{ scan_duration | format_duration }}</span> <span>⏱️ <strong>Duration:</strong> {{ scan_duration | format_duration }}</span>
{% if config_file %} {% if config_id %}
<span>📄 <strong>Config:</strong> {{ config_file }}</span> <span>📄 <strong>Config ID:</strong> {{ config_id }}</span>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -13,7 +13,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from web.app import create_app from web.app import create_app
from web.models import Base, Scan from web.models import Base, Scan, ScanConfig
from web.utils.settings import PasswordManager, SettingsManager from web.utils.settings import PasswordManager, SettingsManager
@@ -53,7 +53,7 @@ def sample_scan_report():
'title': 'Test Scan', 'title': 'Test Scan',
'scan_time': '2025-11-14T10:30:00Z', 'scan_time': '2025-11-14T10:30:00Z',
'scan_duration': 125.5, 'scan_duration': 125.5,
'config_file': '/app/configs/test.yaml', 'config_id': 1,
'sites': [ 'sites': [
{ {
'name': 'Test Site', 'name': 'Test Site',
@@ -199,6 +199,53 @@ def sample_invalid_config_file(tmp_path):
return str(config_file) return str(config_file)
@pytest.fixture
def sample_db_config(db):
"""
Create a sample database config for testing.
Args:
db: Database session fixture
Returns:
ScanConfig model instance with ID
"""
import json
config_data = {
'title': 'Test Scan',
'sites': [
{
'name': 'Test Site',
'ips': [
{
'address': '192.168.1.10',
'expected': {
'ping': True,
'tcp_ports': [22, 80, 443],
'udp_ports': [53],
'services': ['ssh', 'http', 'https']
}
}
]
}
]
}
scan_config = ScanConfig(
title='Test Scan',
config_data=json.dumps(config_data),
created_at=datetime.utcnow(),
updated_at=datetime.utcnow()
)
db.add(scan_config)
db.commit()
db.refresh(scan_config)
return scan_config
@pytest.fixture(scope='function') @pytest.fixture(scope='function')
def app(): def app():
""" """
@@ -269,7 +316,7 @@ def sample_scan(db):
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='completed', status='completed',
config_file='/app/configs/test.yaml', config_id=1,
title='Test Scan', title='Test Scan',
duration=125.5, duration=125.5,
triggered_by='test', triggered_by='test',

View File

@@ -23,12 +23,12 @@ class TestBackgroundJobs:
assert app.scheduler.scheduler is not None assert app.scheduler.scheduler is not None
assert app.scheduler.scheduler.running assert app.scheduler.scheduler.running
def test_queue_scan_job(self, app, db, sample_config_file): def test_queue_scan_job(self, app, db, sample_db_config):
"""Test queuing a scan for background execution.""" """Test queuing a scan for background execution."""
# Create a scan via service # Create a scan via service
scan_service = ScanService(db) scan_service = ScanService(db)
scan_id = scan_service.trigger_scan( scan_id = scan_service.trigger_scan(
config_file=sample_config_file, config_id=sample_db_config.id,
triggered_by='test', triggered_by='test',
scheduler=app.scheduler scheduler=app.scheduler
) )
@@ -43,12 +43,12 @@ class TestBackgroundJobs:
assert job is not None assert job is not None
assert job.id == f'scan_{scan_id}' assert job.id == f'scan_{scan_id}'
def test_trigger_scan_without_scheduler(self, db, sample_config_file): def test_trigger_scan_without_scheduler(self, db, sample_db_config):
"""Test triggering scan without scheduler logs warning.""" """Test triggering scan without scheduler logs warning."""
# Create scan without scheduler # Create scan without scheduler
scan_service = ScanService(db) scan_service = ScanService(db)
scan_id = scan_service.trigger_scan( scan_id = scan_service.trigger_scan(
config_file=sample_config_file, config_id=sample_db_config.id,
triggered_by='test', triggered_by='test',
scheduler=None # No scheduler scheduler=None # No scheduler
) )
@@ -58,13 +58,13 @@ class TestBackgroundJobs:
assert scan is not None assert scan is not None
assert scan.status == 'running' assert scan.status == 'running'
def test_scheduler_service_queue_scan(self, app, db, sample_config_file): def test_scheduler_service_queue_scan(self, app, db, sample_db_config):
"""Test SchedulerService.queue_scan directly.""" """Test SchedulerService.queue_scan directly."""
# Create scan record first # Create scan record first
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='running', status='running',
config_file=sample_config_file, config_id=sample_db_config.id,
title='Test Scan', title='Test Scan',
triggered_by='test' triggered_by='test'
) )
@@ -72,27 +72,27 @@ class TestBackgroundJobs:
db.commit() db.commit()
# Queue the scan # Queue the scan
job_id = app.scheduler.queue_scan(scan.id, sample_config_file) job_id = app.scheduler.queue_scan(scan.id, sample_db_config)
# Verify job was queued # Verify job was queued
assert job_id == f'scan_{scan.id}' assert job_id == f'scan_{scan.id}'
job = app.scheduler.scheduler.get_job(job_id) job = app.scheduler.scheduler.get_job(job_id)
assert job is not None assert job is not None
def test_scheduler_list_jobs(self, app, db, sample_config_file): def test_scheduler_list_jobs(self, app, db, sample_db_config):
"""Test listing scheduled jobs.""" """Test listing scheduled jobs."""
# Queue a few scans # Queue a few scans
for i in range(3): for i in range(3):
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='running', status='running',
config_file=sample_config_file, config_id=sample_db_config.id,
title=f'Test Scan {i}', title=f'Test Scan {i}',
triggered_by='test' triggered_by='test'
) )
db.add(scan) db.add(scan)
db.commit() db.commit()
app.scheduler.queue_scan(scan.id, sample_config_file) app.scheduler.queue_scan(scan.id, sample_db_config)
# List jobs # List jobs
jobs = app.scheduler.list_jobs() jobs = app.scheduler.list_jobs()
@@ -106,20 +106,20 @@ class TestBackgroundJobs:
assert 'name' in job assert 'name' in job
assert 'trigger' in job assert 'trigger' in job
def test_scheduler_get_job_status(self, app, db, sample_config_file): def test_scheduler_get_job_status(self, app, db, sample_db_config):
"""Test getting status of a specific job.""" """Test getting status of a specific job."""
# Create and queue a scan # Create and queue a scan
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='running', status='running',
config_file=sample_config_file, config_id=sample_db_config.id,
title='Test Scan', title='Test Scan',
triggered_by='test' triggered_by='test'
) )
db.add(scan) db.add(scan)
db.commit() db.commit()
job_id = app.scheduler.queue_scan(scan.id, sample_config_file) job_id = app.scheduler.queue_scan(scan.id, sample_db_config)
# Get job status # Get job status
status = app.scheduler.get_job_status(job_id) status = app.scheduler.get_job_status(job_id)
@@ -133,13 +133,13 @@ class TestBackgroundJobs:
status = app.scheduler.get_job_status('nonexistent_job_id') status = app.scheduler.get_job_status('nonexistent_job_id')
assert status is None assert status is None
def test_scan_timing_fields(self, db, sample_config_file): def test_scan_timing_fields(self, db, sample_db_config):
"""Test that scan timing fields are properly set.""" """Test that scan timing fields are properly set."""
# Create scan with started_at # Create scan with started_at
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='running', status='running',
config_file=sample_config_file, config_id=sample_db_config.id,
title='Test Scan', title='Test Scan',
triggered_by='test', triggered_by='test',
started_at=datetime.utcnow() started_at=datetime.utcnow()
@@ -161,13 +161,13 @@ class TestBackgroundJobs:
assert scan.completed_at is not None assert scan.completed_at is not None
assert (scan.completed_at - scan.started_at).total_seconds() >= 0 assert (scan.completed_at - scan.started_at).total_seconds() >= 0
def test_scan_error_handling(self, db, sample_config_file): def test_scan_error_handling(self, db, sample_db_config):
"""Test that error messages are stored correctly.""" """Test that error messages are stored correctly."""
# Create failed scan # Create failed scan
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='failed', status='failed',
config_file=sample_config_file, config_id=sample_db_config.id,
title='Failed Scan', title='Failed Scan',
triggered_by='test', triggered_by='test',
started_at=datetime.utcnow(), started_at=datetime.utcnow(),
@@ -188,7 +188,7 @@ class TestBackgroundJobs:
assert status['error_message'] == 'Test error message' assert status['error_message'] == 'Test error message'
@pytest.mark.skip(reason="Requires actual scanner execution - slow test") @pytest.mark.skip(reason="Requires actual scanner execution - slow test")
def test_background_scan_execution(self, app, db, sample_config_file): def test_background_scan_execution(self, app, db, sample_db_config):
""" """
Integration test for actual background scan execution. Integration test for actual background scan execution.
@@ -200,7 +200,7 @@ class TestBackgroundJobs:
# Trigger scan # Trigger scan
scan_service = ScanService(db) scan_service = ScanService(db)
scan_id = scan_service.trigger_scan( scan_id = scan_service.trigger_scan(
config_file=sample_config_file, config_id=sample_db_config.id,
triggered_by='test', triggered_by='test',
scheduler=app.scheduler scheduler=app.scheduler
) )

View File

@@ -44,7 +44,7 @@ class TestScanAPIEndpoints:
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='completed', status='completed',
config_file=f'/app/configs/test{i}.yaml', config_id=sample_db_config.id,
title=f'Test Scan {i}', title=f'Test Scan {i}',
triggered_by='test' triggered_by='test'
) )
@@ -81,7 +81,7 @@ class TestScanAPIEndpoints:
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status=status, status=status,
config_file='/app/configs/test.yaml', config_id=1,
title=f'{status.capitalize()} Scan', title=f'{status.capitalize()} Scan',
triggered_by='test' triggered_by='test'
) )
@@ -123,10 +123,10 @@ class TestScanAPIEndpoints:
assert 'error' in data assert 'error' in data
assert data['error'] == 'Not found' assert data['error'] == 'Not found'
def test_trigger_scan_success(self, client, db, sample_config_file): def test_trigger_scan_success(self, client, db, sample_db_config):
"""Test triggering a new scan.""" """Test triggering a new scan."""
response = client.post('/api/scans', response = client.post('/api/scans',
json={'config_file': str(sample_config_file)}, json={'config_file': str(sample_db_config)},
content_type='application/json' content_type='application/json'
) )
assert response.status_code == 201 assert response.status_code == 201
@@ -222,7 +222,7 @@ class TestScanAPIEndpoints:
assert 'error' in data assert 'error' in data
assert 'message' in data assert 'message' in data
def test_scan_workflow_integration(self, client, db, sample_config_file): def test_scan_workflow_integration(self, client, db, sample_db_config):
""" """
Test complete scan workflow: trigger → status → retrieve → delete. Test complete scan workflow: trigger → status → retrieve → delete.
@@ -231,7 +231,7 @@ class TestScanAPIEndpoints:
""" """
# Step 1: Trigger scan # Step 1: Trigger scan
response = client.post('/api/scans', response = client.post('/api/scans',
json={'config_file': str(sample_config_file)}, json={'config_file': str(sample_db_config)},
content_type='application/json' content_type='application/json'
) )
assert response.status_code == 201 assert response.status_code == 201

View File

@@ -17,10 +17,10 @@ class TestScanComparison:
"""Tests for scan comparison methods.""" """Tests for scan comparison methods."""
@pytest.fixture @pytest.fixture
def scan1_data(self, test_db, sample_config_file): def scan1_data(self, test_db, sample_db_config):
"""Create first scan with test data.""" """Create first scan with test data."""
service = ScanService(test_db) service = ScanService(test_db)
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual') scan_id = service.trigger_scan(sample_db_config, triggered_by='manual')
# Get scan and add some test data # Get scan and add some test data
scan = test_db.query(Scan).filter(Scan.id == scan_id).first() scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
@@ -77,10 +77,10 @@ class TestScanComparison:
return scan_id return scan_id
@pytest.fixture @pytest.fixture
def scan2_data(self, test_db, sample_config_file): def scan2_data(self, test_db, sample_db_config):
"""Create second scan with modified test data.""" """Create second scan with modified test data."""
service = ScanService(test_db) service = ScanService(test_db)
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual') scan_id = service.trigger_scan(sample_db_config, triggered_by='manual')
# Get scan and add some test data # Get scan and add some test data
scan = test_db.query(Scan).filter(Scan.id == scan_id).first() scan = test_db.query(Scan).filter(Scan.id == scan_id).first()

View File

@@ -13,49 +13,42 @@ from web.services.scan_service import ScanService
class TestScanServiceTrigger: class TestScanServiceTrigger:
"""Tests for triggering scans.""" """Tests for triggering scans."""
def test_trigger_scan_valid_config(self, test_db, sample_config_file): def test_trigger_scan_valid_config(self, db, sample_db_config):
"""Test triggering a scan with valid config file.""" """Test triggering a scan with valid config."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan(sample_config_file, triggered_by='manual') scan_id = service.trigger_scan(config_id=sample_db_config.id, triggered_by='manual')
# Verify scan created # Verify scan created
assert scan_id is not None assert scan_id is not None
assert isinstance(scan_id, int) assert isinstance(scan_id, int)
# Verify scan in database # Verify scan in database
scan = test_db.query(Scan).filter(Scan.id == scan_id).first() scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert scan is not None assert scan is not None
assert scan.status == 'running' assert scan.status == 'running'
assert scan.title == 'Test Scan' assert scan.title == 'Test Scan'
assert scan.triggered_by == 'manual' assert scan.triggered_by == 'manual'
assert scan.config_file == sample_config_file assert scan.config_id == sample_db_config.id
def test_trigger_scan_invalid_config(self, test_db, sample_invalid_config_file): def test_trigger_scan_invalid_config(self, db):
"""Test triggering a scan with invalid config file.""" """Test triggering a scan with invalid config ID."""
service = ScanService(test_db) service = ScanService(db)
with pytest.raises(ValueError, match="Invalid config file"): with pytest.raises(ValueError, match="not found"):
service.trigger_scan(sample_invalid_config_file) service.trigger_scan(config_id=99999)
def test_trigger_scan_nonexistent_file(self, test_db): def test_trigger_scan_with_schedule(self, db, sample_db_config):
"""Test triggering a scan with nonexistent config file."""
service = ScanService(test_db)
with pytest.raises(ValueError, match="does not exist"):
service.trigger_scan('/nonexistent/config.yaml')
def test_trigger_scan_with_schedule(self, test_db, sample_config_file):
"""Test triggering a scan via schedule.""" """Test triggering a scan via schedule."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan( scan_id = service.trigger_scan(
sample_config_file, config_id=sample_db_config.id,
triggered_by='scheduled', triggered_by='scheduled',
schedule_id=42 schedule_id=42
) )
scan = test_db.query(Scan).filter(Scan.id == scan_id).first() scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert scan.triggered_by == 'scheduled' assert scan.triggered_by == 'scheduled'
assert scan.schedule_id == 42 assert scan.schedule_id == 42
@@ -63,19 +56,19 @@ class TestScanServiceTrigger:
class TestScanServiceGet: class TestScanServiceGet:
"""Tests for retrieving scans.""" """Tests for retrieving scans."""
def test_get_scan_not_found(self, test_db): def test_get_scan_not_found(self, db):
"""Test getting a nonexistent scan.""" """Test getting a nonexistent scan."""
service = ScanService(test_db) service = ScanService(db)
result = service.get_scan(999) result = service.get_scan(999)
assert result is None assert result is None
def test_get_scan_found(self, test_db, sample_config_file): def test_get_scan_found(self, db, sample_db_config):
"""Test getting an existing scan.""" """Test getting an existing scan."""
service = ScanService(test_db) service = ScanService(db)
# Create a scan # Create a scan
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
# Retrieve it # Retrieve it
result = service.get_scan(scan_id) result = service.get_scan(scan_id)
@@ -90,9 +83,9 @@ class TestScanServiceGet:
class TestScanServiceList: class TestScanServiceList:
"""Tests for listing scans.""" """Tests for listing scans."""
def test_list_scans_empty(self, test_db): def test_list_scans_empty(self, db):
"""Test listing scans when database is empty.""" """Test listing scans when database is empty."""
service = ScanService(test_db) service = ScanService(db)
result = service.list_scans(page=1, per_page=20) result = service.list_scans(page=1, per_page=20)
@@ -100,13 +93,13 @@ class TestScanServiceList:
assert len(result.items) == 0 assert len(result.items) == 0
assert result.pages == 0 assert result.pages == 0
def test_list_scans_with_data(self, test_db, sample_config_file): def test_list_scans_with_data(self, db, sample_db_config):
"""Test listing scans with multiple scans.""" """Test listing scans with multiple scans."""
service = ScanService(test_db) service = ScanService(db)
# Create 3 scans # Create 3 scans
for i in range(3): for i in range(3):
service.trigger_scan(sample_config_file, triggered_by='api') service.trigger_scan(config_id=sample_db_config.id, triggered_by='api')
# List all scans # List all scans
result = service.list_scans(page=1, per_page=20) result = service.list_scans(page=1, per_page=20)
@@ -115,13 +108,13 @@ class TestScanServiceList:
assert len(result.items) == 3 assert len(result.items) == 3
assert result.pages == 1 assert result.pages == 1
def test_list_scans_pagination(self, test_db, sample_config_file): def test_list_scans_pagination(self, db, sample_db_config):
"""Test pagination.""" """Test pagination."""
service = ScanService(test_db) service = ScanService(db)
# Create 5 scans # Create 5 scans
for i in range(5): for i in range(5):
service.trigger_scan(sample_config_file) service.trigger_scan(config_id=sample_db_config.id)
# Get page 1 (2 items per page) # Get page 1 (2 items per page)
result = service.list_scans(page=1, per_page=2) result = service.list_scans(page=1, per_page=2)
@@ -141,18 +134,18 @@ class TestScanServiceList:
assert len(result.items) == 1 assert len(result.items) == 1
assert result.has_next is False assert result.has_next is False
def test_list_scans_filter_by_status(self, test_db, sample_config_file): def test_list_scans_filter_by_status(self, db, sample_db_config):
"""Test filtering scans by status.""" """Test filtering scans by status."""
service = ScanService(test_db) service = ScanService(db)
# Create scans with different statuses # Create scans with different statuses
scan_id_1 = service.trigger_scan(sample_config_file) scan_id_1 = service.trigger_scan(config_id=sample_db_config.id)
scan_id_2 = service.trigger_scan(sample_config_file) scan_id_2 = service.trigger_scan(config_id=sample_db_config.id)
# Mark one as completed # Mark one as completed
scan = test_db.query(Scan).filter(Scan.id == scan_id_1).first() scan = db.query(Scan).filter(Scan.id == scan_id_1).first()
scan.status = 'completed' scan.status = 'completed'
test_db.commit() db.commit()
# Filter by running # Filter by running
result = service.list_scans(status_filter='running') result = service.list_scans(status_filter='running')
@@ -162,9 +155,9 @@ class TestScanServiceList:
result = service.list_scans(status_filter='completed') result = service.list_scans(status_filter='completed')
assert result.total == 1 assert result.total == 1
def test_list_scans_invalid_status_filter(self, test_db): def test_list_scans_invalid_status_filter(self, db):
"""Test filtering with invalid status.""" """Test filtering with invalid status."""
service = ScanService(test_db) service = ScanService(db)
with pytest.raises(ValueError, match="Invalid status"): with pytest.raises(ValueError, match="Invalid status"):
service.list_scans(status_filter='invalid_status') service.list_scans(status_filter='invalid_status')
@@ -173,46 +166,46 @@ class TestScanServiceList:
class TestScanServiceDelete: class TestScanServiceDelete:
"""Tests for deleting scans.""" """Tests for deleting scans."""
def test_delete_scan_not_found(self, test_db): def test_delete_scan_not_found(self, db):
"""Test deleting a nonexistent scan.""" """Test deleting a nonexistent scan."""
service = ScanService(test_db) service = ScanService(db)
with pytest.raises(ValueError, match="not found"): with pytest.raises(ValueError, match="not found"):
service.delete_scan(999) service.delete_scan(999)
def test_delete_scan_success(self, test_db, sample_config_file): def test_delete_scan_success(self, db, sample_db_config):
"""Test successful scan deletion.""" """Test successful scan deletion."""
service = ScanService(test_db) service = ScanService(db)
# Create a scan # Create a scan
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
# Verify it exists # Verify it exists
assert test_db.query(Scan).filter(Scan.id == scan_id).first() is not None assert db.query(Scan).filter(Scan.id == scan_id).first() is not None
# Delete it # Delete it
result = service.delete_scan(scan_id) result = service.delete_scan(scan_id)
assert result is True assert result is True
# Verify it's gone # Verify it's gone
assert test_db.query(Scan).filter(Scan.id == scan_id).first() is None assert db.query(Scan).filter(Scan.id == scan_id).first() is None
class TestScanServiceStatus: class TestScanServiceStatus:
"""Tests for scan status retrieval.""" """Tests for scan status retrieval."""
def test_get_scan_status_not_found(self, test_db): def test_get_scan_status_not_found(self, db):
"""Test getting status of nonexistent scan.""" """Test getting status of nonexistent scan."""
service = ScanService(test_db) service = ScanService(db)
result = service.get_scan_status(999) result = service.get_scan_status(999)
assert result is None assert result is None
def test_get_scan_status_running(self, test_db, sample_config_file): def test_get_scan_status_running(self, db, sample_db_config):
"""Test getting status of running scan.""" """Test getting status of running scan."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
status = service.get_scan_status(scan_id) status = service.get_scan_status(scan_id)
assert status is not None assert status is not None
@@ -221,16 +214,16 @@ class TestScanServiceStatus:
assert status['progress'] == 'In progress' assert status['progress'] == 'In progress'
assert status['title'] == 'Test Scan' assert status['title'] == 'Test Scan'
def test_get_scan_status_completed(self, test_db, sample_config_file): def test_get_scan_status_completed(self, db, sample_db_config):
"""Test getting status of completed scan.""" """Test getting status of completed scan."""
service = ScanService(test_db) service = ScanService(db)
# Create and mark as completed # Create and mark as completed
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
scan = test_db.query(Scan).filter(Scan.id == scan_id).first() scan = db.query(Scan).filter(Scan.id == scan_id).first()
scan.status = 'completed' scan.status = 'completed'
scan.duration = 125.5 scan.duration = 125.5
test_db.commit() db.commit()
status = service.get_scan_status(scan_id) status = service.get_scan_status(scan_id)
@@ -242,35 +235,35 @@ class TestScanServiceStatus:
class TestScanServiceDatabaseMapping: class TestScanServiceDatabaseMapping:
"""Tests for mapping scan reports to database models.""" """Tests for mapping scan reports to database models."""
def test_save_scan_to_db(self, test_db, sample_config_file, sample_scan_report): def test_save_scan_to_db(self, db, sample_db_config, sample_scan_report):
"""Test saving a complete scan report to database.""" """Test saving a complete scan report to database."""
service = ScanService(test_db) service = ScanService(db)
# Create a scan # Create a scan
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
# Save report to database # Save report to database
service._save_scan_to_db(sample_scan_report, scan_id, status='completed') service._save_scan_to_db(sample_scan_report, scan_id, status='completed')
# Verify scan updated # Verify scan updated
scan = test_db.query(Scan).filter(Scan.id == scan_id).first() scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert scan.status == 'completed' assert scan.status == 'completed'
assert scan.duration == 125.5 assert scan.duration == 125.5
# Verify sites created # Verify sites created
sites = test_db.query(ScanSite).filter(ScanSite.scan_id == scan_id).all() sites = db.query(ScanSite).filter(ScanSite.scan_id == scan_id).all()
assert len(sites) == 1 assert len(sites) == 1
assert sites[0].site_name == 'Test Site' assert sites[0].site_name == 'Test Site'
# Verify IPs created # Verify IPs created
ips = test_db.query(ScanIP).filter(ScanIP.scan_id == scan_id).all() ips = db.query(ScanIP).filter(ScanIP.scan_id == scan_id).all()
assert len(ips) == 1 assert len(ips) == 1
assert ips[0].ip_address == '192.168.1.10' assert ips[0].ip_address == '192.168.1.10'
assert ips[0].ping_expected is True assert ips[0].ping_expected is True
assert ips[0].ping_actual is True assert ips[0].ping_actual is True
# Verify ports created (TCP: 22, 80, 443, 8080 | UDP: 53) # Verify ports created (TCP: 22, 80, 443, 8080 | UDP: 53)
ports = test_db.query(ScanPort).filter(ScanPort.scan_id == scan_id).all() ports = db.query(ScanPort).filter(ScanPort.scan_id == scan_id).all()
assert len(ports) == 5 # 4 TCP + 1 UDP assert len(ports) == 5 # 4 TCP + 1 UDP
# Verify TCP ports # Verify TCP ports
@@ -285,7 +278,7 @@ class TestScanServiceDatabaseMapping:
assert udp_ports[0].port == 53 assert udp_ports[0].port == 53
# Verify services created # Verify services created
services = test_db.query(ScanServiceModel).filter( services = db.query(ScanServiceModel).filter(
ScanServiceModel.scan_id == scan_id ScanServiceModel.scan_id == scan_id
).all() ).all()
assert len(services) == 4 # SSH, HTTP (80), HTTPS, HTTP (8080) assert len(services) == 4 # SSH, HTTP (80), HTTPS, HTTP (8080)
@@ -300,15 +293,15 @@ class TestScanServiceDatabaseMapping:
assert https_service.http_protocol == 'https' assert https_service.http_protocol == 'https'
assert https_service.screenshot_path == 'screenshots/192_168_1_10_443.png' assert https_service.screenshot_path == 'screenshots/192_168_1_10_443.png'
def test_map_port_expected_vs_actual(self, test_db, sample_config_file, sample_scan_report): def test_map_port_expected_vs_actual(self, db, sample_db_config, sample_scan_report):
"""Test that expected vs actual ports are correctly flagged.""" """Test that expected vs actual ports are correctly flagged."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
service._save_scan_to_db(sample_scan_report, scan_id) service._save_scan_to_db(sample_scan_report, scan_id)
# Check TCP ports # Check TCP ports
tcp_ports = test_db.query(ScanPort).filter( tcp_ports = db.query(ScanPort).filter(
ScanPort.scan_id == scan_id, ScanPort.scan_id == scan_id,
ScanPort.protocol == 'tcp' ScanPort.protocol == 'tcp'
).all() ).all()
@@ -322,15 +315,15 @@ class TestScanServiceDatabaseMapping:
# Port 8080 was not expected # Port 8080 was not expected
assert port.expected is False, f"Port {port.port} should not be expected" assert port.expected is False, f"Port {port.port} should not be expected"
def test_map_certificate_and_tls(self, test_db, sample_config_file, sample_scan_report): def test_map_certificate_and_tls(self, db, sample_db_config, sample_scan_report):
"""Test that certificate and TLS data are correctly mapped.""" """Test that certificate and TLS data are correctly mapped."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
service._save_scan_to_db(sample_scan_report, scan_id) service._save_scan_to_db(sample_scan_report, scan_id)
# Find HTTPS service # Find HTTPS service
https_service = test_db.query(ScanServiceModel).filter( https_service = db.query(ScanServiceModel).filter(
ScanServiceModel.scan_id == scan_id, ScanServiceModel.scan_id == scan_id,
ScanServiceModel.service_name == 'https' ScanServiceModel.service_name == 'https'
).first() ).first()
@@ -363,11 +356,11 @@ class TestScanServiceDatabaseMapping:
assert tls_13 is not None assert tls_13 is not None
assert tls_13.supported is True assert tls_13.supported is True
def test_get_scan_with_full_details(self, test_db, sample_config_file, sample_scan_report): def test_get_scan_with_full_details(self, db, sample_db_config, sample_scan_report):
"""Test retrieving scan with all nested relationships.""" """Test retrieving scan with all nested relationships."""
service = ScanService(test_db) service = ScanService(db)
scan_id = service.trigger_scan(sample_config_file) scan_id = service.trigger_scan(config_id=sample_db_config.id)
service._save_scan_to_db(sample_scan_report, scan_id) service._save_scan_to_db(sample_scan_report, scan_id)
# Get full scan details # Get full scan details

View File

@@ -13,20 +13,20 @@ from web.models import Schedule, Scan
@pytest.fixture @pytest.fixture
def sample_schedule(db, sample_config_file): def sample_schedule(db, sample_db_config):
""" """
Create a sample schedule in the database for testing. Create a sample schedule in the database for testing.
Args: Args:
db: Database session fixture db: Database session fixture
sample_config_file: Path to test config file sample_db_config: Path to test config file
Returns: Returns:
Schedule model instance Schedule model instance
""" """
schedule = Schedule( schedule = Schedule(
name='Daily Test Scan', name='Daily Test Scan',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True, enabled=True,
last_run=None, last_run=None,
@@ -68,13 +68,13 @@ class TestScheduleAPIEndpoints:
assert data['schedules'][0]['name'] == sample_schedule.name assert data['schedules'][0]['name'] == sample_schedule.name
assert data['schedules'][0]['cron_expression'] == sample_schedule.cron_expression assert data['schedules'][0]['cron_expression'] == sample_schedule.cron_expression
def test_list_schedules_pagination(self, client, db, sample_config_file): def test_list_schedules_pagination(self, client, db, sample_db_config):
"""Test schedule list pagination.""" """Test schedule list pagination."""
# Create 25 schedules # Create 25 schedules
for i in range(25): for i in range(25):
schedule = Schedule( schedule = Schedule(
name=f'Schedule {i}', name=f'Schedule {i}',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True, enabled=True,
created_at=datetime.utcnow() created_at=datetime.utcnow()
@@ -101,13 +101,13 @@ class TestScheduleAPIEndpoints:
assert len(data['schedules']) == 10 assert len(data['schedules']) == 10
assert data['page'] == 2 assert data['page'] == 2
def test_list_schedules_filter_enabled(self, client, db, sample_config_file): def test_list_schedules_filter_enabled(self, client, db, sample_db_config):
"""Test filtering schedules by enabled status.""" """Test filtering schedules by enabled status."""
# Create enabled and disabled schedules # Create enabled and disabled schedules
for i in range(3): for i in range(3):
schedule = Schedule( schedule = Schedule(
name=f'Enabled Schedule {i}', name=f'Enabled Schedule {i}',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True, enabled=True,
created_at=datetime.utcnow() created_at=datetime.utcnow()
@@ -117,7 +117,7 @@ class TestScheduleAPIEndpoints:
for i in range(2): for i in range(2):
schedule = Schedule( schedule = Schedule(
name=f'Disabled Schedule {i}', name=f'Disabled Schedule {i}',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 3 * * *', cron_expression='0 3 * * *',
enabled=False, enabled=False,
created_at=datetime.utcnow() created_at=datetime.utcnow()
@@ -165,11 +165,11 @@ class TestScheduleAPIEndpoints:
assert 'error' in data assert 'error' in data
assert 'not found' in data['error'].lower() assert 'not found' in data['error'].lower()
def test_create_schedule(self, client, db, sample_config_file): def test_create_schedule(self, client, db, sample_db_config):
"""Test creating a new schedule.""" """Test creating a new schedule."""
schedule_data = { schedule_data = {
'name': 'New Test Schedule', 'name': 'New Test Schedule',
'config_file': sample_config_file, 'config_file': sample_db_config,
'cron_expression': '0 3 * * *', 'cron_expression': '0 3 * * *',
'enabled': True 'enabled': True
} }
@@ -211,11 +211,11 @@ class TestScheduleAPIEndpoints:
assert 'error' in data assert 'error' in data
assert 'missing' in data['error'].lower() assert 'missing' in data['error'].lower()
def test_create_schedule_invalid_cron(self, client, db, sample_config_file): def test_create_schedule_invalid_cron(self, client, db, sample_db_config):
"""Test creating schedule with invalid cron expression.""" """Test creating schedule with invalid cron expression."""
schedule_data = { schedule_data = {
'name': 'Invalid Cron Schedule', 'name': 'Invalid Cron Schedule',
'config_file': sample_config_file, 'config_file': sample_db_config,
'cron_expression': 'invalid cron' 'cron_expression': 'invalid cron'
} }
@@ -360,13 +360,13 @@ class TestScheduleAPIEndpoints:
data = json.loads(response.data) data = json.loads(response.data)
assert 'error' in data assert 'error' in data
def test_delete_schedule_preserves_scans(self, client, db, sample_schedule, sample_config_file): def test_delete_schedule_preserves_scans(self, client, db, sample_schedule, sample_db_config):
"""Test that deleting schedule preserves associated scans.""" """Test that deleting schedule preserves associated scans."""
# Create a scan associated with the schedule # Create a scan associated with the schedule
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='completed', status='completed',
config_file=sample_config_file, config_id=sample_db_config.id,
title='Test Scan', title='Test Scan',
triggered_by='scheduled', triggered_by='scheduled',
schedule_id=sample_schedule.id schedule_id=sample_schedule.id
@@ -409,14 +409,14 @@ class TestScheduleAPIEndpoints:
data = json.loads(response.data) data = json.loads(response.data)
assert 'error' in data assert 'error' in data
def test_get_schedule_with_history(self, client, db, sample_schedule, sample_config_file): def test_get_schedule_with_history(self, client, db, sample_schedule, sample_db_config):
"""Test getting schedule includes execution history.""" """Test getting schedule includes execution history."""
# Create some scans for this schedule # Create some scans for this schedule
for i in range(5): for i in range(5):
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='completed', status='completed',
config_file=sample_config_file, config_id=sample_db_config.id,
title=f'Scheduled Scan {i}', title=f'Scheduled Scan {i}',
triggered_by='scheduled', triggered_by='scheduled',
schedule_id=sample_schedule.id schedule_id=sample_schedule.id
@@ -431,12 +431,12 @@ class TestScheduleAPIEndpoints:
assert 'history' in data assert 'history' in data
assert len(data['history']) == 5 assert len(data['history']) == 5
def test_schedule_workflow_integration(self, client, db, sample_config_file): def test_schedule_workflow_integration(self, client, db, sample_db_config):
"""Test complete schedule workflow: create → update → trigger → delete.""" """Test complete schedule workflow: create → update → trigger → delete."""
# 1. Create schedule # 1. Create schedule
schedule_data = { schedule_data = {
'name': 'Integration Test Schedule', 'name': 'Integration Test Schedule',
'config_file': sample_config_file, 'config_file': sample_db_config,
'cron_expression': '0 2 * * *', 'cron_expression': '0 2 * * *',
'enabled': True 'enabled': True
} }
@@ -482,14 +482,14 @@ class TestScheduleAPIEndpoints:
scan = db.query(Scan).filter(Scan.id == scan_id).first() scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert scan is not None assert scan is not None
def test_list_schedules_ordering(self, client, db, sample_config_file): def test_list_schedules_ordering(self, client, db, sample_db_config):
"""Test that schedules are ordered by next_run time.""" """Test that schedules are ordered by next_run time."""
# Create schedules with different next_run times # Create schedules with different next_run times
schedules = [] schedules = []
for i in range(3): for i in range(3):
schedule = Schedule( schedule = Schedule(
name=f'Schedule {i}', name=f'Schedule {i}',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True, enabled=True,
next_run=datetime(2025, 11, 15 + i, 2, 0, 0), next_run=datetime(2025, 11, 15 + i, 2, 0, 0),
@@ -501,7 +501,7 @@ class TestScheduleAPIEndpoints:
# Create a disabled schedule (next_run is None) # Create a disabled schedule (next_run is None)
disabled_schedule = Schedule( disabled_schedule = Schedule(
name='Disabled Schedule', name='Disabled Schedule',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 3 * * *', cron_expression='0 3 * * *',
enabled=False, enabled=False,
next_run=None, next_run=None,
@@ -523,11 +523,11 @@ class TestScheduleAPIEndpoints:
assert returned_schedules[2]['id'] == schedules[2].id assert returned_schedules[2]['id'] == schedules[2].id
assert returned_schedules[3]['id'] == disabled_schedule.id assert returned_schedules[3]['id'] == disabled_schedule.id
def test_create_schedule_with_disabled(self, client, db, sample_config_file): def test_create_schedule_with_disabled(self, client, db, sample_db_config):
"""Test creating a disabled schedule.""" """Test creating a disabled schedule."""
schedule_data = { schedule_data = {
'name': 'Disabled Schedule', 'name': 'Disabled Schedule',
'config_file': sample_config_file, 'config_file': sample_db_config,
'cron_expression': '0 2 * * *', 'cron_expression': '0 2 * * *',
'enabled': False 'enabled': False
} }
@@ -587,7 +587,7 @@ class TestScheduleAPIAuthentication:
class TestScheduleAPICronValidation: class TestScheduleAPICronValidation:
"""Test suite for cron expression validation.""" """Test suite for cron expression validation."""
def test_valid_cron_expressions(self, client, db, sample_config_file): def test_valid_cron_expressions(self, client, db, sample_db_config):
"""Test various valid cron expressions.""" """Test various valid cron expressions."""
valid_expressions = [ valid_expressions = [
'0 2 * * *', # Daily at 2am '0 2 * * *', # Daily at 2am
@@ -600,7 +600,7 @@ class TestScheduleAPICronValidation:
for cron_expr in valid_expressions: for cron_expr in valid_expressions:
schedule_data = { schedule_data = {
'name': f'Schedule for {cron_expr}', 'name': f'Schedule for {cron_expr}',
'config_file': sample_config_file, 'config_file': sample_db_config,
'cron_expression': cron_expr 'cron_expression': cron_expr
} }
@@ -612,7 +612,7 @@ class TestScheduleAPICronValidation:
assert response.status_code == 201, \ assert response.status_code == 201, \
f"Valid cron expression '{cron_expr}' should be accepted" f"Valid cron expression '{cron_expr}' should be accepted"
def test_invalid_cron_expressions(self, client, db, sample_config_file): def test_invalid_cron_expressions(self, client, db, sample_db_config):
"""Test various invalid cron expressions.""" """Test various invalid cron expressions."""
invalid_expressions = [ invalid_expressions = [
'invalid', 'invalid',
@@ -626,7 +626,7 @@ class TestScheduleAPICronValidation:
for cron_expr in invalid_expressions: for cron_expr in invalid_expressions:
schedule_data = { schedule_data = {
'name': f'Schedule for {cron_expr}', 'name': f'Schedule for {cron_expr}',
'config_file': sample_config_file, 'config_file': sample_db_config,
'cron_expression': cron_expr 'cron_expression': cron_expr
} }

View File

@@ -15,13 +15,13 @@ from web.services.schedule_service import ScheduleService
class TestScheduleServiceCreate: class TestScheduleServiceCreate:
"""Tests for creating schedules.""" """Tests for creating schedules."""
def test_create_schedule_valid(self, test_db, sample_config_file): def test_create_schedule_valid(self, db, sample_db_config):
"""Test creating a schedule with valid parameters.""" """Test creating a schedule with valid parameters."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Daily Scan', name='Daily Scan',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -31,57 +31,57 @@ class TestScheduleServiceCreate:
assert isinstance(schedule_id, int) assert isinstance(schedule_id, int)
# Verify schedule in database # Verify schedule in database
schedule = test_db.query(Schedule).filter(Schedule.id == schedule_id).first() schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
assert schedule is not None assert schedule is not None
assert schedule.name == 'Daily Scan' assert schedule.name == 'Daily Scan'
assert schedule.config_file == sample_config_file assert schedule.config_id == sample_db_config.id
assert schedule.cron_expression == '0 2 * * *' assert schedule.cron_expression == '0 2 * * *'
assert schedule.enabled is True assert schedule.enabled is True
assert schedule.next_run is not None assert schedule.next_run is not None
assert schedule.last_run is None assert schedule.last_run is None
def test_create_schedule_disabled(self, test_db, sample_config_file): def test_create_schedule_disabled(self, db, sample_db_config):
"""Test creating a disabled schedule.""" """Test creating a disabled schedule."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Disabled Scan', name='Disabled Scan',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 3 * * *', cron_expression='0 3 * * *',
enabled=False enabled=False
) )
schedule = test_db.query(Schedule).filter(Schedule.id == schedule_id).first() schedule = db.query(Schedule).filter(Schedule.id == schedule_id).first()
assert schedule.enabled is False assert schedule.enabled is False
assert schedule.next_run is None assert schedule.next_run is None
def test_create_schedule_invalid_cron(self, test_db, sample_config_file): def test_create_schedule_invalid_cron(self, db, sample_db_config):
"""Test creating a schedule with invalid cron expression.""" """Test creating a schedule with invalid cron expression."""
service = ScheduleService(test_db) service = ScheduleService(db)
with pytest.raises(ValueError, match="Invalid cron expression"): with pytest.raises(ValueError, match="Invalid cron expression"):
service.create_schedule( service.create_schedule(
name='Invalid Schedule', name='Invalid Schedule',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='invalid cron', cron_expression='invalid cron',
enabled=True enabled=True
) )
def test_create_schedule_nonexistent_config(self, test_db): def test_create_schedule_nonexistent_config(self, db):
"""Test creating a schedule with nonexistent config file.""" """Test creating a schedule with nonexistent config."""
service = ScheduleService(test_db) service = ScheduleService(db)
with pytest.raises(ValueError, match="Config file not found"): with pytest.raises(ValueError, match="not found"):
service.create_schedule( service.create_schedule(
name='Bad Config', name='Bad Config',
config_file='/nonexistent/config.yaml', config_id=99999,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
def test_create_schedule_various_cron_expressions(self, test_db, sample_config_file): def test_create_schedule_various_cron_expressions(self, db, sample_db_config):
"""Test creating schedules with various valid cron expressions.""" """Test creating schedules with various valid cron expressions."""
service = ScheduleService(test_db) service = ScheduleService(db)
cron_expressions = [ cron_expressions = [
'0 0 * * *', # Daily at midnight '0 0 * * *', # Daily at midnight
@@ -94,7 +94,7 @@ class TestScheduleServiceCreate:
for i, cron in enumerate(cron_expressions): for i, cron in enumerate(cron_expressions):
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name=f'Schedule {i}', name=f'Schedule {i}',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression=cron, cron_expression=cron,
enabled=True enabled=True
) )
@@ -104,21 +104,21 @@ class TestScheduleServiceCreate:
class TestScheduleServiceGet: class TestScheduleServiceGet:
"""Tests for retrieving schedules.""" """Tests for retrieving schedules."""
def test_get_schedule_not_found(self, test_db): def test_get_schedule_not_found(self, db):
"""Test getting a nonexistent schedule.""" """Test getting a nonexistent schedule."""
service = ScheduleService(test_db) service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"): with pytest.raises(ValueError, match="Schedule .* not found"):
service.get_schedule(999) service.get_schedule(999)
def test_get_schedule_found(self, test_db, sample_config_file): def test_get_schedule_found(self, db, sample_db_config):
"""Test getting an existing schedule.""" """Test getting an existing schedule."""
service = ScheduleService(test_db) service = ScheduleService(db)
# Create a schedule # Create a schedule
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test Schedule', name='Test Schedule',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -134,14 +134,14 @@ class TestScheduleServiceGet:
assert 'history' in result assert 'history' in result
assert isinstance(result['history'], list) assert isinstance(result['history'], list)
def test_get_schedule_with_history(self, test_db, sample_config_file): def test_get_schedule_with_history(self, db, sample_db_config):
"""Test getting schedule includes execution history.""" """Test getting schedule includes execution history."""
service = ScheduleService(test_db) service = ScheduleService(db)
# Create schedule # Create schedule
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test Schedule', name='Test Schedule',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -151,13 +151,13 @@ class TestScheduleServiceGet:
scan = Scan( scan = Scan(
timestamp=datetime.utcnow() - timedelta(days=i), timestamp=datetime.utcnow() - timedelta(days=i),
status='completed', status='completed',
config_file=sample_config_file, config_id=sample_db_config.id,
title=f'Scan {i}', title=f'Scan {i}',
triggered_by='scheduled', triggered_by='scheduled',
schedule_id=schedule_id schedule_id=schedule_id
) )
test_db.add(scan) db.add(scan)
test_db.commit() db.commit()
# Get schedule # Get schedule
result = service.get_schedule(schedule_id) result = service.get_schedule(schedule_id)
@@ -169,9 +169,9 @@ class TestScheduleServiceGet:
class TestScheduleServiceList: class TestScheduleServiceList:
"""Tests for listing schedules.""" """Tests for listing schedules."""
def test_list_schedules_empty(self, test_db): def test_list_schedules_empty(self, db):
"""Test listing schedules when database is empty.""" """Test listing schedules when database is empty."""
service = ScheduleService(test_db) service = ScheduleService(db)
result = service.list_schedules(page=1, per_page=20) result = service.list_schedules(page=1, per_page=20)
@@ -180,15 +180,15 @@ class TestScheduleServiceList:
assert result['page'] == 1 assert result['page'] == 1
assert result['per_page'] == 20 assert result['per_page'] == 20
def test_list_schedules_populated(self, test_db, sample_config_file): def test_list_schedules_populated(self, db, sample_db_config):
"""Test listing schedules with data.""" """Test listing schedules with data."""
service = ScheduleService(test_db) service = ScheduleService(db)
# Create multiple schedules # Create multiple schedules
for i in range(5): for i in range(5):
service.create_schedule( service.create_schedule(
name=f'Schedule {i}', name=f'Schedule {i}',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -199,15 +199,15 @@ class TestScheduleServiceList:
assert len(result['schedules']) == 5 assert len(result['schedules']) == 5
assert all('name' in s for s in result['schedules']) assert all('name' in s for s in result['schedules'])
def test_list_schedules_pagination(self, test_db, sample_config_file): def test_list_schedules_pagination(self, db, sample_db_config):
"""Test schedule pagination.""" """Test schedule pagination."""
service = ScheduleService(test_db) service = ScheduleService(db)
# Create 25 schedules # Create 25 schedules
for i in range(25): for i in range(25):
service.create_schedule( service.create_schedule(
name=f'Schedule {i:02d}', name=f'Schedule {i:02d}',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -226,22 +226,22 @@ class TestScheduleServiceList:
result_page3 = service.list_schedules(page=3, per_page=10) result_page3 = service.list_schedules(page=3, per_page=10)
assert len(result_page3['schedules']) == 5 assert len(result_page3['schedules']) == 5
def test_list_schedules_filter_enabled(self, test_db, sample_config_file): def test_list_schedules_filter_enabled(self, db, sample_db_config):
"""Test filtering schedules by enabled status.""" """Test filtering schedules by enabled status."""
service = ScheduleService(test_db) service = ScheduleService(db)
# Create enabled and disabled schedules # Create enabled and disabled schedules
for i in range(3): for i in range(3):
service.create_schedule( service.create_schedule(
name=f'Enabled {i}', name=f'Enabled {i}',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
for i in range(2): for i in range(2):
service.create_schedule( service.create_schedule(
name=f'Disabled {i}', name=f'Disabled {i}',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=False enabled=False
) )
@@ -262,13 +262,13 @@ class TestScheduleServiceList:
class TestScheduleServiceUpdate: class TestScheduleServiceUpdate:
"""Tests for updating schedules.""" """Tests for updating schedules."""
def test_update_schedule_name(self, test_db, sample_config_file): def test_update_schedule_name(self, db, sample_db_config):
"""Test updating schedule name.""" """Test updating schedule name."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Old Name', name='Old Name',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -278,13 +278,13 @@ class TestScheduleServiceUpdate:
assert result['name'] == 'New Name' assert result['name'] == 'New Name'
assert result['cron_expression'] == '0 2 * * *' assert result['cron_expression'] == '0 2 * * *'
def test_update_schedule_cron(self, test_db, sample_config_file): def test_update_schedule_cron(self, db, sample_db_config):
"""Test updating cron expression recalculates next_run.""" """Test updating cron expression recalculates next_run."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -302,13 +302,13 @@ class TestScheduleServiceUpdate:
assert result['cron_expression'] == '0 3 * * *' assert result['cron_expression'] == '0 3 * * *'
assert result['next_run'] != original_next_run assert result['next_run'] != original_next_run
def test_update_schedule_invalid_cron(self, test_db, sample_config_file): def test_update_schedule_invalid_cron(self, db, sample_db_config):
"""Test updating with invalid cron expression fails.""" """Test updating with invalid cron expression fails."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -316,67 +316,67 @@ class TestScheduleServiceUpdate:
with pytest.raises(ValueError, match="Invalid cron expression"): with pytest.raises(ValueError, match="Invalid cron expression"):
service.update_schedule(schedule_id, cron_expression='invalid') service.update_schedule(schedule_id, cron_expression='invalid')
def test_update_schedule_not_found(self, test_db): def test_update_schedule_not_found(self, db):
"""Test updating nonexistent schedule fails.""" """Test updating nonexistent schedule fails."""
service = ScheduleService(test_db) service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"): with pytest.raises(ValueError, match="Schedule .* not found"):
service.update_schedule(999, name='New Name') service.update_schedule(999, name='New Name')
def test_update_schedule_invalid_config_file(self, test_db, sample_config_file): def test_update_schedule_invalid_config_id(self, db, sample_db_config):
"""Test updating with nonexistent config file fails.""" """Test updating with nonexistent config ID fails."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
with pytest.raises(ValueError, match="Config file not found"): with pytest.raises(ValueError, match="not found"):
service.update_schedule(schedule_id, config_file='/nonexistent.yaml') service.update_schedule(schedule_id, config_id=99999)
class TestScheduleServiceDelete: class TestScheduleServiceDelete:
"""Tests for deleting schedules.""" """Tests for deleting schedules."""
def test_delete_schedule(self, test_db, sample_config_file): def test_delete_schedule(self, db, sample_db_config):
"""Test deleting a schedule.""" """Test deleting a schedule."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='To Delete', name='To Delete',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
# Verify exists # Verify exists
assert test_db.query(Schedule).filter(Schedule.id == schedule_id).first() is not None assert db.query(Schedule).filter(Schedule.id == schedule_id).first() is not None
# Delete # Delete
result = service.delete_schedule(schedule_id) result = service.delete_schedule(schedule_id)
assert result is True assert result is True
# Verify deleted # Verify deleted
assert test_db.query(Schedule).filter(Schedule.id == schedule_id).first() is None assert db.query(Schedule).filter(Schedule.id == schedule_id).first() is None
def test_delete_schedule_not_found(self, test_db): def test_delete_schedule_not_found(self, db):
"""Test deleting nonexistent schedule fails.""" """Test deleting nonexistent schedule fails."""
service = ScheduleService(test_db) service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"): with pytest.raises(ValueError, match="Schedule .* not found"):
service.delete_schedule(999) service.delete_schedule(999)
def test_delete_schedule_preserves_scans(self, test_db, sample_config_file): def test_delete_schedule_preserves_scans(self, db, sample_db_config):
"""Test that deleting schedule preserves associated scans.""" """Test that deleting schedule preserves associated scans."""
service = ScheduleService(test_db) service = ScheduleService(db)
# Create schedule # Create schedule
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -385,20 +385,20 @@ class TestScheduleServiceDelete:
scan = Scan( scan = Scan(
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='completed', status='completed',
config_file=sample_config_file, config_id=sample_db_config.id,
title='Test Scan', title='Test Scan',
triggered_by='scheduled', triggered_by='scheduled',
schedule_id=schedule_id schedule_id=schedule_id
) )
test_db.add(scan) db.add(scan)
test_db.commit() db.commit()
scan_id = scan.id scan_id = scan.id
# Delete schedule # Delete schedule
service.delete_schedule(schedule_id) service.delete_schedule(schedule_id)
# Verify scan still exists (schedule_id becomes null) # Verify scan still exists (schedule_id becomes null)
remaining_scan = test_db.query(Scan).filter(Scan.id == scan_id).first() remaining_scan = db.query(Scan).filter(Scan.id == scan_id).first()
assert remaining_scan is not None assert remaining_scan is not None
assert remaining_scan.schedule_id is None assert remaining_scan.schedule_id is None
@@ -406,13 +406,13 @@ class TestScheduleServiceDelete:
class TestScheduleServiceToggle: class TestScheduleServiceToggle:
"""Tests for toggling schedule enabled status.""" """Tests for toggling schedule enabled status."""
def test_toggle_enabled_to_disabled(self, test_db, sample_config_file): def test_toggle_enabled_to_disabled(self, db, sample_db_config):
"""Test disabling an enabled schedule.""" """Test disabling an enabled schedule."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -422,13 +422,13 @@ class TestScheduleServiceToggle:
assert result['enabled'] is False assert result['enabled'] is False
assert result['next_run'] is None assert result['next_run'] is None
def test_toggle_disabled_to_enabled(self, test_db, sample_config_file): def test_toggle_disabled_to_enabled(self, db, sample_db_config):
"""Test enabling a disabled schedule.""" """Test enabling a disabled schedule."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=False enabled=False
) )
@@ -442,13 +442,13 @@ class TestScheduleServiceToggle:
class TestScheduleServiceRunTimes: class TestScheduleServiceRunTimes:
"""Tests for updating run times.""" """Tests for updating run times."""
def test_update_run_times(self, test_db, sample_config_file): def test_update_run_times(self, db, sample_db_config):
"""Test updating last_run and next_run.""" """Test updating last_run and next_run."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -463,9 +463,9 @@ class TestScheduleServiceRunTimes:
assert schedule['last_run'] is not None assert schedule['last_run'] is not None
assert schedule['next_run'] is not None assert schedule['next_run'] is not None
def test_update_run_times_not_found(self, test_db): def test_update_run_times_not_found(self, db):
"""Test updating run times for nonexistent schedule.""" """Test updating run times for nonexistent schedule."""
service = ScheduleService(test_db) service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"): with pytest.raises(ValueError, match="Schedule .* not found"):
service.update_run_times( service.update_run_times(
@@ -478,9 +478,9 @@ class TestScheduleServiceRunTimes:
class TestCronValidation: class TestCronValidation:
"""Tests for cron expression validation.""" """Tests for cron expression validation."""
def test_validate_cron_valid_expressions(self, test_db): def test_validate_cron_valid_expressions(self, db):
"""Test validating various valid cron expressions.""" """Test validating various valid cron expressions."""
service = ScheduleService(test_db) service = ScheduleService(db)
valid_expressions = [ valid_expressions = [
'0 0 * * *', # Daily at midnight '0 0 * * *', # Daily at midnight
@@ -496,9 +496,9 @@ class TestCronValidation:
assert is_valid is True, f"Expression '{expr}' should be valid" assert is_valid is True, f"Expression '{expr}' should be valid"
assert error is None assert error is None
def test_validate_cron_invalid_expressions(self, test_db): def test_validate_cron_invalid_expressions(self, db):
"""Test validating invalid cron expressions.""" """Test validating invalid cron expressions."""
service = ScheduleService(test_db) service = ScheduleService(db)
invalid_expressions = [ invalid_expressions = [
'invalid', 'invalid',
@@ -518,9 +518,9 @@ class TestCronValidation:
class TestNextRunCalculation: class TestNextRunCalculation:
"""Tests for next run time calculation.""" """Tests for next run time calculation."""
def test_calculate_next_run(self, test_db): def test_calculate_next_run(self, db):
"""Test calculating next run time.""" """Test calculating next run time."""
service = ScheduleService(test_db) service = ScheduleService(db)
# Daily at 2 AM # Daily at 2 AM
next_run = service.calculate_next_run('0 2 * * *') next_run = service.calculate_next_run('0 2 * * *')
@@ -529,9 +529,9 @@ class TestNextRunCalculation:
assert isinstance(next_run, datetime) assert isinstance(next_run, datetime)
assert next_run > datetime.utcnow() assert next_run > datetime.utcnow()
def test_calculate_next_run_from_time(self, test_db): def test_calculate_next_run_from_time(self, db):
"""Test calculating next run from specific time.""" """Test calculating next run from specific time."""
service = ScheduleService(test_db) service = ScheduleService(db)
base_time = datetime(2025, 1, 1, 0, 0, 0) base_time = datetime(2025, 1, 1, 0, 0, 0)
next_run = service.calculate_next_run('0 2 * * *', from_time=base_time) next_run = service.calculate_next_run('0 2 * * *', from_time=base_time)
@@ -540,9 +540,9 @@ class TestNextRunCalculation:
assert next_run.hour == 2 assert next_run.hour == 2
assert next_run.minute == 0 assert next_run.minute == 0
def test_calculate_next_run_invalid_cron(self, test_db): def test_calculate_next_run_invalid_cron(self, db):
"""Test calculating next run with invalid cron raises error.""" """Test calculating next run with invalid cron raises error."""
service = ScheduleService(test_db) service = ScheduleService(db)
with pytest.raises(ValueError, match="Invalid cron expression"): with pytest.raises(ValueError, match="Invalid cron expression"):
service.calculate_next_run('invalid cron') service.calculate_next_run('invalid cron')
@@ -551,13 +551,13 @@ class TestNextRunCalculation:
class TestScheduleHistory: class TestScheduleHistory:
"""Tests for schedule execution history.""" """Tests for schedule execution history."""
def test_get_schedule_history_empty(self, test_db, sample_config_file): def test_get_schedule_history_empty(self, db, sample_db_config):
"""Test getting history for schedule with no executions.""" """Test getting history for schedule with no executions."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -565,13 +565,13 @@ class TestScheduleHistory:
history = service.get_schedule_history(schedule_id) history = service.get_schedule_history(schedule_id)
assert len(history) == 0 assert len(history) == 0
def test_get_schedule_history_with_scans(self, test_db, sample_config_file): def test_get_schedule_history_with_scans(self, db, sample_db_config):
"""Test getting history with multiple scans.""" """Test getting history with multiple scans."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -581,26 +581,26 @@ class TestScheduleHistory:
scan = Scan( scan = Scan(
timestamp=datetime.utcnow() - timedelta(days=i), timestamp=datetime.utcnow() - timedelta(days=i),
status='completed', status='completed',
config_file=sample_config_file, config_id=sample_db_config.id,
title=f'Scan {i}', title=f'Scan {i}',
triggered_by='scheduled', triggered_by='scheduled',
schedule_id=schedule_id schedule_id=schedule_id
) )
test_db.add(scan) db.add(scan)
test_db.commit() db.commit()
# Get history (default limit 10) # Get history (default limit 10)
history = service.get_schedule_history(schedule_id, limit=10) history = service.get_schedule_history(schedule_id, limit=10)
assert len(history) == 10 assert len(history) == 10
assert history[0]['title'] == 'Scan 0' # Most recent first assert history[0]['title'] == 'Scan 0' # Most recent first
def test_get_schedule_history_custom_limit(self, test_db, sample_config_file): def test_get_schedule_history_custom_limit(self, db, sample_db_config):
"""Test getting history with custom limit.""" """Test getting history with custom limit."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -610,13 +610,13 @@ class TestScheduleHistory:
scan = Scan( scan = Scan(
timestamp=datetime.utcnow() - timedelta(days=i), timestamp=datetime.utcnow() - timedelta(days=i),
status='completed', status='completed',
config_file=sample_config_file, config_id=sample_db_config.id,
title=f'Scan {i}', title=f'Scan {i}',
triggered_by='scheduled', triggered_by='scheduled',
schedule_id=schedule_id schedule_id=schedule_id
) )
test_db.add(scan) db.add(scan)
test_db.commit() db.commit()
# Get only 5 # Get only 5
history = service.get_schedule_history(schedule_id, limit=5) history = service.get_schedule_history(schedule_id, limit=5)
@@ -626,13 +626,13 @@ class TestScheduleHistory:
class TestScheduleSerialization: class TestScheduleSerialization:
"""Tests for schedule serialization.""" """Tests for schedule serialization."""
def test_schedule_to_dict(self, test_db, sample_config_file): def test_schedule_to_dict(self, db, sample_db_config):
"""Test converting schedule to dictionary.""" """Test converting schedule to dictionary."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test Schedule', name='Test Schedule',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )
@@ -642,7 +642,7 @@ class TestScheduleSerialization:
# Verify all required fields # Verify all required fields
assert 'id' in result assert 'id' in result
assert 'name' in result assert 'name' in result
assert 'config_file' in result assert 'config_id' in result
assert 'cron_expression' in result assert 'cron_expression' in result
assert 'enabled' in result assert 'enabled' in result
assert 'last_run' in result assert 'last_run' in result
@@ -652,13 +652,13 @@ class TestScheduleSerialization:
assert 'updated_at' in result assert 'updated_at' in result
assert 'history' in result assert 'history' in result
def test_schedule_relative_time_formatting(self, test_db, sample_config_file): def test_schedule_relative_time_formatting(self, db, sample_db_config):
"""Test relative time formatting in schedule dict.""" """Test relative time formatting in schedule dict."""
service = ScheduleService(test_db) service = ScheduleService(db)
schedule_id = service.create_schedule( schedule_id = service.create_schedule(
name='Test', name='Test',
config_file=sample_config_file, config_id=sample_db_config.id,
cron_expression='0 2 * * *', cron_expression='0 2 * * *',
enabled=True enabled=True
) )

View File

@@ -20,7 +20,7 @@ class TestStatsAPI:
scan_date = today - timedelta(days=i) scan_date = today - timedelta(days=i)
for j in range(i + 1): # Create 1, 2, 3, 4, 5 scans per day for j in range(i + 1): # Create 1, 2, 3, 4, 5 scans per day
scan = Scan( scan = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=scan_date, timestamp=scan_date,
status='completed', status='completed',
duration=10.5 duration=10.5
@@ -56,7 +56,7 @@ class TestStatsAPI:
today = datetime.utcnow() today = datetime.utcnow()
for i in range(10): for i in range(10):
scan = Scan( scan = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=today - timedelta(days=i), timestamp=today - timedelta(days=i),
status='completed', status='completed',
duration=10.5 duration=10.5
@@ -105,7 +105,7 @@ class TestStatsAPI:
# Create scan 5 days ago # Create scan 5 days ago
scan1 = Scan( scan1 = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=today - timedelta(days=5), timestamp=today - timedelta(days=5),
status='completed', status='completed',
duration=10.5 duration=10.5
@@ -114,7 +114,7 @@ class TestStatsAPI:
# Create scan 10 days ago # Create scan 10 days ago
scan2 = Scan( scan2 = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=today - timedelta(days=10), timestamp=today - timedelta(days=10),
status='completed', status='completed',
duration=10.5 duration=10.5
@@ -148,7 +148,7 @@ class TestStatsAPI:
# 5 completed scans # 5 completed scans
for i in range(5): for i in range(5):
scan = Scan( scan = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=today - timedelta(days=i), timestamp=today - timedelta(days=i),
status='completed', status='completed',
duration=10.5 duration=10.5
@@ -158,7 +158,7 @@ class TestStatsAPI:
# 2 failed scans # 2 failed scans
for i in range(2): for i in range(2):
scan = Scan( scan = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=today - timedelta(days=i), timestamp=today - timedelta(days=i),
status='failed', status='failed',
duration=5.0 duration=5.0
@@ -167,7 +167,7 @@ class TestStatsAPI:
# 1 running scan # 1 running scan
scan = Scan( scan = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=today, timestamp=today,
status='running', status='running',
duration=None duration=None
@@ -217,7 +217,7 @@ class TestStatsAPI:
# Create 3 scans today # Create 3 scans today
for i in range(3): for i in range(3):
scan = Scan( scan = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=today, timestamp=today,
status='completed', status='completed',
duration=10.5 duration=10.5
@@ -227,7 +227,7 @@ class TestStatsAPI:
# Create 2 scans yesterday # Create 2 scans yesterday
for i in range(2): for i in range(2):
scan = Scan( scan = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=yesterday, timestamp=yesterday,
status='completed', status='completed',
duration=10.5 duration=10.5
@@ -250,7 +250,7 @@ class TestStatsAPI:
# Create scans over the last 10 days # Create scans over the last 10 days
for i in range(10): for i in range(10):
scan = Scan( scan = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=today - timedelta(days=i), timestamp=today - timedelta(days=i),
status='completed', status='completed',
duration=10.5 duration=10.5
@@ -275,7 +275,7 @@ class TestStatsAPI:
"""Test scan trend returns dates in correct format.""" """Test scan trend returns dates in correct format."""
# Create a scan # Create a scan
scan = Scan( scan = Scan(
config_file='/app/configs/test.yaml', config_id=1,
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
status='completed', status='completed',
duration=10.5 duration=10.5

View File

@@ -11,7 +11,6 @@ from sqlalchemy.exc import SQLAlchemyError
from web.auth.decorators import api_auth_required from web.auth.decorators import api_auth_required
from web.services.scan_service import ScanService from web.services.scan_service import ScanService
from web.utils.validators import validate_config_file
from web.utils.pagination import validate_page_params from web.utils.pagination import validate_page_params
bp = Blueprint('scans', __name__) bp = Blueprint('scans', __name__)

View File

@@ -88,7 +88,7 @@ def create_schedule():
Request body: Request body:
name: Schedule name (required) name: Schedule name (required)
config_file: Path to YAML config (required) config_id: Database config ID (required)
cron_expression: Cron expression (required, e.g., '0 2 * * *') cron_expression: Cron expression (required, e.g., '0 2 * * *')
enabled: Whether schedule is active (optional, default: true) enabled: Whether schedule is active (optional, default: true)
@@ -99,7 +99,7 @@ def create_schedule():
data = request.get_json() or {} data = request.get_json() or {}
# Validate required fields # Validate required fields
required = ['name', 'config_file', 'cron_expression'] required = ['name', 'config_id', 'cron_expression']
missing = [field for field in required if field not in data] missing = [field for field in required if field not in data]
if missing: if missing:
return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400 return jsonify({'error': f'Missing required fields: {", ".join(missing)}'}), 400
@@ -108,7 +108,7 @@ def create_schedule():
schedule_service = ScheduleService(current_app.db_session) schedule_service = ScheduleService(current_app.db_session)
schedule_id = schedule_service.create_schedule( schedule_id = schedule_service.create_schedule(
name=data['name'], name=data['name'],
config_file=data['config_file'], config_id=data['config_id'],
cron_expression=data['cron_expression'], cron_expression=data['cron_expression'],
enabled=data.get('enabled', True) enabled=data.get('enabled', True)
) )
@@ -121,7 +121,7 @@ def create_schedule():
try: try:
current_app.scheduler.add_scheduled_scan( current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id, schedule_id=schedule_id,
config_file=schedule['config_file'], config_id=schedule['config_id'],
cron_expression=schedule['cron_expression'] cron_expression=schedule['cron_expression']
) )
logger.info(f"Schedule {schedule_id} added to APScheduler") logger.info(f"Schedule {schedule_id} added to APScheduler")
@@ -154,7 +154,7 @@ def update_schedule(schedule_id):
Request body: Request body:
name: Schedule name (optional) name: Schedule name (optional)
config_file: Path to YAML config (optional) config_id: Database config ID (optional)
cron_expression: Cron expression (optional) cron_expression: Cron expression (optional)
enabled: Whether schedule is active (optional) enabled: Whether schedule is active (optional)
@@ -181,7 +181,7 @@ def update_schedule(schedule_id):
try: try:
# If cron expression or config changed, or enabled status changed # If cron expression or config changed, or enabled status changed
cron_changed = 'cron_expression' in data cron_changed = 'cron_expression' in data
config_changed = 'config_file' in data config_changed = 'config_id' in data
enabled_changed = 'enabled' in data enabled_changed = 'enabled' in data
if enabled_changed: if enabled_changed:
@@ -189,7 +189,7 @@ def update_schedule(schedule_id):
# Re-add to scheduler (replaces existing) # Re-add to scheduler (replaces existing)
current_app.scheduler.add_scheduled_scan( current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id, schedule_id=schedule_id,
config_file=updated_schedule['config_file'], config_id=updated_schedule['config_id'],
cron_expression=updated_schedule['cron_expression'] cron_expression=updated_schedule['cron_expression']
) )
logger.info(f"Schedule {schedule_id} enabled and added to APScheduler") logger.info(f"Schedule {schedule_id} enabled and added to APScheduler")
@@ -201,7 +201,7 @@ def update_schedule(schedule_id):
# Reload schedule in APScheduler # Reload schedule in APScheduler
current_app.scheduler.add_scheduled_scan( current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id, schedule_id=schedule_id,
config_file=updated_schedule['config_file'], config_id=updated_schedule['config_id'],
cron_expression=updated_schedule['cron_expression'] cron_expression=updated_schedule['cron_expression']
) )
logger.info(f"Schedule {schedule_id} reloaded in APScheduler") logger.info(f"Schedule {schedule_id} reloaded in APScheduler")
@@ -293,7 +293,7 @@ def trigger_schedule(schedule_id):
scheduler = current_app.scheduler if hasattr(current_app, 'scheduler') else None scheduler = current_app.scheduler if hasattr(current_app, 'scheduler') else None
scan_id = scan_service.trigger_scan( scan_id = scan_service.trigger_scan(
config_file=schedule['config_file'], config_id=schedule['config_id'],
triggered_by='manual', triggered_by='manual',
schedule_id=schedule_id, schedule_id=schedule_id,
scheduler=scheduler scheduler=scheduler

View File

@@ -198,12 +198,12 @@ def scan_history(scan_id):
if not reference_scan: if not reference_scan:
return jsonify({'error': 'Scan not found'}), 404 return jsonify({'error': 'Scan not found'}), 404
config_file = reference_scan.config_file config_id = reference_scan.config_id
# Query historical scans with the same config file # Query historical scans with the same config_id
historical_scans = ( historical_scans = (
db_session.query(Scan) db_session.query(Scan)
.filter(Scan.config_file == config_file) .filter(Scan.config_id == config_id)
.filter(Scan.status == 'completed') .filter(Scan.status == 'completed')
.order_by(Scan.timestamp.desc()) .order_by(Scan.timestamp.desc())
.limit(limit) .limit(limit)
@@ -247,7 +247,7 @@ def scan_history(scan_id):
'scans': scans_data, 'scans': scans_data,
'labels': labels, 'labels': labels,
'port_counts': port_counts, 'port_counts': port_counts,
'config_file': config_file 'config_id': config_id
}), 200 }), 200
except SQLAlchemyError as e: except SQLAlchemyError as e:

View File

@@ -21,7 +21,7 @@ from web.services.alert_service import AlertService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, db_url: str = None): def execute_scan(scan_id: int, config_id: int, db_url: str = None):
""" """
Execute a scan in the background. Execute a scan in the background.
@@ -31,12 +31,9 @@ def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, d
Args: Args:
scan_id: ID of the scan record in database scan_id: ID of the scan record in database
config_file: Path to YAML configuration file (legacy, optional) config_id: Database config ID
config_id: Database config ID (preferred, optional)
db_url: Database connection URL db_url: Database connection URL
Note: Provide exactly one of config_file or config_id
Workflow: Workflow:
1. Create new database session for this thread 1. Create new database session for this thread
2. Update scan status to 'running' 2. Update scan status to 'running'
@@ -45,8 +42,7 @@ def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, d
5. Save results to database 5. Save results to database
6. Update status to 'completed' or 'failed' 6. Update status to 'completed' or 'failed'
""" """
config_desc = f"config_id={config_id}" if config_id else f"config_file={config_file}" logger.info(f"Starting background scan execution: scan_id={scan_id}, config_id={config_id}")
logger.info(f"Starting background scan execution: scan_id={scan_id}, {config_desc}")
# Create new database session for this thread # Create new database session for this thread
engine = create_engine(db_url, echo=False) engine = create_engine(db_url, echo=False)
@@ -65,21 +61,10 @@ def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, d
scan.started_at = datetime.utcnow() scan.started_at = datetime.utcnow()
session.commit() session.commit()
logger.info(f"Scan {scan_id}: Initializing scanner with {config_desc}") logger.info(f"Scan {scan_id}: Initializing scanner with config_id={config_id}")
# Initialize scanner based on config type # Initialize scanner with database config
if config_id:
# Use database config
scanner = SneakyScanner(config_id=config_id) scanner = SneakyScanner(config_id=config_id)
else:
# Use YAML config file
# Convert config_file to full path if it's just a filename
if not config_file.startswith('/'):
config_path = f'/app/configs/{config_file}'
else:
config_path = config_file
scanner = SneakyScanner(config_path=config_path)
# Execute scan # Execute scan
logger.info(f"Scan {scan_id}: Running scanner...") logger.info(f"Scan {scan_id}: Running scanner...")

View File

@@ -46,7 +46,6 @@ class Scan(Base):
timestamp = Column(DateTime, nullable=False, index=True, comment="Scan start time (UTC)") timestamp = Column(DateTime, nullable=False, index=True, comment="Scan start time (UTC)")
duration = Column(Float, nullable=True, comment="Total scan duration in seconds") duration = Column(Float, nullable=True, comment="Total scan duration in seconds")
status = Column(String(20), nullable=False, default='running', comment="running, completed, failed") status = Column(String(20), nullable=False, default='running', comment="running, completed, failed")
config_file = Column(Text, nullable=True, comment="Path to YAML config used (deprecated)")
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table") config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table")
title = Column(Text, nullable=True, comment="Scan title from config") title = Column(Text, nullable=True, comment="Scan title from config")
json_path = Column(Text, nullable=True, comment="Path to JSON report") json_path = Column(Text, nullable=True, comment="Path to JSON report")
@@ -403,7 +402,6 @@ class Schedule(Base):
id = Column(Integer, primary_key=True, autoincrement=True) id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(255), nullable=False, comment="Schedule name (e.g., 'Daily prod scan')") name = Column(String(255), nullable=False, comment="Schedule name (e.g., 'Daily prod scan')")
config_file = Column(Text, nullable=True, comment="Path to YAML config (deprecated)")
config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table") config_id = Column(Integer, ForeignKey('scan_configs.id'), nullable=True, index=True, comment="FK to scan_configs table")
cron_expression = Column(String(100), nullable=False, comment="Cron-like schedule (e.g., '0 2 * * *')") cron_expression = Column(String(100), nullable=False, comment="Cron-like schedule (e.g., '0 2 * * *')")
enabled = Column(Boolean, nullable=False, default=True, comment="Is schedule active?") enabled = Column(Boolean, nullable=False, default=True, comment="Is schedule active?")

View File

@@ -101,22 +101,19 @@ def create_schedule():
Create new schedule form page. Create new schedule form page.
Returns: Returns:
Rendered schedule create template with available config files Rendered schedule create template with available configs
""" """
import os from web.models import ScanConfig
# Get list of available config files # Get list of available configs from database
configs_dir = '/app/configs' configs = []
config_files = []
try: try:
if os.path.exists(configs_dir): configs = current_app.db_session.query(ScanConfig).order_by(ScanConfig.title).all()
config_files = [f for f in os.listdir(configs_dir) if f.endswith('.yaml')]
config_files.sort()
except Exception as e: except Exception as e:
logger.error(f"Error listing config files: {e}") logger.error(f"Error listing configs: {e}")
return render_template('schedule_create.html', config_files=config_files) return render_template('schedule_create.html', configs=configs)
@bp.route('/schedules/<int:schedule_id>/edit') @bp.route('/schedules/<int:schedule_id>/edit')

View File

@@ -58,7 +58,7 @@ class AlertService:
for rule in rules: for rule in rules:
try: try:
# Check if rule applies to this scan's config # Check if rule applies to this scan's config
if rule.config_file and scan.config_file != rule.config_file: if rule.config_id and scan.config_id != rule.config_id:
logger.debug(f"Skipping rule {rule.id} - config mismatch") logger.debug(f"Skipping rule {rule.id} - config mismatch")
continue continue
@@ -178,10 +178,10 @@ class AlertService:
""" """
alerts_to_create = [] alerts_to_create = []
# Find previous scan with same config_file # Find previous scan with same config_id
previous_scan = ( previous_scan = (
self.db.query(Scan) self.db.query(Scan)
.filter(Scan.config_file == scan.config_file) .filter(Scan.config_id == scan.config_id)
.filter(Scan.id < scan.id) .filter(Scan.id < scan.id)
.filter(Scan.status == 'completed') .filter(Scan.status == 'completed')
.order_by(Scan.started_at.desc() if Scan.started_at else Scan.timestamp.desc()) .order_by(Scan.started_at.desc() if Scan.started_at else Scan.timestamp.desc())
@@ -189,7 +189,7 @@ class AlertService:
) )
if not previous_scan: if not previous_scan:
logger.info(f"No previous scan found for config {scan.config_file}") logger.info(f"No previous scan found for config_id {scan.config_id}")
return [] return []
try: try:

View File

@@ -654,22 +654,12 @@ class ConfigService:
# Build full path for comparison # Build full path for comparison
config_path = os.path.join(self.configs_dir, filename) config_path = os.path.join(self.configs_dir, filename)
# Find and delete all schedules using this config (enabled or disabled) # Note: This function is deprecated. Schedules now use config_id.
# This code path should not be reached for new configs.
deleted_schedules = [] deleted_schedules = []
for schedule in schedules:
schedule_config = schedule.get('config_file', '')
# Handle both absolute paths and just filenames
if schedule_config == filename or schedule_config == config_path:
schedule_id = schedule.get('id')
schedule_name = schedule.get('name', 'Unknown')
try:
schedule_service.delete_schedule(schedule_id)
deleted_schedules.append(schedule_name)
except Exception as e:
import logging import logging
logging.getLogger(__name__).warning( logging.getLogger(__name__).warning(
f"Failed to delete schedule {schedule_id} ('{schedule_name}'): {e}" f"delete_config_file called for '{filename}' - this is deprecated. Use database configs with config_id instead."
) )
if deleted_schedules: if deleted_schedules:
@@ -841,18 +831,9 @@ class ConfigService:
# Build full path for comparison # Build full path for comparison
config_path = os.path.join(self.configs_dir, filename) config_path = os.path.join(self.configs_dir, filename)
# Find schedules using this config (only enabled schedules) # Note: This function is deprecated. Schedules now use config_id.
using_schedules = [] # Return empty list as schedules no longer use config_file.
for schedule in schedules: return []
schedule_config = schedule.get('config_file', '')
# Handle both absolute paths and just filenames
if schedule_config == filename or schedule_config == config_path:
# Only count enabled schedules
if schedule.get('enabled', False):
using_schedules.append(schedule.get('name', 'Unknown'))
return using_schedules
except ImportError: except ImportError:
# If ScheduleService doesn't exist yet, return empty list # If ScheduleService doesn't exist yet, return empty list

View File

@@ -19,7 +19,7 @@ from web.models import (
ScanCertificate, ScanTLSVersion, Site, ScanSiteAssociation ScanCertificate, ScanTLSVersion, Site, ScanSiteAssociation
) )
from web.utils.pagination import paginate, PaginatedResult from web.utils.pagination import paginate, PaginatedResult
from web.utils.validators import validate_config_file, validate_scan_status from web.utils.validators import validate_scan_status
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -41,7 +41,7 @@ class ScanService:
""" """
self.db = db_session self.db = db_session
def trigger_scan(self, config_file: str = None, config_id: int = None, def trigger_scan(self, config_id: int,
triggered_by: str = 'manual', schedule_id: Optional[int] = None, triggered_by: str = 'manual', schedule_id: Optional[int] = None,
scheduler=None) -> int: scheduler=None) -> int:
""" """
@@ -51,8 +51,7 @@ class ScanService:
queues the scan for background execution. queues the scan for background execution.
Args: Args:
config_file: Path to YAML configuration file (legacy, optional) config_id: Database config ID
config_id: Database config ID (preferred, optional)
triggered_by: Source that triggered scan (manual, scheduled, api) triggered_by: Source that triggered scan (manual, scheduled, api)
schedule_id: Optional schedule ID if triggered by schedule schedule_id: Optional schedule ID if triggered by schedule
scheduler: Optional SchedulerService instance for queuing background jobs scheduler: Optional SchedulerService instance for queuing background jobs
@@ -61,14 +60,8 @@ class ScanService:
Scan ID of the created scan Scan ID of the created scan
Raises: Raises:
ValueError: If config is invalid or both/neither config_file and config_id provided ValueError: If config is invalid
""" """
# Validate that exactly one config source is provided
if not (bool(config_file) ^ bool(config_id)):
raise ValueError("Must provide exactly one of config_file or config_id")
# Handle database config
if config_id:
from web.models import ScanConfig from web.models import ScanConfig
# Validate config exists # Validate config exists
@@ -110,58 +103,6 @@ class ScanService:
return scan.id return scan.id
# Handle legacy YAML config file
else:
# Validate config file
is_valid, error_msg = validate_config_file(config_file)
if not is_valid:
raise ValueError(f"Invalid config file: {error_msg}")
# Convert config_file to full path if it's just a filename
if not config_file.startswith('/'):
config_path = f'/app/configs/{config_file}'
else:
config_path = config_file
# Load config to get title
import yaml
with open(config_path, 'r') as f:
config = yaml.safe_load(f)
# Create scan record
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_file=config_file,
title=config.get('title', 'Untitled Scan'),
triggered_by=triggered_by,
schedule_id=schedule_id,
created_at=datetime.utcnow()
)
self.db.add(scan)
self.db.commit()
self.db.refresh(scan)
logger.info(f"Scan {scan.id} triggered via {triggered_by}")
# Queue background job if scheduler provided
if scheduler:
try:
job_id = scheduler.queue_scan(scan.id, config_file=config_file)
logger.info(f"Scan {scan.id} queued for background execution (job_id={job_id})")
except Exception as e:
logger.error(f"Failed to queue scan {scan.id}: {str(e)}")
# Mark scan as failed if job queuing fails
scan.status = 'failed'
scan.error_message = f"Failed to queue background job: {str(e)}"
self.db.commit()
raise
else:
logger.warning(f"Scan {scan.id} created but not queued (no scheduler provided)")
return scan.id
def get_scan(self, scan_id: int) -> Optional[Dict[str, Any]]: def get_scan(self, scan_id: int) -> Optional[Dict[str, Any]]:
""" """
Get scan details with all related data. Get scan details with all related data.
@@ -614,7 +555,7 @@ class ScanService:
'duration': scan.duration, 'duration': scan.duration,
'status': scan.status, 'status': scan.status,
'title': scan.title, 'title': scan.title,
'config_file': scan.config_file, 'config_id': scan.config_id,
'json_path': scan.json_path, 'json_path': scan.json_path,
'html_path': scan.html_path, 'html_path': scan.html_path,
'zip_path': scan.zip_path, 'zip_path': scan.zip_path,
@@ -640,7 +581,7 @@ class ScanService:
'duration': scan.duration, 'duration': scan.duration,
'status': scan.status, 'status': scan.status,
'title': scan.title, 'title': scan.title,
'config_file': scan.config_file, 'config_id': scan.config_id,
'triggered_by': scan.triggered_by, 'triggered_by': scan.triggered_by,
'created_at': scan.created_at.isoformat() if scan.created_at else None 'created_at': scan.created_at.isoformat() if scan.created_at else None
} }
@@ -783,17 +724,17 @@ class ScanService:
return None return None
# Check if scans use the same configuration # Check if scans use the same configuration
config1 = scan1.get('config_file', '') config1 = scan1.get('config_id')
config2 = scan2.get('config_file', '') config2 = scan2.get('config_id')
same_config = (config1 == config2) and (config1 != '') same_config = (config1 == config2) and (config1 is not None)
# Generate warning message if configs differ # Generate warning message if configs differ
config_warning = None config_warning = None
if not same_config: if not same_config:
config_warning = ( config_warning = (
f"These scans use different configurations. " f"These scans use different configurations. "
f"Scan #{scan1_id} used '{config1 or 'unknown'}' and " f"Scan #{scan1_id} used config_id={config1 or 'unknown'} and "
f"Scan #{scan2_id} used '{config2 or 'unknown'}'. " f"Scan #{scan2_id} used config_id={config2 or 'unknown'}. "
f"The comparison may show all changes as additions/removals if the scans " f"The comparison may show all changes as additions/removals if the scans "
f"cover different IP ranges or infrastructure." f"cover different IP ranges or infrastructure."
) )
@@ -832,14 +773,14 @@ class ScanService:
'timestamp': scan1['timestamp'], 'timestamp': scan1['timestamp'],
'title': scan1['title'], 'title': scan1['title'],
'status': scan1['status'], 'status': scan1['status'],
'config_file': config1 'config_id': config1
}, },
'scan2': { 'scan2': {
'id': scan2['id'], 'id': scan2['id'],
'timestamp': scan2['timestamp'], 'timestamp': scan2['timestamp'],
'title': scan2['title'], 'title': scan2['title'],
'status': scan2['status'], 'status': scan2['status'],
'config_file': config2 'config_id': config2
}, },
'same_config': same_config, 'same_config': same_config,
'config_warning': config_warning, 'config_warning': config_warning,

View File

@@ -6,14 +6,13 @@ scheduled scans with cron expressions.
""" """
import logging import logging
import os
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from croniter import croniter from croniter import croniter
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from web.models import Schedule, Scan from web.models import Schedule, Scan, ScanConfig
from web.utils.pagination import paginate, PaginatedResult from web.utils.pagination import paginate, PaginatedResult
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -39,7 +38,7 @@ class ScheduleService:
def create_schedule( def create_schedule(
self, self,
name: str, name: str,
config_file: str, config_id: int,
cron_expression: str, cron_expression: str,
enabled: bool = True enabled: bool = True
) -> int: ) -> int:
@@ -48,7 +47,7 @@ class ScheduleService:
Args: Args:
name: Human-readable schedule name name: Human-readable schedule name
config_file: Path to YAML configuration file config_id: Database config ID
cron_expression: Cron expression (e.g., '0 2 * * *') cron_expression: Cron expression (e.g., '0 2 * * *')
enabled: Whether schedule is active enabled: Whether schedule is active
@@ -56,22 +55,17 @@ class ScheduleService:
Schedule ID of the created schedule Schedule ID of the created schedule
Raises: Raises:
ValueError: If cron expression is invalid or config file doesn't exist ValueError: If cron expression is invalid or config doesn't exist
""" """
# Validate cron expression # Validate cron expression
is_valid, error_msg = self.validate_cron_expression(cron_expression) is_valid, error_msg = self.validate_cron_expression(cron_expression)
if not is_valid: if not is_valid:
raise ValueError(f"Invalid cron expression: {error_msg}") raise ValueError(f"Invalid cron expression: {error_msg}")
# Validate config file exists # Validate config exists
# If config_file is just a filename, prepend the configs directory db_config = self.db.query(ScanConfig).filter_by(id=config_id).first()
if not config_file.startswith('/'): if not db_config:
config_file_path = os.path.join('/app/configs', config_file) raise ValueError(f"Config with ID {config_id} not found")
else:
config_file_path = config_file
if not os.path.isfile(config_file_path):
raise ValueError(f"Config file not found: {config_file}")
# Calculate next run time # Calculate next run time
next_run = self.calculate_next_run(cron_expression) if enabled else None next_run = self.calculate_next_run(cron_expression) if enabled else None
@@ -79,7 +73,7 @@ class ScheduleService:
# Create schedule record # Create schedule record
schedule = Schedule( schedule = Schedule(
name=name, name=name,
config_file=config_file, config_id=config_id,
cron_expression=cron_expression, cron_expression=cron_expression,
enabled=enabled, enabled=enabled,
last_run=None, last_run=None,
@@ -200,17 +194,11 @@ class ScheduleService:
if schedule.enabled or updates.get('enabled', False): if schedule.enabled or updates.get('enabled', False):
updates['next_run'] = self.calculate_next_run(updates['cron_expression']) updates['next_run'] = self.calculate_next_run(updates['cron_expression'])
# Validate config file if being updated # Validate config_id if being updated
if 'config_file' in updates: if 'config_id' in updates:
config_file = updates['config_file'] db_config = self.db.query(ScanConfig).filter_by(id=updates['config_id']).first()
# If config_file is just a filename, prepend the configs directory if not db_config:
if not config_file.startswith('/'): raise ValueError(f"Config with ID {updates['config_id']} not found")
config_file_path = os.path.join('/app/configs', config_file)
else:
config_file_path = config_file
if not os.path.isfile(config_file_path):
raise ValueError(f"Config file not found: {updates['config_file']}")
# Handle enabled toggle # Handle enabled toggle
if 'enabled' in updates: if 'enabled' in updates:
@@ -400,7 +388,7 @@ class ScheduleService:
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None, 'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
'status': scan.status, 'status': scan.status,
'title': scan.title, 'title': scan.title,
'config_file': scan.config_file 'config_id': scan.config_id
} }
for scan in scans for scan in scans
] ]
@@ -418,7 +406,7 @@ class ScheduleService:
return { return {
'id': schedule.id, 'id': schedule.id,
'name': schedule.name, 'name': schedule.name,
'config_file': schedule.config_file, 'config_id': schedule.config_id,
'cron_expression': schedule.cron_expression, 'cron_expression': schedule.cron_expression,
'enabled': schedule.enabled, 'enabled': schedule.enabled,
'last_run': schedule.last_run.isoformat() if schedule.last_run else None, 'last_run': schedule.last_run.isoformat() if schedule.last_run else None,

View File

@@ -131,7 +131,7 @@ class SchedulerService:
try: try:
self.add_scheduled_scan( self.add_scheduled_scan(
schedule_id=schedule.id, schedule_id=schedule.id,
config_file=schedule.config_file, config_id=schedule.config_id,
cron_expression=schedule.cron_expression cron_expression=schedule.cron_expression
) )
logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'") logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'")
@@ -149,16 +149,13 @@ class SchedulerService:
except Exception as e: except Exception as e:
logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True) logger.error(f"Error loading schedules on startup: {str(e)}", exc_info=True)
def queue_scan(self, scan_id: int, config_file: str = None, config_id: int = None) -> str: def queue_scan(self, scan_id: int, config_id: int) -> str:
""" """
Queue a scan for immediate background execution. Queue a scan for immediate background execution.
Args: Args:
scan_id: Database ID of the scan scan_id: Database ID of the scan
config_file: Path to YAML configuration file (legacy, optional) config_id: Database config ID
config_id: Database config ID (preferred, optional)
Note: Provide exactly one of config_file or config_id
Returns: Returns:
Job ID from APScheduler Job ID from APScheduler
@@ -172,7 +169,7 @@ class SchedulerService:
# Add job to run immediately # Add job to run immediately
job = self.scheduler.add_job( job = self.scheduler.add_job(
func=execute_scan, func=execute_scan,
kwargs={'scan_id': scan_id, 'config_file': config_file, 'config_id': config_id, 'db_url': self.db_url}, kwargs={'scan_id': scan_id, 'config_id': config_id, 'db_url': self.db_url},
id=f'scan_{scan_id}', id=f'scan_{scan_id}',
name=f'Scan {scan_id}', name=f'Scan {scan_id}',
replace_existing=True, replace_existing=True,
@@ -182,14 +179,14 @@ class SchedulerService:
logger.info(f"Queued scan {scan_id} for background execution (job_id={job.id})") logger.info(f"Queued scan {scan_id} for background execution (job_id={job.id})")
return job.id return job.id
def add_scheduled_scan(self, schedule_id: int, config_file: str, def add_scheduled_scan(self, schedule_id: int, config_id: int,
cron_expression: str) -> str: cron_expression: str) -> str:
""" """
Add a recurring scheduled scan. Add a recurring scheduled scan.
Args: Args:
schedule_id: Database ID of the schedule schedule_id: Database ID of the schedule
config_file: Path to YAML configuration file config_id: Database config ID
cron_expression: Cron expression (e.g., "0 2 * * *" for 2am daily) cron_expression: Cron expression (e.g., "0 2 * * *" for 2am daily)
Returns: Returns:
@@ -286,14 +283,14 @@ class SchedulerService:
# Create and trigger scan # Create and trigger scan
scan_service = ScanService(session) scan_service = ScanService(session)
scan_id = scan_service.trigger_scan( scan_id = scan_service.trigger_scan(
config_file=schedule['config_file'], config_id=schedule['config_id'],
triggered_by='scheduled', triggered_by='scheduled',
schedule_id=schedule_id, schedule_id=schedule_id,
scheduler=None # Don't pass scheduler to avoid recursion scheduler=None # Don't pass scheduler to avoid recursion
) )
# Queue the scan for execution # Queue the scan for execution
self.queue_scan(scan_id, schedule['config_file']) self.queue_scan(scan_id, schedule['config_id'])
# Update schedule's last_run and next_run # Update schedule's last_run and next_run
from croniter import croniter from croniter import croniter

View File

@@ -87,7 +87,7 @@ class TemplateService:
"timestamp": scan.timestamp, "timestamp": scan.timestamp,
"duration": scan.duration, "duration": scan.duration,
"status": scan.status, "status": scan.status,
"config_file": scan.config_file, "config_id": scan.config_id,
"triggered_by": scan.triggered_by, "triggered_by": scan.triggered_by,
"started_at": scan.started_at, "started_at": scan.started_at,
"completed_at": scan.completed_at, "completed_at": scan.completed_at,
@@ -247,7 +247,7 @@ class TemplateService:
"timestamp": datetime.utcnow(), "timestamp": datetime.utcnow(),
"duration": 125.5, "duration": 125.5,
"status": "completed", "status": "completed",
"config_file": "production-scan.yaml", "config_id": 1,
"triggered_by": "schedule", "triggered_by": "schedule",
"started_at": datetime.utcnow(), "started_at": datetime.utcnow(),
"completed_at": datetime.utcnow(), "completed_at": datetime.utcnow(),

View File

@@ -375,12 +375,12 @@
document.getElementById('scan1-id').textContent = data.scan1.id; document.getElementById('scan1-id').textContent = data.scan1.id;
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan'; document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString(); document.getElementById('scan1-timestamp').textContent = new Date(data.scan1.timestamp).toLocaleString();
document.getElementById('scan1-config').textContent = data.scan1.config_file || 'Unknown'; document.getElementById('scan1-config').textContent = data.scan1.config_id || 'Unknown';
document.getElementById('scan2-id').textContent = data.scan2.id; document.getElementById('scan2-id').textContent = data.scan2.id;
document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan'; document.getElementById('scan2-title').textContent = data.scan2.title || 'Untitled Scan';
document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString(); document.getElementById('scan2-timestamp').textContent = new Date(data.scan2.timestamp).toLocaleString();
document.getElementById('scan2-config').textContent = data.scan2.config_file || 'Unknown'; document.getElementById('scan2-config').textContent = data.scan2.config_id || 'Unknown';
// Ports comparison // Ports comparison
populatePortsComparison(data.ports); populatePortsComparison(data.ports);

View File

@@ -218,7 +218,7 @@
document.getElementById('scan-timestamp').textContent = new Date(scan.timestamp).toLocaleString(); document.getElementById('scan-timestamp').textContent = new Date(scan.timestamp).toLocaleString();
document.getElementById('scan-duration').textContent = scan.duration ? `${scan.duration.toFixed(1)}s` : '-'; document.getElementById('scan-duration').textContent = scan.duration ? `${scan.duration.toFixed(1)}s` : '-';
document.getElementById('scan-triggered-by').textContent = scan.triggered_by || 'manual'; document.getElementById('scan-triggered-by').textContent = scan.triggered_by || 'manual';
document.getElementById('scan-config-file').textContent = scan.config_file || '-'; document.getElementById('scan-config-id').textContent = scan.config_id || '-';
// Status badge // Status badge
let statusBadge = ''; let statusBadge = '';
@@ -449,13 +449,13 @@
// Find previous scan and show compare button // Find previous scan and show compare button
let previousScanId = null; let previousScanId = null;
let currentConfigFile = null; let currentConfigId = null;
async function findPreviousScan() { async function findPreviousScan() {
try { try {
// Get current scan details first to know which config it used // Get current scan details first to know which config it used
const currentScanResponse = await fetch(`/api/scans/${scanId}`); const currentScanResponse = await fetch(`/api/scans/${scanId}`);
const currentScanData = await currentScanResponse.json(); const currentScanData = await currentScanResponse.json();
currentConfigFile = currentScanData.config_file; currentConfigId = currentScanData.config_id;
// Get list of completed scans // Get list of completed scans
const response = await fetch('/api/scans?per_page=100&status=completed'); const response = await fetch('/api/scans?per_page=100&status=completed');
@@ -466,12 +466,12 @@
const currentScanIndex = data.scans.findIndex(s => s.id === scanId); const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
if (currentScanIndex !== -1) { if (currentScanIndex !== -1) {
// Look for the most recent previous scan with the SAME config file // Look for the most recent previous scan with the SAME config
for (let i = currentScanIndex + 1; i < data.scans.length; i++) { for (let i = currentScanIndex + 1; i < data.scans.length; i++) {
const previousScan = data.scans[i]; const previousScan = data.scans[i];
// Check if this scan uses the same config // Check if this scan uses the same config
if (previousScan.config_file === currentConfigFile) { if (previousScan.config_id === currentConfigId) {
previousScanId = previousScan.id; previousScanId = previousScan.id;
// Show the compare button // Show the compare button

View File

@@ -32,13 +32,13 @@
<small class="form-text text-muted">A descriptive name for this schedule</small> <small class="form-text text-muted">A descriptive name for this schedule</small>
</div> </div>
<!-- Config File --> <!-- Config -->
<div class="mb-3"> <div class="mb-3">
<label for="config-file" class="form-label">Configuration File <span class="text-danger">*</span></label> <label for="config-id" class="form-label">Configuration <span class="text-danger">*</span></label>
<select class="form-select" id="config-file" name="config_file" required> <select class="form-select" id="config-id" name="config_id" required>
<option value="">Select a configuration file...</option> <option value="">Select a configuration...</option>
{% for config in config_files %} {% for config in configs %}
<option value="{{ config }}">{{ config }}</option> <option value="{{ config.id }}">{{ config.title }}</option>
{% endfor %} {% endfor %}
</select> </select>
<small class="form-text text-muted">The scan configuration to use for this schedule</small> <small class="form-text text-muted">The scan configuration to use for this schedule</small>
@@ -369,13 +369,13 @@ document.getElementById('create-schedule-form').addEventListener('submit', async
// Get form data // Get form data
const formData = { const formData = {
name: document.getElementById('schedule-name').value.trim(), name: document.getElementById('schedule-name').value.trim(),
config_file: document.getElementById('config-file').value, config_id: parseInt(document.getElementById('config-id').value),
cron_expression: document.getElementById('cron-expression').value.trim(), cron_expression: document.getElementById('cron-expression').value.trim(),
enabled: document.getElementById('schedule-enabled').checked enabled: document.getElementById('schedule-enabled').checked
}; };
// Validate // Validate
if (!formData.name || !formData.config_file || !formData.cron_expression) { if (!formData.name || !formData.config_id || !formData.cron_expression) {
showNotification('Please fill in all required fields', 'warning'); showNotification('Please fill in all required fields', 'warning');
return; return;
} }

View File

@@ -298,7 +298,7 @@ async function loadSchedule() {
function populateForm(schedule) { function populateForm(schedule) {
document.getElementById('schedule-id').value = schedule.id; document.getElementById('schedule-id').value = schedule.id;
document.getElementById('schedule-name').value = schedule.name; document.getElementById('schedule-name').value = schedule.name;
document.getElementById('config-file').value = schedule.config_file; document.getElementById('config-id').value = schedule.config_id;
document.getElementById('cron-expression').value = schedule.cron_expression; document.getElementById('cron-expression').value = schedule.cron_expression;
document.getElementById('schedule-enabled').checked = schedule.enabled; document.getElementById('schedule-enabled').checked = schedule.enabled;

View File

@@ -198,7 +198,7 @@ function renderSchedules() {
<td> <td>
<strong>${escapeHtml(schedule.name)}</strong> <strong>${escapeHtml(schedule.name)}</strong>
<br> <br>
<small class="text-muted">${escapeHtml(schedule.config_file)}</small> <small class="text-muted">Config ID: ${schedule.config_id || 'N/A'}</small>
</td> </td>
<td class="mono"><code>${escapeHtml(schedule.cron_expression)}</code></td> <td class="mono"><code>${escapeHtml(schedule.cron_expression)}</code></td>
<td>${formatRelativeTime(schedule.next_run)}</td> <td>${formatRelativeTime(schedule.next_run)}</td>

View File

@@ -13,7 +13,9 @@ import yaml
def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]: def validate_config_file(file_path: str) -> tuple[bool, Optional[str]]:
""" """
Validate that a configuration file exists and is valid YAML. [DEPRECATED] Validate that a configuration file exists and is valid YAML.
This function is deprecated. Use config_id with database-stored configs instead.
Args: Args:
file_path: Path to configuration file (absolute or relative filename) file_path: Path to configuration file (absolute or relative filename)

View File

@@ -720,7 +720,7 @@ Retrieve a paginated list of scans with optional status filtering.
"duration": 125.5, "duration": 125.5,
"status": "completed", "status": "completed",
"title": "Production Network Scan", "title": "Production Network Scan",
"config_file": "/app/configs/production.yaml", "config_id": "/app/configs/production.yaml",
"triggered_by": "manual", "triggered_by": "manual",
"started_at": "2025-11-14T10:30:00Z", "started_at": "2025-11-14T10:30:00Z",
"completed_at": "2025-11-14T10:32:05Z" "completed_at": "2025-11-14T10:32:05Z"
@@ -731,7 +731,7 @@ Retrieve a paginated list of scans with optional status filtering.
"duration": 98.2, "duration": 98.2,
"status": "completed", "status": "completed",
"title": "Development Network Scan", "title": "Development Network Scan",
"config_file": "/app/configs/dev.yaml", "config_id": "/app/configs/dev.yaml",
"triggered_by": "scheduled", "triggered_by": "scheduled",
"started_at": "2025-11-13T15:00:00Z", "started_at": "2025-11-13T15:00:00Z",
"completed_at": "2025-11-13T15:01:38Z" "completed_at": "2025-11-13T15:01:38Z"
@@ -793,7 +793,7 @@ Retrieve complete details for a specific scan, including all sites, IPs, ports,
"duration": 125.5, "duration": 125.5,
"status": "completed", "status": "completed",
"title": "Production Network Scan", "title": "Production Network Scan",
"config_file": "/app/configs/production.yaml", "config_id": "/app/configs/production.yaml",
"json_path": "/app/output/scan_report_20251114_103000.json", "json_path": "/app/output/scan_report_20251114_103000.json",
"html_path": "/app/output/scan_report_20251114_103000.html", "html_path": "/app/output/scan_report_20251114_103000.html",
"zip_path": "/app/output/scan_report_20251114_103000.zip", "zip_path": "/app/output/scan_report_20251114_103000.zip",
@@ -1111,7 +1111,7 @@ Retrieve a list of all schedules with pagination and filtering.
{ {
"id": 1, "id": 1,
"name": "Daily Production Scan", "name": "Daily Production Scan",
"config_file": "/app/configs/prod-scan.yaml", "config_id": "/app/configs/prod-scan.yaml",
"cron_expression": "0 2 * * *", "cron_expression": "0 2 * * *",
"enabled": true, "enabled": true,
"created_at": "2025-11-01T10:00:00Z", "created_at": "2025-11-01T10:00:00Z",
@@ -1157,7 +1157,7 @@ Retrieve details for a specific schedule including execution history.
{ {
"id": 1, "id": 1,
"name": "Daily Production Scan", "name": "Daily Production Scan",
"config_file": "/app/configs/prod-scan.yaml", "config_id": "/app/configs/prod-scan.yaml",
"cron_expression": "0 2 * * *", "cron_expression": "0 2 * * *",
"enabled": true, "enabled": true,
"created_at": "2025-11-01T10:00:00Z", "created_at": "2025-11-01T10:00:00Z",
@@ -1201,7 +1201,7 @@ Create a new scheduled scan.
```json ```json
{ {
"name": "Daily Production Scan", "name": "Daily Production Scan",
"config_file": "/app/configs/prod-scan.yaml", "config_id": "/app/configs/prod-scan.yaml",
"cron_expression": "0 2 * * *", "cron_expression": "0 2 * * *",
"enabled": true "enabled": true
} }
@@ -1215,7 +1215,7 @@ Create a new scheduled scan.
"schedule": { "schedule": {
"id": 1, "id": 1,
"name": "Daily Production Scan", "name": "Daily Production Scan",
"config_file": "/app/configs/prod-scan.yaml", "config_id": "/app/configs/prod-scan.yaml",
"cron_expression": "0 2 * * *", "cron_expression": "0 2 * * *",
"enabled": true, "enabled": true,
"created_at": "2025-11-01T10:00:00Z" "created_at": "2025-11-01T10:00:00Z"
@@ -1228,7 +1228,7 @@ Create a new scheduled scan.
*400 Bad Request* - Missing required fields or invalid cron expression: *400 Bad Request* - Missing required fields or invalid cron expression:
```json ```json
{ {
"error": "Missing required fields: name, config_file" "error": "Missing required fields: name, config_id"
} }
``` ```
@@ -1236,7 +1236,7 @@ Create a new scheduled scan.
```bash ```bash
curl -X POST http://localhost:5000/api/schedules \ curl -X POST http://localhost:5000/api/schedules \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"name":"Daily Scan","config_file":"/app/configs/prod.yaml","cron_expression":"0 2 * * *"}' \ -d '{"name":"Daily Scan","config_id":"/app/configs/prod.yaml","cron_expression":"0 2 * * *"}' \
-b cookies.txt -b cookies.txt
``` ```
@@ -1270,7 +1270,7 @@ Update an existing schedule.
"schedule": { "schedule": {
"id": 1, "id": 1,
"name": "Updated Schedule Name", "name": "Updated Schedule Name",
"config_file": "/app/configs/prod-scan.yaml", "config_id": "/app/configs/prod-scan.yaml",
"cron_expression": "0 3 * * *", "cron_expression": "0 3 * * *",
"enabled": false, "enabled": false,
"updated_at": "2025-11-15T10:00:00Z" "updated_at": "2025-11-15T10:00:00Z"
@@ -1512,7 +1512,7 @@ Get historical trend data for scans with the same configuration.
], ],
"labels": ["2025-11-10 12:00", "2025-11-15 12:00"], "labels": ["2025-11-10 12:00", "2025-11-15 12:00"],
"port_counts": [25, 26], "port_counts": [25, 26],
"config_file": "/app/configs/prod-scan.yaml" "config_id": "/app/configs/prod-scan.yaml"
} }
``` ```

View File

@@ -163,7 +163,7 @@ Machine-readable scan data with all discovered services, ports, and SSL/TLS info
"title": "Scan Title", "title": "Scan Title",
"scan_time": "2025-01-15T10:30:00Z", "scan_time": "2025-01-15T10:30:00Z",
"scan_duration": 95.3, "scan_duration": 95.3,
"config_file": "/app/configs/example-site.yaml", "config_id": "/app/configs/example-site.yaml",
"sites": [ "sites": [
{ {
"name": "Site Name", "name": "Site Name",

View File

@@ -607,7 +607,7 @@ curl -X DELETE http://localhost:5000/api/configs/test-network.yaml \
# Trigger a scan # Trigger a scan
curl -X POST http://localhost:5000/api/scans \ curl -X POST http://localhost:5000/api/scans \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"config_file": "/app/configs/prod-network.yaml"}' \ -d '{"config_id": "/app/configs/prod-network.yaml"}' \
-b cookies.txt -b cookies.txt
# List all scans # List all scans
@@ -639,7 +639,7 @@ curl -X POST http://localhost:5000/api/schedules \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"name": "Daily Production Scan", "name": "Daily Production Scan",
"config_file": "/app/configs/prod-network.yaml", "config_id": "/app/configs/prod-network.yaml",
"cron_expression": "0 2 * * *", "cron_expression": "0 2 * * *",
"enabled": true "enabled": true
}' \ }' \

View File

@@ -46,7 +46,7 @@ Stores metadata about each scan execution.
| `timestamp` | DATETIME | Scan start time (UTC) | | `timestamp` | DATETIME | Scan start time (UTC) |
| `duration` | FLOAT | Total scan duration (seconds) | | `duration` | FLOAT | Total scan duration (seconds) |
| `status` | VARCHAR(20) | `running`, `completed`, `failed` | | `status` | VARCHAR(20) | `running`, `completed`, `failed` |
| `config_file` | TEXT | Path to YAML config used | | `config_id` | INTEGER | FK to scan_configs table |
| `title` | TEXT | Scan title from config | | `title` | TEXT | Scan title from config |
| `json_path` | TEXT | Path to JSON report | | `json_path` | TEXT | Path to JSON report |
| `html_path` | TEXT | Path to HTML report | | `html_path` | TEXT | Path to HTML report |
@@ -144,7 +144,7 @@ Scheduled scan configurations.
|--------|------|-------------| |--------|------|-------------|
| `id` | INTEGER PRIMARY KEY | Unique schedule ID | | `id` | INTEGER PRIMARY KEY | Unique schedule ID |
| `name` | VARCHAR(255) | Schedule name (e.g., "Daily prod scan") | | `name` | VARCHAR(255) | Schedule name (e.g., "Daily prod scan") |
| `config_file` | TEXT | Path to YAML config | | `config_id` | INTEGER | FK to scan_configs table |
| `cron_expression` | VARCHAR(100) | Cron-like schedule (e.g., `0 2 * * *`) | | `cron_expression` | VARCHAR(100) | Cron-like schedule (e.g., `0 2 * * *`) |
| `enabled` | BOOLEAN | Is schedule active? | | `enabled` | BOOLEAN | Is schedule active? |
| `last_run` | DATETIME | Last execution time | | `last_run` | DATETIME | Last execution time |
@@ -214,7 +214,7 @@ All API endpoints return JSON and follow RESTful conventions.
|--------|----------|-------------|--------------|----------| |--------|----------|-------------|--------------|----------|
| `GET` | `/api/scans` | List all scans (paginated) | - | `{ "scans": [...], "total": N, "page": 1 }` | | `GET` | `/api/scans` | List all scans (paginated) | - | `{ "scans": [...], "total": N, "page": 1 }` |
| `GET` | `/api/scans/{id}` | Get scan details | - | `{ "scan": {...} }` | | `GET` | `/api/scans/{id}` | Get scan details | - | `{ "scan": {...} }` |
| `POST` | `/api/scans` | Trigger new scan | `{ "config_file": "path" }` | `{ "scan_id": N, "status": "running" }` | | `POST` | `/api/scans` | Trigger new scan | `{ "config_id": "path" }` | `{ "scan_id": N, "status": "running" }` |
| `DELETE` | `/api/scans/{id}` | Delete scan and files | - | `{ "status": "deleted" }` | | `DELETE` | `/api/scans/{id}` | Delete scan and files | - | `{ "status": "deleted" }` |
| `GET` | `/api/scans/{id}/status` | Get scan status | - | `{ "status": "running", "progress": "45%" }` | | `GET` | `/api/scans/{id}/status` | Get scan status | - | `{ "status": "running", "progress": "45%" }` |
| `GET` | `/api/scans/{id1}/compare/{id2}` | Compare two scans | - | `{ "diff": {...} }` | | `GET` | `/api/scans/{id1}/compare/{id2}` | Compare two scans | - | `{ "diff": {...} }` |
@@ -225,7 +225,7 @@ All API endpoints return JSON and follow RESTful conventions.
|--------|----------|-------------|--------------|----------| |--------|----------|-------------|--------------|----------|
| `GET` | `/api/schedules` | List all schedules | - | `{ "schedules": [...] }` | | `GET` | `/api/schedules` | List all schedules | - | `{ "schedules": [...] }` |
| `GET` | `/api/schedules/{id}` | Get schedule details | - | `{ "schedule": {...} }` | | `GET` | `/api/schedules/{id}` | Get schedule details | - | `{ "schedule": {...} }` |
| `POST` | `/api/schedules` | Create new schedule | `{ "name": "...", "config_file": "...", "cron_expression": "..." }` | `{ "schedule_id": N }` | | `POST` | `/api/schedules` | Create new schedule | `{ "name": "...", "config_id": "...", "cron_expression": "..." }` | `{ "schedule_id": N }` |
| `PUT` | `/api/schedules/{id}` | Update schedule | `{ "enabled": true, "cron_expression": "..." }` | `{ "status": "updated" }` | | `PUT` | `/api/schedules/{id}` | Update schedule | `{ "enabled": true, "cron_expression": "..." }` | `{ "status": "updated" }` |
| `DELETE` | `/api/schedules/{id}` | Delete schedule | - | `{ "status": "deleted" }` | | `DELETE` | `/api/schedules/{id}` | Delete schedule | - | `{ "status": "deleted" }` |
| `POST` | `/api/schedules/{id}/trigger` | Manually trigger scheduled scan | - | `{ "scan_id": N }` | | `POST` | `/api/schedules/{id}/trigger` | Manually trigger scheduled scan | - | `{ "scan_id": N }` |
@@ -438,7 +438,7 @@ alert_rules:
"scan_id": 123, "scan_id": 123,
"title": "Production Network Scan", "title": "Production Network Scan",
"timestamp": "2025-11-17T14:15:00Z", "timestamp": "2025-11-17T14:15:00Z",
"config_file": "prod_config.yaml", "config_id": "prod_config.yaml",
"triggered_by": "scheduled" "triggered_by": "scheduled"
}, },
"alert_details": { "alert_details": {