feat: recon surveys with OUI/vendor lookup and report views

Reconnaissance surveys (GPSD-tethered scan recording), OUI vendor
lookup for client/AP tables, survey/report pages, and the matching
test_recon.py suite. Co-authored with the recon-feature agent whose
work was finished in this checkout.
This commit is contained in:
2026-08-18 08:35:22 -05:00
parent f5cddb335a
commit 251b1f6261
7 changed files with 2095 additions and 45 deletions
+484
View File
@@ -635,3 +635,487 @@ class HandshakeRoutesTest(unittest.TestCase):
self.assertEqual(data['files'], [])
self.assertEqual(data['handshakes'], [])
self.assertEqual(os.listdir(self.dir), ['.hidden'])
def make_survey_db():
"""Single-scan recon DB so the newest scan carries the AP data."""
fd, db = tempfile.mkstemp(suffix='.db')
os.close(fd)
conn = sqlite3.connect(db)
conn.executescript(SCHEMA)
conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u1', 1786466531, 'pager')")
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (1, 1, 'AE77C0EB3141', 1786466531, -71, 2412, 5)")
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (2, 1, 'C89E43648080', 1786466532, -76, 5745, 9)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (10, 2, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0, 1786466532, -76, 5745, 149, 0x400400108)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (11, 2, 1, 8, '506F9A010000', X'', 1, 1786466532, -64, 5745, 149, 0)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (12, 1, 1, 4, NULL, X'5A6E6574', NULL, 1786466531, -40, 2412, NULL, NULL)")
conn.execute("INSERT INTO handshake (hash, scan, stahash, aphash, time) VALUES (20, 1, 1, 2, 1786466600)")
conn.commit()
conn.close()
return db
class OuiVendorTest(unittest.TestCase):
def test_oui_prefix_forms(self):
self.assertEqual(server._oui_prefix('C8:9E:43:64:80:80'), 'C89E43')
self.assertEqual(server._oui_prefix('C89E43648080'), 'C89E43')
self.assertEqual(server._oui_prefix('c8:9e:43:64:80:80'), 'C89E43')
self.assertIsNone(server._oui_prefix(None))
self.assertIsNone(server._oui_prefix(''))
self.assertIsNone(server._oui_prefix('XX:YY:ZZ:00:00:00'))
def test_oui_vendor_lookup(self):
self.assertEqual(server.oui_vendor('B8:27:EB:00:00:00'), 'Raspberry Pi')
self.assertEqual(server.oui_vendor('10:BF:48:00:00:00'), 'Apple')
self.assertEqual(server.oui_vendor('14:CC:20:00:00:00'), 'TP-Link')
self.assertEqual(server.oui_vendor('FC:63:3E:00:00:00'), 'Google')
def test_oui_vendor_unknown_and_local(self):
self.assertEqual(server.oui_vendor('C8:9E:43:64:80:80'), 'Unknown')
self.assertEqual(server.oui_vendor('AE:77:C0:EB:31:41'), 'Local')
self.assertEqual(server.oui_vendor(None), 'Unknown')
self.assertEqual(server.oui_vendor('--'), 'Unknown')
def test_band_of_frequencies(self):
self.assertEqual(server.band_of(2412), '2.4')
self.assertEqual(server.band_of(5200), '5')
self.assertEqual(server.band_of(6180), '6')
self.assertEqual(server.band_of(0), '--')
self.assertEqual(server.band_of(None), '--')
def test_curated_table_has_no_garbage_keys(self):
for key in server.OUI_VENDORS:
self.assertRegex(key, r'^[0-9A-F]{6}$')
self.assertNotIn('349A...', server.OUI_VENDORS)
class ReconEnrichmentTest(unittest.TestCase):
def setUp(self):
self.db = make_db()
server.RECON_DB = self.db
def tearDown(self):
os.unlink(self.db)
def test_scan_detail_enriches_aps(self):
data = server.recon_scan_data(1)
aps = {a['bssid']: a for a in data['aps']}
a = aps['C8:9E:43:64:80:80']
self.assertEqual(a['band'], '5')
self.assertEqual(a['vendor'], 'Unknown')
self.assertEqual(a['first_seen'], 1786466532)
self.assertEqual(a['last_seen'], 1786466532)
hidden = aps['50:6F:9A:01:00:00']
self.assertEqual(hidden['band'], '5')
self.assertEqual(hidden['vendor'], 'Unknown')
def test_scan_detail_unassociated_count(self):
data = server.recon_scan_data(1)
self.assertEqual(data['unassociated'], 1)
def test_scan_detail_bounded_mode_counts_unassociated(self):
data = server.recon_scan_data(1, _limit=1)
self.assertEqual(data['unassociated'], 1)
self.assertEqual(len(data['aps']), 2)
self.assertLessEqual(len(data['clients']), 1)
self.assertEqual(data['scan']['id'], 1)
def test_first_last_seen_span_multiple_rows(self):
conn = sqlite3.connect(self.db)
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (30, 2, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0, 1786466540, -80, 5745, 149, 0x400400108)")
conn.commit()
conn.close()
data = server.recon_scan_data(1)
a = [a for a in data['aps'] if a['bssid'] == 'C8:9E:43:64:80:80'][0]
self.assertEqual(a['first_seen'], 1786466532)
self.assertEqual(a['last_seen'], 1786466540)
def test_band_for_24ghz_ap(self):
conn = sqlite3.connect(self.db)
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (3, 1, 'FC633E000001', 1786466533, -60, 2412, 4)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (31, 3, 1, 8, 'FC633E000001', X'4E6574776F726B', 0, 1786466533, -60, 2412, 6, 0x08)")
conn.commit()
conn.close()
data = server.recon_scan_data(1)
a = [a for a in data['aps'] if a['bssid'] == 'FC:63:3E:00:00:01'][0]
self.assertEqual(a['band'], '2.4')
self.assertEqual(a['vendor'], 'Google')
class ReconReportTest(unittest.TestCase):
def setUp(self):
self.db = make_db()
server.RECON_DB = self.db
def tearDown(self):
os.unlink(self.db)
def _ctx(self, args=()):
return type('C', (), {'args': args, 'body': {}})()
def test_csv_download_contains_aps_and_unassociated(self):
status, payload = server.h_recon_scan_download_csv(self._ctx(('1',)))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, 'text/csv')
self.assertEqual(payload.filename, 'scan-1.csv')
text = payload.data.decode('utf-8')
self.assertIn('Anderson-5', text)
self.assertIn('unassociated,1', text)
self.assertIn('C8:9E:43:64:80:80', text)
def test_html_download_contains_stats(self):
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, 'text/html')
self.assertEqual(payload.filename, 'scan-1.html')
text = payload.data.decode('utf-8')
self.assertIn('Scan #1', text)
self.assertIn('Anderson-5', text)
self.assertIn('WPA3 WPA2', text)
self.assertIn('Unassociated', text)
def test_download_404_for_missing_scan(self):
status, payload = server.h_recon_scan_download_csv(self._ctx(('999',)))
self.assertEqual(status, 404)
status, payload = server.h_recon_scan_download_html(self._ctx(('999',)))
self.assertEqual(status, 404)
def test_download_503_when_db_unavailable(self):
with mock.patch.object(server, 'recon_scan_data',
side_effect=RuntimeError('sqlite read failed: locked')), \
mock.patch.object(server.time, 'sleep'):
status, payload = server.h_recon_scan_download_csv(self._ctx(('1',)))
self.assertEqual(status, 503)
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
self.assertEqual(status, 503)
class GpsTest(unittest.TestCase):
def setUp(self):
server._gps_cache.update({'updated': 0, 'data': None})
@unittest.skipIf(os.name == 'nt', 'symlinks are not reliably available on Windows')
def test_serial_candidates_detect_bypath_targets(self):
d = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, d)
self.addCleanup(setattr, server, 'SERIAL_DIR', server.SERIAL_DIR)
server.SERIAL_DIR = d
os.symlink('/dev/ttyACM0', os.path.join(d, '1.3_1-1.3:1.0'))
os.symlink('/dev/ttyACM1', os.path.join(d, '1.3_1-1.3:1.2'))
with open(os.path.join(d, 'not-a-serial'), 'w') as f:
f.write('x')
candidates = server._gps_serial_candidates()
names = [name for name, _ in candidates]
self.assertEqual(names, ['1.3_1-1.3:1.0', '1.3_1-1.3:1.2'])
def test_gps_status_passthrough(self):
with mock.patch.object(server, '_gps_status_data_nocache',
return_value={'present': True, 'wigle': True}):
status, data = server.h_recon_gps(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertTrue(data['present'])
self.assertTrue(data['wigle'])
def test_configure_binds_preferred_device_and_locks(self):
candidates = [('1.2_1-1.2:1.0', '/dev/1.2'), ('1.3_2-1.3:1.0', '/dev/1.3')]
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
mock.patch.object(server, '_uci_gps_get', return_value='1.3_2-1.3:1.0'), \
mock.patch.object(server, '_uci_gps_set') as uci_set, \
mock.patch.object(server, '_gpsd_restart'), \
mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
mock.patch.object(server, '_gps_from_gpspipe',
return_value={'fix': 3, 'lat': 37.7, 'lon': -122.4, 'satellites': 8}), \
mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertTrue(data['lock'])
self.assertEqual(data['tried'], ['1.3_2-1.3:1.0'])
uci_set.assert_called_once_with('1.3_2-1.3:1.0')
def test_configure_no_candidates_errors(self):
with mock.patch.object(server, '_gps_serial_candidates', return_value=[]):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertIn('error', data)
def test_configure_fallback_binds_first_with_note(self):
candidates = [('1.2_1-1.2:1.0', '/dev/1.2'), ('1.3_2-1.3:1.0', '/dev/1.3')]
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
mock.patch.object(server, '_uci_gps_get', return_value=None), \
mock.patch.object(server, '_uci_gps_set'), \
mock.patch.object(server, '_gpsd_restart'), \
mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertEqual(data['device'], '1.2_1-1.2:1.0')
self.assertIn('waiting for a fix', data['note'])
def test_configure_tries_at_most_three_candidates(self):
candidates = [(str(i), '/dev/%d' % i) for i in range(5)]
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
mock.patch.object(server, '_uci_gps_get', return_value=None), \
mock.patch.object(server, '_uci_gps_set'), \
mock.patch.object(server, '_gpsd_restart'), \
mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertEqual(data['tried'], ['0', '1', '2'])
class WigleTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self._orig = server.WIGLE_DIR
server.WIGLE_DIR = self.dir
def tearDown(self):
server.WIGLE_DIR = self._orig
shutil.rmtree(self.dir)
def _ctx(self, args=(), body=None):
return type('C', (), {'args': args, 'body': body or {}})()
def _write(self, name, content):
with open(os.path.join(self.dir, name), 'w') as f:
f.write(content)
def test_file_rows_count_excludes_header(self):
self._write('a.csv', 'header\nr1\nr2\n')
self._write('b.csv', 'onlyheader\n')
status, data = server.h_recon_wigle_files(self._ctx())
self.assertEqual(status, 200)
files = {f['name']: f for f in data['files']}
self.assertEqual(files['a.csv']['rows'], 2)
self.assertEqual(files['b.csv']['rows'], 0)
self.assertEqual(files['a.csv']['size'], len('header\nr1\nr2\n'))
def test_file_rows_count_ignores_wigle_meta_and_header(self):
meta = 'WigleWifi-1.6,appRelease=0.0.0,model=pineapplepager,release=0.0.0\n'
header = 'MAC,SSID,AuthMode,FirstSeen,Channel,Frequency,RSSI,CurrentLatitude,CurrentLongitude\n'
self._write('empty.csv', meta + header)
self._write('full.csv', meta + header + 'AA:BB:CC:DD:EE:FF,test,0,,1,2412,-60,37.7,-122.4\n')
status, data = server.h_recon_wigle_files(self._ctx())
files = {f['name']: f for f in data['files']}
self.assertEqual(files['empty.csv']['rows'], 0)
self.assertEqual(files['full.csv']['rows'], 1)
def test_file_download(self):
self._write('wigle-1.csv', 'lat,lon\n37.7,-122.4\n')
status, payload = server.h_recon_wigle_file(self._ctx(('wigle-1.csv',)))
self.assertEqual(status, 200)
self.assertEqual(payload.filename, 'wigle-1.csv')
self.assertIn(b'37.7', payload.data)
def test_file_download_404_and_traversal(self):
status, payload = server.h_recon_wigle_file(self._ctx(('missing.csv',)))
self.assertEqual(status, 404)
status, payload = server.h_recon_wigle_file(self._ctx(('..%2F..%2Fetc%2Fpasswd',)))
self.assertEqual(status, 404)
def test_toggle_enable_and_disable(self):
with mock.patch.object(server, '_wigle_set', return_value=(200, {'ok': True})), \
mock.patch.object(server, 'hak5') as hak5, \
mock.patch.object(server, 'wigle_files_data',
return_value={'files': [{'name': 'w.csv'}]}):
status, data = server.h_recon_wigle(self._ctx(body={'enable': True}))
self.assertEqual(status, 200)
self.assertTrue(data['wigle'])
self.assertEqual(data['filename'], 'w.csv')
hak5.assert_called_once_with('WIGLE_START', timeout=10)
status, data = server.h_recon_wigle(self._ctx(body={'enable': False}))
self.assertEqual(status, 200)
self.assertFalse(data['wigle'])
hak5.assert_called_with('WIGLE_STOP', timeout=10)
class SurveyTest(unittest.TestCase):
def setUp(self):
self.db = make_survey_db()
server.RECON_DB = self.db
self.dir = tempfile.mkdtemp()
self._orig = {
'SURVEY_DIR': server.SURVEY_DIR,
'SURVEY_MAX_SAMPLES': server.SURVEY_MAX_SAMPLES,
'SURVEY_SAMPLE_INTERVAL': server.SURVEY_SAMPLE_INTERVAL,
}
server.SURVEY_DIR = self.dir
server.SURVEY_MAX_SAMPLES = 3
server.SURVEY_SAMPLE_INTERVAL = 0.0
server._survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
'started': 0, 'samples': 0, 'last_sample': 0}
server._gps_cache.update({'updated': 0, 'data': None})
def tearDown(self):
server.SURVEY_DIR = self._orig['SURVEY_DIR']
server.SURVEY_MAX_SAMPLES = self._orig['SURVEY_MAX_SAMPLES']
server.SURVEY_SAMPLE_INTERVAL = self._orig['SURVEY_SAMPLE_INTERVAL']
server._survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
'started': 0, 'samples': 0, 'last_sample': 0}
server._gps_cache.update({'updated': 0, 'data': None})
shutil.rmtree(self.dir)
os.unlink(self.db)
def _ctx(self, args=(), body=None):
return type('C', (), {'args': args, 'body': body or {}})()
def test_start_creates_meta_file(self):
status, data = server.h_recon_survey_start(self._ctx(body={'name': 'Kitchen Walk'}))
self.assertEqual(status, 200)
self.assertTrue(data['ok'])
path = os.path.join(self.dir, data['id'] + '.jsonl')
self.assertTrue(os.path.isfile(path))
with open(path) as f:
first = f.readline()
self.assertIn('"meta"', first)
self.assertIn('Kitchen Walk', first)
def test_start_rejects_duplicate(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
status, data = server.h_recon_survey_start(self._ctx(body={'name': 'B'}))
self.assertEqual(status, 409)
def test_sample_via_watchdog_and_cap(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
server._recon_watchdog_tick()
server._recon_watchdog_tick()
self.assertEqual(server._survey_state['samples'], 2)
status, data = server.h_recon_survey_stop(self._ctx())
self.assertEqual(status, 200)
self.assertEqual(data['samples'], 2)
self.assertFalse(server._survey_state['active'])
def test_sample_cap_stops_recording(self):
server.SURVEY_MAX_SAMPLES = 2
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
for _ in range(4):
server._recon_watchdog_tick()
self.assertFalse(server._survey_state['active'])
self.assertEqual(server._survey_state['samples'], 2)
def test_live_reports_scan_unassociated_and_recording(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
server._recon_watchdog_tick()
status, data = server.h_recon_survey_live(self._ctx())
self.assertEqual(status, 200)
self.assertEqual(data['scan']['id'], 1)
self.assertEqual(len(data['aps']), 2)
self.assertEqual(data['unassociated'], 1)
self.assertTrue(data['recording']['active'])
self.assertEqual(data['recording']['samples'], 1)
self.assertIn('wigle', data['gps'])
def test_live_recording_none_when_stopped(self):
status, data = server.h_recon_survey_live(self._ctx())
self.assertEqual(status, 200)
self.assertIsNone(data['recording'])
def test_surveys_list_counts_samples(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
server._recon_watchdog_tick()
server.h_recon_survey_stop(self._ctx())
status, data = server.h_recon_surveys(self._ctx())
self.assertEqual(status, 200)
self.assertEqual(len(data['surveys']), 1)
survey = data['surveys'][0]
self.assertEqual(survey['name'], 'A')
self.assertEqual(survey['samples'], 1)
self.assertGreater(survey['size'], 0)
def test_detail_aggregates_signal(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
server._recon_watchdog_tick()
server._recon_watchdog_tick()
sid = server._survey_state['id']
server.h_recon_survey_stop(self._ctx())
status, data = server.h_recon_survey_detail(self._ctx((sid,)))
self.assertEqual(status, 200)
aps = {a['bssid']: a for a in data['aps']}
a = aps['C8:9E:43:64:80:80']
self.assertEqual(a['min'], -76)
self.assertEqual(a['max'], -76)
self.assertEqual(a['avg'], -76)
self.assertEqual(a['samples'], 2)
self.assertEqual(a['band'], '5')
self.assertEqual(a['channel'], 149)
self.assertEqual(a['first_seen'], a['last_seen'])
self.assertEqual(data['gps_fixes'], 0)
def test_downloads_all_formats(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
server._recon_watchdog_tick()
sid = server._survey_state['id']
server.h_recon_survey_stop(self._ctx())
for fmt, ctype in [('json', 'application/json'), ('csv', 'text/csv'), ('html', 'text/html')]:
status, payload = server.h_recon_survey_download(self._ctx((sid, fmt)))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, ctype)
self.assertEqual(payload.filename, 'survey-%s.%s' % (sid, fmt))
status, payload = server.h_recon_survey_download(self._ctx((sid, 'csv')))
self.assertIn('C8:9E:43:64:80:80', payload.data.decode('utf-8'))
def test_delete_removes_file_then_404(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
sid = server._survey_state['id']
server.h_recon_survey_stop(self._ctx())
status, data = server.h_recon_survey_delete(self._ctx((sid,)))
self.assertEqual(status, 200)
self.assertFalse(os.listdir(self.dir))
status, data = server.h_recon_survey_delete(self._ctx((sid,)))
self.assertEqual(status, 404)
def test_delete_blocks_active_survey(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
sid = server._survey_state['id']
status, data = server.h_recon_survey_delete(self._ctx((sid,)))
self.assertEqual(status, 409)
def test_detail_404_missing(self):
status, data = server.h_recon_survey_detail(self._ctx(('nope',)))
self.assertEqual(status, 404)
class ReconRoutesTest(unittest.TestCase):
def test_new_routes_registered(self):
expected = [
('GET', '/api/recon/scans/1/download/csv', 'h_recon_scan_download_csv'),
('GET', '/api/recon/scans/1/download/html', 'h_recon_scan_download_html'),
('GET', '/api/recon/gps', 'h_recon_gps'),
('POST', '/api/recon/gps/configure', 'h_recon_gps_configure'),
('POST', '/api/recon/wigle', 'h_recon_wigle'),
('GET', '/api/recon/wigle/files', 'h_recon_wigle_files'),
('GET', '/api/recon/wigle/files/x.csv', 'h_recon_wigle_file'),
('GET', '/api/recon/survey/live', 'h_recon_survey_live'),
('POST', '/api/recon/survey/start', 'h_recon_survey_start'),
('POST', '/api/recon/survey/stop', 'h_recon_survey_stop'),
('GET', '/api/recon/surveys', 'h_recon_surveys'),
('GET', '/api/recon/surveys/20260818-120000-A', 'h_recon_survey_detail'),
('GET', '/api/recon/surveys/20260818-120000-A/download/csv', 'h_recon_survey_download'),
('GET', '/api/recon/surveys/20260818-120000-A/download/json', 'h_recon_survey_download'),
('GET', '/api/recon/surveys/20260818-120000-A/download/html', 'h_recon_survey_download'),
('DELETE', '/api/recon/surveys/20260818-120000-A', 'h_recon_survey_delete'),
]
for method, path, handler in expected:
h, args = server.ROUTER.dispatch(method, path)
self.assertIsNotNone(h, '%s %s' % (method, path))
self.assertEqual(h.__name__, handler, '%s %s' % (method, path))
def test_survey_download_route_captures_format(self):
h, args = server.ROUTER.dispatch('GET', '/api/recon/surveys/abc/download/csv')
self.assertEqual(args, ('abc', 'csv'))
def test_original_recon_routes_unchanged(self):
for path in ['/api/recon/start', '/api/recon/status', '/api/recon/scans',
'/api/recon/events']:
method = 'GET' if path.endswith(('status', 'scans', 'events')) else 'POST'
h, args = server.ROUTER.dispatch(method, path)
self.assertIsNotNone(h, path)