Files
StarPilot/openpilot/starpilot/galaxy/tests/test_bluetooth_http.py
T

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()