test_mqtt.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. # systemctl-mqtt - MQTT client triggering & reporting shutdown on systemd-based systems
  2. #
  3. # Copyright (C) 2020 Fabian Peter Hammerle <fabian@hammerle.me>
  4. #
  5. # This program is free software: you can redistribute it and/or modify
  6. # it under the terms of the GNU General Public License as published by
  7. # the Free Software Foundation, either version 3 of the License, or
  8. # any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. # GNU General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU General Public License
  16. # along with this program. If not, see <https://www.gnu.org/licenses/>.
  17. import datetime
  18. import logging
  19. import ssl
  20. import unittest.mock
  21. import aiomqtt
  22. import jeepney.fds
  23. import jeepney.low_level
  24. import pytest
  25. import systemctl_mqtt
  26. # pylint: disable=protected-access,too-many-positional-arguments
  27. @pytest.mark.asyncio
  28. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  29. @pytest.mark.parametrize("mqtt_port", [1833])
  30. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "system/command"])
  31. @pytest.mark.parametrize("homeassistant_discovery_prefix", ["homeassistant"])
  32. @pytest.mark.parametrize("homeassistant_discovery_object_id", ["host", "node"])
  33. async def test__run(
  34. caplog,
  35. mqtt_host,
  36. mqtt_port,
  37. mqtt_topic_prefix,
  38. homeassistant_discovery_prefix,
  39. homeassistant_discovery_object_id,
  40. ):
  41. # pylint: disable=too-many-locals,too-many-arguments
  42. caplog.set_level(logging.DEBUG)
  43. login_manager_mock = unittest.mock.MagicMock()
  44. with unittest.mock.patch(
  45. "aiomqtt.Client", autospec=False
  46. ) as mqtt_client_class_mock, unittest.mock.patch(
  47. "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
  48. ), unittest.mock.patch(
  49. "systemctl_mqtt._dbus_signal_loop"
  50. ) as dbus_signal_loop_mock:
  51. login_manager_mock.Inhibit.return_value = (jeepney.fds.FileDescriptor(-1),)
  52. login_manager_mock.Get.return_value = (("b", False),)
  53. await systemctl_mqtt._run(
  54. mqtt_host=mqtt_host,
  55. mqtt_port=mqtt_port,
  56. mqtt_username=None,
  57. mqtt_password=None,
  58. mqtt_topic_prefix=mqtt_topic_prefix,
  59. homeassistant_discovery_prefix=homeassistant_discovery_prefix,
  60. homeassistant_discovery_object_id=homeassistant_discovery_object_id,
  61. poweroff_delay=datetime.timedelta(),
  62. )
  63. assert caplog.records[0].levelno == logging.INFO
  64. assert caplog.records[0].message == (
  65. f"connecting to MQTT broker {mqtt_host}:{mqtt_port} (TLS enabled)"
  66. )
  67. mqtt_client_class_mock.assert_called_once()
  68. _, mqtt_client_init_kwargs = mqtt_client_class_mock.call_args
  69. assert mqtt_client_init_kwargs.pop("hostname") == mqtt_host
  70. assert mqtt_client_init_kwargs.pop("port") == mqtt_port
  71. assert isinstance(mqtt_client_init_kwargs.pop("tls_context"), ssl.SSLContext)
  72. assert mqtt_client_init_kwargs.pop("username") is None
  73. assert mqtt_client_init_kwargs.pop("password") is None
  74. assert mqtt_client_init_kwargs.pop("will") == aiomqtt.Will(
  75. topic=mqtt_topic_prefix + "/status",
  76. payload="offline",
  77. qos=0,
  78. retain=True,
  79. properties=None,
  80. )
  81. assert not mqtt_client_init_kwargs
  82. login_manager_mock.Inhibit.assert_called_once_with(
  83. what="shutdown",
  84. who="systemctl-mqtt",
  85. why="Report shutdown via MQTT",
  86. mode="delay",
  87. )
  88. login_manager_mock.Get.assert_called_once_with("PreparingForShutdown")
  89. async with mqtt_client_class_mock() as mqtt_client_mock:
  90. pass
  91. assert mqtt_client_mock.publish.call_count == 4
  92. assert (
  93. mqtt_client_mock.publish.call_args_list[0][1]["topic"]
  94. == f"{homeassistant_discovery_prefix}/device/{homeassistant_discovery_object_id}/config"
  95. )
  96. assert mqtt_client_mock.publish.call_args_list[1] == unittest.mock.call(
  97. topic=mqtt_topic_prefix + "/preparing-for-shutdown",
  98. payload="false",
  99. retain=False,
  100. )
  101. assert mqtt_client_mock.publish.call_args_list[2][1] == {
  102. "topic": mqtt_topic_prefix + "/status",
  103. "payload": "online",
  104. "retain": True,
  105. }
  106. assert mqtt_client_mock.publish.call_args_list[3][1] == {
  107. "topic": mqtt_topic_prefix + "/status",
  108. "payload": "offline",
  109. "retain": True,
  110. }
  111. assert sorted(mqtt_client_mock.subscribe.call_args_list) == [
  112. unittest.mock.call(mqtt_topic_prefix + "/lock-all-sessions"),
  113. unittest.mock.call(mqtt_topic_prefix + "/poweroff"),
  114. unittest.mock.call(mqtt_topic_prefix + "/suspend"),
  115. ]
  116. assert caplog.records[1].levelno == logging.DEBUG
  117. assert (
  118. caplog.records[1].message == f"connected to MQTT broker {mqtt_host}:{mqtt_port}"
  119. )
  120. assert caplog.records[2].levelno == logging.DEBUG
  121. assert caplog.records[2].message == "acquired shutdown inhibitor lock"
  122. assert caplog.records[3].levelno == logging.DEBUG
  123. assert (
  124. caplog.records[3].message
  125. == "publishing home assistant config on "
  126. + homeassistant_discovery_prefix
  127. + "/device/"
  128. + homeassistant_discovery_object_id
  129. + "/config"
  130. )
  131. assert caplog.records[4].levelno == logging.INFO
  132. assert (
  133. caplog.records[4].message
  134. == f"publishing 'false' on {mqtt_topic_prefix}/preparing-for-shutdown"
  135. )
  136. assert all(r.levelno == logging.INFO for r in caplog.records[5::2])
  137. assert {r.message for r in caplog.records[5:]} == {
  138. f"subscribing to {mqtt_topic_prefix}/{s}"
  139. for s in ("poweroff", "lock-all-sessions", "suspend")
  140. }
  141. dbus_signal_loop_mock.assert_awaited_once()
  142. @pytest.mark.asyncio
  143. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  144. @pytest.mark.parametrize("mqtt_port", [1833])
  145. @pytest.mark.parametrize("mqtt_disable_tls", [True, False])
  146. async def test__run_tls(caplog, mqtt_host, mqtt_port, mqtt_disable_tls):
  147. caplog.set_level(logging.INFO)
  148. with unittest.mock.patch(
  149. "aiomqtt.Client"
  150. ) as mqtt_client_class_mock, unittest.mock.patch(
  151. "systemctl_mqtt._dbus_signal_loop"
  152. ) as dbus_signal_loop_mock:
  153. await systemctl_mqtt._run(
  154. mqtt_host=mqtt_host,
  155. mqtt_port=mqtt_port,
  156. mqtt_disable_tls=mqtt_disable_tls,
  157. mqtt_username=None,
  158. mqtt_password=None,
  159. mqtt_topic_prefix="systemctl/hosts",
  160. homeassistant_discovery_prefix="homeassistant",
  161. homeassistant_discovery_object_id="host",
  162. poweroff_delay=datetime.timedelta(),
  163. )
  164. mqtt_client_class_mock.assert_called_once()
  165. _, mqtt_client_init_kwargs = mqtt_client_class_mock.call_args
  166. assert mqtt_client_init_kwargs.pop("hostname") == mqtt_host
  167. assert mqtt_client_init_kwargs.pop("port") == mqtt_port
  168. if mqtt_disable_tls:
  169. assert mqtt_client_init_kwargs.pop("tls_context") is None
  170. else:
  171. assert isinstance(mqtt_client_init_kwargs.pop("tls_context"), ssl.SSLContext)
  172. assert set(mqtt_client_init_kwargs.keys()) == {"username", "password", "will"}
  173. assert caplog.records[0].levelno == logging.INFO
  174. assert caplog.records[0].message == (
  175. f"connecting to MQTT broker {mqtt_host}:{mqtt_port}"
  176. f" (TLS {'disabled' if mqtt_disable_tls else 'enabled'})"
  177. )
  178. dbus_signal_loop_mock.assert_awaited_once()
  179. @pytest.mark.asyncio
  180. async def test__run_tls_default():
  181. with unittest.mock.patch(
  182. "aiomqtt.Client"
  183. ) as mqtt_client_class_mock, unittest.mock.patch(
  184. "systemctl_mqtt._dbus_signal_loop"
  185. ) as dbus_signal_loop_mock:
  186. await systemctl_mqtt._run(
  187. mqtt_host="mqtt-broker.local",
  188. mqtt_port=1833,
  189. # mqtt_disable_tls default,
  190. mqtt_username=None,
  191. mqtt_password=None,
  192. mqtt_topic_prefix="systemctl/hosts",
  193. homeassistant_discovery_prefix="homeassistant",
  194. homeassistant_discovery_object_id="host",
  195. poweroff_delay=datetime.timedelta(),
  196. )
  197. mqtt_client_class_mock.assert_called_once()
  198. # enabled by default
  199. assert isinstance(
  200. mqtt_client_class_mock.call_args[1]["tls_context"], ssl.SSLContext
  201. )
  202. dbus_signal_loop_mock.assert_awaited_once()
  203. @pytest.mark.asyncio
  204. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  205. @pytest.mark.parametrize("mqtt_port", [1833])
  206. @pytest.mark.parametrize("mqtt_username", ["me"])
  207. @pytest.mark.parametrize("mqtt_password", [None, "secret"])
  208. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host"])
  209. async def test__run_authentication(
  210. mqtt_host, mqtt_port, mqtt_username, mqtt_password, mqtt_topic_prefix
  211. ):
  212. with unittest.mock.patch(
  213. "aiomqtt.Client"
  214. ) as mqtt_client_class_mock, unittest.mock.patch(
  215. "systemctl_mqtt._dbus_signal_loop"
  216. ) as dbus_signal_loop_mock:
  217. await systemctl_mqtt._run(
  218. mqtt_host=mqtt_host,
  219. mqtt_port=mqtt_port,
  220. mqtt_username=mqtt_username,
  221. mqtt_password=mqtt_password,
  222. mqtt_topic_prefix=mqtt_topic_prefix,
  223. homeassistant_discovery_prefix="discovery-prefix",
  224. homeassistant_discovery_object_id="node-id",
  225. poweroff_delay=datetime.timedelta(),
  226. )
  227. mqtt_client_class_mock.assert_called_once()
  228. _, mqtt_client_init_kwargs = mqtt_client_class_mock.call_args
  229. assert mqtt_client_init_kwargs["username"] == mqtt_username
  230. if mqtt_password:
  231. assert mqtt_client_init_kwargs["password"] == mqtt_password
  232. else:
  233. assert mqtt_client_init_kwargs["password"] is None
  234. dbus_signal_loop_mock.assert_awaited_once()
  235. @pytest.mark.asyncio
  236. @pytest.mark.parametrize("mqtt_host", ["mqtt-broker.local"])
  237. @pytest.mark.parametrize("mqtt_port", [1833])
  238. @pytest.mark.parametrize("mqtt_password", ["secret"])
  239. async def test__run_authentication_missing_username(
  240. mqtt_host: str, mqtt_port: int, mqtt_password: str
  241. ) -> None:
  242. with unittest.mock.patch("aiomqtt.Client"), unittest.mock.patch(
  243. "systemctl_mqtt._dbus.get_login_manager_proxy"
  244. ), unittest.mock.patch("systemctl_mqtt._dbus_signal_loop") as dbus_signal_loop_mock:
  245. with pytest.raises(ValueError, match=r"^Missing MQTT username$"):
  246. await systemctl_mqtt._run(
  247. mqtt_host=mqtt_host,
  248. mqtt_port=mqtt_port,
  249. mqtt_username=None,
  250. mqtt_password=mqtt_password,
  251. mqtt_topic_prefix="prefix",
  252. homeassistant_discovery_prefix="discovery-prefix",
  253. homeassistant_discovery_object_id="node-id",
  254. poweroff_delay=datetime.timedelta(),
  255. )
  256. dbus_signal_loop_mock.assert_not_called()
  257. @pytest.mark.asyncio
  258. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host"])
  259. async def test__run_sigint(mqtt_topic_prefix: str):
  260. login_manager_mock = unittest.mock.MagicMock()
  261. with unittest.mock.patch(
  262. "aiomqtt.Client", autospec=False
  263. ) as mqtt_client_class_mock, unittest.mock.patch(
  264. "systemctl_mqtt._dbus.get_login_manager_proxy", return_value=login_manager_mock
  265. ), unittest.mock.patch(
  266. "asyncio.gather", side_effect=KeyboardInterrupt
  267. ):
  268. login_manager_mock.Inhibit.return_value = (jeepney.fds.FileDescriptor(-1),)
  269. login_manager_mock.Get.return_value = (("b", False),)
  270. with pytest.raises(KeyboardInterrupt):
  271. await systemctl_mqtt._run(
  272. mqtt_host="mqtt-broker.local",
  273. mqtt_port=1883,
  274. mqtt_username=None,
  275. mqtt_password=None,
  276. mqtt_topic_prefix=mqtt_topic_prefix,
  277. homeassistant_discovery_prefix="homeassistant",
  278. homeassistant_discovery_object_id="host",
  279. poweroff_delay=datetime.timedelta(),
  280. )
  281. async with mqtt_client_class_mock() as mqtt_client_mock:
  282. pass
  283. assert mqtt_client_mock.publish.call_count == 4
  284. assert mqtt_client_mock.publish.call_args_list[0][1]["topic"].endswith("/config")
  285. assert mqtt_client_mock.publish.call_args_list[1][1]["topic"].endswith(
  286. "/preparing-for-shutdown"
  287. )
  288. assert mqtt_client_mock.publish.call_args_list[2][1] == {
  289. "topic": mqtt_topic_prefix + "/status",
  290. "payload": "online",
  291. "retain": True,
  292. }
  293. assert mqtt_client_mock.publish.call_args_list[3][1] == {
  294. "topic": mqtt_topic_prefix + "/status",
  295. "payload": "offline",
  296. "retain": True,
  297. }
  298. @pytest.mark.asyncio
  299. @pytest.mark.filterwarnings("ignore:coroutine '_dbus_signal_loop' was never awaited")
  300. @pytest.mark.filterwarnings("ignore:coroutine '_mqtt_message_loop' was never awaited")
  301. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host", "system/command"])
  302. async def test__mqtt_message_loop_trigger_poweroff(
  303. caplog: pytest.LogCaptureFixture, mqtt_topic_prefix: str
  304. ) -> None:
  305. state = systemctl_mqtt._State(
  306. mqtt_topic_prefix=mqtt_topic_prefix,
  307. homeassistant_discovery_prefix="homeassistant",
  308. homeassistant_discovery_object_id="whatever",
  309. poweroff_delay=datetime.timedelta(seconds=21),
  310. )
  311. mqtt_client_mock = unittest.mock.AsyncMock()
  312. mqtt_client_mock.messages.__aiter__.return_value = [
  313. aiomqtt.Message(
  314. topic=mqtt_topic_prefix + "/poweroff",
  315. payload=b"some-payload",
  316. qos=0,
  317. retain=False,
  318. mid=42 // 2,
  319. properties=None,
  320. )
  321. ]
  322. with unittest.mock.patch(
  323. "systemctl_mqtt._dbus.schedule_shutdown"
  324. ) as schedule_shutdown_mock, caplog.at_level(logging.DEBUG):
  325. await systemctl_mqtt._mqtt_message_loop(
  326. state=state, mqtt_client=mqtt_client_mock
  327. )
  328. assert sorted(mqtt_client_mock.subscribe.await_args_list) == [
  329. unittest.mock.call(mqtt_topic_prefix + "/lock-all-sessions"),
  330. unittest.mock.call(mqtt_topic_prefix + "/poweroff"),
  331. unittest.mock.call(mqtt_topic_prefix + "/suspend"),
  332. ]
  333. schedule_shutdown_mock.assert_called_once_with(
  334. action="poweroff", delay=datetime.timedelta(seconds=21)
  335. )
  336. assert [
  337. t for t in caplog.record_tuples[2:] if not t[2].startswith("subscribing to ")
  338. ] == [
  339. (
  340. "systemctl_mqtt",
  341. logging.DEBUG,
  342. f"received message on topic '{mqtt_topic_prefix}/poweroff': b'some-payload'",
  343. ),
  344. ]
  345. @pytest.mark.asyncio
  346. @pytest.mark.parametrize("mqtt_topic_prefix", ["systemctl/host"])
  347. async def test__mqtt_message_loop_retained(
  348. caplog: pytest.LogCaptureFixture, mqtt_topic_prefix: str
  349. ) -> None:
  350. state = systemctl_mqtt._State(
  351. mqtt_topic_prefix=mqtt_topic_prefix,
  352. homeassistant_discovery_prefix="homeassistant",
  353. homeassistant_discovery_object_id="whatever",
  354. poweroff_delay=datetime.timedelta(seconds=21),
  355. )
  356. mqtt_client_mock = unittest.mock.AsyncMock()
  357. mqtt_client_mock.messages.__aiter__.return_value = [
  358. aiomqtt.Message(
  359. topic=mqtt_topic_prefix + "/poweroff",
  360. payload=b"some-payload",
  361. qos=0,
  362. retain=True,
  363. mid=42 // 2,
  364. properties=None,
  365. )
  366. ]
  367. with unittest.mock.patch(
  368. "systemctl_mqtt._dbus.schedule_shutdown"
  369. ) as schedule_shutdown_mock, caplog.at_level(logging.DEBUG):
  370. await systemctl_mqtt._mqtt_message_loop(
  371. state=state, mqtt_client=mqtt_client_mock
  372. )
  373. schedule_shutdown_mock.assert_not_called()
  374. assert [
  375. t for t in caplog.record_tuples[2:] if not t[2].startswith("subscribing to ")
  376. ] == [
  377. (
  378. "systemctl_mqtt",
  379. logging.INFO,
  380. "ignoring retained message on topic 'systemctl/host/poweroff'",
  381. ),
  382. ]