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

View File

@@ -78,7 +78,7 @@ class HTMLReportGenerator:
'title': self.report_data.get('title', 'SneakyScanner Report'),
'scan_time': self.report_data.get('scan_time'),
'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', []),
'summary_stats': summary_stats,
'drift_alerts': drift_alerts,

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
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
@@ -53,7 +53,7 @@ def sample_scan_report():
'title': 'Test Scan',
'scan_time': '2025-11-14T10:30:00Z',
'scan_duration': 125.5,
'config_file': '/app/configs/test.yaml',
'config_id': 1,
'sites': [
{
'name': 'Test Site',
@@ -199,6 +199,53 @@ def sample_invalid_config_file(tmp_path):
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')
def app():
"""
@@ -269,7 +316,7 @@ def sample_scan(db):
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_file='/app/configs/test.yaml',
config_id=1,
title='Test Scan',
duration=125.5,
triggered_by='test',

View File

@@ -23,12 +23,12 @@ class TestBackgroundJobs:
assert app.scheduler.scheduler is not None
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."""
# Create a scan via service
scan_service = ScanService(db)
scan_id = scan_service.trigger_scan(
config_file=sample_config_file,
config_id=sample_db_config.id,
triggered_by='test',
scheduler=app.scheduler
)
@@ -43,12 +43,12 @@ class TestBackgroundJobs:
assert job is not None
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."""
# Create scan without scheduler
scan_service = ScanService(db)
scan_id = scan_service.trigger_scan(
config_file=sample_config_file,
config_id=sample_db_config.id,
triggered_by='test',
scheduler=None # No scheduler
)
@@ -58,13 +58,13 @@ class TestBackgroundJobs:
assert scan is not None
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."""
# Create scan record first
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_file=sample_config_file,
config_id=sample_db_config.id,
title='Test Scan',
triggered_by='test'
)
@@ -72,27 +72,27 @@ class TestBackgroundJobs:
db.commit()
# 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
assert job_id == f'scan_{scan.id}'
job = app.scheduler.scheduler.get_job(job_id)
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."""
# Queue a few scans
for i in range(3):
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_file=sample_config_file,
config_id=sample_db_config.id,
title=f'Test Scan {i}',
triggered_by='test'
)
db.add(scan)
db.commit()
app.scheduler.queue_scan(scan.id, sample_config_file)
app.scheduler.queue_scan(scan.id, sample_db_config)
# List jobs
jobs = app.scheduler.list_jobs()
@@ -106,20 +106,20 @@ class TestBackgroundJobs:
assert 'name' 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."""
# Create and queue a scan
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_file=sample_config_file,
config_id=sample_db_config.id,
title='Test Scan',
triggered_by='test'
)
db.add(scan)
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
status = app.scheduler.get_job_status(job_id)
@@ -133,13 +133,13 @@ class TestBackgroundJobs:
status = app.scheduler.get_job_status('nonexistent_job_id')
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."""
# Create scan with started_at
scan = Scan(
timestamp=datetime.utcnow(),
status='running',
config_file=sample_config_file,
config_id=sample_db_config.id,
title='Test Scan',
triggered_by='test',
started_at=datetime.utcnow()
@@ -161,13 +161,13 @@ class TestBackgroundJobs:
assert scan.completed_at is not None
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."""
# Create failed scan
scan = Scan(
timestamp=datetime.utcnow(),
status='failed',
config_file=sample_config_file,
config_id=sample_db_config.id,
title='Failed Scan',
triggered_by='test',
started_at=datetime.utcnow(),
@@ -188,7 +188,7 @@ class TestBackgroundJobs:
assert status['error_message'] == 'Test error message'
@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.
@@ -200,7 +200,7 @@ class TestBackgroundJobs:
# Trigger scan
scan_service = ScanService(db)
scan_id = scan_service.trigger_scan(
config_file=sample_config_file,
config_id=sample_db_config.id,
triggered_by='test',
scheduler=app.scheduler
)

View File

@@ -44,7 +44,7 @@ class TestScanAPIEndpoints:
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_file=f'/app/configs/test{i}.yaml',
config_id=sample_db_config.id,
title=f'Test Scan {i}',
triggered_by='test'
)
@@ -81,7 +81,7 @@ class TestScanAPIEndpoints:
scan = Scan(
timestamp=datetime.utcnow(),
status=status,
config_file='/app/configs/test.yaml',
config_id=1,
title=f'{status.capitalize()} Scan',
triggered_by='test'
)
@@ -123,10 +123,10 @@ class TestScanAPIEndpoints:
assert 'error' in data
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."""
response = client.post('/api/scans',
json={'config_file': str(sample_config_file)},
json={'config_file': str(sample_db_config)},
content_type='application/json'
)
assert response.status_code == 201
@@ -222,7 +222,7 @@ class TestScanAPIEndpoints:
assert 'error' 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.
@@ -231,7 +231,7 @@ class TestScanAPIEndpoints:
"""
# Step 1: Trigger scan
response = client.post('/api/scans',
json={'config_file': str(sample_config_file)},
json={'config_file': str(sample_db_config)},
content_type='application/json'
)
assert response.status_code == 201

View File

@@ -17,10 +17,10 @@ class TestScanComparison:
"""Tests for scan comparison methods."""
@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."""
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
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
@@ -77,10 +77,10 @@ class TestScanComparison:
return scan_id
@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."""
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
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:
"""Tests for triggering scans."""
def test_trigger_scan_valid_config(self, test_db, sample_config_file):
"""Test triggering a scan with valid config file."""
service = ScanService(test_db)
def test_trigger_scan_valid_config(self, db, sample_db_config):
"""Test triggering a scan with valid config."""
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
assert scan_id is not None
assert isinstance(scan_id, int)
# 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.status == 'running'
assert scan.title == 'Test Scan'
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):
"""Test triggering a scan with invalid config file."""
service = ScanService(test_db)
def test_trigger_scan_invalid_config(self, db):
"""Test triggering a scan with invalid config ID."""
service = ScanService(db)
with pytest.raises(ValueError, match="Invalid config file"):
service.trigger_scan(sample_invalid_config_file)
with pytest.raises(ValueError, match="not found"):
service.trigger_scan(config_id=99999)
def test_trigger_scan_nonexistent_file(self, test_db):
"""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):
def test_trigger_scan_with_schedule(self, db, sample_db_config):
"""Test triggering a scan via schedule."""
service = ScanService(test_db)
service = ScanService(db)
scan_id = service.trigger_scan(
sample_config_file,
config_id=sample_db_config.id,
triggered_by='scheduled',
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.schedule_id == 42
@@ -63,19 +56,19 @@ class TestScanServiceTrigger:
class TestScanServiceGet:
"""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."""
service = ScanService(test_db)
service = ScanService(db)
result = service.get_scan(999)
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."""
service = ScanService(test_db)
service = ScanService(db)
# Create a scan
scan_id = service.trigger_scan(sample_config_file)
scan_id = service.trigger_scan(config_id=sample_db_config.id)
# Retrieve it
result = service.get_scan(scan_id)
@@ -90,9 +83,9 @@ class TestScanServiceGet:
class TestScanServiceList:
"""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."""
service = ScanService(test_db)
service = ScanService(db)
result = service.list_scans(page=1, per_page=20)
@@ -100,13 +93,13 @@ class TestScanServiceList:
assert len(result.items) == 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."""
service = ScanService(test_db)
service = ScanService(db)
# Create 3 scans
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
result = service.list_scans(page=1, per_page=20)
@@ -115,13 +108,13 @@ class TestScanServiceList:
assert len(result.items) == 3
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."""
service = ScanService(test_db)
service = ScanService(db)
# Create 5 scans
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)
result = service.list_scans(page=1, per_page=2)
@@ -141,18 +134,18 @@ class TestScanServiceList:
assert len(result.items) == 1
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."""
service = ScanService(test_db)
service = ScanService(db)
# Create scans with different statuses
scan_id_1 = service.trigger_scan(sample_config_file)
scan_id_2 = service.trigger_scan(sample_config_file)
scan_id_1 = service.trigger_scan(config_id=sample_db_config.id)
scan_id_2 = service.trigger_scan(config_id=sample_db_config.id)
# 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'
test_db.commit()
db.commit()
# Filter by running
result = service.list_scans(status_filter='running')
@@ -162,9 +155,9 @@ class TestScanServiceList:
result = service.list_scans(status_filter='completed')
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."""
service = ScanService(test_db)
service = ScanService(db)
with pytest.raises(ValueError, match="Invalid status"):
service.list_scans(status_filter='invalid_status')
@@ -173,46 +166,46 @@ class TestScanServiceList:
class TestScanServiceDelete:
"""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."""
service = ScanService(test_db)
service = ScanService(db)
with pytest.raises(ValueError, match="not found"):
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."""
service = ScanService(test_db)
service = ScanService(db)
# 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
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
result = service.delete_scan(scan_id)
assert result is True
# 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:
"""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."""
service = ScanService(test_db)
service = ScanService(db)
result = service.get_scan_status(999)
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."""
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)
assert status is not None
@@ -221,16 +214,16 @@ class TestScanServiceStatus:
assert status['progress'] == 'In progress'
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."""
service = ScanService(test_db)
service = ScanService(db)
# Create and mark as completed
scan_id = service.trigger_scan(sample_config_file)
scan = test_db.query(Scan).filter(Scan.id == scan_id).first()
scan_id = service.trigger_scan(config_id=sample_db_config.id)
scan = db.query(Scan).filter(Scan.id == scan_id).first()
scan.status = 'completed'
scan.duration = 125.5
test_db.commit()
db.commit()
status = service.get_scan_status(scan_id)
@@ -242,35 +235,35 @@ class TestScanServiceStatus:
class TestScanServiceDatabaseMapping:
"""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."""
service = ScanService(test_db)
service = ScanService(db)
# 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
service._save_scan_to_db(sample_scan_report, scan_id, status='completed')
# 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.duration == 125.5
# 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 sites[0].site_name == 'Test Site'
# 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 ips[0].ip_address == '192.168.1.10'
assert ips[0].ping_expected is True
assert ips[0].ping_actual is True
# 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
# Verify TCP ports
@@ -285,7 +278,7 @@ class TestScanServiceDatabaseMapping:
assert udp_ports[0].port == 53
# Verify services created
services = test_db.query(ScanServiceModel).filter(
services = db.query(ScanServiceModel).filter(
ScanServiceModel.scan_id == scan_id
).all()
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.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."""
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)
# Check TCP ports
tcp_ports = test_db.query(ScanPort).filter(
tcp_ports = db.query(ScanPort).filter(
ScanPort.scan_id == scan_id,
ScanPort.protocol == 'tcp'
).all()
@@ -322,15 +315,15 @@ class TestScanServiceDatabaseMapping:
# Port 8080 was not 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."""
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)
# Find HTTPS service
https_service = test_db.query(ScanServiceModel).filter(
https_service = db.query(ScanServiceModel).filter(
ScanServiceModel.scan_id == scan_id,
ScanServiceModel.service_name == 'https'
).first()
@@ -363,11 +356,11 @@ class TestScanServiceDatabaseMapping:
assert tls_13 is not None
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."""
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)
# Get full scan details

View File

@@ -13,20 +13,20 @@ from web.models import Schedule, Scan
@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.
Args:
db: Database session fixture
sample_config_file: Path to test config file
sample_db_config: Path to test config file
Returns:
Schedule model instance
"""
schedule = Schedule(
name='Daily Test Scan',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True,
last_run=None,
@@ -68,13 +68,13 @@ class TestScheduleAPIEndpoints:
assert data['schedules'][0]['name'] == sample_schedule.name
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."""
# Create 25 schedules
for i in range(25):
schedule = Schedule(
name=f'Schedule {i}',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True,
created_at=datetime.utcnow()
@@ -101,13 +101,13 @@ class TestScheduleAPIEndpoints:
assert len(data['schedules']) == 10
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."""
# Create enabled and disabled schedules
for i in range(3):
schedule = Schedule(
name=f'Enabled Schedule {i}',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True,
created_at=datetime.utcnow()
@@ -117,7 +117,7 @@ class TestScheduleAPIEndpoints:
for i in range(2):
schedule = Schedule(
name=f'Disabled Schedule {i}',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 3 * * *',
enabled=False,
created_at=datetime.utcnow()
@@ -165,11 +165,11 @@ class TestScheduleAPIEndpoints:
assert 'error' in data
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."""
schedule_data = {
'name': 'New Test Schedule',
'config_file': sample_config_file,
'config_file': sample_db_config,
'cron_expression': '0 3 * * *',
'enabled': True
}
@@ -211,11 +211,11 @@ class TestScheduleAPIEndpoints:
assert 'error' in data
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."""
schedule_data = {
'name': 'Invalid Cron Schedule',
'config_file': sample_config_file,
'config_file': sample_db_config,
'cron_expression': 'invalid cron'
}
@@ -360,13 +360,13 @@ class TestScheduleAPIEndpoints:
data = json.loads(response.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."""
# Create a scan associated with the schedule
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_file=sample_config_file,
config_id=sample_db_config.id,
title='Test Scan',
triggered_by='scheduled',
schedule_id=sample_schedule.id
@@ -409,14 +409,14 @@ class TestScheduleAPIEndpoints:
data = json.loads(response.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."""
# Create some scans for this schedule
for i in range(5):
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_file=sample_config_file,
config_id=sample_db_config.id,
title=f'Scheduled Scan {i}',
triggered_by='scheduled',
schedule_id=sample_schedule.id
@@ -431,12 +431,12 @@ class TestScheduleAPIEndpoints:
assert 'history' in data
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."""
# 1. Create schedule
schedule_data = {
'name': 'Integration Test Schedule',
'config_file': sample_config_file,
'config_file': sample_db_config,
'cron_expression': '0 2 * * *',
'enabled': True
}
@@ -482,14 +482,14 @@ class TestScheduleAPIEndpoints:
scan = db.query(Scan).filter(Scan.id == scan_id).first()
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."""
# Create schedules with different next_run times
schedules = []
for i in range(3):
schedule = Schedule(
name=f'Schedule {i}',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True,
next_run=datetime(2025, 11, 15 + i, 2, 0, 0),
@@ -501,7 +501,7 @@ class TestScheduleAPIEndpoints:
# Create a disabled schedule (next_run is None)
disabled_schedule = Schedule(
name='Disabled Schedule',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 3 * * *',
enabled=False,
next_run=None,
@@ -523,11 +523,11 @@ class TestScheduleAPIEndpoints:
assert returned_schedules[2]['id'] == schedules[2].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."""
schedule_data = {
'name': 'Disabled Schedule',
'config_file': sample_config_file,
'config_file': sample_db_config,
'cron_expression': '0 2 * * *',
'enabled': False
}
@@ -587,7 +587,7 @@ class TestScheduleAPIAuthentication:
class TestScheduleAPICronValidation:
"""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."""
valid_expressions = [
'0 2 * * *', # Daily at 2am
@@ -600,7 +600,7 @@ class TestScheduleAPICronValidation:
for cron_expr in valid_expressions:
schedule_data = {
'name': f'Schedule for {cron_expr}',
'config_file': sample_config_file,
'config_file': sample_db_config,
'cron_expression': cron_expr
}
@@ -612,7 +612,7 @@ class TestScheduleAPICronValidation:
assert response.status_code == 201, \
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."""
invalid_expressions = [
'invalid',
@@ -626,7 +626,7 @@ class TestScheduleAPICronValidation:
for cron_expr in invalid_expressions:
schedule_data = {
'name': f'Schedule for {cron_expr}',
'config_file': sample_config_file,
'config_file': sample_db_config,
'cron_expression': cron_expr
}

View File

@@ -15,13 +15,13 @@ from web.services.schedule_service import ScheduleService
class TestScheduleServiceCreate:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Daily Scan',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -31,57 +31,57 @@ class TestScheduleServiceCreate:
assert isinstance(schedule_id, int)
# 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.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.enabled is True
assert schedule.next_run is not 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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Disabled Scan',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 3 * * *',
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.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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
with pytest.raises(ValueError, match="Invalid cron expression"):
service.create_schedule(
name='Invalid Schedule',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='invalid cron',
enabled=True
)
def test_create_schedule_nonexistent_config(self, test_db):
"""Test creating a schedule with nonexistent config file."""
service = ScheduleService(test_db)
def test_create_schedule_nonexistent_config(self, db):
"""Test creating a schedule with nonexistent config."""
service = ScheduleService(db)
with pytest.raises(ValueError, match="Config file not found"):
with pytest.raises(ValueError, match="not found"):
service.create_schedule(
name='Bad Config',
config_file='/nonexistent/config.yaml',
config_id=99999,
cron_expression='0 2 * * *',
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
cron_expressions = [
'0 0 * * *', # Daily at midnight
@@ -94,7 +94,7 @@ class TestScheduleServiceCreate:
for i, cron in enumerate(cron_expressions):
schedule_id = service.create_schedule(
name=f'Schedule {i}',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression=cron,
enabled=True
)
@@ -104,21 +104,21 @@ class TestScheduleServiceCreate:
class TestScheduleServiceGet:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"):
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
# Create a schedule
schedule_id = service.create_schedule(
name='Test Schedule',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -134,14 +134,14 @@ class TestScheduleServiceGet:
assert 'history' in result
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
# Create schedule
schedule_id = service.create_schedule(
name='Test Schedule',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -151,13 +151,13 @@ class TestScheduleServiceGet:
scan = Scan(
timestamp=datetime.utcnow() - timedelta(days=i),
status='completed',
config_file=sample_config_file,
config_id=sample_db_config.id,
title=f'Scan {i}',
triggered_by='scheduled',
schedule_id=schedule_id
)
test_db.add(scan)
test_db.commit()
db.add(scan)
db.commit()
# Get schedule
result = service.get_schedule(schedule_id)
@@ -169,9 +169,9 @@ class TestScheduleServiceGet:
class TestScheduleServiceList:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
result = service.list_schedules(page=1, per_page=20)
@@ -180,15 +180,15 @@ class TestScheduleServiceList:
assert result['page'] == 1
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
# Create multiple schedules
for i in range(5):
service.create_schedule(
name=f'Schedule {i}',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -199,15 +199,15 @@ class TestScheduleServiceList:
assert len(result['schedules']) == 5
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
# Create 25 schedules
for i in range(25):
service.create_schedule(
name=f'Schedule {i:02d}',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -226,22 +226,22 @@ class TestScheduleServiceList:
result_page3 = service.list_schedules(page=3, per_page=10)
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
# Create enabled and disabled schedules
for i in range(3):
service.create_schedule(
name=f'Enabled {i}',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
for i in range(2):
service.create_schedule(
name=f'Disabled {i}',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=False
)
@@ -262,13 +262,13 @@ class TestScheduleServiceList:
class TestScheduleServiceUpdate:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Old Name',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -278,13 +278,13 @@ class TestScheduleServiceUpdate:
assert result['name'] == 'New Name'
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -302,13 +302,13 @@ class TestScheduleServiceUpdate:
assert result['cron_expression'] == '0 3 * * *'
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -316,67 +316,67 @@ class TestScheduleServiceUpdate:
with pytest.raises(ValueError, match="Invalid cron expression"):
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"):
service.update_schedule(999, name='New Name')
def test_update_schedule_invalid_config_file(self, test_db, sample_config_file):
"""Test updating with nonexistent config file fails."""
service = ScheduleService(test_db)
def test_update_schedule_invalid_config_id(self, db, sample_db_config):
"""Test updating with nonexistent config ID fails."""
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
with pytest.raises(ValueError, match="Config file not found"):
service.update_schedule(schedule_id, config_file='/nonexistent.yaml')
with pytest.raises(ValueError, match="not found"):
service.update_schedule(schedule_id, config_id=99999)
class TestScheduleServiceDelete:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='To Delete',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
# 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
result = service.delete_schedule(schedule_id)
assert result is True
# 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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"):
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
# Create schedule
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -385,20 +385,20 @@ class TestScheduleServiceDelete:
scan = Scan(
timestamp=datetime.utcnow(),
status='completed',
config_file=sample_config_file,
config_id=sample_db_config.id,
title='Test Scan',
triggered_by='scheduled',
schedule_id=schedule_id
)
test_db.add(scan)
test_db.commit()
db.add(scan)
db.commit()
scan_id = scan.id
# Delete schedule
service.delete_schedule(schedule_id)
# 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.schedule_id is None
@@ -406,13 +406,13 @@ class TestScheduleServiceDelete:
class TestScheduleServiceToggle:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -422,13 +422,13 @@ class TestScheduleServiceToggle:
assert result['enabled'] is False
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=False
)
@@ -442,13 +442,13 @@ class TestScheduleServiceToggle:
class TestScheduleServiceRunTimes:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -463,9 +463,9 @@ class TestScheduleServiceRunTimes:
assert schedule['last_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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
with pytest.raises(ValueError, match="Schedule .* not found"):
service.update_run_times(
@@ -478,9 +478,9 @@ class TestScheduleServiceRunTimes:
class TestCronValidation:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
valid_expressions = [
'0 0 * * *', # Daily at midnight
@@ -496,9 +496,9 @@ class TestCronValidation:
assert is_valid is True, f"Expression '{expr}' should be valid"
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
invalid_expressions = [
'invalid',
@@ -518,9 +518,9 @@ class TestCronValidation:
class TestNextRunCalculation:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
# Daily at 2 AM
next_run = service.calculate_next_run('0 2 * * *')
@@ -529,9 +529,9 @@ class TestNextRunCalculation:
assert isinstance(next_run, datetime)
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
base_time = datetime(2025, 1, 1, 0, 0, 0)
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.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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
with pytest.raises(ValueError, match="Invalid cron expression"):
service.calculate_next_run('invalid cron')
@@ -551,13 +551,13 @@ class TestNextRunCalculation:
class TestScheduleHistory:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -565,13 +565,13 @@ class TestScheduleHistory:
history = service.get_schedule_history(schedule_id)
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -581,26 +581,26 @@ class TestScheduleHistory:
scan = Scan(
timestamp=datetime.utcnow() - timedelta(days=i),
status='completed',
config_file=sample_config_file,
config_id=sample_db_config.id,
title=f'Scan {i}',
triggered_by='scheduled',
schedule_id=schedule_id
)
test_db.add(scan)
test_db.commit()
db.add(scan)
db.commit()
# Get history (default limit 10)
history = service.get_schedule_history(schedule_id, limit=10)
assert len(history) == 10
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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -610,13 +610,13 @@ class TestScheduleHistory:
scan = Scan(
timestamp=datetime.utcnow() - timedelta(days=i),
status='completed',
config_file=sample_config_file,
config_id=sample_db_config.id,
title=f'Scan {i}',
triggered_by='scheduled',
schedule_id=schedule_id
)
test_db.add(scan)
test_db.commit()
db.add(scan)
db.commit()
# Get only 5
history = service.get_schedule_history(schedule_id, limit=5)
@@ -626,13 +626,13 @@ class TestScheduleHistory:
class TestScheduleSerialization:
"""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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test Schedule',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)
@@ -642,7 +642,7 @@ class TestScheduleSerialization:
# Verify all required fields
assert 'id' in result
assert 'name' in result
assert 'config_file' in result
assert 'config_id' in result
assert 'cron_expression' in result
assert 'enabled' in result
assert 'last_run' in result
@@ -652,13 +652,13 @@ class TestScheduleSerialization:
assert 'updated_at' 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."""
service = ScheduleService(test_db)
service = ScheduleService(db)
schedule_id = service.create_schedule(
name='Test',
config_file=sample_config_file,
config_id=sample_db_config.id,
cron_expression='0 2 * * *',
enabled=True
)

View File

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

View File

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

View File

@@ -88,7 +88,7 @@ def create_schedule():
Request body:
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 * * *')
enabled: Whether schedule is active (optional, default: true)
@@ -99,7 +99,7 @@ def create_schedule():
data = request.get_json() or {}
# 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]
if missing:
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_id = schedule_service.create_schedule(
name=data['name'],
config_file=data['config_file'],
config_id=data['config_id'],
cron_expression=data['cron_expression'],
enabled=data.get('enabled', True)
)
@@ -121,7 +121,7 @@ def create_schedule():
try:
current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id,
config_file=schedule['config_file'],
config_id=schedule['config_id'],
cron_expression=schedule['cron_expression']
)
logger.info(f"Schedule {schedule_id} added to APScheduler")
@@ -154,7 +154,7 @@ def update_schedule(schedule_id):
Request body:
name: Schedule name (optional)
config_file: Path to YAML config (optional)
config_id: Database config ID (optional)
cron_expression: Cron expression (optional)
enabled: Whether schedule is active (optional)
@@ -181,7 +181,7 @@ def update_schedule(schedule_id):
try:
# If cron expression or config changed, or enabled status changed
cron_changed = 'cron_expression' in data
config_changed = 'config_file' in data
config_changed = 'config_id' in data
enabled_changed = 'enabled' in data
if enabled_changed:
@@ -189,7 +189,7 @@ def update_schedule(schedule_id):
# Re-add to scheduler (replaces existing)
current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id,
config_file=updated_schedule['config_file'],
config_id=updated_schedule['config_id'],
cron_expression=updated_schedule['cron_expression']
)
logger.info(f"Schedule {schedule_id} enabled and added to APScheduler")
@@ -201,7 +201,7 @@ def update_schedule(schedule_id):
# Reload schedule in APScheduler
current_app.scheduler.add_scheduled_scan(
schedule_id=schedule_id,
config_file=updated_schedule['config_file'],
config_id=updated_schedule['config_id'],
cron_expression=updated_schedule['cron_expression']
)
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
scan_id = scan_service.trigger_scan(
config_file=schedule['config_file'],
config_id=schedule['config_id'],
triggered_by='manual',
schedule_id=schedule_id,
scheduler=scheduler

View File

@@ -198,12 +198,12 @@ def scan_history(scan_id):
if not reference_scan:
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 = (
db_session.query(Scan)
.filter(Scan.config_file == config_file)
.filter(Scan.config_id == config_id)
.filter(Scan.status == 'completed')
.order_by(Scan.timestamp.desc())
.limit(limit)
@@ -247,7 +247,7 @@ def scan_history(scan_id):
'scans': scans_data,
'labels': labels,
'port_counts': port_counts,
'config_file': config_file
'config_id': config_id
}), 200
except SQLAlchemyError as e:

View File

@@ -21,7 +21,7 @@ from web.services.alert_service import AlertService
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.
@@ -31,12 +31,9 @@ def execute_scan(scan_id: int, config_file: str = None, config_id: int = None, d
Args:
scan_id: ID of the scan record in database
config_file: Path to YAML configuration file (legacy, optional)
config_id: Database config ID (preferred, optional)
config_id: Database config ID
db_url: Database connection URL
Note: Provide exactly one of config_file or config_id
Workflow:
1. Create new database session for this thread
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
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_desc}")
logger.info(f"Starting background scan execution: scan_id={scan_id}, config_id={config_id}")
# Create new database session for this thread
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()
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
if config_id:
# Use database config
# Initialize scanner with database config
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
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)")
duration = Column(Float, nullable=True, comment="Total scan duration in seconds")
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")
title = Column(Text, nullable=True, comment="Scan title from config")
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)
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")
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?")

View File

@@ -101,22 +101,19 @@ def create_schedule():
Create new schedule form page.
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
configs_dir = '/app/configs'
config_files = []
# Get list of available configs from database
configs = []
try:
if os.path.exists(configs_dir):
config_files = [f for f in os.listdir(configs_dir) if f.endswith('.yaml')]
config_files.sort()
configs = current_app.db_session.query(ScanConfig).order_by(ScanConfig.title).all()
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')

View File

@@ -58,7 +58,7 @@ class AlertService:
for rule in rules:
try:
# 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")
continue
@@ -178,10 +178,10 @@ class AlertService:
"""
alerts_to_create = []
# Find previous scan with same config_file
# Find previous scan with same config_id
previous_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.status == 'completed')
.order_by(Scan.started_at.desc() if Scan.started_at else Scan.timestamp.desc())
@@ -189,7 +189,7 @@ class AlertService:
)
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 []
try:

View File

@@ -654,22 +654,12 @@ class ConfigService:
# Build full path for comparison
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 = []
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
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:
@@ -841,18 +831,9 @@ class ConfigService:
# Build full path for comparison
config_path = os.path.join(self.configs_dir, filename)
# Find schedules using this config (only enabled schedules)
using_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:
# Only count enabled schedules
if schedule.get('enabled', False):
using_schedules.append(schedule.get('name', 'Unknown'))
return using_schedules
# Note: This function is deprecated. Schedules now use config_id.
# Return empty list as schedules no longer use config_file.
return []
except ImportError:
# If ScheduleService doesn't exist yet, return empty list

View File

@@ -19,7 +19,7 @@ from web.models import (
ScanCertificate, ScanTLSVersion, Site, ScanSiteAssociation
)
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__)
@@ -41,7 +41,7 @@ class ScanService:
"""
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,
scheduler=None) -> int:
"""
@@ -51,8 +51,7 @@ class ScanService:
queues the scan for background execution.
Args:
config_file: Path to YAML configuration file (legacy, optional)
config_id: Database config ID (preferred, optional)
config_id: Database config ID
triggered_by: Source that triggered scan (manual, scheduled, api)
schedule_id: Optional schedule ID if triggered by schedule
scheduler: Optional SchedulerService instance for queuing background jobs
@@ -61,14 +60,8 @@ class ScanService:
Scan ID of the created scan
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
# Validate config exists
@@ -110,58 +103,6 @@ class ScanService:
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]]:
"""
Get scan details with all related data.
@@ -614,7 +555,7 @@ class ScanService:
'duration': scan.duration,
'status': scan.status,
'title': scan.title,
'config_file': scan.config_file,
'config_id': scan.config_id,
'json_path': scan.json_path,
'html_path': scan.html_path,
'zip_path': scan.zip_path,
@@ -640,7 +581,7 @@ class ScanService:
'duration': scan.duration,
'status': scan.status,
'title': scan.title,
'config_file': scan.config_file,
'config_id': scan.config_id,
'triggered_by': scan.triggered_by,
'created_at': scan.created_at.isoformat() if scan.created_at else None
}
@@ -783,17 +724,17 @@ class ScanService:
return None
# Check if scans use the same configuration
config1 = scan1.get('config_file', '')
config2 = scan2.get('config_file', '')
same_config = (config1 == config2) and (config1 != '')
config1 = scan1.get('config_id')
config2 = scan2.get('config_id')
same_config = (config1 == config2) and (config1 is not None)
# Generate warning message if configs differ
config_warning = None
if not same_config:
config_warning = (
f"These scans use different configurations. "
f"Scan #{scan1_id} used '{config1 or 'unknown'}' and "
f"Scan #{scan2_id} used '{config2 or 'unknown'}'. "
f"Scan #{scan1_id} used config_id={config1 or 'unknown'} and "
f"Scan #{scan2_id} used config_id={config2 or 'unknown'}. "
f"The comparison may show all changes as additions/removals if the scans "
f"cover different IP ranges or infrastructure."
)
@@ -832,14 +773,14 @@ class ScanService:
'timestamp': scan1['timestamp'],
'title': scan1['title'],
'status': scan1['status'],
'config_file': config1
'config_id': config1
},
'scan2': {
'id': scan2['id'],
'timestamp': scan2['timestamp'],
'title': scan2['title'],
'status': scan2['status'],
'config_file': config2
'config_id': config2
},
'same_config': same_config,
'config_warning': config_warning,

View File

@@ -6,14 +6,13 @@ scheduled scans with cron expressions.
"""
import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from croniter import croniter
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
logger = logging.getLogger(__name__)
@@ -39,7 +38,7 @@ class ScheduleService:
def create_schedule(
self,
name: str,
config_file: str,
config_id: int,
cron_expression: str,
enabled: bool = True
) -> int:
@@ -48,7 +47,7 @@ class ScheduleService:
Args:
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 * * *')
enabled: Whether schedule is active
@@ -56,22 +55,17 @@ class ScheduleService:
Schedule ID of the created schedule
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
is_valid, error_msg = self.validate_cron_expression(cron_expression)
if not is_valid:
raise ValueError(f"Invalid cron expression: {error_msg}")
# Validate config file exists
# If config_file is just a filename, prepend the configs directory
if not config_file.startswith('/'):
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: {config_file}")
# Validate config exists
db_config = self.db.query(ScanConfig).filter_by(id=config_id).first()
if not db_config:
raise ValueError(f"Config with ID {config_id} not found")
# Calculate next run time
next_run = self.calculate_next_run(cron_expression) if enabled else None
@@ -79,7 +73,7 @@ class ScheduleService:
# Create schedule record
schedule = Schedule(
name=name,
config_file=config_file,
config_id=config_id,
cron_expression=cron_expression,
enabled=enabled,
last_run=None,
@@ -200,17 +194,11 @@ class ScheduleService:
if schedule.enabled or updates.get('enabled', False):
updates['next_run'] = self.calculate_next_run(updates['cron_expression'])
# Validate config file if being updated
if 'config_file' in updates:
config_file = updates['config_file']
# If config_file is just a filename, prepend the configs directory
if not config_file.startswith('/'):
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']}")
# Validate config_id if being updated
if 'config_id' in updates:
db_config = self.db.query(ScanConfig).filter_by(id=updates['config_id']).first()
if not db_config:
raise ValueError(f"Config with ID {updates['config_id']} not found")
# Handle enabled toggle
if 'enabled' in updates:
@@ -400,7 +388,7 @@ class ScheduleService:
'timestamp': scan.timestamp.isoformat() if scan.timestamp else None,
'status': scan.status,
'title': scan.title,
'config_file': scan.config_file
'config_id': scan.config_id
}
for scan in scans
]
@@ -418,7 +406,7 @@ class ScheduleService:
return {
'id': schedule.id,
'name': schedule.name,
'config_file': schedule.config_file,
'config_id': schedule.config_id,
'cron_expression': schedule.cron_expression,
'enabled': schedule.enabled,
'last_run': schedule.last_run.isoformat() if schedule.last_run else None,

View File

@@ -131,7 +131,7 @@ class SchedulerService:
try:
self.add_scheduled_scan(
schedule_id=schedule.id,
config_file=schedule.config_file,
config_id=schedule.config_id,
cron_expression=schedule.cron_expression
)
logger.info(f"Loaded schedule {schedule.id}: '{schedule.name}'")
@@ -149,16 +149,13 @@ class SchedulerService:
except Exception as e:
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.
Args:
scan_id: Database ID of the scan
config_file: Path to YAML configuration file (legacy, optional)
config_id: Database config ID (preferred, optional)
Note: Provide exactly one of config_file or config_id
config_id: Database config ID
Returns:
Job ID from APScheduler
@@ -172,7 +169,7 @@ class SchedulerService:
# Add job to run immediately
job = self.scheduler.add_job(
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}',
name=f'Scan {scan_id}',
replace_existing=True,
@@ -182,14 +179,14 @@ class SchedulerService:
logger.info(f"Queued scan {scan_id} for background execution (job_id={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:
"""
Add a recurring scheduled scan.
Args:
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)
Returns:
@@ -286,14 +283,14 @@ class SchedulerService:
# Create and trigger scan
scan_service = ScanService(session)
scan_id = scan_service.trigger_scan(
config_file=schedule['config_file'],
config_id=schedule['config_id'],
triggered_by='scheduled',
schedule_id=schedule_id,
scheduler=None # Don't pass scheduler to avoid recursion
)
# 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
from croniter import croniter

View File

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

View File

@@ -375,12 +375,12 @@
document.getElementById('scan1-id').textContent = data.scan1.id;
document.getElementById('scan1-title').textContent = data.scan1.title || 'Untitled Scan';
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-title').textContent = data.scan2.title || 'Untitled Scan';
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
populatePortsComparison(data.ports);

View File

@@ -218,7 +218,7 @@
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-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
let statusBadge = '';
@@ -449,13 +449,13 @@
// Find previous scan and show compare button
let previousScanId = null;
let currentConfigFile = null;
let currentConfigId = null;
async function findPreviousScan() {
try {
// Get current scan details first to know which config it used
const currentScanResponse = await fetch(`/api/scans/${scanId}`);
const currentScanData = await currentScanResponse.json();
currentConfigFile = currentScanData.config_file;
currentConfigId = currentScanData.config_id;
// Get list of completed scans
const response = await fetch('/api/scans?per_page=100&status=completed');
@@ -466,12 +466,12 @@
const currentScanIndex = data.scans.findIndex(s => s.id === scanId);
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++) {
const previousScan = data.scans[i];
// Check if this scan uses the same config
if (previousScan.config_file === currentConfigFile) {
if (previousScan.config_id === currentConfigId) {
previousScanId = previousScan.id;
// Show the compare button

View File

@@ -32,13 +32,13 @@
<small class="form-text text-muted">A descriptive name for this schedule</small>
</div>
<!-- Config File -->
<!-- Config -->
<div class="mb-3">
<label for="config-file" class="form-label">Configuration File <span class="text-danger">*</span></label>
<select class="form-select" id="config-file" name="config_file" required>
<option value="">Select a configuration file...</option>
{% for config in config_files %}
<option value="{{ config }}">{{ config }}</option>
<label for="config-id" class="form-label">Configuration <span class="text-danger">*</span></label>
<select class="form-select" id="config-id" name="config_id" required>
<option value="">Select a configuration...</option>
{% for config in configs %}
<option value="{{ config.id }}">{{ config.title }}</option>
{% endfor %}
</select>
<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
const formData = {
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(),
enabled: document.getElementById('schedule-enabled').checked
};
// 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');
return;
}

View File

@@ -298,7 +298,7 @@ async function loadSchedule() {
function populateForm(schedule) {
document.getElementById('schedule-id').value = schedule.id;
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('schedule-enabled').checked = schedule.enabled;

View File

@@ -198,7 +198,7 @@ function renderSchedules() {
<td>
<strong>${escapeHtml(schedule.name)}</strong>
<br>
<small class="text-muted">${escapeHtml(schedule.config_file)}</small>
<small class="text-muted">Config ID: ${schedule.config_id || 'N/A'}</small>
</td>
<td class="mono"><code>${escapeHtml(schedule.cron_expression)}</code></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]]:
"""
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:
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,
"status": "completed",
"title": "Production Network Scan",
"config_file": "/app/configs/production.yaml",
"config_id": "/app/configs/production.yaml",
"triggered_by": "manual",
"started_at": "2025-11-14T10:30:00Z",
"completed_at": "2025-11-14T10:32:05Z"
@@ -731,7 +731,7 @@ Retrieve a paginated list of scans with optional status filtering.
"duration": 98.2,
"status": "completed",
"title": "Development Network Scan",
"config_file": "/app/configs/dev.yaml",
"config_id": "/app/configs/dev.yaml",
"triggered_by": "scheduled",
"started_at": "2025-11-13T15:00:00Z",
"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,
"status": "completed",
"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",
"html_path": "/app/output/scan_report_20251114_103000.html",
"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,
"name": "Daily Production Scan",
"config_file": "/app/configs/prod-scan.yaml",
"config_id": "/app/configs/prod-scan.yaml",
"cron_expression": "0 2 * * *",
"enabled": true,
"created_at": "2025-11-01T10:00:00Z",
@@ -1157,7 +1157,7 @@ Retrieve details for a specific schedule including execution history.
{
"id": 1,
"name": "Daily Production Scan",
"config_file": "/app/configs/prod-scan.yaml",
"config_id": "/app/configs/prod-scan.yaml",
"cron_expression": "0 2 * * *",
"enabled": true,
"created_at": "2025-11-01T10:00:00Z",
@@ -1201,7 +1201,7 @@ Create a new scheduled scan.
```json
{
"name": "Daily Production Scan",
"config_file": "/app/configs/prod-scan.yaml",
"config_id": "/app/configs/prod-scan.yaml",
"cron_expression": "0 2 * * *",
"enabled": true
}
@@ -1215,7 +1215,7 @@ Create a new scheduled scan.
"schedule": {
"id": 1,
"name": "Daily Production Scan",
"config_file": "/app/configs/prod-scan.yaml",
"config_id": "/app/configs/prod-scan.yaml",
"cron_expression": "0 2 * * *",
"enabled": true,
"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:
```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
curl -X POST http://localhost:5000/api/schedules \
-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
```
@@ -1270,7 +1270,7 @@ Update an existing schedule.
"schedule": {
"id": 1,
"name": "Updated Schedule Name",
"config_file": "/app/configs/prod-scan.yaml",
"config_id": "/app/configs/prod-scan.yaml",
"cron_expression": "0 3 * * *",
"enabled": false,
"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"],
"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",
"scan_time": "2025-01-15T10:30:00Z",
"scan_duration": 95.3,
"config_file": "/app/configs/example-site.yaml",
"config_id": "/app/configs/example-site.yaml",
"sites": [
{
"name": "Site Name",

View File

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

View File

@@ -46,7 +46,7 @@ Stores metadata about each scan execution.
| `timestamp` | DATETIME | Scan start time (UTC) |
| `duration` | FLOAT | Total scan duration (seconds) |
| `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 |
| `json_path` | TEXT | Path to JSON report |
| `html_path` | TEXT | Path to HTML report |
@@ -144,7 +144,7 @@ Scheduled scan configurations.
|--------|------|-------------|
| `id` | INTEGER PRIMARY KEY | Unique schedule ID |
| `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 * * *`) |
| `enabled` | BOOLEAN | Is schedule active? |
| `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/{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" }` |
| `GET` | `/api/scans/{id}/status` | Get scan status | - | `{ "status": "running", "progress": "45%" }` |
| `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/{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" }` |
| `DELETE` | `/api/schedules/{id}` | Delete schedule | - | `{ "status": "deleted" }` |
| `POST` | `/api/schedules/{id}/trigger` | Manually trigger scheduled scan | - | `{ "scan_id": N }` |
@@ -438,7 +438,7 @@ alert_rules:
"scan_id": 123,
"title": "Production Network Scan",
"timestamp": "2025-11-17T14:15:00Z",
"config_file": "prod_config.yaml",
"config_id": "prod_config.yaml",
"triggered_by": "scheduled"
},
"alert_details": {