test_encrypted_device.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  1. """Tests for SwitchbotEncryptedDevice base class."""
  2. from __future__ import annotations
  3. import asyncio
  4. from typing import Any
  5. from unittest.mock import AsyncMock, patch
  6. import pytest
  7. from bleak.exc import BleakDBusError
  8. from switchbot import SwitchbotModel
  9. from switchbot.devices.device import (
  10. AESMode,
  11. SwitchbotEncryptedDevice,
  12. SwitchbotOperationError,
  13. )
  14. from .test_adv_parser import generate_ble_device
  15. class MockEncryptedDevice(SwitchbotEncryptedDevice):
  16. """Mock encrypted device for testing."""
  17. def __init__(self, *args: Any, **kwargs: Any) -> None:
  18. super().__init__(*args, **kwargs)
  19. self.update_count: int = 0
  20. async def update(self, interface: int | None = None) -> None:
  21. self.update_count += 1
  22. def create_encrypted_device(
  23. model: SwitchbotModel = SwitchbotModel.LOCK,
  24. ) -> MockEncryptedDevice:
  25. """Create an encrypted device for testing."""
  26. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Test Device")
  27. return MockEncryptedDevice(
  28. ble_device, "01", "0123456789abcdef0123456789abcdef", model=model
  29. )
  30. @pytest.mark.asyncio
  31. async def test_encrypted_device_init() -> None:
  32. """Test encrypted device initialization."""
  33. device = create_encrypted_device()
  34. assert device._key_id == "01"
  35. assert device._encryption_key == bytearray.fromhex(
  36. "0123456789abcdef0123456789abcdef"
  37. )
  38. assert device._iv is None
  39. assert device._cipher is None
  40. @pytest.mark.asyncio
  41. async def test_encrypted_device_init_validation() -> None:
  42. """Test encrypted device initialization with invalid parameters."""
  43. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Test Device")
  44. # Test empty key_id
  45. with pytest.raises(ValueError, match="key_id is missing"):
  46. MockEncryptedDevice(
  47. ble_device,
  48. "",
  49. "0123456789abcdef0123456789abcdef",
  50. model=SwitchbotModel.LOCK,
  51. )
  52. # Test invalid key_id length
  53. with pytest.raises(ValueError, match="key_id is invalid"):
  54. MockEncryptedDevice(
  55. ble_device,
  56. "1",
  57. "0123456789abcdef0123456789abcdef",
  58. model=SwitchbotModel.LOCK,
  59. )
  60. # Test empty encryption_key
  61. with pytest.raises(ValueError, match="encryption_key is missing"):
  62. MockEncryptedDevice(ble_device, "01", "", model=SwitchbotModel.LOCK)
  63. # Test invalid encryption_key length
  64. with pytest.raises(ValueError, match="encryption_key is invalid"):
  65. MockEncryptedDevice(
  66. ble_device, "01", "0123456789abcdef", model=SwitchbotModel.LOCK
  67. )
  68. @pytest.mark.asyncio
  69. async def test_send_command_unencrypted() -> None:
  70. """Test sending unencrypted command."""
  71. device = create_encrypted_device()
  72. with patch.object(device, "_send_command_locked_with_retry") as mock_send:
  73. mock_send.return_value = b"\x01\x00\x00\x00"
  74. result = await device._send_command("570200", encrypt=False)
  75. assert result == b"\x01\x00\x00\x00"
  76. mock_send.assert_called_once()
  77. # Verify the key was padded with zeros for unencrypted command
  78. call_args = mock_send.call_args[0]
  79. assert call_args[0] == "570000000200" # Original key with zeros inserted
  80. @pytest.mark.asyncio
  81. async def test_send_command_encrypted_success() -> None:
  82. """Test successful encrypted command."""
  83. device = create_encrypted_device()
  84. # Mock the connection and command execution
  85. with (
  86. patch.object(device, "_send_command_locked_with_retry") as mock_send,
  87. patch.object(device, "_decrypt") as mock_decrypt,
  88. ):
  89. mock_decrypt.return_value = b"decrypted_response"
  90. # First call is for IV initialization, second is for the actual command
  91. mock_send.side_effect = [
  92. b"\x01\x00\x00\x00\x12\x34\x56\x78\x9a\xbc\xde\xf0\x12\x34\x56\x78\x9a\xbc\xde\xf0", # IV response (16 bytes)
  93. b"\x01\x00\x00\x00encrypted_response", # Command response
  94. ]
  95. result = await device._send_command("570200", encrypt=True)
  96. assert result is not None
  97. assert mock_send.call_count == 2
  98. # Verify IV was initialized
  99. assert device._iv is not None
  100. @pytest.mark.asyncio
  101. async def test_send_command_iv_already_initialized() -> None:
  102. """Test sending encrypted command when IV is already initialized."""
  103. device = create_encrypted_device()
  104. # Pre-set the IV
  105. device._iv = b"\x12\x34\x56\x78\x9a\xbc\xde\xf0\x12\x34\x56\x78\x9a\xbc\xde\xf0"
  106. with (
  107. patch.object(device, "_send_command_locked_with_retry") as mock_send,
  108. patch.object(device, "_encrypt") as mock_encrypt,
  109. patch.object(device, "_decrypt") as mock_decrypt,
  110. ):
  111. mock_encrypt.return_value = (
  112. "656e637279707465645f64617461", # "encrypted_data" in hex
  113. "abcd",
  114. )
  115. mock_decrypt.return_value = b"decrypted_response"
  116. mock_send.return_value = b"\x01\x00\x00\x00encrypted_response"
  117. result = await device._send_command("570200", encrypt=True)
  118. assert result == b"\x01decrypted_response"
  119. # Should only call once since IV is already initialized
  120. mock_send.assert_called_once()
  121. mock_encrypt.assert_called_once()
  122. mock_decrypt.assert_called_once()
  123. @pytest.mark.asyncio
  124. async def test_iv_race_condition_during_disconnect() -> None:
  125. """Test that commands during disconnect are handled properly."""
  126. device = create_encrypted_device()
  127. # Pre-set the IV
  128. device._iv = b"\x12\x34\x56\x78\x9a\xbc\xde\xf0\x12\x34\x56\x78"
  129. # Mock the connection
  130. mock_client = AsyncMock()
  131. mock_client.is_connected = True
  132. device._client = mock_client
  133. async def simulate_disconnect() -> None:
  134. """Simulate disconnect happening during command execution."""
  135. await asyncio.sleep(0.01) # Small delay
  136. await device._execute_disconnect()
  137. with (
  138. patch.object(device, "_send_command_locked_with_retry") as mock_send,
  139. patch.object(device, "_ensure_connected"),
  140. patch.object(device, "_encrypt") as mock_encrypt,
  141. patch.object(device, "_decrypt") as mock_decrypt,
  142. ):
  143. mock_encrypt.return_value = ("656e63727970746564", "abcd")
  144. mock_decrypt.return_value = b"response"
  145. mock_send.return_value = b"\x01\x00\x00\x00response"
  146. # Start command and disconnect concurrently
  147. command_task = asyncio.create_task(device._send_command("570200"))
  148. disconnect_task = asyncio.create_task(simulate_disconnect())
  149. # Both should complete without error
  150. result, _ = await asyncio.gather(
  151. command_task, disconnect_task, return_exceptions=True
  152. )
  153. # Command should have completed successfully
  154. assert isinstance(result, bytes) or result is None
  155. # IV should be cleared after disconnect
  156. assert device._iv is None
  157. @pytest.mark.asyncio
  158. async def test_ensure_encryption_initialized_with_lock_held() -> None:
  159. """Test that _ensure_encryption_initialized properly handles the operation lock."""
  160. device = create_encrypted_device()
  161. # Acquire the operation lock
  162. async with device._operation_lock:
  163. with patch.object(device, "_send_command_locked_with_retry") as mock_send:
  164. mock_send.return_value = b"\x01\x00\x00\x00\x12\x34\x56\x78\x9a\xbc\xde\xf0\x12\x34\x56\x78\x9a\xbc\xde\xf0"
  165. result = await device._ensure_encryption_initialized()
  166. assert result is True
  167. assert (
  168. device._iv
  169. == b"\x12\x34\x56\x78\x9a\xbc\xde\xf0\x12\x34\x56\x78\x9a\xbc\xde\xf0"
  170. )
  171. assert device._cipher is None # Should be reset when IV changes
  172. @pytest.mark.asyncio
  173. async def test_ensure_encryption_initialized_sets_gcm_mode() -> None:
  174. """Test that GCM mode is detected from device response."""
  175. device = create_encrypted_device()
  176. gcm_iv = b"\x01" * 12
  177. response = b"\x01\x00\x01\x00" + gcm_iv + b"\x00\x00\x00\x00"
  178. async with device._operation_lock:
  179. with patch.object(device, "_send_command_locked_with_retry") as mock_send:
  180. mock_send.return_value = response
  181. result = await device._ensure_encryption_initialized()
  182. assert result is True
  183. assert device._encryption_mode == AESMode.GCM
  184. assert device._iv == gcm_iv
  185. @pytest.mark.asyncio
  186. async def test_ensure_encryption_initialized_invalid_iv_length_gcm() -> None:
  187. """Test that invalid IV length for GCM mode returns False."""
  188. device = create_encrypted_device()
  189. # GCM expects 12 bytes IV, but response has wrong length (only 8 bytes after trimming)
  190. response = b"\x01\x00\x01\x00" + b"\x01" * 8 + b"\x00\x00\x00\x00"
  191. async with device._operation_lock:
  192. with patch.object(device, "_send_command_locked_with_retry") as mock_send:
  193. mock_send.return_value = response
  194. result = await device._ensure_encryption_initialized()
  195. assert result is False
  196. assert device._iv is None
  197. @pytest.mark.asyncio
  198. async def test_ensure_encryption_initialized_invalid_iv_length_ctr() -> None:
  199. """Test that invalid IV length for CTR mode returns False."""
  200. device = create_encrypted_device()
  201. # CTR expects 16 bytes IV, but response has only 8 bytes
  202. response = b"\x01\x00\x00\x00" + b"\x01" * 8
  203. async with device._operation_lock:
  204. with patch.object(device, "_send_command_locked_with_retry") as mock_send:
  205. mock_send.return_value = response
  206. result = await device._ensure_encryption_initialized()
  207. assert result is False
  208. assert device._iv is None
  209. @pytest.mark.asyncio
  210. async def test_device_with_gcm_mode() -> None:
  211. """Test that device initializes correctly in GCM mode and increments GCM IV."""
  212. device = create_encrypted_device()
  213. device._encryption_mode = AESMode.GCM
  214. device._iv = b"\x01" * 12
  215. with (
  216. patch.object(device, "_ensure_encryption_initialized") as mock_ensure,
  217. patch.object(device, "_send_command_locked_with_retry") as mock_send,
  218. patch.object(device, "_decrypt") as mock_decrypt,
  219. patch.object(device, "_encrypt") as mock_encrypt,
  220. patch.object(device, "_increment_gcm_iv") as mock_inc_iv,
  221. ):
  222. mock_ensure.return_value = True
  223. mock_encrypt.return_value = ("10203040", "abcd")
  224. mock_send.return_value = b"\x01\x00\x00\x00\x10\x20\x30\x40"
  225. mock_decrypt.return_value = b"\x10\x20\x30\x40"
  226. await device._send_command("570200")
  227. mock_inc_iv.assert_called_once()
  228. @pytest.mark.asyncio
  229. async def test_resolve_encryption_mode_invalid() -> None:
  230. """Test that invalid mode byte raises error."""
  231. device = create_encrypted_device()
  232. with pytest.raises(ValueError, match="Unsupported encryption mode"):
  233. device._resolve_encryption_mode(2)
  234. @pytest.mark.asyncio
  235. async def test_resolve_encryption_mode_missing() -> None:
  236. """Test that missing mode byte raises error."""
  237. device = create_encrypted_device()
  238. with pytest.raises(ValueError, match="Encryption mode byte is missing"):
  239. device._resolve_encryption_mode(None)
  240. @pytest.mark.asyncio
  241. async def test_resolve_encryption_mode_conflict() -> None:
  242. """Test that conflicting encryption modes raise error."""
  243. device = create_encrypted_device()
  244. device._encryption_mode = AESMode.CTR
  245. with pytest.raises(
  246. ValueError,
  247. match="Conflicting encryption modes detected: CTR vs GCM",
  248. ):
  249. device._resolve_encryption_mode(1)
  250. @pytest.mark.asyncio
  251. async def test_increment_gcm_iv() -> None:
  252. """Test GCM IV increment logic."""
  253. device = create_encrypted_device()
  254. device._encryption_mode = AESMode.GCM
  255. device._iv = b"\x00" * 11 + b"\x01"
  256. device._increment_gcm_iv()
  257. assert device._iv == b"\x00" * 11 + b"\x02"
  258. assert device._cipher is None
  259. @pytest.mark.asyncio
  260. @pytest.mark.parametrize(
  261. ("initial_iv", "expected_exception", "expected_message"),
  262. [
  263. (None, RuntimeError, "Cannot increment GCM IV: IV is None"),
  264. (
  265. b"\x00" * 10,
  266. RuntimeError,
  267. "Cannot increment GCM IV: IV length is not 12 bytes",
  268. ),
  269. ],
  270. )
  271. async def test_increment_gcm_iv_invalid(
  272. initial_iv, expected_exception, expected_message
  273. ) -> None:
  274. """Test GCM IV increment with invalid IV states."""
  275. device = create_encrypted_device()
  276. device._encryption_mode = AESMode.GCM
  277. device._iv = initial_iv
  278. with pytest.raises(expected_exception, match=expected_message):
  279. device._increment_gcm_iv()
  280. @pytest.mark.asyncio
  281. async def test_gcm_encrypt_decrypt_without_finalize() -> None:
  282. """Test GCM encrypt/decrypt works without finalize in decrypt."""
  283. device = create_encrypted_device()
  284. device._encryption_mode = AESMode.GCM
  285. device._iv = b"\x10" * 12
  286. ciphertext_hex, _ = device._encrypt("48656c6c6f")
  287. decrypted = device._decrypt(bytearray.fromhex(ciphertext_hex))
  288. assert decrypted.hex() == "48656c6c6f"
  289. @pytest.mark.asyncio
  290. async def test_ensure_encryption_initialized_failure() -> None:
  291. """Test _ensure_encryption_initialized when IV initialization fails."""
  292. device = create_encrypted_device()
  293. async with device._operation_lock:
  294. with patch.object(device, "_send_command_locked_with_retry") as mock_send:
  295. # Return failure response
  296. mock_send.return_value = b"\x00"
  297. result = await device._ensure_encryption_initialized()
  298. assert result is False
  299. assert device._iv is None
  300. @pytest.mark.asyncio
  301. async def test_encrypt_decrypt_with_valid_iv() -> None:
  302. """Test encryption and decryption with valid IV."""
  303. device = create_encrypted_device()
  304. device._iv = b"\x00" * 16 # Use zeros for predictable test
  305. # Test encryption
  306. ciphertext_hex, header_hex = device._encrypt("48656c6c6f") # "Hello" in hex
  307. assert isinstance(ciphertext_hex, str)
  308. assert isinstance(header_hex, str)
  309. assert len(ciphertext_hex) > 0
  310. # Test decryption
  311. decrypted = device._decrypt(bytearray.fromhex(ciphertext_hex))
  312. assert decrypted.hex() == "48656c6c6f"
  313. @pytest.mark.asyncio
  314. async def test_encrypt_with_none_iv() -> None:
  315. """Test that encryption raises error when IV is None."""
  316. device = create_encrypted_device()
  317. device._iv = None
  318. with pytest.raises(RuntimeError, match="Cannot encrypt: IV is None"):
  319. device._encrypt("48656c6c6f")
  320. @pytest.mark.asyncio
  321. async def test_decrypt_with_none_iv() -> None:
  322. """Test that decryption raises error when IV is None."""
  323. device = create_encrypted_device()
  324. device._iv = None
  325. with pytest.raises(RuntimeError, match="Cannot decrypt: IV is None"):
  326. device._decrypt(bytearray.fromhex("48656c6c6f"))
  327. @pytest.mark.asyncio
  328. async def test_get_cipher_with_none_iv() -> None:
  329. """Test that _get_cipher raises error when IV is None."""
  330. device = create_encrypted_device()
  331. device._iv = None
  332. with pytest.raises(RuntimeError, match="Cannot create cipher: IV is None"):
  333. device._get_cipher()
  334. @pytest.mark.asyncio
  335. async def test_execute_disconnect_clears_encryption_state() -> None:
  336. """Test that disconnect properly clears encryption state."""
  337. device = create_encrypted_device()
  338. device._iv = b"\x12\x34\x56\x78\x9a\xbc\xde\xf0\x12\x34\x56\x78\x9a\xbc\xde\xf0"
  339. device._cipher = None # type: ignore[assignment]
  340. device._encryption_mode = AESMode.CTR
  341. # Mock client
  342. mock_client = AsyncMock()
  343. device._client = mock_client
  344. with patch.object(device, "_execute_disconnect_with_lock") as mock_disconnect:
  345. await device._execute_disconnect()
  346. assert device._iv is None
  347. assert device._cipher is None
  348. assert device._encryption_mode is None
  349. mock_disconnect.assert_called_once()
  350. @pytest.mark.asyncio
  351. async def test_concurrent_commands_with_same_device() -> None:
  352. """Test multiple concurrent commands on the same device."""
  353. device = create_encrypted_device()
  354. # Pre-initialize IV (16 bytes for AES CTR mode)
  355. device._iv = b"\x12\x34\x56\x78\x9a\xbc\xde\xf0\x12\x34\x56\x78\x9a\xbc\xde\xf0"
  356. with (
  357. patch.object(device, "_send_command_locked_with_retry") as mock_send,
  358. patch.object(device, "_encrypt") as mock_encrypt,
  359. patch.object(device, "_decrypt") as mock_decrypt,
  360. ):
  361. mock_encrypt.return_value = ("656e63727970746564", "abcd")
  362. mock_decrypt.return_value = b"response"
  363. mock_send.return_value = b"\x01\x00\x00\x00data"
  364. # Send multiple commands concurrently
  365. tasks = [
  366. device._send_command("570200"),
  367. device._send_command("570201"),
  368. device._send_command("570202"),
  369. ]
  370. results = await asyncio.gather(*tasks)
  371. # All commands should succeed
  372. assert all(result == b"\x01response" for result in results)
  373. assert mock_send.call_count == 3
  374. @pytest.mark.asyncio
  375. async def test_command_retry_with_encryption() -> None:
  376. """Test command retry logic with encrypted commands."""
  377. device = create_encrypted_device()
  378. device._retry_count = 2
  379. # Pre-initialize IV (16 bytes for AES CTR mode)
  380. device._iv = b"\x12\x34\x56\x78\x9a\xbc\xde\xf0\x12\x34\x56\x78\x9a\xbc\xde\xf0"
  381. with (
  382. patch.object(device, "_send_command_locked") as mock_send_locked,
  383. patch.object(device, "_ensure_connected"),
  384. patch.object(device, "_encrypt") as mock_encrypt,
  385. patch.object(device, "_decrypt") as mock_decrypt,
  386. ):
  387. mock_encrypt.return_value = ("656e63727970746564", "abcd")
  388. mock_decrypt.return_value = b"response"
  389. # First attempt fails, second succeeds
  390. mock_send_locked.side_effect = [
  391. BleakDBusError("org.bluez.Error", []),
  392. b"\x01\x00\x00\x00data",
  393. ]
  394. result = await device._send_command("570200")
  395. assert result == b"\x01response"
  396. assert mock_send_locked.call_count == 2
  397. @pytest.mark.asyncio
  398. async def test_empty_data_encryption_decryption() -> None:
  399. """Test encryption/decryption of empty data."""
  400. device = create_encrypted_device()
  401. device._iv = b"\x00" * 16
  402. # Test empty encryption
  403. encrypted = device._encrypt("")
  404. assert encrypted == ("", "")
  405. # Test empty decryption
  406. decrypted = device._decrypt(bytearray())
  407. assert decrypted == b""
  408. @pytest.mark.asyncio
  409. async def test_verify_encryption_key_falls_back_to_classvar() -> None:
  410. """verify_encryption_key resolves `model` from `cls._model` when omitted."""
  411. class ClassvarModelDevice(MockEncryptedDevice):
  412. _model = SwitchbotModel.LOCK
  413. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Test Device")
  414. with patch.object(
  415. ClassvarModelDevice,
  416. "get_basic_info",
  417. new_callable=AsyncMock,
  418. return_value={"ok": True},
  419. ):
  420. result = await ClassvarModelDevice.verify_encryption_key(
  421. ble_device, "01", "0123456789abcdef0123456789abcdef"
  422. )
  423. assert result is True
  424. @pytest.mark.asyncio
  425. async def test_verify_encryption_key_without_model_or_classvar_raises() -> None:
  426. """verify_encryption_key raises when neither `model=` nor `cls._model` is set."""
  427. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Test Device")
  428. with pytest.raises(ValueError, match="model must be provided"):
  429. await MockEncryptedDevice.verify_encryption_key(
  430. ble_device, "01", "0123456789abcdef0123456789abcdef"
  431. )
  432. def test_init_without_model_or_classvar_raises() -> None:
  433. """__init__ raises when neither `model=` nor `cls._model` is set."""
  434. ble_device = generate_ble_device("aa:bb:cc:dd:ee:ff", "Test Device")
  435. with pytest.raises(ValueError, match="model must be provided"):
  436. MockEncryptedDevice(ble_device, "01", "0123456789abcdef0123456789abcdef")
  437. @pytest.mark.asyncio
  438. async def test_decrypt_with_none_iv_during_disconnect() -> None:
  439. """Test that decryption returns empty bytes when IV is None during expected disconnect."""
  440. device = create_encrypted_device()
  441. # Simulate disconnection in progress
  442. device._expected_disconnect = True
  443. device._iv = None
  444. # Should return empty bytes instead of raising
  445. result = device._decrypt(bytearray(b"encrypted_data"))
  446. assert result == b""
  447. # Verify it still raises when not disconnecting
  448. device._expected_disconnect = False
  449. with pytest.raises(RuntimeError, match="Cannot decrypt: IV is None"):
  450. device._decrypt(bytearray(b"encrypted_data"))
  451. @pytest.mark.asyncio
  452. @pytest.mark.parametrize(
  453. ("info", "error", "expected"),
  454. [
  455. ({"ok": True}, None, True),
  456. (None, None, False),
  457. (None, SwitchbotOperationError("Verification failed"), False),
  458. (None, RuntimeError("Unexpected failure"), None),
  459. ],
  460. )
  461. async def test_verify_encryption_key_disconnects(
  462. info: dict | None, error: Exception | None, expected: bool | None
  463. ) -> None:
  464. """Verification releases its temporary client and disconnect timer."""
  465. instances: list[MockEncryptedDevice] = []
  466. client = AsyncMock()
  467. timer = None
  468. async def get_basic_info(device: MockEncryptedDevice) -> dict | None:
  469. nonlocal timer
  470. instances.append(device)
  471. device._client = client
  472. device._reset_disconnect_timer()
  473. timer = device._disconnect_timer
  474. if error is not None:
  475. raise error
  476. return info
  477. with (
  478. patch.object(
  479. MockEncryptedDevice,
  480. "get_basic_info",
  481. autospec=True,
  482. side_effect=get_basic_info,
  483. ),
  484. ):
  485. verification = MockEncryptedDevice.verify_encryption_key(
  486. generate_ble_device("aa:bb:cc:dd:ee:ff", "Test Device"),
  487. "01",
  488. "0123456789abcdef0123456789abcdef",
  489. model=SwitchbotModel.LOCK,
  490. )
  491. if isinstance(error, RuntimeError):
  492. with pytest.raises(RuntimeError, match="Unexpected failure"):
  493. await verification
  494. else:
  495. assert await verification is expected
  496. (device,) = instances
  497. client.disconnect.assert_awaited_once_with()
  498. assert device._client is None
  499. assert device._disconnect_timer is None
  500. assert timer is not None
  501. assert timer.cancelled()
  502. @pytest.mark.asyncio
  503. async def test_verify_encryption_key_cancellation_disconnects() -> None:
  504. """Cancelling verification waits for its temporary client to disconnect."""
  505. instances: list[MockEncryptedDevice] = []
  506. client = AsyncMock()
  507. query_started = asyncio.Event()
  508. disconnect_started = asyncio.Event()
  509. allow_disconnect = asyncio.Event()
  510. async def get_basic_info(device: MockEncryptedDevice) -> None:
  511. instances.append(device)
  512. device._client = client
  513. device._reset_disconnect_timer()
  514. query_started.set()
  515. await asyncio.Future()
  516. async def disconnect() -> None:
  517. disconnect_started.set()
  518. await allow_disconnect.wait()
  519. client.disconnect.side_effect = disconnect
  520. with (
  521. patch.object(
  522. MockEncryptedDevice,
  523. "get_basic_info",
  524. autospec=True,
  525. side_effect=get_basic_info,
  526. ),
  527. ):
  528. task = asyncio.create_task(
  529. MockEncryptedDevice.verify_encryption_key(
  530. generate_ble_device("aa:bb:cc:dd:ee:ff", "Test Device"),
  531. "01",
  532. "0123456789abcdef0123456789abcdef",
  533. model=SwitchbotModel.LOCK,
  534. )
  535. )
  536. await asyncio.wait_for(query_started.wait(), timeout=1)
  537. task.cancel()
  538. await asyncio.wait_for(disconnect_started.wait(), timeout=1)
  539. assert not task.done()
  540. allow_disconnect.set()
  541. with pytest.raises(asyncio.CancelledError):
  542. await task
  543. (device,) = instances
  544. client.disconnect.assert_awaited_once_with()
  545. assert device._client is None
  546. assert device._disconnect_timer is None
  547. @pytest.mark.asyncio
  548. async def test_verify_encryption_key_invalid_credentials_do_not_disconnect() -> None:
  549. """Invalid constructor arguments do not attempt connection cleanup."""
  550. with patch.object(
  551. MockEncryptedDevice, "_execute_forced_disconnect", new_callable=AsyncMock
  552. ) as disconnect:
  553. assert not await MockEncryptedDevice.verify_encryption_key(
  554. generate_ble_device("aa:bb:cc:dd:ee:ff", "Test Device"),
  555. "",
  556. "0123456789abcdef0123456789abcdef",
  557. model=SwitchbotModel.LOCK,
  558. )
  559. disconnect.assert_not_awaited()