test_rest_smart_plug.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  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()
  341. class TestLogsKeepCredentialsOut:
  342. """A REST URL may carry the device credential in its login, query or path.
  343. The log names the device by scheme and host only, also inside an error's
  344. own text.
  345. """
  346. URL = "http://admin:login-value@plug.elsewhere.example:8080/api/webhook/path-value?auth_key=query-value"
  347. @pytest.fixture
  348. def plug(self, mock_plug):
  349. mock_plug.rest_on_url = self.URL
  350. mock_plug.rest_off_url = self.URL
  351. return mock_plug
  352. @staticmethod
  353. def _client(side_effect=None):
  354. client = MagicMock()
  355. client.__aenter__ = AsyncMock(return_value=client)
  356. client.__aexit__ = AsyncMock(return_value=False)
  357. if side_effect is None:
  358. response = MagicMock()
  359. response.raise_for_status = MagicMock()
  360. client.request = AsyncMock(return_value=response)
  361. else:
  362. client.request = AsyncMock(side_effect=side_effect)
  363. return client
  364. @pytest.mark.asyncio
  365. @pytest.mark.parametrize(
  366. "error",
  367. [
  368. None,
  369. httpx.TimeoutException("timed out"),
  370. httpx.HTTPStatusError(
  371. f"Client error '404 Not Found' for url '{URL}'",
  372. request=httpx.Request("GET", URL),
  373. response=httpx.Response(404, request=httpx.Request("GET", URL)),
  374. ),
  375. httpx.ConnectError(f"cannot reach {URL}"),
  376. RuntimeError(f"odd failure for {URL}"),
  377. ],
  378. )
  379. async def test_switching_logs_only_the_host(self, service, plug, caplog, error):
  380. caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
  381. with patch("httpx.AsyncClient", return_value=self._client(error)):
  382. await service.turn_on(plug)
  383. await service.turn_off(plug)
  384. assert "plug.elsewhere.example:8080" in caplog.text
  385. assert "-value" not in caplog.text
  386. @pytest.mark.asyncio
  387. async def test_a_refused_url_logs_only_the_host(self, service, caplog):
  388. caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
  389. await service._send_request("http://admin:login-value@169.254.169.254/path-value?auth_key=query-value")
  390. assert "169.254.169.254" in caplog.text
  391. assert "-value" not in caplog.text
  392. @pytest.mark.asyncio
  393. @pytest.mark.parametrize("delimiter", ["?", "/", "#"])
  394. async def test_a_login_the_parser_cannot_find_is_hidden_whole(self, service, mock_plug, caplog, delimiter):
  395. """An unencoded ? / or # in the password ends the host early: the login
  396. cannot be told apart, so none of the URL is logged."""
  397. caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
  398. mock_plug.rest_on_url = f"http://admin:p{delimiter}w-value@plug.elsewhere.example/x"
  399. with patch("httpx.AsyncClient", return_value=self._client(httpx.ConnectError("refused"))):
  400. await service.turn_on(mock_plug)
  401. assert "admin" not in caplog.text
  402. assert "-value" not in caplog.text
  403. assert "http://<hidden>" in caplog.text
  404. @pytest.mark.asyncio
  405. @pytest.mark.parametrize(
  406. "url",
  407. [
  408. "http://admin:pa@ssw0rd-value?more@plug.elsewhere.example/x",
  409. "http://a:Sec@ret-value/Tail@plug.elsewhere.example",
  410. "http://u:a@b-value#c@plug.elsewhere.example",
  411. ],
  412. )
  413. async def test_an_at_after_the_host_hides_the_url_whole(self, service, mock_plug, caplog, url):
  414. caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
  415. mock_plug.rest_on_url = url
  416. with patch("httpx.AsyncClient", return_value=self._client(httpx.ConnectError("refused"))):
  417. await service.turn_on(mock_plug)
  418. assert "-value" not in caplog.text
  419. assert "http://<hidden>" in caplog.text
  420. @pytest.mark.asyncio
  421. @pytest.mark.parametrize(
  422. "error",
  423. [httpx.LocalProtocolError("Illegal header value b'Bearer header-value '"), RuntimeError("odd header-value")],
  424. )
  425. async def test_an_error_that_may_quote_a_header_is_logged_by_its_type(self, service, mock_plug, caplog, error):
  426. caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
  427. with patch("httpx.AsyncClient", return_value=self._client(error)):
  428. await service.turn_on(mock_plug)
  429. assert "-value" not in caplog.text
  430. assert type(error).__name__ in caplog.text