mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-08 05:23:41 +08:00
208 lines
12 KiB
Python
208 lines
12 KiB
Python
import http.client
|
|
import json
|
|
import tempfile
|
|
import threading
|
|
import unittest
|
|
from contextlib import ExitStack
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
from openpilot.starpilot.bluetooth.owner import BluetoothRejected, BluetoothUnavailable
|
|
from openpilot.starpilot.galaxy.access import GalaxyAccessOwner
|
|
from openpilot.starpilot.galaxy.server import make_server
|
|
|
|
|
|
class FakeBluetooth:
|
|
def __init__(self):
|
|
self.parked = True
|
|
self.calls = []
|
|
self.canceled = []
|
|
|
|
def snapshot(self, *, session=None):
|
|
return {'version': 1, 'available': True, 'parked': self.parked, 'powered': True,
|
|
'discovering': False, 'devices': [], 'errorCode': None, 'pairing': None}
|
|
|
|
def request(self, operation, *, address=None, enabled=None, session=None, prompt_id=None, accepted=None, value=''):
|
|
if not self.parked:
|
|
raise BluetoothRejected('Park required')
|
|
self.calls.append((operation, address, enabled, session, prompt_id, accepted, value))
|
|
return self.snapshot()
|
|
|
|
def cancel_session(self, identity):
|
|
self.canceled.append(identity)
|
|
|
|
|
|
class BluetoothHttpTest(unittest.TestCase):
|
|
def test_retired_pan_actions_are_rejected(self):
|
|
payload = {'operation': 'pan_connect', 'address': 'AA:BB:CC:DD:EE:FF'}
|
|
self.assertEqual(self.request('/api/bluetooth/action', payload)[0], 401)
|
|
_, _, headers = self.request('/api/auth/login', {'password': 'password123'})
|
|
cookie = headers['Set-Cookie'].split(';', 1)[0]
|
|
for operation in ('pan_connect', 'pan_disconnect'):
|
|
payload['operation'] = operation
|
|
self.assertEqual(self.request('/api/bluetooth/action', payload, cookie)[0], 400)
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': operation}, cookie)[0], 400)
|
|
self.assertEqual(self.bluetooth.calls, [])
|
|
|
|
def setUp(self):
|
|
temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temporary.cleanup)
|
|
claim_path = mock.patch('openpilot.starpilot.galaxy_bluetooth.bridge.claim.CLAIM_PATH', Path(temporary.name) / 'claim.json')
|
|
claim_path.start()
|
|
self.addCleanup(claim_path.stop)
|
|
access = GalaxyAccessOwner(Path(temporary.name) / 'access')
|
|
self.assertTrue(access.configure('password123', lambda: True))
|
|
self.access = access
|
|
self.bluetooth = FakeBluetooth()
|
|
# Never inherit the host's AA setting or delegate into a live supervisor.
|
|
starport_setup = mock.Mock()
|
|
starport_setup.enabled.return_value = False
|
|
self.server = make_server(port=0, owner=access, bluetooth=self.bluetooth, starport_setup=starport_setup)
|
|
self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True)
|
|
self.worker.start()
|
|
self.addCleanup(self.stop)
|
|
|
|
def stop(self):
|
|
self.server.shutdown()
|
|
self.worker.join(timeout=2)
|
|
self.server.server_close()
|
|
|
|
def request(self, path, payload=None, cookie=''):
|
|
connection = http.client.HTTPConnection('127.0.0.1', self.server.server_port, timeout=3)
|
|
headers = {'Cookie': cookie}
|
|
if payload is not None:
|
|
headers.update({'Content-Type': 'application/json', 'Origin': f'http://127.0.0.1:{self.server.server_port}'})
|
|
try:
|
|
connection.request('POST' if payload is not None else 'GET', path,
|
|
body=json.dumps(payload) if payload is not None else None, headers=headers)
|
|
response = connection.getresponse()
|
|
return response.status, json.loads(response.read()), dict(response.getheaders())
|
|
finally:
|
|
connection.close()
|
|
|
|
def test_accept_pair_opens_a_window_and_prepares_the_music_receiver(self):
|
|
self.stop()
|
|
starport_setup = mock.Mock()
|
|
starport_setup.enabled.return_value = False
|
|
self.server = make_server(port=0, owner=self.access, bluetooth=self.bluetooth, starport_setup=starport_setup, parked=lambda: True)
|
|
self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True)
|
|
self.worker.start()
|
|
_, _, headers = self.request('/api/auth/login', {'password': 'password123'})
|
|
cookie = headers['Set-Cookie'].split(';', 1)[0]
|
|
with mock.patch('openpilot.starpilot.galaxy.spotify_setup.SpotifySetup.prepare_phone_pairing') as prepare:
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'accept_pair'}, cookie)[0], 200)
|
|
prepare.assert_called_once_with()
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'accept_pair', 'address': 'AA:BB:CC:DD:EE:FF'}, cookie)[0], 400)
|
|
prepare.assert_called_once_with()
|
|
operation, address, *_ = self.bluetooth.calls[-1]
|
|
self.assertEqual((operation, address), ('accept_pair', None))
|
|
self.assertEqual(len(self.bluetooth.calls), 1)
|
|
|
|
def test_the_music_window_also_opens_and_closes_the_app_claim(self):
|
|
from openpilot.starpilot.galaxy_bluetooth.bridge import claim
|
|
self.stop()
|
|
starport_setup = mock.Mock()
|
|
starport_setup.enabled.return_value = False
|
|
self.server = make_server(port=0, owner=self.access, bluetooth=self.bluetooth, starport_setup=starport_setup, parked=lambda: True)
|
|
self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True)
|
|
self.worker.start()
|
|
_, _, headers = self.request('/api/auth/login', {'password': 'password123'})
|
|
cookie = headers['Set-Cookie'].split(';', 1)[0]
|
|
with tempfile.TemporaryDirectory() as directory, mock.patch.object(claim, 'CLAIM_PATH', Path(directory) / 'claim.json'), \
|
|
mock.patch('openpilot.starpilot.galaxy.spotify_setup.SpotifySetup.prepare_phone_pairing'), \
|
|
mock.patch.object(self.bluetooth, 'snapshot', return_value={'adapterAddress': 'AA:BB:CC:DD:EE:FF', 'adapterName': 'comma'}):
|
|
self.assertIsNone(claim.window(claim.CLAIM_PATH))
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'accept_pair'}, cookie)[0], 200)
|
|
window = claim.window(claim.CLAIM_PATH)
|
|
self.assertEqual((window['adapterAddress'], window['adapterName']), ('AA:BB:CC:DD:EE:FF', 'comma'))
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'cancel_pair'}, cookie)[0], 200)
|
|
self.assertIsNone(claim.window(claim.CLAIM_PATH))
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'scan'}, cookie)[0], 200)
|
|
self.assertIsNone(claim.window(claim.CLAIM_PATH))
|
|
|
|
def test_forgetting_a_device_also_forgets_its_app_key(self):
|
|
self.stop()
|
|
starport_setup = mock.Mock()
|
|
starport_setup.enabled.return_value = False
|
|
self.server = make_server(port=0, owner=self.access, bluetooth=self.bluetooth, starport_setup=starport_setup, parked=lambda: True)
|
|
self.worker = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True)
|
|
self.worker.start()
|
|
_, _, headers = self.request('/api/auth/login', {'password': 'password123'})
|
|
cookie = headers['Set-Cookie'].split(';', 1)[0]
|
|
with mock.patch('openpilot.starpilot.galaxy_bluetooth.bridge.pairing.remove_by_address') as remove:
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'connect', 'address': 'AA:BB:CC:DD:EE:FF'}, cookie)[0], 200)
|
|
remove.assert_not_called()
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'forget', 'address': 'AA:BB:CC:DD:EE:FF'}, cookie)[0], 200)
|
|
remove.assert_called_once_with('AA:BB:CC:DD:EE:FF')
|
|
|
|
def test_authenticated_status_and_parked_mutation(self):
|
|
self.assertEqual(self.request('/api/bluetooth/status')[0], 401)
|
|
code, _, headers = self.request('/api/auth/login', {'password': 'password123'})
|
|
self.assertEqual(code, 200)
|
|
cookie = headers['Set-Cookie'].split(';', 1)[0]
|
|
self.assertEqual(self.request('/api/bluetooth/status', cookie=cookie)[1]['powered'], True)
|
|
self.assertEqual(self.request('/api/bluetooth/status', cookie=cookie)[1]['claim']['open'], False)
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'connect', 'address': 'AA:BB:CC:DD:EE:FF'}, cookie)[0], 200)
|
|
self.assertEqual(self.bluetooth.calls[0][:3], ('connect', 'AA:BB:CC:DD:EE:FF', None))
|
|
self.bluetooth.parked = False
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'disconnect', 'address': 'AA:BB:CC:DD:EE:FF'}, cookie)[0], 409)
|
|
self.assertEqual(len(self.bluetooth.calls), 1)
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'pair', 'address': 'AA:BB:CC:DD:EE:FF'}, cookie)[0], 409)
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'connect', 'address': 'bad'}, cookie)[0], 400)
|
|
self.assertEqual(self.request('/api/auth/logout', {}, cookie)[0], 200)
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'pair', 'address': '11:22:33:44:55:66'}, cookie)[0], 401)
|
|
self.assertEqual(self.bluetooth.canceled, [self.bluetooth.calls[0][3]])
|
|
self.assertEqual(self.request('/api/bluetooth/status', cookie=cookie)[0], 401)
|
|
|
|
def test_radio_errors_keep_their_cause_without_exposing_internal_details(self):
|
|
_, _, headers = self.request('/api/auth/login', {'password': 'password123'})
|
|
cookie = headers['Set-Cookie'].split(';', 1)[0]
|
|
for error, expected_status, expected_code in (
|
|
(BluetoothRejected('private owner detail', code='busy'), 409, 'busy'),
|
|
(BluetoothRejected('private state detail', code='park_required'), 409, 'park_required'),
|
|
(BluetoothUnavailable('private system bus detail'), 503, 'service_unavailable'),
|
|
(BluetoothUnavailable('private adapter detail', code='adapter_unavailable'), 503, 'adapter_unavailable'),
|
|
):
|
|
with mock.patch.object(self.bluetooth, 'request', side_effect=error):
|
|
code, body, _ = self.request('/api/bluetooth/action', {'operation': 'power', 'enabled': True}, cookie)
|
|
self.assertEqual(code, expected_status)
|
|
self.assertEqual(body['code'], expected_code)
|
|
self.assertNotIn('private', json.dumps(body))
|
|
|
|
def test_pairing_actions_are_scoped_and_strict(self):
|
|
code, _, headers = self.request('/api/auth/login', {'password': 'password123'})
|
|
self.assertEqual(code, 200)
|
|
cookie = headers['Set-Cookie'].split(';', 1)[0]
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'pair', 'address': '11:22:33:44:55:66'}, cookie)[0], 200)
|
|
self.assertEqual(self.bluetooth.calls[-1][:2], ('pair', '11:22:33:44:55:66'))
|
|
self.assertIsNotNone(self.bluetooth.calls[-1][3])
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'pairing_response', 'promptId': 'a' * 32,
|
|
'accepted': True, 'value': '123456'}, cookie)[0], 200)
|
|
self.assertEqual(self.bluetooth.calls[-1][4:], ('a' * 32, True, '123456'))
|
|
self.assertEqual(self.request('/api/bluetooth/action', {'operation': 'cancel_pair'}, cookie)[0], 200)
|
|
for payload in ({'operation': 'pairing_response', 'promptId': 'bad', 'accepted': True},
|
|
{'operation': 'pairing_response', 'promptId': 'a' * 32, 'accepted': 1},
|
|
{'operation': 'pair', 'address': '11:22:33:44:55:66', 'enabled': True}):
|
|
self.assertEqual(self.request('/api/bluetooth/action', payload, cookie)[0], 400)
|
|
self.assertEqual(self.request('/api/auth/logout', {}, cookie)[0], 200)
|
|
|
|
def test_revoked_during_status_read_returns_401_without_pairing_prompt(self):
|
|
code, _, headers = self.request('/api/auth/login', {'password': 'password123'})
|
|
self.assertEqual(code, 200)
|
|
cookie = headers['Set-Cookie'].split(';', 1)[0]
|
|
with ExitStack() as stack:
|
|
def revoke_during_snapshot(*, session=None):
|
|
stack.enter_context(mock.patch.object(self.access, 'current_generation', return_value=None))
|
|
return {'version': 1, 'available': True, 'parked': True, 'powered': True,
|
|
'discovering': False, 'devices': [], 'errorCode': None,
|
|
'pairing': {'address': '11:22:33:44:55:66', 'state': 'pairing',
|
|
'prompt': {'id': 'a' * 32, 'kind': 'pin', 'value': 'private', 'displayOnly': False}}}
|
|
stack.enter_context(mock.patch.object(self.bluetooth, 'snapshot', side_effect=revoke_during_snapshot))
|
|
status, body, _ = self.request('/api/bluetooth/status', cookie=cookie)
|
|
self.assertEqual(status, 401)
|
|
self.assertNotIn('private', json.dumps(body))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|