import asyncio import base64 import json from pathlib import Path import struct import zlib import sys import unittest from unittest.mock import patch sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "bridge")) sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "vendor")) from dbus_next import Variant, DBusError from Crypto.Cipher import AES from protocol import (Assembler, MAX_BODY, MAX_FRAME, NOTIFICATION_WINDOW, open_message, packets, seal, stream_tag, notification_packets) from server import Advertisement, check_adapter, Application, Characteristic, NotificationCharacteristic, GattService, Gateway, SERVICE_PATH KEY = bytes(range(32)) class ProtocolTests(unittest.TestCase): def test_notification_packet_tags_binary_and_mtu(self): tag = stream_tag("01" * 16, 1) self.assertNotEqual(tag, stream_tag("01" * 16, 2)) self.assertNotEqual(tag, stream_tag("02" * 16, 1)) value = {"body": base64.b64encode(bytes(range(256)) * 100).decode(), "status": 200} frame = seal(value, KEY, "response", compact_body=True) for size in (20, 180, 244, 512): assembler = Assembler() for packet in notification_packets(frame, tag, size): self.assertLessEqual(len(packet), size) self.assertEqual(packet[1:9], tag) decoded = assembler.add(b"\x01" + packet[9:]) self.assertEqual(open_message(decoded, KEY, "response"), value) def test_roundtrip_all_att_sizes_and_binary_body(self): value = {"body": base64.b64encode(bytes(range(256)) * 32).decode(), "unicode": "Galaxy ✨", "status": 200} frame = seal(value, KEY, "response") for size in (20, 64, 180, 244, 512): assembler = Assembler() decoded = None for packet in packets(frame, size): self.assertLessEqual(len(packet), size) decoded = assembler.add(packet) self.assertEqual(open_message(decoded, KEY, "response"), value) def test_ciphertext_tamper_wrong_key_and_direction(self): message = seal({"id": "test"}, KEY, "request")[4:] for data, key, direction in [(message[:-1] + bytes([message[-1] ^ 1]), KEY, "request"), (message, bytes(32), "request"), (message, KEY, "response")]: with self.assertRaises(ValueError): open_message(data, key, direction) def test_compression_and_bounded_frames(self): frame = seal({"data": "parameter" * 20000}, KEY, "response") self.assertLess(len(frame), 1000) with self.assertRaises(ValueError): Assembler().add(b"\x01" + struct.pack(">I", 0) + struct.pack(">I", MAX_FRAME + 1)) with self.assertRaises(ValueError): Assembler().add(b"\x01" + struct.pack(">I", 1) + b"bad") def test_reject_trailing_frame_bytes(self): frame = seal({"id": "a"}, KEY, "request") with self.assertRaises(ValueError): Assembler().add(b"\x01" + bytes(4) + frame + b"extra") def test_compact_response_preserves_json_and_binary(self): for body in (json.dumps([{ "repeated_setting": n, "label": "Galaxy ✨" } for n in range(1000)]).encode(), bytes(range(256)) * 100, b""): value = {"id": "compact", "status": 200, "headers": {"content-type": "application/json"}, "body": base64.b64encode(body).decode()} legacy = seal(value, KEY, "response") compact = seal(value, KEY, "response", compact_body=True) self.assertLessEqual(len(compact), len(legacy)) self.assertEqual(open_message(compact[4:], KEY, "response"), value) with self.assertRaises(ValueError): seal({"body": base64.b64encode(b"x" * (MAX_BODY + 1)).decode()}, KEY, "response", compact_body=True) def test_compact_response_rejects_bad_metadata_and_expansion(self): for payload in (b"bad", struct.pack(">I", 50) + b"{}", struct.pack(">I", 2) + b"[]", struct.pack(">I", 11) + b'{"body":""}', struct.pack(">I", 2) + b"{}" + b"x" * (MAX_BODY + 1)): encoded = b"\x02" + struct.pack(">I", len(payload)) + zlib.compress(payload, wbits=-15) cipher = AES.new(KEY, AES.MODE_GCM, nonce=bytes(12)) cipher.update(b"galaxy-ble-v1/response") ciphertext, tag = cipher.encrypt_and_digest(encoded) with self.assertRaises(ValueError): open_message(bytes(cipher.nonce) + ciphertext + tag, KEY, "response") class CountingProxy: def __init__(self): self.calls = 0 def handle(self, request): self.calls += 1 return {"id": request["id"], "status": 200, "headers": {}, "body": ""} class GatewayTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.proxy = CountingProxy() self.gateway = Gateway(KEY, self.proxy) self.options = {"device": Variant("o", "/org/bluez/hci0/dev_01_02_03_04_05_06"), "mtu": Variant("q", 185)} self.state = self.gateway.session(self.options, fresh=True) async def submit(self, counter=1, session=None, key=KEY, response_codec=None, response_flow=None): request = {"id": "unique", "session": session or self.state.challenge.hex(), "counter": counter, "path": "/api/params", "method": "PUT", "body": ""} if response_codec is not None: request["responseCodec"] = response_codec if response_flow is not None: request["responseFlow"] = response_flow for packet in packets(seal(request, key, "request")): self.gateway.write(packet, self.options) task = self.state.task await task def drain(self): assembler = Assembler() decoded = None while self.state.response: packet = self.gateway.read(self.options) self.assertEqual(self.gateway.read(self.options), packet) # Read is repeatable until ACK. decoded = assembler.add(packet) self.gateway.write(b"\x02" + packet[1:5], self.options) return open_message(decoded, KEY, "response") if decoded else None async def test_actual_gatt_request_ack_and_replay(self): await self.submit() reply = self.drain() self.assertEqual(reply["counter"], 1) self.assertEqual(self.proxy.calls, 1) await self.submit() # Same session + counter cannot mutate twice. self.assertFalse(self.state.response) self.assertEqual(self.proxy.calls, 1) await self.submit(counter=2) self.drain() self.assertEqual(self.proxy.calls, 2) async def test_wrong_key_and_old_session_never_reach_galaxy(self): await self.submit(key=bytes(32)) await self.submit(session="00" * 16) self.assertEqual(self.proxy.calls, 0) self.assertFalse(self.state.response) async def test_dbus_exports_and_mtu_minimum(self): characteristic = Characteristic("uuid", ["read"], self.gateway, "tx") service = GattService() application = Application({SERVICE_PATH: service}) self.assertIn(SERVICE_PATH, application.GetManagedObjects.__wrapped__(application)) self.assertEqual(characteristic.props()["Flags"].value, ["read"]) self.options["mtu"] = Variant("q", 23) await self.submit() self.assertTrue(all(len(p) <= 20 for p in self.state.response)) self.drain() async def test_fast_packets_require_capability_and_keep_negotiated_mtu(self): self.options["mtu"] = Variant("q", 517) self.gateway.session(self.options) no_mtu = {"device": self.options["device"]} self.assertEqual(self.gateway.session(no_mtu).packet_size, 512) body = json.dumps([{ "repeated_setting": n, "label": "Galaxy ✨" } for n in range(1000)]).encode() def response(request): return {"id": request["id"], "status": 200, "headers": {}, "body": base64.b64encode(body).decode()} self.proxy.handle = response # ty: ignore[invalid-assignment] await self.submit() self.assertTrue(all(len(p) <= 180 for p in self.state.response)) legacy_count = len(self.state.response) self.drain() await self.submit(counter=2, response_codec=2) self.assertEqual(len(self.state.response[0]), 512) self.assertLess(len(self.state.response), legacy_count) self.assertEqual(base64.b64decode(self.drain()["body"]), body) self.options["mtu"] = Variant("q", 23) await self.submit(counter=3, response_codec=2) self.assertTrue(all(len(p) <= 20 for p in self.state.response)) self.drain() async def test_stream_reads_once_then_acknowledges_verified_frame(self): body = json.dumps([{ "repeated_setting": n } for n in range(1000)]).encode() def response(request): return {"id": request["id"], "status": 200, "headers": {}, "body": base64.b64encode(body).decode()} self.proxy.handle = response # ty: ignore[invalid-assignment] await self.submit(response_codec=2, response_flow="read-stream") count = len(self.state.response) self.assertGreater(count, 1) assembler = Assembler() for sequence in range(count): packet = self.gateway.read(self.options) self.assertEqual(struct.unpack(">I", packet[1:5])[0], sequence) data = assembler.add(packet) if sequence == 0: with self.assertRaises(DBusError): self.gateway.write(b"\x02" + packet[1:5], self.options) self.assertEqual(self.gateway.read(self.options), b"\x00") self.assertTrue(self.state.busy) self.assertEqual(base64.b64decode(open_message(data, KEY, "response")["body"]), body) self.gateway.write(b"\x02" + struct.pack(">I", count - 1), self.options) self.assertFalse(self.state.response) self.assertFalse(self.state.busy) def notifications(self): notifier = NotificationCharacteristic(self.gateway) sent = [] def changed(values, *args): if "Value" in values: sent.append(values["Value"]) notifier.emit_properties_changed = changed NotificationCharacteristic.StartNotify.__wrapped__(notifier) self.assertTrue(notifier.props()["Notifying"].value) self.assertEqual(notifier.props()["Flags"].value, ["notify"]) return notifier, sent async def wait_for_packets(self, sent, count): async def wait(): while len(sent) < count: # noqa: ASYNC110 -- polls a list filled by a test stub await asyncio.sleep(0.001) await asyncio.wait_for(wait(), timeout=2) def notification_body(self): body = json.dumps([{ "repeated_setting": n, "label": "Galaxy ✨" } for n in range(2000)]).encode() def response(request): self.proxy.calls += 1 return {"id": request["id"], "status": 200, "headers": {}, "body": base64.b64encode(body).decode()} self.proxy.handle = response # ty: ignore[invalid-assignment] return body async def test_notifications_are_bounded_and_verified_before_final_ack(self): notifier, sent = self.notifications() body = self.notification_body() self.options["mtu"] = Variant("q", 517) await self.submit(response_codec=2, response_flow="notify-window8") publisher = self.state.notification_task count = len(self.state.response) self.assertGreater(count, NOTIFICATION_WINDOW) await self.wait_for_packets(sent, NOTIFICATION_WINDOW) await asyncio.sleep(0.03) self.assertEqual(len(sent), NOTIFICATION_WINDOW, "Publisher must wait for window credit") tag = self.state.notification_tag for bad in (b"\x04" + bytes(8) + struct.pack(">I", 7), b"\x04" + tag + struct.pack(">I", 0), b"\x02" + struct.pack(">I", 7)): with self.assertRaises(DBusError): self.gateway.write(bad, self.options) self.assertEqual(self.gateway.read(self.options), b"\x00") assembler = Assembler() cursor = 0 while cursor < count: end = min(cursor + NOTIFICATION_WINDOW, count) await self.wait_for_packets(sent, end) for packet in sent[cursor:end]: self.assertLessEqual(len(packet), 512) data = assembler.add(b"\x01" + packet[9:]) self.assertTrue(self.state.busy) if end == count: response = open_message(data, KEY, "response") self.assertEqual(base64.b64decode(response["body"]), body) self.gateway.write(b"\x04" + tag + struct.pack(">I", end - 1), self.options) cursor = end await publisher self.assertFalse(self.state.busy) self.assertEqual(self.proxy.calls, 1) await self.submit(response_codec=2, response_flow="notify-window8") self.assertEqual(self.proxy.calls, 1, "A replay cannot execute the setting change twice") self.assertFalse(self.state.response) async def test_no_notifications_or_proxy_call_before_subscription_and_authentication(self): await self.submit(response_codec=2, response_flow="notify-window8") self.assertEqual(self.proxy.calls, 0) notifier, sent = self.notifications() await self.submit(response_codec=2, response_flow="notify-window8", key=bytes(32)) await self.submit(response_codec=2, response_flow="notify-window8", session="00" * 16) self.assertFalse(sent) self.assertEqual(self.proxy.calls, 0) async def test_notification_subscription_end_and_fresh_session_cancel_without_retry(self): notifier, sent = self.notifications() self.notification_body() await self.submit(response_codec=2, response_flow="notify-window8") publisher = self.state.notification_task await self.wait_for_packets(sent, NOTIFICATION_WINDOW) NotificationCharacteristic.StopNotify.__wrapped__(notifier) with self.assertRaises(asyncio.CancelledError): await publisher self.assertTrue(self.state.busy) self.assertEqual(self.proxy.calls, 1) new_state = self.gateway.session(self.options, fresh=True) self.assertTrue(self.state.closed) self.assertFalse(new_state.busy) self.assertNotEqual(self.state.challenge, new_state.challenge) with self.assertRaises(DBusError): self.gateway.write(b"\x04" + self.state.notification_tag + struct.pack(">I", 7), self.options) async def test_missing_notification_ack_stops_at_one_window_without_repeating_mutation(self): notifier, sent = self.notifications() self.notification_body() with patch("server.NOTIFICATION_ACK_TIMEOUT", 0.03): await self.submit(response_codec=2, response_flow="notify-window8") publisher = self.state.notification_task await publisher self.assertEqual(len(sent), NOTIFICATION_WINDOW) self.assertTrue(self.state.busy) self.assertEqual(self.proxy.calls, 1) with self.assertRaises(DBusError): await self.submit(counter=2, response_codec=2, response_flow="notify-window8") self.assertEqual(self.proxy.calls, 1) async def test_clients_have_distinct_tags_and_serialized_publishers(self): notifier, sent = self.notifications() self.notification_body() await self.submit(response_codec=2, response_flow="notify-window8") first_task = self.state.notification_task first_count = len(self.state.response) other_options = {"device": Variant("o", "/org/bluez/hci0/dev_11_12_13_14_15_16"), "mtu": Variant("q", 185)} other = self.gateway.session(other_options, fresh=True) request = {"id": "other", "session": other.challenge.hex(), "counter": 1, "method": "PUT", "path": "/api/params", "body": "", "responseCodec": 2, "responseFlow": "notify-window8"} for packet in packets(seal(request, KEY, "request")): self.gateway.write(packet, other_options) await other.task second_task = other.notification_task self.assertNotEqual(self.state.notification_tag, other.notification_tag) offset = 0 for state, options, count in [(self.state, self.options, first_count), (other, other_options, len(other.response))]: cursor = 0 while cursor < count: end = min(cursor + NOTIFICATION_WINDOW, count) await self.wait_for_packets(sent, offset + end) window = sent[offset + cursor:offset + end] self.assertTrue(all(p[1:9] == state.notification_tag for p in window)) if state is self.state: with self.assertRaises(DBusError): self.gateway.write(b"\x04" + state.notification_tag + struct.pack(">I", end - 1), other_options) self.gateway.write(b"\x04" + state.notification_tag + struct.pack(">I", end - 1), options) cursor = end offset += count await first_task await second_task self.assertEqual(self.proxy.calls, 2) if __name__ == "__main__": unittest.main() class AdapterHealthTests(unittest.IsolatedAsyncioTestCase): async def test_registered_advertisement_and_power_on_are_required(self): from unittest.mock import AsyncMock properties = type('Properties', (), {})() properties.call_get = AsyncMock(return_value=Variant('b', True)) advertisement = Advertisement() await check_adapter(properties, advertisement) advertisement.released = True with self.assertRaisesRegex(RuntimeError, 'released'): await check_adapter(properties, advertisement) advertisement.released = False properties.call_get.return_value = Variant('b', False) with self.assertRaisesRegex(RuntimeError, 'switched off'): await check_adapter(properties, advertisement) properties.call_get.side_effect = ConnectionError('BlueZ owner disappeared') with self.assertRaises(ConnectionError): await check_adapter(properties, advertisement)