test_rest_smart_plug.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548
  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_refused(self, service):
  50. """The IPv4 link-local range is where cloud providers put their
  51. metadata endpoints, listed or not, so the whole /16 is refused."""
  52. assert service._validate_url("http://169.254.1.1/api") is False
  53. @pytest.mark.parametrize(
  54. "url",
  55. [
  56. "http://169.254.169.254/latest/meta-data/",
  57. "http://100.100.100.200/",
  58. "http://[fd00:ec2::254]/",
  59. "http://metadata.google.internal/",
  60. "http://[::ffff:169.254.169.254]/",
  61. "http://2130706433/",
  62. "http://0.0.0.0/",
  63. ],
  64. )
  65. def test_metadata_and_encoded_targets_blocked(self, service, url):
  66. """The gap the previous hand-rolled check left: anything that was not a
  67. bare IP literal fell through to True, and the literals it did parse were
  68. only tested for loopback/link-local."""
  69. assert service._validate_url(url) is False
  70. def test_empty_hostname(self, service):
  71. assert service._validate_url("http:///api") is False
  72. class TestParseHeaders:
  73. def test_valid_json(self, service):
  74. headers = service._parse_headers('{"Authorization": "Bearer abc", "X-Custom": "val"}')
  75. assert headers == {"Authorization": "Bearer abc", "X-Custom": "val"}
  76. def test_none_headers(self, service):
  77. assert service._parse_headers(None) == {}
  78. def test_empty_string(self, service):
  79. assert service._parse_headers("") == {}
  80. def test_invalid_json(self, service):
  81. assert service._parse_headers("not json") == {}
  82. class TestExtractJsonPath:
  83. def test_simple_path(self, service):
  84. data = {"state": "ON"}
  85. assert service._extract_json_path(data, "state") == "ON"
  86. def test_nested_path(self, service):
  87. data = {"data": {"power": {"current": 42.5}}}
  88. assert service._extract_json_path(data, "data.power.current") == 42.5
  89. def test_missing_path(self, service):
  90. data = {"state": "ON"}
  91. assert service._extract_json_path(data, "missing") is None
  92. def test_empty_path(self, service):
  93. assert service._extract_json_path({"a": 1}, "") is None
  94. def test_none_path(self, service):
  95. assert service._extract_json_path({"a": 1}, None) is None
  96. class TestTurnOn:
  97. @pytest.mark.asyncio
  98. async def test_turn_on_success(self, service, mock_plug):
  99. mock_response = MagicMock()
  100. mock_response.status_code = 200
  101. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  102. result = await service.turn_on(mock_plug)
  103. assert result is True
  104. @pytest.mark.asyncio
  105. async def test_turn_on_failure(self, service, mock_plug):
  106. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=None):
  107. result = await service.turn_on(mock_plug)
  108. assert result is False
  109. @pytest.mark.asyncio
  110. async def test_turn_on_no_url(self, service, mock_plug):
  111. mock_plug.rest_on_url = None
  112. result = await service.turn_on(mock_plug)
  113. assert result is False
  114. class TestTurnOff:
  115. @pytest.mark.asyncio
  116. async def test_turn_off_success(self, service, mock_plug):
  117. mock_response = MagicMock()
  118. mock_response.status_code = 200
  119. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  120. result = await service.turn_off(mock_plug)
  121. assert result is True
  122. @pytest.mark.asyncio
  123. async def test_turn_off_no_url(self, service, mock_plug):
  124. mock_plug.rest_off_url = None
  125. result = await service.turn_off(mock_plug)
  126. assert result is False
  127. class TestGetStatus:
  128. @pytest.mark.asyncio
  129. async def test_status_on(self, service, mock_plug):
  130. mock_response = MagicMock()
  131. mock_response.json.return_value = {"state": "ON"}
  132. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  133. result = await service.get_status(mock_plug)
  134. assert result["state"] == "ON"
  135. assert result["reachable"] is True
  136. @pytest.mark.asyncio
  137. async def test_status_off(self, service, mock_plug):
  138. mock_response = MagicMock()
  139. mock_response.json.return_value = {"state": "OFF"}
  140. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  141. result = await service.get_status(mock_plug)
  142. assert result["state"] == "OFF"
  143. assert result["reachable"] is True
  144. @pytest.mark.asyncio
  145. async def test_status_unreachable(self, service, mock_plug):
  146. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=None):
  147. result = await service.get_status(mock_plug)
  148. assert result["state"] is None
  149. assert result["reachable"] is False
  150. @pytest.mark.asyncio
  151. async def test_status_no_url(self, service, mock_plug):
  152. mock_plug.rest_status_url = None
  153. result = await service.get_status(mock_plug)
  154. assert result["state"] is None
  155. assert result["reachable"] is True # No URL = assume reachable
  156. class TestGetEnergy:
  157. @pytest.mark.asyncio
  158. async def test_energy_with_paths(self, service, mock_plug):
  159. mock_response = MagicMock()
  160. mock_response.json.return_value = {"power": 42.5, "energy": {"today": 1.23}}
  161. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  162. result = await service.get_energy(mock_plug)
  163. assert result["power"] == 42.5
  164. assert result["today"] == 1.23
  165. class TestGetEnergyLifetimeCounter:
  166. """#2539. A Shelly Plug S Gen3 reports exactly one energy figure, and it is
  167. cumulative. It has to land in ``total``, not ``today``.
  168. """
  169. # The reporter's own Switch.GetStatus payload.
  170. SHELLY = {"apower": 84.0, "aenergy": {"total": 2620.197}}
  171. @pytest.fixture
  172. def shelly(self, mock_plug):
  173. mock_plug.rest_power_path = "apower"
  174. mock_plug.rest_power_multiplier = 1.0
  175. mock_plug.rest_energy_path = None # a Shelly has no notion of "today"
  176. mock_plug.rest_energy_total_path = "aenergy.total"
  177. mock_plug.rest_energy_total_multiplier = 0.001 # Wh -> kWh
  178. return mock_plug
  179. @pytest.mark.asyncio
  180. async def test_lifetime_counter_lands_in_total_not_today(self, service, shelly):
  181. response = MagicMock()
  182. response.json.return_value = self.SHELLY
  183. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response):
  184. result = await service.get_energy(shelly)
  185. assert result["power"] == 84.0
  186. assert result["total"] == pytest.approx(2.620197)
  187. # The bug: this used to be 2.620197, a lifetime figure wearing today's
  188. # label, which then never reset at midnight.
  189. assert "today" not in result
  190. @pytest.mark.asyncio
  191. async def test_a_plug_reporting_both_counters_keeps_them_apart(self, service, mock_plug):
  192. """A Tasmota behind a REST bridge exposes Today and Total. Neither may
  193. overwrite the other.
  194. """
  195. mock_plug.rest_power_path = "power"
  196. mock_plug.rest_energy_path = "energy.today"
  197. mock_plug.rest_energy_multiplier = 1.0
  198. mock_plug.rest_energy_total_path = "energy.total"
  199. mock_plug.rest_energy_total_multiplier = 1.0
  200. response = MagicMock()
  201. response.json.return_value = {"power": 42.5, "energy": {"today": 1.23, "total": 987.6}}
  202. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response):
  203. result = await service.get_energy(mock_plug)
  204. assert result["today"] == 1.23
  205. assert result["total"] == 987.6
  206. @pytest.mark.asyncio
  207. async def test_total_path_alone_is_enough_to_read_energy(self, service, mock_plug):
  208. """No power path, no today path — only the lifetime counter. get_energy
  209. used to bail out entirely, since its guard only knew about the other two.
  210. """
  211. mock_plug.rest_power_path = None
  212. mock_plug.rest_energy_path = None
  213. mock_plug.rest_energy_total_path = "aenergy.total"
  214. mock_plug.rest_energy_total_multiplier = 0.001
  215. response = MagicMock()
  216. response.json.return_value = self.SHELLY
  217. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response):
  218. result = await service.get_energy(mock_plug)
  219. assert result == {"total": pytest.approx(2.620197)}
  220. @pytest.mark.asyncio
  221. async def test_both_counters_share_one_fetch(self, service, shelly):
  222. """Today and Total ride on the same Shelly response. Reading them must not
  223. cost two HTTP round-trips against a device on the end of a wifi link.
  224. """
  225. shelly.rest_energy_path = "aenergy.total" # same URL as the total path
  226. response = MagicMock()
  227. response.json.return_value = self.SHELLY
  228. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=response) as send:
  229. await service.get_energy(shelly)
  230. assert send.await_count == 1
  231. @pytest.mark.asyncio
  232. async def test_energy_no_status_url_no_separate_urls(self, service, mock_plug):
  233. """No URLs at all (status=None, power_url=None, energy_url=None) → None."""
  234. mock_plug.rest_status_url = None
  235. mock_plug.rest_power_url = None
  236. mock_plug.rest_energy_url = None
  237. result = await service.get_energy(mock_plug)
  238. assert result is None
  239. @pytest.mark.asyncio
  240. async def test_energy_no_paths(self, service, mock_plug):
  241. mock_plug.rest_power_path = None
  242. mock_plug.rest_energy_path = None
  243. result = await service.get_energy(mock_plug)
  244. assert result is None
  245. @pytest.mark.asyncio
  246. async def test_energy_with_separate_urls(self, service, mock_plug):
  247. """Power and energy fetched from different URLs."""
  248. mock_plug.rest_power_url = "http://192.168.1.50:8087/power"
  249. mock_plug.rest_energy_url = "http://192.168.1.50:8087/energy"
  250. power_response = MagicMock()
  251. power_response.json.return_value = {"power": 9.5}
  252. energy_response = MagicMock()
  253. energy_response.json.return_value = {"energy": {"today": 30947.07}}
  254. call_count = 0
  255. async def mock_send(url, method="GET", headers=None, body=None):
  256. nonlocal call_count
  257. call_count += 1
  258. if "power" in url:
  259. return power_response
  260. return energy_response
  261. with patch.object(service, "_send_request", side_effect=mock_send):
  262. result = await service.get_energy(mock_plug)
  263. assert call_count == 2
  264. assert result["power"] == 9.5
  265. assert result["today"] == 30947.07
  266. @pytest.mark.asyncio
  267. async def test_energy_with_multipliers(self, service, mock_plug):
  268. """Multipliers convert units (e.g., Wh → kWh)."""
  269. mock_plug.rest_energy_multiplier = 0.001 # Wh → kWh
  270. mock_response = MagicMock()
  271. mock_response.json.return_value = {"power": 9.5, "energy": {"today": 30947.07}}
  272. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  273. result = await service.get_energy(mock_plug)
  274. assert result["power"] == 9.5 # No multiplier (default 1.0)
  275. assert result["today"] == pytest.approx(30.94707) # 30947.07 * 0.001
  276. @pytest.mark.asyncio
  277. async def test_energy_separate_url_falls_back_to_status(self, service, mock_plug):
  278. """When no separate URL is set, falls back to status URL."""
  279. mock_plug.rest_power_url = None
  280. mock_plug.rest_energy_url = None
  281. mock_response = MagicMock()
  282. mock_response.json.return_value = {"power": 42.5, "energy": {"today": 1.23}}
  283. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response):
  284. result = await service.get_energy(mock_plug)
  285. assert result["power"] == 42.5
  286. assert result["today"] == 1.23
  287. @pytest.mark.asyncio
  288. async def test_energy_no_urls_at_all(self, service, mock_plug):
  289. """No status URL and no separate URLs → None."""
  290. mock_plug.rest_status_url = None
  291. mock_plug.rest_power_url = None
  292. mock_plug.rest_energy_url = None
  293. result = await service.get_energy(mock_plug)
  294. assert result is None
  295. @pytest.mark.asyncio
  296. async def test_energy_deduplicates_same_url(self, service, mock_plug):
  297. """When power and energy both fall back to status URL, only one HTTP request is made."""
  298. mock_plug.rest_power_url = None
  299. mock_plug.rest_energy_url = None
  300. mock_response = MagicMock()
  301. mock_response.json.return_value = {"power": 42.5, "energy": {"today": 1.23}}
  302. with patch.object(service, "_send_request", new_callable=AsyncMock, return_value=mock_response) as mock_send:
  303. result = await service.get_energy(mock_plug)
  304. assert mock_send.call_count == 1
  305. assert result["power"] == 42.5
  306. assert result["today"] == 1.23
  307. class TestTestConnection:
  308. @pytest.mark.asyncio
  309. async def test_connection_success(self, service):
  310. with patch("httpx.AsyncClient") as mock_client_cls:
  311. mock_client = AsyncMock()
  312. mock_response = MagicMock()
  313. mock_response.raise_for_status = MagicMock()
  314. mock_client.request = AsyncMock(return_value=mock_response)
  315. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  316. mock_client.__aexit__ = AsyncMock(return_value=None)
  317. mock_client_cls.return_value = mock_client
  318. result = await service.test_connection("http://192.168.1.50:8080/api")
  319. assert result["success"] is True
  320. @pytest.mark.asyncio
  321. async def test_connection_timeout(self, service):
  322. with patch("httpx.AsyncClient") as mock_client_cls:
  323. mock_client = AsyncMock()
  324. mock_client.request = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
  325. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  326. mock_client.__aexit__ = AsyncMock(return_value=None)
  327. mock_client_cls.return_value = mock_client
  328. result = await service.test_connection("http://192.168.1.50:8080/api")
  329. assert result["success"] is False
  330. assert "timed out" in result["error"]
  331. @pytest.mark.asyncio
  332. async def test_connection_invalid_url(self, service):
  333. """127.0.0.1 is now permitted (see TestURLValidation), so the rejection
  334. case here is a target that is out of policy under any topology. The
  335. error is the guard's own message rather than a fixed sentence, so the
  336. user learns which rule the URL broke."""
  337. result = await service.test_connection("http://169.254.169.254/latest/meta-data/")
  338. assert result["success"] is False
  339. assert "cloud metadata" in result["error"].lower()
  340. class TestLogsKeepCredentialsOut:
  341. """A REST URL may carry the device credential in its login, query or path.
  342. The log names the device by scheme and host only, also inside an error's
  343. own text.
  344. """
  345. URL = "http://admin:login-value@plug.elsewhere.example:8080/api/webhook/path-value?auth_key=query-value"
  346. @pytest.fixture
  347. def plug(self, mock_plug):
  348. mock_plug.rest_on_url = self.URL
  349. mock_plug.rest_off_url = self.URL
  350. return mock_plug
  351. @staticmethod
  352. def _client(side_effect=None):
  353. client = MagicMock()
  354. client.__aenter__ = AsyncMock(return_value=client)
  355. client.__aexit__ = AsyncMock(return_value=False)
  356. if side_effect is None:
  357. response = MagicMock()
  358. response.raise_for_status = MagicMock()
  359. client.request = AsyncMock(return_value=response)
  360. else:
  361. client.request = AsyncMock(side_effect=side_effect)
  362. return client
  363. @pytest.mark.asyncio
  364. @pytest.mark.parametrize(
  365. "error",
  366. [
  367. None,
  368. httpx.TimeoutException("timed out"),
  369. httpx.HTTPStatusError(
  370. f"Client error '404 Not Found' for url '{URL}'",
  371. request=httpx.Request("GET", URL),
  372. response=httpx.Response(404, request=httpx.Request("GET", URL)),
  373. ),
  374. httpx.ConnectError(f"cannot reach {URL}"),
  375. RuntimeError(f"odd failure for {URL}"),
  376. ],
  377. )
  378. async def test_switching_logs_only_the_host(self, service, plug, caplog, error):
  379. caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
  380. with patch("httpx.AsyncClient", return_value=self._client(error)):
  381. await service.turn_on(plug)
  382. await service.turn_off(plug)
  383. assert "plug.elsewhere.example:8080" in caplog.text
  384. assert "-value" not in caplog.text
  385. @pytest.mark.asyncio
  386. async def test_a_refused_url_logs_only_the_host(self, service, caplog):
  387. caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
  388. await service._send_request("http://admin:login-value@169.254.169.254/path-value?auth_key=query-value")
  389. assert "169.254.169.254" in caplog.text
  390. assert "-value" not in caplog.text
  391. @pytest.mark.asyncio
  392. @pytest.mark.parametrize("delimiter", ["?", "/", "#"])
  393. async def test_a_login_the_parser_cannot_find_is_hidden_whole(self, service, mock_plug, caplog, delimiter):
  394. """An unencoded ? / or # in the password ends the host early: the login
  395. cannot be told apart, so none of the URL is logged."""
  396. caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
  397. mock_plug.rest_on_url = f"http://admin:p{delimiter}w-value@plug.elsewhere.example/x"
  398. with patch("httpx.AsyncClient", return_value=self._client(httpx.ConnectError("refused"))):
  399. await service.turn_on(mock_plug)
  400. assert "admin" not in caplog.text
  401. assert "-value" not in caplog.text
  402. assert "http://<hidden>" in caplog.text
  403. @pytest.mark.asyncio
  404. @pytest.mark.parametrize(
  405. "url",
  406. [
  407. "http://admin:pa@ssw0rd-value?more@plug.elsewhere.example/x",
  408. "http://a:Sec@ret-value/Tail@plug.elsewhere.example",
  409. "http://u:a@b-value#c@plug.elsewhere.example",
  410. ],
  411. )
  412. async def test_an_at_after_the_host_hides_the_url_whole(self, service, mock_plug, caplog, url):
  413. caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
  414. mock_plug.rest_on_url = url
  415. with patch("httpx.AsyncClient", return_value=self._client(httpx.ConnectError("refused"))):
  416. await service.turn_on(mock_plug)
  417. assert "-value" not in caplog.text
  418. assert "http://<hidden>" in caplog.text
  419. @pytest.mark.asyncio
  420. @pytest.mark.parametrize(
  421. "error",
  422. [httpx.LocalProtocolError("Illegal header value b'Bearer header-value '"), RuntimeError("odd header-value")],
  423. )
  424. async def test_an_error_that_may_quote_a_header_is_logged_by_its_type(self, service, mock_plug, caplog, error):
  425. caplog.set_level("DEBUG", logger="backend.app.services.rest_smart_plug")
  426. with patch("httpx.AsyncClient", return_value=self._client(error)):
  427. await service.turn_on(mock_plug)
  428. assert "-value" not in caplog.text
  429. assert type(error).__name__ in caplog.text