test_rest_smart_plug.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. """Unit tests for REST smart plug service."""
  2. import json
  3. from unittest.mock import AsyncMock, MagicMock, patch
  4. import httpx
  5. import pytest
  6. from backend.app.services.rest_smart_plug import RESTSmartPlugService
  7. @pytest.fixture
  8. def service():
  9. return RESTSmartPlugService(timeout=5.0)
  10. @pytest.fixture
  11. def mock_plug():
  12. plug = MagicMock()
  13. plug.name = "Test REST Plug"
  14. plug.plug_type = "rest"
  15. plug.rest_on_url = "http://192.168.1.50:8080/api/plug/on"
  16. plug.rest_on_body = '{"state": "on"}'
  17. plug.rest_off_url = "http://192.168.1.50:8080/api/plug/off"
  18. plug.rest_off_body = '{"state": "off"}'
  19. plug.rest_method = "POST"
  20. plug.rest_headers = '{"Authorization": "Bearer test-token"}'
  21. plug.rest_status_url = "http://192.168.1.50:8080/api/plug/status"
  22. plug.rest_status_path = "state"
  23. plug.rest_status_on_value = "ON"
  24. plug.rest_power_url = None
  25. plug.rest_power_path = "power"
  26. plug.rest_power_multiplier = 1.0
  27. plug.rest_energy_url = None
  28. plug.rest_energy_path = "energy.today"
  29. plug.rest_energy_multiplier = 1.0
  30. # Pinned to None rather than left as a MagicMock: an auto-created attribute is
  31. # truthy, so get_energy would think a lifetime path was configured and take a
  32. # branch no test meant to exercise.
  33. plug.rest_energy_total_path = None
  34. plug.rest_energy_total_multiplier = 1.0
  35. return plug
  36. class TestURLValidation:
  37. def test_valid_ip_url(self, service):
  38. assert service._validate_url("http://192.168.1.50:8080/api") is True
  39. def test_hostname_url(self, service):
  40. assert service._validate_url("http://openhab.local:8080/api") is True
  41. def test_loopback_allowed(self, service):
  42. """Deliberate change: the LAN-service policy permits loopback, because
  43. an openHAB/Node-RED bridge on the same host is the normal topology.
  44. The previous check rejected a literal 127.0.0.1 while accepting the
  45. equivalent "localhost", so the same target was configurable one way and
  46. not the other. See test_outbound_url_ssrf_guards.py for the policy.
  47. """
  48. assert service._validate_url("http://127.0.0.1/api") is True
  49. def test_link_local_allowed(self, service):
  50. """Also deliberate: a generic APIPA address is a LAN host like any
  51. other. The cloud-metadata address inside that range is blocked by
  52. name, not by rejecting the whole /16 — see test_metadata_blocked."""
  53. assert service._validate_url("http://169.254.1.1/api") is True
  54. @pytest.mark.parametrize(
  55. "url",
  56. [
  57. "http://169.254.169.254/latest/meta-data/",
  58. "http://100.100.100.200/",
  59. "http://[fd00:ec2::254]/",
  60. "http://metadata.google.internal/",
  61. "http://[::ffff:169.254.169.254]/",
  62. "http://2130706433/",
  63. "http://0.0.0.0/",
  64. ],
  65. )
  66. def test_metadata_and_encoded_targets_blocked(self, service, url):
  67. """The gap the previous hand-rolled check left: anything that was not a
  68. bare IP literal fell through to True, and the literals it did parse were
  69. only tested for loopback/link-local."""
  70. assert service._validate_url(url) is False
  71. def test_empty_hostname(self, service):
  72. assert service._validate_url("http:///api") is False
  73. class TestParseHeaders:
  74. def test_valid_json(self, service):
  75. headers = service._parse_headers('{"Authorization": "Bearer abc", "X-Custom": "val"}')
  76. assert headers == {"Authorization": "Bearer abc", "X-Custom": "val"}
  77. def test_none_headers(self, service):
  78. assert service._parse_headers(None) == {}
  79. def test_empty_string(self, service):
  80. assert service._parse_headers("") == {}
  81. def test_invalid_json(self, service):
  82. assert service._parse_headers("not json") == {}
  83. class TestExtractJsonPath:
  84. def test_simple_path(self, service):
  85. data = {"state": "ON"}
  86. assert service._extract_json_path(data, "state") == "ON"
  87. def test_nested_path(self, service):
  88. data = {"data": {"power": {"current": 42.5}}}
  89. assert service._extract_json_path(data, "data.power.current") == 42.5
  90. def test_missing_path(self, service):
  91. data = {"state": "ON"}
  92. assert service._extract_json_path(data, "missing") is None
  93. def test_empty_path(self, service):
  94. assert service._extract_json_path({"a": 1}, "") is None
  95. def test_none_path(self, service):
  96. assert service._extract_json_path({"a": 1}, None) is None
  97. class TestTurnOn:
  98. @pytest.mark.asyncio
  99. async def test_turn_on_success(self, service, mock_plug):
  100. mock_response = MagicMock()
  101. mock_response.status_code = 200
  102. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  103. result = await service.turn_on(mock_plug)
  104. assert result is True
  105. @pytest.mark.asyncio
  106. async def test_turn_on_failure(self, service, mock_plug):
  107. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=None):
  108. result = await service.turn_on(mock_plug)
  109. assert result is False
  110. @pytest.mark.asyncio
  111. async def test_turn_on_no_url(self, service, mock_plug):
  112. mock_plug.rest_on_url = None
  113. result = await service.turn_on(mock_plug)
  114. assert result is False
  115. class TestTurnOff:
  116. @pytest.mark.asyncio
  117. async def test_turn_off_success(self, service, mock_plug):
  118. mock_response = MagicMock()
  119. mock_response.status_code = 200
  120. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  121. result = await service.turn_off(mock_plug)
  122. assert result is True
  123. @pytest.mark.asyncio
  124. async def test_turn_off_no_url(self, service, mock_plug):
  125. mock_plug.rest_off_url = None
  126. result = await service.turn_off(mock_plug)
  127. assert result is False
  128. class TestGetStatus:
  129. @pytest.mark.asyncio
  130. async def test_status_on(self, service, mock_plug):
  131. mock_response = MagicMock()
  132. mock_response.json.return_value = {"state": "ON"}
  133. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  134. result = await service.get_status(mock_plug)
  135. assert result["state"] == "ON"
  136. assert result["reachable"] is True
  137. @pytest.mark.asyncio
  138. async def test_status_off(self, service, mock_plug):
  139. mock_response = MagicMock()
  140. mock_response.json.return_value = {"state": "OFF"}
  141. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  142. result = await service.get_status(mock_plug)
  143. assert result["state"] == "OFF"
  144. assert result["reachable"] is True
  145. @pytest.mark.asyncio
  146. async def test_status_unreachable(self, service, mock_plug):
  147. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=None):
  148. result = await service.get_status(mock_plug)
  149. assert result["state"] is None
  150. assert result["reachable"] is False
  151. @pytest.mark.asyncio
  152. async def test_status_no_url(self, service, mock_plug):
  153. mock_plug.rest_status_url = None
  154. result = await service.get_status(mock_plug)
  155. assert result["state"] is None
  156. assert result["reachable"] is True # No URL = assume reachable
  157. class TestGetEnergy:
  158. @pytest.mark.asyncio
  159. async def test_energy_with_paths(self, service, mock_plug):
  160. mock_response = MagicMock()
  161. mock_response.json.return_value = {"power": 42.5, "energy": {"today": 1.23}}
  162. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  163. result = await service.get_energy(mock_plug)
  164. assert result["power"] == 42.5
  165. assert result["today"] == 1.23
  166. class TestGetEnergyLifetimeCounter:
  167. """#2539. A Shelly Plug S Gen3 reports exactly one energy figure, and it is
  168. cumulative. It has to land in ``total``, not ``today``.
  169. """
  170. # The reporter's own Switch.GetStatus payload.
  171. SHELLY = {"apower": 84.0, "aenergy": {"total": 2620.197}}
  172. @pytest.fixture
  173. def shelly(self, mock_plug):
  174. mock_plug.rest_power_path = "apower"
  175. mock_plug.rest_power_multiplier = 1.0
  176. mock_plug.rest_energy_path = None # a Shelly has no notion of "today"
  177. mock_plug.rest_energy_total_path = "aenergy.total"
  178. mock_plug.rest_energy_total_multiplier = 0.001 # Wh -> kWh
  179. return mock_plug
  180. @pytest.mark.asyncio
  181. async def test_lifetime_counter_lands_in_total_not_today(self, service, shelly):
  182. response = MagicMock()
  183. response.json.return_value = self.SHELLY
  184. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response):
  185. result = await service.get_energy(shelly)
  186. assert result["power"] == 84.0
  187. assert result["total"] == pytest.approx(2.620197)
  188. # The bug: this used to be 2.620197, a lifetime figure wearing today's
  189. # label, which then never reset at midnight.
  190. assert "today" not in result
  191. @pytest.mark.asyncio
  192. async def test_a_plug_reporting_both_counters_keeps_them_apart(self, service, mock_plug):
  193. """A Tasmota behind a REST bridge exposes Today and Total. Neither may
  194. overwrite the other.
  195. """
  196. mock_plug.rest_power_path = "power"
  197. mock_plug.rest_energy_path = "energy.today"
  198. mock_plug.rest_energy_multiplier = 1.0
  199. mock_plug.rest_energy_total_path = "energy.total"
  200. mock_plug.rest_energy_total_multiplier = 1.0
  201. response = MagicMock()
  202. response.json.return_value = {"power": 42.5, "energy": {"today": 1.23, "total": 987.6}}
  203. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response):
  204. result = await service.get_energy(mock_plug)
  205. assert result["today"] == 1.23
  206. assert result["total"] == 987.6
  207. @pytest.mark.asyncio
  208. async def test_total_path_alone_is_enough_to_read_energy(self, service, mock_plug):
  209. """No power path, no today path — only the lifetime counter. get_energy
  210. used to bail out entirely, since its guard only knew about the other two.
  211. """
  212. mock_plug.rest_power_path = None
  213. mock_plug.rest_energy_path = None
  214. mock_plug.rest_energy_total_path = "aenergy.total"
  215. mock_plug.rest_energy_total_multiplier = 0.001
  216. response = MagicMock()
  217. response.json.return_value = self.SHELLY
  218. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response):
  219. result = await service.get_energy(mock_plug)
  220. assert result == {"total": pytest.approx(2.620197)}
  221. @pytest.mark.asyncio
  222. async def test_both_counters_share_one_fetch(self, service, shelly):
  223. """Today and Total ride on the same Shelly response. Reading them must not
  224. cost two HTTP round-trips against a device on the end of a wifi link.
  225. """
  226. shelly.rest_energy_path = "aenergy.total" # same URL as the total path
  227. response = MagicMock()
  228. response.json.return_value = self.SHELLY
  229. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response) as send:
  230. await service.get_energy(shelly)
  231. assert send.await_count == 1
  232. @pytest.mark.asyncio
  233. async def test_energy_no_status_url_no_separate_urls(self, service, mock_plug):
  234. """No URLs at all (status=None, power_url=None, energy_url=None) → None."""
  235. mock_plug.rest_status_url = None
  236. mock_plug.rest_power_url = None
  237. mock_plug.rest_energy_url = None
  238. result = await service.get_energy(mock_plug)
  239. assert result is None
  240. @pytest.mark.asyncio
  241. async def test_energy_no_paths(self, service, mock_plug):
  242. mock_plug.rest_power_path = None
  243. mock_plug.rest_energy_path = None
  244. result = await service.get_energy(mock_plug)
  245. assert result is None
  246. @pytest.mark.asyncio
  247. async def test_energy_with_separate_urls(self, service, mock_plug):
  248. """Power and energy fetched from different URLs."""
  249. mock_plug.rest_power_url = "http://192.168.1.50:8087/power"
  250. mock_plug.rest_energy_url = "http://192.168.1.50:8087/energy"
  251. power_response = MagicMock()
  252. power_response.json.return_value = {"power": 9.5}
  253. energy_response = MagicMock()
  254. energy_response.json.return_value = {"energy": {"today": 30947.07}}
  255. call_count = 0
  256. async def mock_send(url, method="GET", headers=None, body=None):
  257. nonlocal call_count
  258. call_count += 1
  259. if "power" in url:
  260. return power_response
  261. return energy_response
  262. with patch.object(service, "_send_request", side_effect=mock_send):
  263. result = await service.get_energy(mock_plug)
  264. assert call_count == 2
  265. assert result["power"] == 9.5
  266. assert result["today"] == 30947.07
  267. @pytest.mark.asyncio
  268. async def test_energy_with_multipliers(self, service, mock_plug):
  269. """Multipliers convert units (e.g., Wh → kWh)."""
  270. mock_plug.rest_energy_multiplier = 0.001 # Wh → kWh
  271. mock_response = MagicMock()
  272. mock_response.json.return_value = {"power": 9.5, "energy": {"today": 30947.07}}
  273. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  274. result = await service.get_energy(mock_plug)
  275. assert result["power"] == 9.5 # No multiplier (default 1.0)
  276. assert result["today"] == pytest.approx(30.94707) # 30947.07 * 0.001
  277. @pytest.mark.asyncio
  278. async def test_energy_separate_url_falls_back_to_status(self, service, mock_plug):
  279. """When no separate URL is set, falls back to status URL."""
  280. mock_plug.rest_power_url = None
  281. mock_plug.rest_energy_url = None
  282. mock_response = MagicMock()
  283. mock_response.json.return_value = {"power": 42.5, "energy": {"today": 1.23}}
  284. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  285. result = await service.get_energy(mock_plug)
  286. assert result["power"] == 42.5
  287. assert result["today"] == 1.23
  288. @pytest.mark.asyncio
  289. async def test_energy_no_urls_at_all(self, service, mock_plug):
  290. """No status URL and no separate URLs → None."""
  291. mock_plug.rest_status_url = None
  292. mock_plug.rest_power_url = None
  293. mock_plug.rest_energy_url = None
  294. result = await service.get_energy(mock_plug)
  295. assert result is None
  296. @pytest.mark.asyncio
  297. async def test_energy_deduplicates_same_url(self, service, mock_plug):
  298. """When power and energy both fall back to status URL, only one HTTP request is made."""
  299. mock_plug.rest_power_url = None
  300. mock_plug.rest_energy_url = None
  301. mock_response = MagicMock()
  302. mock_response.json.return_value = {"power": 42.5, "energy": {"today": 1.23}}
  303. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response) as mock_send:
  304. result = await service.get_energy(mock_plug)
  305. assert mock_send.call_count == 1
  306. assert result["power"] == 42.5
  307. assert result["today"] == 1.23
  308. class TestTestConnection:
  309. @pytest.mark.asyncio
  310. async def test_connection_success(self, service):
  311. with patch("httpx.AsyncClient") as mock_client_cls:
  312. mock_client = AsyncMock()
  313. mock_response = MagicMock()
  314. mock_response.raise_for_status = MagicMock()
  315. mock_client.request = AsyncMock(return_value=mock_response)
  316. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  317. mock_client.__aexit__ = AsyncMock(return_value=None)
  318. mock_client_cls.return_value = mock_client
  319. result = await service.test_connection("http://192.168.1.50:8080/api")
  320. assert result["success"] is True
  321. @pytest.mark.asyncio
  322. async def test_connection_timeout(self, service):
  323. with patch("httpx.AsyncClient") as mock_client_cls:
  324. mock_client = AsyncMock()
  325. mock_client.request = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
  326. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  327. mock_client.__aexit__ = AsyncMock(return_value=None)
  328. mock_client_cls.return_value = mock_client
  329. result = await service.test_connection("http://192.168.1.50:8080/api")
  330. assert result["success"] is False
  331. assert "timed out" in result["error"]
  332. @pytest.mark.asyncio
  333. async def test_connection_invalid_url(self, service):
  334. """127.0.0.1 is now permitted (see TestURLValidation), so the rejection
  335. case here is a target that is out of policy under any topology. The
  336. error is the guard's own message rather than a fixed sentence, so the
  337. user learns which rule the URL broke."""
  338. result = await service.test_connection("http://169.254.169.254/latest/meta-data/")
  339. assert result["success"] is False
  340. assert "cloud metadata" in result["error"].lower()