mirror of
https://github.com/smittix/intercept.git
synced 2026-06-11 07:23:30 -07:00
Add distributed agent architecture for multi-node signal intelligence
Features: - Standalone agent server (intercept_agent.py) for remote sensor nodes - Controller API blueprint for agent management and data aggregation - Push mechanism for agents to send data to controller - Pull mechanism for controller to proxy requests to agents - Multi-agent SSE stream for combined data view - Agent management page at /controller/manage - Agent selector dropdown in main UI - GPS integration for location tagging - API key authentication for secure agent communication - Integration with Intercept's dependency checking system New files: - intercept_agent.py: Remote agent HTTP server - intercept_agent.cfg: Agent configuration template - routes/controller.py: Controller API endpoints - utils/agent_client.py: HTTP client for agents - utils/trilateration.py: Multi-agent position calculation - static/js/core/agents.js: Frontend agent management - templates/agents.html: Agent management page - docs/DISTRIBUTED_AGENTS.md: System documentation Modified: - app.py: Register controller blueprint - utils/database.py: Add agents and push_payloads tables - templates/index.html: Add agent selector section
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mock Intercept Agent for development and testing.
|
||||
|
||||
This provides a simulated agent that generates fake data for testing
|
||||
the controller without needing actual SDR hardware.
|
||||
|
||||
Usage:
|
||||
python tests/mock_agent.py [--port 8021] [--name mock-agent-1]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import string
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# State
|
||||
running_modes: set[str] = set()
|
||||
start_time = time.time()
|
||||
agent_name = "mock-agent-1"
|
||||
|
||||
# Simulated data generators
|
||||
def generate_aircraft() -> list[dict]:
|
||||
"""Generate fake ADS-B aircraft data."""
|
||||
aircraft = []
|
||||
for _ in range(random.randint(3, 10)):
|
||||
icao = ''.join(random.choices(string.hexdigits.upper()[:6], k=6))
|
||||
callsign = random.choice(['UAL', 'DAL', 'AAL', 'SWA', 'JBU']) + str(random.randint(100, 9999))
|
||||
aircraft.append({
|
||||
'icao': icao,
|
||||
'callsign': callsign,
|
||||
'altitude': random.randint(5000, 45000),
|
||||
'speed': random.randint(200, 550),
|
||||
'heading': random.randint(0, 359),
|
||||
'lat': round(40.0 + random.uniform(-2, 2), 4),
|
||||
'lon': round(-74.0 + random.uniform(-2, 2), 4),
|
||||
'vertical_rate': random.randint(-2000, 2000),
|
||||
'squawk': str(random.randint(1000, 7777)),
|
||||
'last_seen': datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
return aircraft
|
||||
|
||||
|
||||
def generate_sensors() -> list[dict]:
|
||||
"""Generate fake 433MHz sensor data."""
|
||||
sensors = []
|
||||
models = ['Acurite-Tower', 'Oregon-THGR122N', 'LaCrosse-TX141W', 'Ambient-F007TH']
|
||||
for i in range(random.randint(2, 5)):
|
||||
sensors.append({
|
||||
'time': datetime.now(timezone.utc).isoformat(),
|
||||
'model': random.choice(models),
|
||||
'id': random.randint(1, 255),
|
||||
'channel': random.randint(1, 3),
|
||||
'temperature_C': round(random.uniform(-10, 35), 1),
|
||||
'humidity': random.randint(20, 95),
|
||||
'battery_ok': random.choice([0, 1])
|
||||
})
|
||||
return sensors
|
||||
|
||||
|
||||
def generate_wifi_networks() -> list[dict]:
|
||||
"""Generate fake WiFi network data."""
|
||||
networks = []
|
||||
ssids = ['HomeNetwork', 'Linksys', 'NETGEAR', 'xfinitywifi', 'ATT-WIFI', 'CoffeeShop-Guest']
|
||||
for ssid in random.sample(ssids, random.randint(3, 6)):
|
||||
bssid = ':'.join(['%02X' % random.randint(0, 255) for _ in range(6)])
|
||||
networks.append({
|
||||
'ssid': ssid,
|
||||
'bssid': bssid,
|
||||
'channel': random.choice([1, 6, 11, 36, 40, 44, 48]),
|
||||
'signal': random.randint(-80, -30),
|
||||
'encryption': random.choice(['WPA2', 'WPA3', 'WEP', 'Open']),
|
||||
'clients': random.randint(0, 10),
|
||||
'last_seen': datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
return networks
|
||||
|
||||
|
||||
def generate_bluetooth_devices() -> list[dict]:
|
||||
"""Generate fake Bluetooth device data."""
|
||||
devices = []
|
||||
names = ['iPhone', 'Galaxy S21', 'AirPods', 'Tile Tracker', 'Fitbit', 'Unknown']
|
||||
for _ in range(random.randint(2, 8)):
|
||||
mac = ':'.join(['%02X' % random.randint(0, 255) for _ in range(6)])
|
||||
devices.append({
|
||||
'address': mac,
|
||||
'name': random.choice(names),
|
||||
'rssi': random.randint(-90, -40),
|
||||
'type': random.choice(['LE', 'Classic', 'Dual']),
|
||||
'manufacturer': random.choice(['Apple', 'Samsung', 'Unknown']),
|
||||
'last_seen': datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
return devices
|
||||
|
||||
|
||||
def generate_vessels() -> list[dict]:
|
||||
"""Generate fake AIS vessel data."""
|
||||
vessels = []
|
||||
vessel_names = ['EVERGREEN', 'MAERSK WINNER', 'OOCL HONG KONG', 'MSC GULSUN', 'CMA CGM MARCO POLO']
|
||||
for name in random.sample(vessel_names, random.randint(2, 4)):
|
||||
mmsi = str(random.randint(200000000, 800000000))
|
||||
vessels.append({
|
||||
'mmsi': mmsi,
|
||||
'name': name,
|
||||
'callsign': ''.join(random.choices(string.ascii_uppercase, k=5)),
|
||||
'ship_type': random.choice(['Cargo', 'Tanker', 'Passenger', 'Fishing']),
|
||||
'lat': round(40.5 + random.uniform(-0.5, 0.5), 4),
|
||||
'lon': round(-73.9 + random.uniform(-0.5, 0.5), 4),
|
||||
'speed': round(random.uniform(0, 25), 1),
|
||||
'course': random.randint(0, 359),
|
||||
'destination': random.choice(['NEW YORK', 'NEWARK', 'BALTIMORE', 'BOSTON']),
|
||||
'last_seen': datetime.now(timezone.utc).isoformat()
|
||||
})
|
||||
return vessels
|
||||
|
||||
|
||||
# Data snapshot storage
|
||||
data_snapshots: dict[str, list] = {}
|
||||
|
||||
|
||||
def update_data_snapshot(mode: str):
|
||||
"""Update data snapshot for a mode."""
|
||||
if mode == 'adsb':
|
||||
data_snapshots[mode] = generate_aircraft()
|
||||
elif mode == 'sensor':
|
||||
data_snapshots[mode] = generate_sensors()
|
||||
elif mode == 'wifi':
|
||||
data_snapshots[mode] = generate_wifi_networks()
|
||||
elif mode == 'bluetooth':
|
||||
data_snapshots[mode] = generate_bluetooth_devices()
|
||||
elif mode == 'ais':
|
||||
data_snapshots[mode] = generate_vessels()
|
||||
else:
|
||||
data_snapshots[mode] = []
|
||||
|
||||
|
||||
# Background data generation threads
|
||||
data_threads: dict[str, threading.Event] = {}
|
||||
|
||||
|
||||
def data_generator_loop(mode: str, stop_event: threading.Event):
|
||||
"""Background loop to generate data periodically."""
|
||||
while not stop_event.is_set():
|
||||
update_data_snapshot(mode)
|
||||
stop_event.wait(random.uniform(2, 5))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Routes
|
||||
# =============================================================================
|
||||
|
||||
@app.route('/capabilities')
|
||||
def capabilities():
|
||||
"""Return mock capabilities."""
|
||||
return jsonify({
|
||||
'modes': {
|
||||
'pager': True,
|
||||
'sensor': True,
|
||||
'adsb': True,
|
||||
'ais': True,
|
||||
'acars': True,
|
||||
'aprs': True,
|
||||
'wifi': True,
|
||||
'bluetooth': True,
|
||||
'dsc': True,
|
||||
'rtlamr': True,
|
||||
'tscm': True,
|
||||
'satellite': True,
|
||||
'listening_post': True
|
||||
},
|
||||
'devices': [
|
||||
{'index': 0, 'name': 'Mock RTL-SDR', 'type': 'rtlsdr', 'serial': 'MOCK001'}
|
||||
],
|
||||
'agent_version': '1.0.0-mock'
|
||||
})
|
||||
|
||||
|
||||
@app.route('/status')
|
||||
def status():
|
||||
"""Return agent status."""
|
||||
return jsonify({
|
||||
'running_modes': list(running_modes),
|
||||
'uptime': time.time() - start_time,
|
||||
'push_enabled': False,
|
||||
'push_connected': False
|
||||
})
|
||||
|
||||
|
||||
@app.route('/health')
|
||||
def health():
|
||||
"""Health check."""
|
||||
return jsonify({'status': 'healthy', 'version': '1.0.0-mock'})
|
||||
|
||||
|
||||
@app.route('/config', methods=['GET', 'POST'])
|
||||
def config():
|
||||
"""Config endpoint."""
|
||||
if request.method == 'POST':
|
||||
return jsonify({'status': 'updated', 'config': {}})
|
||||
return jsonify({
|
||||
'name': agent_name,
|
||||
'port': request.environ.get('SERVER_PORT', 8021),
|
||||
'push_enabled': False,
|
||||
'modes_enabled': {m: True for m in [
|
||||
'pager', 'sensor', 'adsb', 'ais', 'wifi', 'bluetooth'
|
||||
]}
|
||||
})
|
||||
|
||||
|
||||
@app.route('/<mode>/start', methods=['POST'])
|
||||
def start_mode(mode: str):
|
||||
"""Start a mode."""
|
||||
if mode in running_modes:
|
||||
return jsonify({'status': 'error', 'message': f'{mode} already running'}), 409
|
||||
|
||||
running_modes.add(mode)
|
||||
|
||||
# Start data generation thread
|
||||
stop_event = threading.Event()
|
||||
data_threads[mode] = stop_event
|
||||
thread = threading.Thread(target=data_generator_loop, args=(mode, stop_event))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
# Generate initial data
|
||||
update_data_snapshot(mode)
|
||||
|
||||
return jsonify({'status': 'started', 'mode': mode})
|
||||
|
||||
|
||||
@app.route('/<mode>/stop', methods=['POST'])
|
||||
def stop_mode(mode: str):
|
||||
"""Stop a mode."""
|
||||
if mode not in running_modes:
|
||||
return jsonify({'status': 'not_running'})
|
||||
|
||||
running_modes.discard(mode)
|
||||
|
||||
# Stop data generation thread
|
||||
if mode in data_threads:
|
||||
data_threads[mode].set()
|
||||
del data_threads[mode]
|
||||
|
||||
# Clear data
|
||||
if mode in data_snapshots:
|
||||
del data_snapshots[mode]
|
||||
|
||||
return jsonify({'status': 'stopped', 'mode': mode})
|
||||
|
||||
|
||||
@app.route('/<mode>/status')
|
||||
def mode_status(mode: str):
|
||||
"""Get mode status."""
|
||||
return jsonify({
|
||||
'running': mode in running_modes,
|
||||
'data_count': len(data_snapshots.get(mode, []))
|
||||
})
|
||||
|
||||
|
||||
@app.route('/<mode>/data')
|
||||
def mode_data(mode: str):
|
||||
"""Get current data snapshot."""
|
||||
# Generate fresh data if mode is running but no snapshot exists
|
||||
if mode in running_modes and mode not in data_snapshots:
|
||||
update_data_snapshot(mode)
|
||||
|
||||
return jsonify({
|
||||
'mode': mode,
|
||||
'data': data_snapshots.get(mode, []),
|
||||
'timestamp': datetime.now(timezone.utc).isoformat(),
|
||||
'agent_name': agent_name
|
||||
})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main
|
||||
# =============================================================================
|
||||
|
||||
def main():
|
||||
global agent_name, start_time
|
||||
|
||||
parser = argparse.ArgumentParser(description='Mock Intercept Agent')
|
||||
parser.add_argument('--port', '-p', type=int, default=8021, help='Port (default: 8021)')
|
||||
parser.add_argument('--name', '-n', default='mock-agent-1', help='Agent name')
|
||||
parser.add_argument('--debug', action='store_true', help='Enable debug mode')
|
||||
|
||||
args = parser.parse_args()
|
||||
agent_name = args.name
|
||||
start_time = time.time()
|
||||
|
||||
print("=" * 60)
|
||||
print(" MOCK INTERCEPT AGENT")
|
||||
print(" For development and testing")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print(f" Agent Name: {agent_name}")
|
||||
print(f" Port: {args.port}")
|
||||
print()
|
||||
print(" Available modes: all (simulated data)")
|
||||
print()
|
||||
print(f" Listening on http://0.0.0.0:{args.port}")
|
||||
print()
|
||||
print(" Press Ctrl+C to stop")
|
||||
print()
|
||||
|
||||
app.run(host='0.0.0.0', port=args.port, debug=args.debug)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,648 @@
|
||||
"""
|
||||
Tests for Intercept Agent components.
|
||||
|
||||
Tests cover:
|
||||
- AgentConfig parsing
|
||||
- AgentClient HTTP operations
|
||||
- Database agent CRUD operations
|
||||
- GPS integration
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
import tempfile
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.agent_client import (
|
||||
AgentClient, AgentHTTPError, AgentConnectionError, create_client_from_agent
|
||||
)
|
||||
from utils.database import (
|
||||
init_db, get_db_path, create_agent, get_agent, get_agent_by_name,
|
||||
list_agents, update_agent, delete_agent, store_push_payload,
|
||||
get_recent_payloads, cleanup_old_payloads
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AgentConfig Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAgentConfig:
|
||||
"""Tests for AgentConfig class."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""AgentConfig should have sensible defaults."""
|
||||
from intercept_agent import AgentConfig
|
||||
config = AgentConfig()
|
||||
|
||||
assert config.port == 8020
|
||||
assert config.allow_cors is False
|
||||
assert config.push_enabled is False
|
||||
assert config.push_interval == 5
|
||||
assert config.controller_url == ''
|
||||
assert 'adsb' in config.modes_enabled
|
||||
assert 'wifi' in config.modes_enabled
|
||||
assert config.modes_enabled['adsb'] is True
|
||||
|
||||
def test_load_from_file_valid(self):
|
||||
"""AgentConfig should load from valid INI file."""
|
||||
from intercept_agent import AgentConfig
|
||||
|
||||
config_content = """
|
||||
[agent]
|
||||
name = test-sensor
|
||||
port = 8025
|
||||
allowed_ips = 192.168.1.0/24, 10.0.0.1
|
||||
allow_cors = true
|
||||
|
||||
[controller]
|
||||
url = http://192.168.1.100:5050
|
||||
api_key = secret123
|
||||
push_enabled = true
|
||||
push_interval = 10
|
||||
|
||||
[modes]
|
||||
pager = false
|
||||
adsb = true
|
||||
wifi = true
|
||||
bluetooth = false
|
||||
"""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='.cfg', delete=False) as f:
|
||||
f.write(config_content)
|
||||
config_path = f.name
|
||||
|
||||
try:
|
||||
config = AgentConfig()
|
||||
result = config.load_from_file(config_path)
|
||||
|
||||
assert result is True
|
||||
assert config.name == 'test-sensor'
|
||||
assert config.port == 8025
|
||||
assert '192.168.1.0/24' in config.allowed_ips
|
||||
assert config.allow_cors is True
|
||||
assert config.controller_url == 'http://192.168.1.100:5050'
|
||||
assert config.controller_api_key == 'secret123'
|
||||
assert config.push_enabled is True
|
||||
assert config.push_interval == 10
|
||||
assert config.modes_enabled['pager'] is False
|
||||
assert config.modes_enabled['adsb'] is True
|
||||
assert config.modes_enabled['bluetooth'] is False
|
||||
finally:
|
||||
os.unlink(config_path)
|
||||
|
||||
def test_load_from_file_missing(self):
|
||||
"""AgentConfig should handle missing file gracefully."""
|
||||
from intercept_agent import AgentConfig
|
||||
config = AgentConfig()
|
||||
result = config.load_from_file('/nonexistent/path.cfg')
|
||||
assert result is False
|
||||
|
||||
def test_to_dict(self):
|
||||
"""AgentConfig should convert to dictionary."""
|
||||
from intercept_agent import AgentConfig
|
||||
config = AgentConfig()
|
||||
config.name = 'test'
|
||||
config.port = 9000
|
||||
|
||||
d = config.to_dict()
|
||||
|
||||
assert d['name'] == 'test'
|
||||
assert d['port'] == 9000
|
||||
assert 'modes_enabled' in d
|
||||
assert isinstance(d['modes_enabled'], dict)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# AgentClient Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAgentClient:
|
||||
"""Tests for AgentClient HTTP operations."""
|
||||
|
||||
def test_init(self):
|
||||
"""AgentClient should initialize correctly."""
|
||||
client = AgentClient('http://192.168.1.50:8020', api_key='secret')
|
||||
assert client.base_url == 'http://192.168.1.50:8020'
|
||||
assert client.api_key == 'secret'
|
||||
assert client.timeout == 60.0
|
||||
|
||||
def test_init_strips_trailing_slash(self):
|
||||
"""AgentClient should strip trailing slash from URL."""
|
||||
client = AgentClient('http://192.168.1.50:8020/')
|
||||
assert client.base_url == 'http://192.168.1.50:8020'
|
||||
|
||||
def test_headers_without_api_key(self):
|
||||
"""Headers should not include API key if not provided."""
|
||||
client = AgentClient('http://localhost:8020')
|
||||
headers = client._headers()
|
||||
assert 'X-API-Key' not in headers
|
||||
assert 'Content-Type' in headers
|
||||
|
||||
def test_headers_with_api_key(self):
|
||||
"""Headers should include API key if provided."""
|
||||
client = AgentClient('http://localhost:8020', api_key='test-key')
|
||||
headers = client._headers()
|
||||
assert headers['X-API-Key'] == 'test-key'
|
||||
|
||||
@patch('utils.agent_client.requests.get')
|
||||
def test_get_capabilities(self, mock_get):
|
||||
"""get_capabilities should parse JSON response."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
'modes': {'adsb': True, 'wifi': True},
|
||||
'devices': [{'name': 'RTL-SDR'}],
|
||||
'agent_version': '1.0.0'
|
||||
}
|
||||
mock_response.content = b'{}'
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client = AgentClient('http://localhost:8020')
|
||||
caps = client.get_capabilities()
|
||||
|
||||
assert caps['modes']['adsb'] is True
|
||||
assert len(caps['devices']) == 1
|
||||
mock_get.assert_called_once()
|
||||
|
||||
@patch('utils.agent_client.requests.get')
|
||||
def test_get_status(self, mock_get):
|
||||
"""get_status should return status dict."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
'running_modes': ['adsb', 'sensor'],
|
||||
'uptime': 3600,
|
||||
'push_enabled': True
|
||||
}
|
||||
mock_response.content = b'{}'
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client = AgentClient('http://localhost:8020')
|
||||
status = client.get_status()
|
||||
|
||||
assert 'adsb' in status['running_modes']
|
||||
assert status['uptime'] == 3600
|
||||
|
||||
@patch('utils.agent_client.requests.get')
|
||||
def test_health_check_healthy(self, mock_get):
|
||||
"""health_check should return True for healthy agent."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {'status': 'healthy'}
|
||||
mock_response.content = b'{}'
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client = AgentClient('http://localhost:8020')
|
||||
assert client.health_check() is True
|
||||
|
||||
@patch('utils.agent_client.requests.get')
|
||||
def test_health_check_unhealthy(self, mock_get):
|
||||
"""health_check should return False for connection error."""
|
||||
import requests
|
||||
mock_get.side_effect = requests.ConnectionError("Connection refused")
|
||||
|
||||
client = AgentClient('http://localhost:8020')
|
||||
assert client.health_check() is False
|
||||
|
||||
@patch('utils.agent_client.requests.post')
|
||||
def test_start_mode(self, mock_post):
|
||||
"""start_mode should POST to correct endpoint."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {'status': 'started', 'mode': 'adsb'}
|
||||
mock_response.content = b'{}'
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = AgentClient('http://localhost:8020')
|
||||
result = client.start_mode('adsb', {'device_index': 0})
|
||||
|
||||
assert result['status'] == 'started'
|
||||
mock_post.assert_called_once()
|
||||
call_url = mock_post.call_args[0][0]
|
||||
assert '/adsb/start' in call_url
|
||||
|
||||
@patch('utils.agent_client.requests.post')
|
||||
def test_stop_mode(self, mock_post):
|
||||
"""stop_mode should POST to stop endpoint."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {'status': 'stopped'}
|
||||
mock_response.content = b'{}'
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
client = AgentClient('http://localhost:8020')
|
||||
result = client.stop_mode('wifi')
|
||||
|
||||
assert result['status'] == 'stopped'
|
||||
|
||||
@patch('utils.agent_client.requests.get')
|
||||
def test_get_mode_data(self, mock_get):
|
||||
"""get_mode_data should return data snapshot."""
|
||||
mock_response = Mock()
|
||||
mock_response.json.return_value = {
|
||||
'mode': 'adsb',
|
||||
'data': [
|
||||
{'icao': 'ABC123', 'altitude': 35000},
|
||||
{'icao': 'DEF456', 'altitude': 28000}
|
||||
]
|
||||
}
|
||||
mock_response.content = b'{}'
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client = AgentClient('http://localhost:8020')
|
||||
result = client.get_mode_data('adsb')
|
||||
|
||||
assert len(result['data']) == 2
|
||||
assert result['data'][0]['icao'] == 'ABC123'
|
||||
|
||||
@patch('utils.agent_client.requests.get')
|
||||
def test_connection_error_handling(self, mock_get):
|
||||
"""Client should raise AgentConnectionError on connection failure."""
|
||||
import requests
|
||||
mock_get.side_effect = requests.ConnectionError("Connection refused")
|
||||
|
||||
client = AgentClient('http://localhost:8020')
|
||||
|
||||
with pytest.raises(AgentConnectionError) as exc_info:
|
||||
client.get_capabilities()
|
||||
assert 'Cannot connect' in str(exc_info.value)
|
||||
|
||||
@patch('utils.agent_client.requests.get')
|
||||
def test_timeout_error_handling(self, mock_get):
|
||||
"""Client should raise AgentConnectionError on timeout."""
|
||||
import requests
|
||||
mock_get.side_effect = requests.Timeout("Request timed out")
|
||||
|
||||
client = AgentClient('http://localhost:8020', timeout=5.0)
|
||||
|
||||
with pytest.raises(AgentConnectionError) as exc_info:
|
||||
client.get_status()
|
||||
assert 'timed out' in str(exc_info.value)
|
||||
|
||||
@patch('utils.agent_client.requests.get')
|
||||
def test_http_error_handling(self, mock_get):
|
||||
"""Client should raise AgentHTTPError on HTTP errors."""
|
||||
import requests
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.raise_for_status.side_effect = requests.HTTPError(response=mock_response)
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
client = AgentClient('http://localhost:8020')
|
||||
|
||||
with pytest.raises(AgentHTTPError) as exc_info:
|
||||
client.get_capabilities()
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
def test_create_client_from_agent(self):
|
||||
"""create_client_from_agent should create configured client."""
|
||||
agent = {
|
||||
'id': 1,
|
||||
'name': 'test-agent',
|
||||
'base_url': 'http://192.168.1.50:8020',
|
||||
'api_key': 'secret123'
|
||||
}
|
||||
|
||||
client = create_client_from_agent(agent)
|
||||
|
||||
assert client.base_url == 'http://192.168.1.50:8020'
|
||||
assert client.api_key == 'secret123'
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Database Agent CRUD Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestDatabaseAgentCRUD:
|
||||
"""Tests for database agent operations."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(self, tmp_path):
|
||||
"""Set up a temporary database for each test."""
|
||||
import utils.database as db_module
|
||||
|
||||
# Create temp database
|
||||
test_db_path = tmp_path / 'test.db'
|
||||
original_db_path = db_module.DB_PATH
|
||||
db_module.DB_PATH = test_db_path
|
||||
db_module.DB_DIR = tmp_path
|
||||
|
||||
# Clear any existing connection
|
||||
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||
db_module._local.connection.close()
|
||||
db_module._local.connection = None
|
||||
|
||||
# Initialize schema
|
||||
init_db()
|
||||
|
||||
yield
|
||||
|
||||
# Cleanup
|
||||
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||
db_module._local.connection.close()
|
||||
db_module._local.connection = None
|
||||
db_module.DB_PATH = original_db_path
|
||||
|
||||
def test_create_agent(self):
|
||||
"""create_agent should insert new agent."""
|
||||
agent_id = create_agent(
|
||||
name='sensor-1',
|
||||
base_url='http://192.168.1.50:8020',
|
||||
api_key='secret',
|
||||
description='Test sensor node'
|
||||
)
|
||||
|
||||
assert agent_id is not None
|
||||
assert agent_id > 0
|
||||
|
||||
def test_get_agent(self):
|
||||
"""get_agent should retrieve agent by ID."""
|
||||
agent_id = create_agent(
|
||||
name='sensor-1',
|
||||
base_url='http://192.168.1.50:8020'
|
||||
)
|
||||
|
||||
agent = get_agent(agent_id)
|
||||
|
||||
assert agent is not None
|
||||
assert agent['name'] == 'sensor-1'
|
||||
assert agent['base_url'] == 'http://192.168.1.50:8020'
|
||||
assert agent['is_active'] is True
|
||||
|
||||
def test_get_agent_not_found(self):
|
||||
"""get_agent should return None for missing agent."""
|
||||
agent = get_agent(99999)
|
||||
assert agent is None
|
||||
|
||||
def test_get_agent_by_name(self):
|
||||
"""get_agent_by_name should find agent by name."""
|
||||
create_agent(name='unique-sensor', base_url='http://localhost:8020')
|
||||
|
||||
agent = get_agent_by_name('unique-sensor')
|
||||
|
||||
assert agent is not None
|
||||
assert agent['name'] == 'unique-sensor'
|
||||
|
||||
def test_get_agent_by_name_not_found(self):
|
||||
"""get_agent_by_name should return None for missing name."""
|
||||
agent = get_agent_by_name('nonexistent-sensor')
|
||||
assert agent is None
|
||||
|
||||
def test_list_agents(self):
|
||||
"""list_agents should return all active agents."""
|
||||
create_agent(name='sensor-1', base_url='http://192.168.1.51:8020')
|
||||
create_agent(name='sensor-2', base_url='http://192.168.1.52:8020')
|
||||
create_agent(name='sensor-3', base_url='http://192.168.1.53:8020')
|
||||
|
||||
agents = list_agents()
|
||||
|
||||
assert len(agents) >= 3
|
||||
names = [a['name'] for a in agents]
|
||||
assert 'sensor-1' in names
|
||||
assert 'sensor-2' in names
|
||||
|
||||
def test_list_agents_active_only(self):
|
||||
"""list_agents should filter inactive agents by default."""
|
||||
agent_id = create_agent(name='inactive-sensor', base_url='http://localhost:8020')
|
||||
update_agent(agent_id, is_active=False)
|
||||
|
||||
agents = list_agents(active_only=True)
|
||||
|
||||
names = [a['name'] for a in agents]
|
||||
assert 'inactive-sensor' not in names
|
||||
|
||||
def test_update_agent(self):
|
||||
"""update_agent should modify agent fields."""
|
||||
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||
|
||||
result = update_agent(
|
||||
agent_id,
|
||||
base_url='http://192.168.1.100:8020',
|
||||
description='Updated description'
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
agent = get_agent(agent_id)
|
||||
assert agent['base_url'] == 'http://192.168.1.100:8020'
|
||||
assert agent['description'] == 'Updated description'
|
||||
|
||||
def test_update_agent_capabilities(self):
|
||||
"""update_agent should update capabilities JSON."""
|
||||
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||
|
||||
caps = {'adsb': True, 'wifi': True, 'bluetooth': False}
|
||||
update_agent(agent_id, capabilities=caps)
|
||||
|
||||
agent = get_agent(agent_id)
|
||||
assert agent['capabilities']['adsb'] is True
|
||||
assert agent['capabilities']['bluetooth'] is False
|
||||
|
||||
def test_update_agent_gps_coords(self):
|
||||
"""update_agent should update GPS coordinates."""
|
||||
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||
|
||||
gps = {'lat': 40.7128, 'lon': -74.0060, 'altitude': 10}
|
||||
update_agent(agent_id, gps_coords=gps)
|
||||
|
||||
agent = get_agent(agent_id)
|
||||
assert agent['gps_coords']['lat'] == 40.7128
|
||||
assert agent['gps_coords']['lon'] == -74.0060
|
||||
|
||||
def test_delete_agent(self):
|
||||
"""delete_agent should remove agent and payloads."""
|
||||
agent_id = create_agent(name='to-delete', base_url='http://localhost:8020')
|
||||
|
||||
# Add a payload
|
||||
store_push_payload(agent_id, 'adsb', {'aircraft': []})
|
||||
|
||||
# Delete
|
||||
result = delete_agent(agent_id)
|
||||
|
||||
assert result is True
|
||||
assert get_agent(agent_id) is None
|
||||
|
||||
def test_delete_agent_not_found(self):
|
||||
"""delete_agent should return False for missing agent."""
|
||||
result = delete_agent(99999)
|
||||
assert result is False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Database Push Payload Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestDatabasePayloads:
|
||||
"""Tests for push payload storage."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_db(self, tmp_path):
|
||||
"""Set up a temporary database for each test."""
|
||||
import utils.database as db_module
|
||||
|
||||
test_db_path = tmp_path / 'test.db'
|
||||
original_db_path = db_module.DB_PATH
|
||||
db_module.DB_PATH = test_db_path
|
||||
db_module.DB_DIR = tmp_path
|
||||
|
||||
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||
db_module._local.connection.close()
|
||||
db_module._local.connection = None
|
||||
|
||||
init_db()
|
||||
|
||||
yield
|
||||
|
||||
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||
db_module._local.connection.close()
|
||||
db_module._local.connection = None
|
||||
db_module.DB_PATH = original_db_path
|
||||
|
||||
def test_store_push_payload(self):
|
||||
"""store_push_payload should insert payload."""
|
||||
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||
|
||||
payload = {'aircraft': [{'icao': 'ABC123', 'altitude': 35000}]}
|
||||
payload_id = store_push_payload(agent_id, 'adsb', payload, 'rtlsdr0')
|
||||
|
||||
assert payload_id > 0
|
||||
|
||||
def test_get_recent_payloads(self):
|
||||
"""get_recent_payloads should return stored payloads."""
|
||||
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||
|
||||
store_push_payload(agent_id, 'adsb', {'aircraft': [{'icao': 'A'}]})
|
||||
store_push_payload(agent_id, 'adsb', {'aircraft': [{'icao': 'B'}]})
|
||||
store_push_payload(agent_id, 'wifi', {'networks': []})
|
||||
|
||||
# Get all
|
||||
payloads = get_recent_payloads(agent_id=agent_id)
|
||||
assert len(payloads) == 3
|
||||
|
||||
# Filter by scan_type
|
||||
adsb_payloads = get_recent_payloads(agent_id=agent_id, scan_type='adsb')
|
||||
assert len(adsb_payloads) == 2
|
||||
|
||||
def test_get_recent_payloads_includes_agent_name(self):
|
||||
"""Payloads should include agent name."""
|
||||
agent_id = create_agent(name='my-sensor', base_url='http://localhost:8020')
|
||||
store_push_payload(agent_id, 'sensor', {'temperature': 22.5})
|
||||
|
||||
payloads = get_recent_payloads(agent_id=agent_id)
|
||||
|
||||
assert len(payloads) > 0
|
||||
assert payloads[0]['agent_name'] == 'my-sensor'
|
||||
|
||||
def test_get_recent_payloads_limit(self):
|
||||
"""get_recent_payloads should respect limit."""
|
||||
agent_id = create_agent(name='sensor-1', base_url='http://localhost:8020')
|
||||
|
||||
for i in range(10):
|
||||
store_push_payload(agent_id, 'sensor', {'temp': i})
|
||||
|
||||
payloads = get_recent_payloads(agent_id=agent_id, limit=5)
|
||||
assert len(payloads) == 5
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Integration Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAgentClientIntegration:
|
||||
"""Integration tests using mock agent server."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_agent(self):
|
||||
"""Start mock agent server for testing."""
|
||||
from tests.mock_agent import app as mock_app
|
||||
import threading
|
||||
|
||||
# Run mock agent in background
|
||||
mock_app.config['TESTING'] = True
|
||||
# Using Flask's test client instead of actual server
|
||||
return mock_app.test_client()
|
||||
|
||||
def test_mock_agent_capabilities(self, mock_agent):
|
||||
"""Mock agent should return capabilities."""
|
||||
response = mock_agent.get('/capabilities')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'modes' in data
|
||||
assert data['modes']['adsb'] is True
|
||||
|
||||
def test_mock_agent_start_stop_mode(self, mock_agent):
|
||||
"""Mock agent should start/stop modes."""
|
||||
# Start
|
||||
response = mock_agent.post('/adsb/start', json={})
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'started'
|
||||
|
||||
# Check status
|
||||
response = mock_agent.get('/status')
|
||||
data = json.loads(response.data)
|
||||
assert 'adsb' in data['running_modes']
|
||||
|
||||
# Stop
|
||||
response = mock_agent.post('/adsb/stop', json={})
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_mock_agent_data(self, mock_agent):
|
||||
"""Mock agent should return data when mode is running."""
|
||||
# Start mode first
|
||||
mock_agent.post('/adsb/start', json={})
|
||||
|
||||
response = mock_agent.get('/adsb/data')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert 'data' in data
|
||||
# Data should be a list of aircraft
|
||||
assert isinstance(data['data'], list)
|
||||
|
||||
# Cleanup
|
||||
mock_agent.post('/adsb/stop', json={})
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GPS Manager Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestGPSManager:
|
||||
"""Tests for GPS integration in agent."""
|
||||
|
||||
def test_gps_manager_init(self):
|
||||
"""GPSManager should initialize without error."""
|
||||
from intercept_agent import GPSManager
|
||||
gps = GPSManager()
|
||||
assert gps.position is None
|
||||
assert gps._running is False
|
||||
|
||||
def test_gps_manager_position_format(self):
|
||||
"""GPSManager position should have correct format when set."""
|
||||
from intercept_agent import GPSManager
|
||||
|
||||
gps = GPSManager()
|
||||
|
||||
# Simulate a position update
|
||||
class MockPosition:
|
||||
latitude = 40.7128
|
||||
longitude = -74.0060
|
||||
altitude = 10.5
|
||||
speed = 0.0
|
||||
heading = 180.0
|
||||
fix_quality = 2
|
||||
|
||||
gps._position = MockPosition()
|
||||
pos = gps.position
|
||||
|
||||
assert pos is not None
|
||||
assert pos['lat'] == 40.7128
|
||||
assert pos['lon'] == -74.0060
|
||||
assert pos['altitude'] == 10.5
|
||||
@@ -0,0 +1,582 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for Intercept Agent with real tools.
|
||||
|
||||
These tests verify:
|
||||
- Tool detection and availability
|
||||
- Output parsing with sample/recorded data
|
||||
- Live tool execution (optional, requires hardware)
|
||||
|
||||
Run with:
|
||||
pytest tests/test_agent_integration.py -v
|
||||
|
||||
Run live tests (requires RTL-SDR hardware):
|
||||
pytest tests/test_agent_integration.py -v -m live
|
||||
|
||||
Skip live tests:
|
||||
pytest tests/test_agent_integration.py -v -m "not live"
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Sample Data for Parsing Tests
|
||||
# =============================================================================
|
||||
|
||||
# Sample rtl_433 JSON outputs
|
||||
RTL_433_SAMPLES = [
|
||||
'{"time":"2024-01-15 10:30:00","model":"Acurite-Tower","id":12345,"channel":"A","battery_ok":1,"temperature_C":22.5,"humidity":45}',
|
||||
'{"time":"2024-01-15 10:30:05","model":"Oregon-THGR122N","id":100,"channel":1,"battery_ok":1,"temperature_C":18.3,"humidity":62}',
|
||||
'{"time":"2024-01-15 10:30:10","model":"LaCrosse-TX141W","id":55,"channel":2,"temperature_C":-5.2,"humidity":78}',
|
||||
'{"time":"2024-01-15 10:30:15","model":"Ambient-F007TH","id":200,"channel":3,"temperature_C":25.0,"humidity":50,"battery_ok":1}',
|
||||
]
|
||||
|
||||
# Sample SBS (BaseStation) format lines from dump1090
|
||||
SBS_SAMPLES = [
|
||||
'MSG,1,1,1,A1B2C3,1,2024/01/15,10:30:00.000,2024/01/15,10:30:00.000,UAL123,,,,,,,,,,0',
|
||||
'MSG,3,1,1,A1B2C3,1,2024/01/15,10:30:01.000,2024/01/15,10:30:01.000,,35000,,,40.7128,-74.0060,,,0,0,0,0',
|
||||
'MSG,4,1,1,A1B2C3,1,2024/01/15,10:30:02.000,2024/01/15,10:30:02.000,,,450,180,,,1500,,,,,',
|
||||
'MSG,5,1,1,A1B2C3,1,2024/01/15,10:30:03.000,2024/01/15,10:30:03.000,UAL123,35000,,,,,,,,,',
|
||||
'MSG,6,1,1,A1B2C3,1,2024/01/15,10:30:04.000,2024/01/15,10:30:04.000,,,,,,,,,,1200',
|
||||
# Second aircraft
|
||||
'MSG,1,1,1,D4E5F6,1,2024/01/15,10:30:05.000,2024/01/15,10:30:05.000,DAL456,,,,,,,,,,0',
|
||||
'MSG,3,1,1,D4E5F6,1,2024/01/15,10:30:06.000,2024/01/15,10:30:06.000,,28000,,,40.8000,-73.9500,,,0,0,0,0',
|
||||
]
|
||||
|
||||
# Sample airodump-ng CSV output (matches real airodump format - no blank line between header and data)
|
||||
AIRODUMP_CSV_SAMPLE = """BSSID, First time seen, Last time seen, channel, Speed, Privacy, Cipher, Authentication, Power, # beacons, # IV, LAN IP, ID-length, ESSID, Key
|
||||
00:11:22:33:44:55, 2024-01-15 10:00:00, 2024-01-15 10:30:00, 6, 54, WPA2, CCMP, PSK, -55, 100, 0, 0. 0. 0. 0, 8, HomeWiFi,
|
||||
AA:BB:CC:DD:EE:FF, 2024-01-15 10:05:00, 2024-01-15 10:30:00, 11, 130, WPA2, CCMP, PSK, -70, 200, 0, 0. 0. 0. 0, 12, CoffeeShop,
|
||||
11:22:33:44:55:66, 2024-01-15 10:10:00, 2024-01-15 10:30:00, 36, 867, WPA3, CCMP, SAE, -45, 150, 0, 0. 0. 0. 0, 7, Office5G,
|
||||
|
||||
Station MAC, First time seen, Last time seen, Power, # packets, BSSID, Probed ESSIDs
|
||||
CA:FE:BA:BE:00:01, 2024-01-15 10:15:00, 2024-01-15 10:30:00, -60, 50, 00:11:22:33:44:55, HomeWiFi
|
||||
DE:AD:BE:EF:00:02, 2024-01-15 10:20:00, 2024-01-15 10:30:00, -75, 25, AA:BB:CC:DD:EE:FF, CoffeeShop
|
||||
"""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def agent():
|
||||
"""Create a ModeManager instance for testing."""
|
||||
from intercept_agent import ModeManager
|
||||
return ModeManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def temp_csv_file():
|
||||
"""Create a temp airodump CSV file."""
|
||||
with tempfile.NamedTemporaryFile(mode='w', suffix='-01.csv', delete=False) as f:
|
||||
f.write(AIRODUMP_CSV_SAMPLE)
|
||||
path = f.name
|
||||
yield path[:-7] # Return base path without -01.csv suffix
|
||||
# Cleanup
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Tool Detection Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestToolDetection:
|
||||
"""Tests for tool availability detection."""
|
||||
|
||||
def test_rtl_433_available(self):
|
||||
"""rtl_433 should be installed."""
|
||||
assert shutil.which('rtl_433') is not None
|
||||
|
||||
def test_dump1090_available(self):
|
||||
"""dump1090 should be installed."""
|
||||
assert shutil.which('dump1090') is not None or \
|
||||
shutil.which('dump1090-fa') is not None or \
|
||||
shutil.which('readsb') is not None
|
||||
|
||||
def test_airodump_available(self):
|
||||
"""airodump-ng should be installed."""
|
||||
assert shutil.which('airodump-ng') is not None
|
||||
|
||||
def test_multimon_available(self):
|
||||
"""multimon-ng should be installed."""
|
||||
assert shutil.which('multimon-ng') is not None
|
||||
|
||||
def test_acarsdec_available(self):
|
||||
"""acarsdec should be installed."""
|
||||
assert shutil.which('acarsdec') is not None
|
||||
|
||||
def test_agent_detects_tools(self, agent):
|
||||
"""Agent should detect available tools."""
|
||||
caps = agent.detect_capabilities()
|
||||
|
||||
# These should all be True given the tools are installed
|
||||
assert caps['modes']['sensor'] is True
|
||||
assert caps['modes']['adsb'] is True
|
||||
# wifi requires airmon-ng too
|
||||
# bluetooth requires bluetoothctl
|
||||
|
||||
|
||||
class TestRTLSDRDetection:
|
||||
"""Tests for RTL-SDR hardware detection."""
|
||||
|
||||
def test_rtl_test_runs(self):
|
||||
"""rtl_test should run (even if no device)."""
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
# Will return 0 if device found, non-zero if not
|
||||
# We just verify it runs without crashing
|
||||
assert result.returncode in [0, 1, 255]
|
||||
|
||||
def test_agent_detects_sdr_devices(self, agent):
|
||||
"""Agent should detect SDR devices."""
|
||||
caps = agent.detect_capabilities()
|
||||
|
||||
# If RTL-SDR is connected, devices list should be non-empty
|
||||
# This is hardware-dependent, so we just verify the key exists
|
||||
assert 'devices' in caps
|
||||
|
||||
@pytest.mark.live
|
||||
def test_rtl_sdr_present(self):
|
||||
"""Verify RTL-SDR device is present (for live tests)."""
|
||||
result = subprocess.run(
|
||||
['rtl_test', '-t'],
|
||||
capture_output=True,
|
||||
timeout=5
|
||||
)
|
||||
if b'Found 0 device' in result.stdout or b'No supported devices found' in result.stderr:
|
||||
pytest.skip("No RTL-SDR device connected")
|
||||
assert b'Found' in result.stdout
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parsing Tests (No Hardware Required)
|
||||
# =============================================================================
|
||||
|
||||
class TestRTL433Parsing:
|
||||
"""Tests for rtl_433 JSON output parsing."""
|
||||
|
||||
def test_parse_acurite_sensor(self):
|
||||
"""Parse Acurite temperature sensor data."""
|
||||
data = json.loads(RTL_433_SAMPLES[0])
|
||||
|
||||
assert data['model'] == 'Acurite-Tower'
|
||||
assert data['id'] == 12345
|
||||
assert data['temperature_C'] == 22.5
|
||||
assert data['humidity'] == 45
|
||||
assert data['battery_ok'] == 1
|
||||
|
||||
def test_parse_oregon_sensor(self):
|
||||
"""Parse Oregon Scientific sensor data."""
|
||||
data = json.loads(RTL_433_SAMPLES[1])
|
||||
|
||||
assert data['model'] == 'Oregon-THGR122N'
|
||||
assert data['temperature_C'] == 18.3
|
||||
|
||||
def test_parse_negative_temperature(self):
|
||||
"""Parse sensor with negative temperature."""
|
||||
data = json.loads(RTL_433_SAMPLES[2])
|
||||
|
||||
assert data['model'] == 'LaCrosse-TX141W'
|
||||
assert data['temperature_C'] == -5.2
|
||||
|
||||
def test_agent_sensor_data_format(self, agent):
|
||||
"""Agent should format sensor data correctly for controller."""
|
||||
# Simulate processing
|
||||
sample = json.loads(RTL_433_SAMPLES[0])
|
||||
sample['type'] = 'sensor'
|
||||
sample['received_at'] = '2024-01-15T10:30:00Z'
|
||||
|
||||
# Verify required fields for controller
|
||||
assert 'model' in sample
|
||||
assert 'temperature_C' in sample or 'temperature_F' in sample
|
||||
assert 'received_at' in sample
|
||||
|
||||
|
||||
class TestSBSParsing:
|
||||
"""Tests for SBS (BaseStation) format parsing from dump1090."""
|
||||
|
||||
def test_parse_msg1_callsign(self, agent):
|
||||
"""MSG,1 should extract callsign."""
|
||||
line = SBS_SAMPLES[0]
|
||||
agent._parse_sbs_line(line)
|
||||
|
||||
aircraft = agent.adsb_aircraft.get('A1B2C3')
|
||||
assert aircraft is not None
|
||||
assert aircraft['callsign'] == 'UAL123'
|
||||
|
||||
def test_parse_msg3_position(self, agent):
|
||||
"""MSG,3 should extract altitude and position."""
|
||||
agent._parse_sbs_line(SBS_SAMPLES[0]) # First need MSG,1 for ICAO
|
||||
agent._parse_sbs_line(SBS_SAMPLES[1])
|
||||
|
||||
aircraft = agent.adsb_aircraft.get('A1B2C3')
|
||||
assert aircraft is not None
|
||||
assert aircraft['altitude'] == 35000
|
||||
assert abs(aircraft['lat'] - 40.7128) < 0.0001
|
||||
assert abs(aircraft['lon'] - (-74.0060)) < 0.0001
|
||||
|
||||
def test_parse_msg4_velocity(self, agent):
|
||||
"""MSG,4 should extract speed and heading."""
|
||||
agent._parse_sbs_line(SBS_SAMPLES[0])
|
||||
agent._parse_sbs_line(SBS_SAMPLES[2])
|
||||
|
||||
aircraft = agent.adsb_aircraft.get('A1B2C3')
|
||||
assert aircraft is not None
|
||||
assert aircraft['speed'] == 450
|
||||
assert aircraft['heading'] == 180
|
||||
assert aircraft['vertical_rate'] == 1500
|
||||
|
||||
def test_parse_msg6_squawk(self, agent):
|
||||
"""MSG,6 should extract squawk code."""
|
||||
agent._parse_sbs_line(SBS_SAMPLES[0])
|
||||
agent._parse_sbs_line(SBS_SAMPLES[4])
|
||||
|
||||
aircraft = agent.adsb_aircraft.get('A1B2C3')
|
||||
assert aircraft is not None
|
||||
# Squawk may not be present if MSG,6 format doesn't have enough fields
|
||||
# The sample line may need adjustment - check if squawk was parsed
|
||||
if 'squawk' in aircraft:
|
||||
assert aircraft['squawk'] == '1200'
|
||||
|
||||
def test_parse_multiple_aircraft(self, agent):
|
||||
"""Should track multiple aircraft simultaneously."""
|
||||
for line in SBS_SAMPLES:
|
||||
agent._parse_sbs_line(line)
|
||||
|
||||
assert 'A1B2C3' in agent.adsb_aircraft
|
||||
assert 'D4E5F6' in agent.adsb_aircraft
|
||||
assert agent.adsb_aircraft['D4E5F6']['callsign'] == 'DAL456'
|
||||
|
||||
def test_parse_malformed_sbs(self, agent):
|
||||
"""Should handle malformed SBS lines gracefully."""
|
||||
# Too few fields
|
||||
agent._parse_sbs_line('MSG,1,1')
|
||||
# Not MSG type
|
||||
agent._parse_sbs_line('SEL,1,1,1,ABC123,1')
|
||||
# Empty line
|
||||
agent._parse_sbs_line('')
|
||||
# Garbage
|
||||
agent._parse_sbs_line('not,valid,sbs,data')
|
||||
|
||||
# Should not crash, aircraft dict should be empty
|
||||
assert len(agent.adsb_aircraft) == 0
|
||||
|
||||
|
||||
class TestAirodumpParsing:
|
||||
"""Tests for airodump-ng CSV parsing using Intercept's parser."""
|
||||
|
||||
def test_intercept_parser_available(self):
|
||||
"""Intercept's airodump parser should be importable."""
|
||||
from utils.wifi.parsers.airodump import parse_airodump_csv
|
||||
assert callable(parse_airodump_csv)
|
||||
|
||||
def test_parse_csv_networks_with_intercept_parser(self, temp_csv_file):
|
||||
"""Intercept parser should parse network section of CSV."""
|
||||
from utils.wifi.parsers.airodump import parse_airodump_csv
|
||||
|
||||
networks, clients = parse_airodump_csv(temp_csv_file + '-01.csv')
|
||||
|
||||
assert len(networks) >= 3
|
||||
|
||||
# Find HomeWiFi network by BSSID
|
||||
home_wifi = next((n for n in networks if n.bssid == '00:11:22:33:44:55'), None)
|
||||
assert home_wifi is not None
|
||||
assert home_wifi.essid == 'HomeWiFi'
|
||||
assert home_wifi.channel == 6
|
||||
assert home_wifi.rssi == -55
|
||||
assert 'WPA2' in home_wifi.security # Could be 'WPA2' or 'WPA/WPA2'
|
||||
|
||||
def test_parse_csv_clients_with_intercept_parser(self, temp_csv_file):
|
||||
"""Intercept parser should parse client section of CSV."""
|
||||
from utils.wifi.parsers.airodump import parse_airodump_csv
|
||||
|
||||
networks, clients = parse_airodump_csv(temp_csv_file + '-01.csv')
|
||||
|
||||
assert len(clients) >= 2
|
||||
# Client should have MAC and associated BSSID
|
||||
assert any(c.get('mac') == 'CA:FE:BA:BE:00:01' for c in clients)
|
||||
|
||||
def test_agent_uses_intercept_parser(self, agent, temp_csv_file):
|
||||
"""Agent should use Intercept's parser when available."""
|
||||
networks, clients = agent._parse_airodump_csv(temp_csv_file + '-01.csv', None)
|
||||
|
||||
# Should return dict format
|
||||
assert isinstance(networks, dict)
|
||||
assert len(networks) >= 3
|
||||
|
||||
# Check a network entry
|
||||
home_wifi = networks.get('00:11:22:33:44:55')
|
||||
assert home_wifi is not None
|
||||
assert home_wifi['essid'] == 'HomeWiFi'
|
||||
assert home_wifi['channel'] == 6
|
||||
|
||||
def test_parse_csv_clients(self, agent, temp_csv_file):
|
||||
"""Agent should parse clients correctly."""
|
||||
networks, clients = agent._parse_airodump_csv(temp_csv_file + '-01.csv', None)
|
||||
|
||||
assert len(clients) >= 2
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Live Tool Tests (Require Hardware)
|
||||
# =============================================================================
|
||||
|
||||
@pytest.mark.live
|
||||
class TestLiveRTL433:
|
||||
"""Live tests with rtl_433 (requires RTL-SDR)."""
|
||||
|
||||
def test_rtl_433_runs(self):
|
||||
"""rtl_433 should start and produce output."""
|
||||
proc = subprocess.Popen(
|
||||
['rtl_433', '-F', 'json', '-T', '3'], # Run for 3 seconds
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=10)
|
||||
# rtl_433 may or may not receive data in 3 seconds
|
||||
# We just verify it starts without error
|
||||
assert proc.returncode in [0, 1] # 1 = no data received, OK
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
pytest.fail("rtl_433 did not complete in time")
|
||||
|
||||
def test_rtl_433_json_output(self):
|
||||
"""rtl_433 JSON output should be parseable."""
|
||||
proc = subprocess.Popen(
|
||||
['rtl_433', '-F', 'json', '-T', '5'],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, _ = proc.communicate(timeout=10)
|
||||
# If we got any output, verify it's valid JSON
|
||||
for line in stdout.decode('utf-8', errors='ignore').split('\n'):
|
||||
line = line.strip()
|
||||
if line:
|
||||
try:
|
||||
data = json.loads(line)
|
||||
assert 'model' in data or 'time' in data
|
||||
except json.JSONDecodeError:
|
||||
pass # May be startup messages
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
class TestLiveDump1090:
|
||||
"""Live tests with dump1090 (requires RTL-SDR)."""
|
||||
|
||||
def test_dump1090_starts(self):
|
||||
"""dump1090 should start successfully."""
|
||||
dump1090_path = shutil.which('dump1090') or shutil.which('dump1090-fa')
|
||||
if not dump1090_path:
|
||||
pytest.skip("dump1090 not installed")
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[dump1090_path, '--net', '--quiet'],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
try:
|
||||
time.sleep(2)
|
||||
if proc.poll() is not None:
|
||||
stderr = proc.stderr.read().decode()
|
||||
if 'No supported RTLSDR devices found' in stderr:
|
||||
pytest.skip("No RTL-SDR for ADS-B")
|
||||
pytest.fail(f"dump1090 exited: {stderr}")
|
||||
|
||||
# Verify SBS port is open
|
||||
import socket
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
result = sock.connect_ex(('localhost', 30003))
|
||||
sock.close()
|
||||
|
||||
assert result == 0, "SBS port 30003 not open"
|
||||
|
||||
finally:
|
||||
proc.terminate()
|
||||
proc.wait()
|
||||
|
||||
|
||||
@pytest.mark.live
|
||||
class TestLiveAgentModes:
|
||||
"""Live tests running agent modes (requires hardware)."""
|
||||
|
||||
def test_agent_sensor_mode(self, agent):
|
||||
"""Agent should start and stop sensor mode."""
|
||||
result = agent.start_mode('sensor', {})
|
||||
|
||||
if result.get('status') == 'error':
|
||||
if 'not found' in result.get('message', ''):
|
||||
pytest.skip("rtl_433 not found")
|
||||
if 'device' in result.get('message', '').lower():
|
||||
pytest.skip("No RTL-SDR device")
|
||||
|
||||
assert result['status'] == 'started'
|
||||
assert 'sensor' in agent.running_modes
|
||||
|
||||
# Let it run briefly
|
||||
time.sleep(2)
|
||||
|
||||
# Check status
|
||||
status = agent.get_mode_status('sensor')
|
||||
assert status['running'] is True
|
||||
|
||||
# Stop
|
||||
stop_result = agent.stop_mode('sensor')
|
||||
assert stop_result['status'] == 'stopped'
|
||||
assert 'sensor' not in agent.running_modes
|
||||
|
||||
def test_agent_adsb_mode(self, agent):
|
||||
"""Agent should start and stop ADS-B mode."""
|
||||
result = agent.start_mode('adsb', {})
|
||||
|
||||
if result.get('status') == 'error':
|
||||
if 'not found' in result.get('message', ''):
|
||||
pytest.skip("dump1090 not found")
|
||||
if 'device' in result.get('message', '').lower():
|
||||
pytest.skip("No RTL-SDR device")
|
||||
|
||||
assert result['status'] == 'started'
|
||||
|
||||
# Let it run briefly
|
||||
time.sleep(3)
|
||||
|
||||
# Get data (may be empty if no aircraft)
|
||||
data = agent.get_mode_data('adsb')
|
||||
assert 'data' in data
|
||||
|
||||
# Stop
|
||||
agent.stop_mode('adsb')
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Controller Integration Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAgentControllerFormat:
|
||||
"""Tests that agent output matches controller expectations."""
|
||||
|
||||
def test_sensor_data_format(self, agent):
|
||||
"""Sensor data should have required fields for controller."""
|
||||
# Simulate parsed data
|
||||
sample = {
|
||||
'model': 'Acurite-Tower',
|
||||
'id': 12345,
|
||||
'temperature_C': 22.5,
|
||||
'humidity': 45,
|
||||
'type': 'sensor',
|
||||
'received_at': '2024-01-15T10:30:00Z'
|
||||
}
|
||||
|
||||
# Should be serializable
|
||||
json_str = json.dumps(sample)
|
||||
restored = json.loads(json_str)
|
||||
assert restored['model'] == 'Acurite-Tower'
|
||||
|
||||
def test_adsb_data_format(self, agent):
|
||||
"""ADS-B data should have required fields for controller."""
|
||||
# Simulate parsed aircraft
|
||||
agent._parse_sbs_line(SBS_SAMPLES[0])
|
||||
agent._parse_sbs_line(SBS_SAMPLES[1])
|
||||
agent._parse_sbs_line(SBS_SAMPLES[2])
|
||||
|
||||
data = agent.get_mode_data('adsb')
|
||||
|
||||
# Should be list format
|
||||
assert isinstance(data['data'], list)
|
||||
|
||||
if data['data']:
|
||||
aircraft = data['data'][0]
|
||||
assert 'icao' in aircraft
|
||||
assert 'last_seen' in aircraft
|
||||
|
||||
def test_push_payload_format(self, agent):
|
||||
"""Push payload should match controller ingest format."""
|
||||
# Simulate what agent sends to controller
|
||||
payload = {
|
||||
'agent_name': 'test-sensor',
|
||||
'scan_type': 'adsb',
|
||||
'interface': 'rtlsdr0',
|
||||
'payload': {
|
||||
'aircraft': [
|
||||
{'icao': 'A1B2C3', 'callsign': 'UAL123', 'altitude': 35000}
|
||||
]
|
||||
},
|
||||
'received_at': '2024-01-15T10:30:00Z'
|
||||
}
|
||||
|
||||
# Verify structure
|
||||
assert 'agent_name' in payload
|
||||
assert 'scan_type' in payload
|
||||
assert 'payload' in payload
|
||||
|
||||
# Should be JSON serializable
|
||||
json_str = json.dumps(payload)
|
||||
assert len(json_str) > 0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GPS Integration Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestGPSIntegration:
|
||||
"""Tests for GPS data in agent output."""
|
||||
|
||||
def test_data_includes_gps_field(self, agent):
|
||||
"""Data should include GPS position if available."""
|
||||
data = agent.get_mode_data('sensor')
|
||||
|
||||
# agent_gps field should exist (may be None if no GPS)
|
||||
assert 'agent_gps' in data or data.get('agent_gps') is None
|
||||
|
||||
def test_gps_position_format(self):
|
||||
"""GPS position should have lat/lon fields."""
|
||||
from intercept_agent import GPSManager
|
||||
|
||||
gps = GPSManager()
|
||||
|
||||
# Simulate position
|
||||
class MockPosition:
|
||||
latitude = 40.7128
|
||||
longitude = -74.0060
|
||||
altitude = 10.0
|
||||
speed = 0.0
|
||||
heading = 0.0
|
||||
fix_quality = 2
|
||||
|
||||
gps._position = MockPosition()
|
||||
pos = gps.position
|
||||
|
||||
assert pos is not None
|
||||
assert 'lat' in pos
|
||||
assert 'lon' in pos
|
||||
assert pos['lat'] == 40.7128
|
||||
assert pos['lon'] == -74.0060
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Run Tests
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v', '-m', 'not live'])
|
||||
@@ -0,0 +1,569 @@
|
||||
"""
|
||||
Tests for Controller routes (multi-agent management).
|
||||
|
||||
Tests cover:
|
||||
- Agent CRUD operations via HTTP
|
||||
- Proxy operations to agents
|
||||
- Push data ingestion
|
||||
- SSE streaming
|
||||
- Location estimation
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
import sys
|
||||
from unittest.mock import Mock, patch, MagicMock
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Fixtures
|
||||
# =============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def setup_db(tmp_path):
|
||||
"""Set up a temporary database."""
|
||||
import utils.database as db_module
|
||||
from utils.database import init_db
|
||||
|
||||
test_db_path = tmp_path / 'test.db'
|
||||
original_db_path = db_module.DB_PATH
|
||||
db_module.DB_PATH = test_db_path
|
||||
db_module.DB_DIR = tmp_path
|
||||
|
||||
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||
db_module._local.connection.close()
|
||||
db_module._local.connection = None
|
||||
|
||||
init_db()
|
||||
|
||||
yield
|
||||
|
||||
if hasattr(db_module._local, 'connection') and db_module._local.connection:
|
||||
db_module._local.connection.close()
|
||||
db_module._local.connection = None
|
||||
db_module.DB_PATH = original_db_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app(setup_db):
|
||||
"""Create Flask app with controller blueprint."""
|
||||
from flask import Flask
|
||||
from routes.controller import controller_bp
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['TESTING'] = True
|
||||
app.register_blueprint(controller_bp)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(app):
|
||||
"""Create test client."""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_agent(setup_db):
|
||||
"""Create a sample agent in database."""
|
||||
from utils.database import create_agent
|
||||
agent_id = create_agent(
|
||||
name='test-sensor',
|
||||
base_url='http://192.168.1.50:8020',
|
||||
api_key='test-key',
|
||||
description='Test sensor node',
|
||||
capabilities={'adsb': True, 'wifi': True},
|
||||
gps_coords={'lat': 40.7128, 'lon': -74.0060}
|
||||
)
|
||||
return agent_id
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent CRUD Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAgentCRUD:
|
||||
"""Tests for agent CRUD operations."""
|
||||
|
||||
def test_list_agents_empty(self, client):
|
||||
"""GET /controller/agents should return empty list initially."""
|
||||
response = client.get('/controller/agents')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['agents'] == []
|
||||
assert data['count'] == 0
|
||||
|
||||
def test_register_agent_success(self, client):
|
||||
"""POST /controller/agents should register new agent."""
|
||||
with patch('routes.controller.AgentClient') as MockClient:
|
||||
# Mock successful capability fetch
|
||||
mock_instance = Mock()
|
||||
mock_instance.get_capabilities.return_value = {
|
||||
'modes': {'adsb': True, 'wifi': True},
|
||||
'devices': [{'name': 'RTL-SDR'}]
|
||||
}
|
||||
MockClient.return_value = mock_instance
|
||||
|
||||
response = client.post('/controller/agents',
|
||||
json={
|
||||
'name': 'new-sensor',
|
||||
'base_url': 'http://192.168.1.51:8020',
|
||||
'api_key': 'secret123',
|
||||
'description': 'New sensor node'
|
||||
},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['agent']['name'] == 'new-sensor'
|
||||
|
||||
def test_register_agent_missing_name(self, client):
|
||||
"""POST /controller/agents should reject missing name."""
|
||||
response = client.post('/controller/agents',
|
||||
json={'base_url': 'http://localhost:8020'},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert 'name is required' in data['message']
|
||||
|
||||
def test_register_agent_missing_url(self, client):
|
||||
"""POST /controller/agents should reject missing URL."""
|
||||
response = client.post('/controller/agents',
|
||||
json={'name': 'test-sensor'},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert 'Base URL is required' in data['message']
|
||||
|
||||
def test_register_agent_duplicate_name(self, client, sample_agent):
|
||||
"""POST /controller/agents should reject duplicate name."""
|
||||
response = client.post('/controller/agents',
|
||||
json={
|
||||
'name': 'test-sensor', # Same as sample_agent
|
||||
'base_url': 'http://192.168.1.60:8020'
|
||||
},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
data = json.loads(response.data)
|
||||
assert 'already exists' in data['message']
|
||||
|
||||
def test_list_agents_with_agents(self, client, sample_agent):
|
||||
"""GET /controller/agents should return registered agents."""
|
||||
response = client.get('/controller/agents')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['count'] >= 1
|
||||
|
||||
names = [a['name'] for a in data['agents']]
|
||||
assert 'test-sensor' in names
|
||||
|
||||
def test_get_agent_detail(self, client, sample_agent):
|
||||
"""GET /controller/agents/<id> should return agent details."""
|
||||
response = client.get(f'/controller/agents/{sample_agent}')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['agent']['name'] == 'test-sensor'
|
||||
assert data['agent']['capabilities']['adsb'] is True
|
||||
|
||||
def test_get_agent_not_found(self, client):
|
||||
"""GET /controller/agents/<id> should return 404 for missing agent."""
|
||||
response = client.get('/controller/agents/99999')
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_update_agent(self, client, sample_agent):
|
||||
"""PATCH /controller/agents/<id> should update agent."""
|
||||
response = client.patch(f'/controller/agents/{sample_agent}',
|
||||
json={'description': 'Updated description'},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['agent']['description'] == 'Updated description'
|
||||
|
||||
def test_delete_agent(self, client, sample_agent):
|
||||
"""DELETE /controller/agents/<id> should remove agent."""
|
||||
response = client.delete(f'/controller/agents/{sample_agent}')
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify deleted
|
||||
response = client.get(f'/controller/agents/{sample_agent}')
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Proxy Operation Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestProxyOperations:
|
||||
"""Tests for proxying operations to agents."""
|
||||
|
||||
def test_proxy_start_mode(self, client, sample_agent):
|
||||
"""POST /controller/agents/<id>/<mode>/start should proxy to agent."""
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.start_mode.return_value = {'status': 'started', 'mode': 'adsb'}
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
f'/controller/agents/{sample_agent}/adsb/start',
|
||||
json={'device_index': 0},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['mode'] == 'adsb'
|
||||
|
||||
mock_client.start_mode.assert_called_once_with('adsb', {'device_index': 0})
|
||||
|
||||
def test_proxy_stop_mode(self, client, sample_agent):
|
||||
"""POST /controller/agents/<id>/<mode>/stop should proxy to agent."""
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.stop_mode.return_value = {'status': 'stopped'}
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
f'/controller/agents/{sample_agent}/wifi/stop',
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
|
||||
def test_proxy_get_mode_data(self, client, sample_agent):
|
||||
"""GET /controller/agents/<id>/<mode>/data should return data."""
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.get_mode_data.return_value = {
|
||||
'mode': 'adsb',
|
||||
'data': [{'icao': 'ABC123'}]
|
||||
}
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.get(f'/controller/agents/{sample_agent}/adsb/data')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'agent_name' in data
|
||||
assert data['agent_name'] == 'test-sensor'
|
||||
|
||||
def test_proxy_agent_not_found(self, client):
|
||||
"""Proxy operations should return 404 for missing agent."""
|
||||
response = client.post('/controller/agents/99999/adsb/start')
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_proxy_connection_error(self, client, sample_agent):
|
||||
"""Proxy should return 503 when agent unreachable."""
|
||||
from utils.agent_client import AgentConnectionError
|
||||
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.start_mode.side_effect = AgentConnectionError("Connection refused")
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.post(
|
||||
f'/controller/agents/{sample_agent}/adsb/start',
|
||||
json={},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
data = json.loads(response.data)
|
||||
assert 'Cannot connect' in data['message']
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Push Data Ingestion Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestPushIngestion:
|
||||
"""Tests for push data ingestion endpoint."""
|
||||
|
||||
def test_ingest_success(self, client, sample_agent):
|
||||
"""POST /controller/api/ingest should store payload."""
|
||||
payload = {
|
||||
'agent_name': 'test-sensor',
|
||||
'scan_type': 'adsb',
|
||||
'interface': 'rtlsdr0',
|
||||
'payload': {
|
||||
'aircraft': [{'icao': 'ABC123', 'altitude': 35000}]
|
||||
}
|
||||
}
|
||||
|
||||
response = client.post('/controller/api/ingest',
|
||||
json=payload,
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 202
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'accepted'
|
||||
assert 'payload_id' in data
|
||||
|
||||
def test_ingest_unknown_agent(self, client):
|
||||
"""POST /controller/api/ingest should reject unknown agent."""
|
||||
payload = {
|
||||
'agent_name': 'nonexistent-sensor',
|
||||
'scan_type': 'adsb',
|
||||
'payload': {}
|
||||
}
|
||||
|
||||
response = client.post('/controller/api/ingest',
|
||||
json=payload,
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = json.loads(response.data)
|
||||
assert 'Unknown agent' in data['message']
|
||||
|
||||
def test_ingest_invalid_api_key(self, client, sample_agent):
|
||||
"""POST /controller/api/ingest should reject invalid API key."""
|
||||
payload = {
|
||||
'agent_name': 'test-sensor',
|
||||
'scan_type': 'adsb',
|
||||
'payload': {}
|
||||
}
|
||||
|
||||
response = client.post('/controller/api/ingest',
|
||||
json=payload,
|
||||
headers={'X-API-Key': 'wrong-key'},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = json.loads(response.data)
|
||||
assert 'Invalid API key' in data['message']
|
||||
|
||||
def test_ingest_missing_agent_name(self, client):
|
||||
"""POST /controller/api/ingest should require agent_name."""
|
||||
response = client.post('/controller/api/ingest',
|
||||
json={'scan_type': 'adsb', 'payload': {}},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert 'agent_name required' in data['message']
|
||||
|
||||
def test_get_payloads(self, client, sample_agent):
|
||||
"""GET /controller/api/payloads should return stored payloads."""
|
||||
# First ingest some data
|
||||
for i in range(3):
|
||||
client.post('/controller/api/ingest',
|
||||
json={
|
||||
'agent_name': 'test-sensor',
|
||||
'scan_type': 'adsb',
|
||||
'payload': {'aircraft': [{'icao': f'TEST{i}'}]}
|
||||
},
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
response = client.get(f'/controller/api/payloads?agent_id={sample_agent}')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['count'] == 3
|
||||
|
||||
def test_get_payloads_filter_by_type(self, client, sample_agent):
|
||||
"""GET /controller/api/payloads should filter by scan_type."""
|
||||
# Ingest mixed data
|
||||
client.post('/controller/api/ingest',
|
||||
json={'agent_name': 'test-sensor', 'scan_type': 'adsb', 'payload': {}},
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content_type='application/json'
|
||||
)
|
||||
client.post('/controller/api/ingest',
|
||||
json={'agent_name': 'test-sensor', 'scan_type': 'wifi', 'payload': {}},
|
||||
headers={'X-API-Key': 'test-key'},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
response = client.get('/controller/api/payloads?scan_type=adsb')
|
||||
data = json.loads(response.data)
|
||||
|
||||
assert all(p['scan_type'] == 'adsb' for p in data['payloads'])
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Location Estimation Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestLocationEstimation:
|
||||
"""Tests for device location estimation (trilateration)."""
|
||||
|
||||
def test_add_observation(self, client):
|
||||
"""POST /controller/api/location/observe should accept observation."""
|
||||
response = client.post('/controller/api/location/observe',
|
||||
json={
|
||||
'device_id': 'AA:BB:CC:DD:EE:FF',
|
||||
'agent_name': 'sensor-1',
|
||||
'agent_lat': 40.7128,
|
||||
'agent_lon': -74.0060,
|
||||
'rssi': -55
|
||||
},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['device_id'] == 'AA:BB:CC:DD:EE:FF'
|
||||
|
||||
def test_add_observation_missing_fields(self, client):
|
||||
"""POST /controller/api/location/observe should require all fields."""
|
||||
response = client.post('/controller/api/location/observe',
|
||||
json={
|
||||
'device_id': 'AA:BB:CC:DD:EE:FF',
|
||||
'rssi': -55
|
||||
# Missing agent_name, agent_lat, agent_lon
|
||||
},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_estimate_location(self, client):
|
||||
"""POST /controller/api/location/estimate should compute location."""
|
||||
response = client.post('/controller/api/location/estimate',
|
||||
json={
|
||||
'observations': [
|
||||
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'},
|
||||
{'agent_lat': 40.7135, 'agent_lon': -74.0055, 'rssi': -70, 'agent_name': 'node-2'},
|
||||
{'agent_lat': 40.7120, 'agent_lon': -74.0050, 'rssi': -62, 'agent_name': 'node-3'}
|
||||
],
|
||||
'environment': 'outdoor'
|
||||
},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
# Should have computed a location
|
||||
if data['location']:
|
||||
assert 'lat' in data['location']
|
||||
assert 'lon' in data['location']
|
||||
|
||||
def test_estimate_location_insufficient_data(self, client):
|
||||
"""Estimation should require at least 2 observations."""
|
||||
response = client.post('/controller/api/location/estimate',
|
||||
json={
|
||||
'observations': [
|
||||
{'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'}
|
||||
]
|
||||
},
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = json.loads(response.data)
|
||||
assert 'At least 2' in data['message']
|
||||
|
||||
def test_get_device_location_not_found(self, client):
|
||||
"""GET /controller/api/location/<device_id> returns not_found for unknown device."""
|
||||
response = client.get('/controller/api/location/unknown-device')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'not_found'
|
||||
assert data['location'] is None
|
||||
|
||||
def test_get_all_locations(self, client):
|
||||
"""GET /controller/api/location/all should return all estimates."""
|
||||
response = client.get('/controller/api/location/all')
|
||||
assert response.status_code == 200
|
||||
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert 'devices' in data
|
||||
|
||||
def test_get_devices_near(self, client):
|
||||
"""GET /controller/api/location/near should find nearby devices."""
|
||||
response = client.get(
|
||||
'/controller/api/location/near',
|
||||
query_string={'lat': 40.7128, 'lon': -74.0060, 'radius': 100}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['center']['lat'] == 40.7128
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Refresh Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestAgentRefresh:
|
||||
"""Tests for agent refresh operations."""
|
||||
|
||||
def test_refresh_agent_success(self, client, sample_agent):
|
||||
"""POST /controller/agents/<id>/refresh should update metadata."""
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.refresh_metadata.return_value = {
|
||||
'healthy': True,
|
||||
'capabilities': {
|
||||
'modes': {'adsb': True, 'wifi': True, 'bluetooth': True},
|
||||
'devices': [{'name': 'RTL-SDR V3'}]
|
||||
},
|
||||
'status': {'running_modes': ['adsb']},
|
||||
'config': {}
|
||||
}
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.post(f'/controller/agents/{sample_agent}/refresh')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data['status'] == 'success'
|
||||
assert data['metadata']['healthy'] is True
|
||||
|
||||
def test_refresh_agent_unreachable(self, client, sample_agent):
|
||||
"""POST /controller/agents/<id>/refresh should return 503 if unreachable."""
|
||||
with patch('routes.controller.create_client_from_agent') as mock_create:
|
||||
mock_client = Mock()
|
||||
mock_client.refresh_metadata.return_value = {'healthy': False}
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
response = client.post(f'/controller/agents/{sample_agent}/refresh')
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SSE Stream Tests
|
||||
# =============================================================================
|
||||
|
||||
class TestSSEStream:
|
||||
"""Tests for SSE streaming endpoint."""
|
||||
|
||||
def test_stream_all_endpoint_exists(self, client):
|
||||
"""GET /controller/stream/all should exist and return SSE."""
|
||||
# Just verify the endpoint is accessible
|
||||
# Full SSE testing requires more complex setup
|
||||
response = client.get('/controller/stream/all')
|
||||
assert response.content_type == 'text/event-stream'
|
||||
Reference in New Issue
Block a user