Implemented all 5 scan management endpoints with comprehensive error handling, logging, and integration tests. ## Changes ### API Endpoints (web/api/scans.py) - POST /api/scans - Trigger new scan with config file validation - GET /api/scans - List scans with pagination and status filtering - GET /api/scans/<id> - Retrieve scan details with all relationships - DELETE /api/scans/<id> - Delete scan and associated files - GET /api/scans/<id>/status - Poll scan status for long-running scans ### Features - Comprehensive error handling (400, 404, 500) - Structured logging with appropriate levels - Input validation via validators - Consistent JSON error format - SQLAlchemy error handling with graceful degradation - HTTP status codes following REST conventions ### Testing (tests/test_scan_api.py) - 24 integration tests covering all endpoints - Empty/populated scan lists - Pagination with multiple pages - Status filtering - Error scenarios (invalid input, not found, etc.) - Complete workflow integration test ### Test Infrastructure (tests/conftest.py) - Flask app fixture with test database - Flask test client fixture - Database session fixture compatible with app context - Sample scan fixture for testing ### Documentation (docs/ai/PHASE2.md) - Updated progress: 4/14 days complete (29%) - Marked Step 2 as complete - Added implementation details and testing results ## Implementation Notes - All endpoints use ScanService for business logic separation - Scan triggering returns immediately; client polls status endpoint - Background job execution will be added in Step 3 - Authentication will be added in Step 4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
286 lines
8.8 KiB
Python
286 lines
8.8 KiB
Python
"""
|
|
Pytest configuration and fixtures for SneakyScanner tests.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import yaml
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from web.app import create_app
|
|
from web.models import Base, Scan
|
|
|
|
|
|
@pytest.fixture(scope='function')
|
|
def test_db():
|
|
"""
|
|
Create a temporary test database.
|
|
|
|
Yields a SQLAlchemy session for testing, then cleans up.
|
|
"""
|
|
# Create temporary database file
|
|
db_fd, db_path = tempfile.mkstemp(suffix='.db')
|
|
|
|
# Create engine and session
|
|
engine = create_engine(f'sqlite:///{db_path}', echo=False)
|
|
Base.metadata.create_all(engine)
|
|
|
|
Session = sessionmaker(bind=engine)
|
|
session = Session()
|
|
|
|
yield session
|
|
|
|
# Cleanup
|
|
session.close()
|
|
os.close(db_fd)
|
|
os.unlink(db_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_scan_report():
|
|
"""
|
|
Sample scan report matching the structure from scanner.py.
|
|
|
|
Returns a dictionary representing a typical scan output.
|
|
"""
|
|
return {
|
|
'title': 'Test Scan',
|
|
'scan_time': '2025-11-14T10:30:00Z',
|
|
'scan_duration': 125.5,
|
|
'config_file': '/app/configs/test.yaml',
|
|
'sites': [
|
|
{
|
|
'name': 'Test Site',
|
|
'ips': [
|
|
{
|
|
'address': '192.168.1.10',
|
|
'expected': {
|
|
'ping': True,
|
|
'tcp_ports': [22, 80, 443],
|
|
'udp_ports': [53],
|
|
'services': []
|
|
},
|
|
'actual': {
|
|
'ping': True,
|
|
'tcp_ports': [22, 80, 443, 8080],
|
|
'udp_ports': [53],
|
|
'services': [
|
|
{
|
|
'port': 22,
|
|
'service': 'ssh',
|
|
'product': 'OpenSSH',
|
|
'version': '8.9p1',
|
|
'extrainfo': 'Ubuntu',
|
|
'ostype': 'Linux'
|
|
},
|
|
{
|
|
'port': 443,
|
|
'service': 'https',
|
|
'product': 'nginx',
|
|
'version': '1.24.0',
|
|
'extrainfo': '',
|
|
'ostype': '',
|
|
'http_info': {
|
|
'protocol': 'https',
|
|
'screenshot': 'screenshots/192_168_1_10_443.png',
|
|
'certificate': {
|
|
'subject': 'CN=example.com',
|
|
'issuer': 'CN=Let\'s Encrypt Authority',
|
|
'serial_number': '123456789',
|
|
'not_valid_before': '2025-01-01T00:00:00Z',
|
|
'not_valid_after': '2025-12-31T23:59:59Z',
|
|
'days_until_expiry': 365,
|
|
'sans': ['example.com', 'www.example.com'],
|
|
'is_self_signed': False,
|
|
'tls_versions': {
|
|
'TLS 1.2': {
|
|
'supported': True,
|
|
'cipher_suites': [
|
|
'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384',
|
|
'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256'
|
|
]
|
|
},
|
|
'TLS 1.3': {
|
|
'supported': True,
|
|
'cipher_suites': [
|
|
'TLS_AES_256_GCM_SHA384',
|
|
'TLS_AES_128_GCM_SHA256'
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
{
|
|
'port': 80,
|
|
'service': 'http',
|
|
'product': 'nginx',
|
|
'version': '1.24.0',
|
|
'extrainfo': '',
|
|
'ostype': '',
|
|
'http_info': {
|
|
'protocol': 'http',
|
|
'screenshot': 'screenshots/192_168_1_10_80.png'
|
|
}
|
|
},
|
|
{
|
|
'port': 8080,
|
|
'service': 'http',
|
|
'product': 'Jetty',
|
|
'version': '9.4.48',
|
|
'extrainfo': '',
|
|
'ostype': ''
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_config_file(tmp_path):
|
|
"""
|
|
Create a sample YAML config file for testing.
|
|
|
|
Args:
|
|
tmp_path: pytest temporary directory fixture
|
|
|
|
Returns:
|
|
Path to created config file
|
|
"""
|
|
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']
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
config_file = tmp_path / 'test_config.yaml'
|
|
with open(config_file, 'w') as f:
|
|
yaml.dump(config_data, f)
|
|
|
|
return str(config_file)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_invalid_config_file(tmp_path):
|
|
"""
|
|
Create an invalid config file for testing validation.
|
|
|
|
Returns:
|
|
Path to invalid config file
|
|
"""
|
|
config_file = tmp_path / 'invalid_config.yaml'
|
|
with open(config_file, 'w') as f:
|
|
f.write("invalid: yaml: content: [missing closing bracket")
|
|
|
|
return str(config_file)
|
|
|
|
|
|
@pytest.fixture(scope='function')
|
|
def app():
|
|
"""
|
|
Create Flask application for testing.
|
|
|
|
Returns:
|
|
Configured Flask app instance with test database
|
|
"""
|
|
# Create temporary database
|
|
db_fd, db_path = tempfile.mkstemp(suffix='.db')
|
|
|
|
# Create app with test config
|
|
test_config = {
|
|
'TESTING': True,
|
|
'SQLALCHEMY_DATABASE_URI': f'sqlite:///{db_path}',
|
|
'SECRET_KEY': 'test-secret-key'
|
|
}
|
|
|
|
app = create_app(test_config)
|
|
|
|
yield app
|
|
|
|
# Cleanup
|
|
os.close(db_fd)
|
|
os.unlink(db_path)
|
|
|
|
|
|
@pytest.fixture(scope='function')
|
|
def client(app):
|
|
"""
|
|
Create Flask test client.
|
|
|
|
Args:
|
|
app: Flask application fixture
|
|
|
|
Returns:
|
|
Flask test client for making API requests
|
|
"""
|
|
return app.test_client()
|
|
|
|
|
|
@pytest.fixture(scope='function')
|
|
def db(app):
|
|
"""
|
|
Alias for database session that works with Flask app context.
|
|
|
|
Args:
|
|
app: Flask application fixture
|
|
|
|
Returns:
|
|
SQLAlchemy session
|
|
"""
|
|
with app.app_context():
|
|
yield app.db_session
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_scan(db):
|
|
"""
|
|
Create a sample scan in the database for testing.
|
|
|
|
Args:
|
|
db: Database session fixture
|
|
|
|
Returns:
|
|
Scan model instance
|
|
"""
|
|
scan = Scan(
|
|
timestamp=datetime.utcnow(),
|
|
status='completed',
|
|
config_file='/app/configs/test.yaml',
|
|
title='Test Scan',
|
|
duration=125.5,
|
|
triggered_by='test',
|
|
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',
|
|
screenshot_dir='/app/output/scan_report_20251114_103000_screenshots'
|
|
)
|
|
|
|
db.add(scan)
|
|
db.commit()
|
|
db.refresh(scan)
|
|
|
|
return scan
|