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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user