test_dbus.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450
  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 asyncio
  18. import datetime
  19. import getpass
  20. import logging
  21. import typing
  22. import unittest.mock
  23. import jeepney
  24. import jeepney.low_level
  25. import jeepney.wrappers
  26. import pytest
  27. import systemctl_mqtt._dbus.login_manager
  28. # pylint: disable=protected-access
  29. def test_get_login_manager_proxy():
  30. login_manager = systemctl_mqtt._dbus.login_manager.get_login_manager_proxy()
  31. assert isinstance(login_manager, jeepney.io.blocking.Proxy)
  32. assert login_manager._msggen.interface == "org.freedesktop.login1.Manager"
  33. # https://freedesktop.org/wiki/Software/systemd/logind/
  34. assert login_manager.CanPowerOff() in {("yes",), ("challenge",)}
  35. def test_get_service_manager_proxy():
  36. service_manager = systemctl_mqtt._dbus.service_manager.get_service_manager_proxy()
  37. assert isinstance(service_manager, jeepney.io.blocking.Proxy)
  38. assert service_manager._msggen.interface == "org.freedesktop.systemd1.Manager"
  39. def test__log_shutdown_inhibitors_some(caplog):
  40. login_manager = unittest.mock.MagicMock()
  41. login_manager.ListInhibitors.return_value = (
  42. [
  43. (
  44. "shutdown:sleep",
  45. "Developer",
  46. "Haven't pushed my commits yet",
  47. "delay",
  48. 1000,
  49. 1234,
  50. ),
  51. ("shutdown", "Editor", "", "Unsafed files open", 0, 42),
  52. ],
  53. )
  54. with caplog.at_level(logging.DEBUG):
  55. systemctl_mqtt._dbus.login_manager._log_shutdown_inhibitors(login_manager)
  56. assert len(caplog.records) == 2
  57. assert caplog.records[0].levelno == logging.DEBUG
  58. assert (
  59. caplog.records[0].message
  60. == "detected shutdown inhibitor Developer (pid=1234, uid=1000, mode=delay): "
  61. + "Haven't pushed my commits yet"
  62. )
  63. def test__log_shutdown_inhibitors_none(caplog):
  64. login_manager = unittest.mock.MagicMock()
  65. login_manager.ListInhibitors.return_value = ([],)
  66. with caplog.at_level(logging.DEBUG):
  67. systemctl_mqtt._dbus.login_manager._log_shutdown_inhibitors(login_manager)
  68. assert len(caplog.records) == 1
  69. assert caplog.records[0].levelno == logging.DEBUG
  70. assert caplog.records[0].message == "no shutdown inhibitor locks found"
  71. def test__log_shutdown_inhibitors_fail(caplog):
  72. login_manager = unittest.mock.MagicMock()
  73. login_manager.ListInhibitors.side_effect = DBusErrorResponseMock("error", "mocked")
  74. with caplog.at_level(logging.DEBUG):
  75. systemctl_mqtt._dbus.login_manager._log_shutdown_inhibitors(login_manager)
  76. assert len(caplog.records) == 1
  77. assert caplog.records[0].levelno == logging.WARNING
  78. assert (
  79. caplog.records[0].message
  80. == "failed to fetch shutdown inhibitors: [error] mocked"
  81. )
  82. @pytest.mark.parametrize("action", ["poweroff", "reboot"])
  83. @pytest.mark.parametrize("delay", [datetime.timedelta(0), datetime.timedelta(hours=1)])
  84. def test__schedule_shutdown(action, delay):
  85. login_manager_mock = unittest.mock.MagicMock()
  86. with unittest.mock.patch(
  87. "systemctl_mqtt._dbus.login_manager.get_login_manager_proxy",
  88. return_value=login_manager_mock,
  89. ):
  90. login_manager_mock.ListInhibitors.return_value = ([],)
  91. systemctl_mqtt._dbus.login_manager.schedule_shutdown(action=action, delay=delay)
  92. login_manager_mock.ScheduleShutdown.assert_called_once()
  93. schedule_args, schedule_kwargs = login_manager_mock.ScheduleShutdown.call_args
  94. assert not schedule_args
  95. assert schedule_kwargs.pop("action") == action
  96. actual_delay = schedule_kwargs.pop("time") - datetime.datetime.now()
  97. assert actual_delay.total_seconds() == pytest.approx(delay.total_seconds(), abs=0.1)
  98. assert not schedule_kwargs
  99. class DBusErrorResponseMock(jeepney.wrappers.DBusErrorResponse):
  100. # pylint: disable=missing-class-docstring,super-init-not-called
  101. def __init__(self, name: str, data: typing.Any):
  102. self.name = name
  103. self.data = data
  104. @pytest.mark.parametrize(
  105. ("action", "error_name", "error_message", "log_message"),
  106. [
  107. (
  108. "poweroff",
  109. "test error",
  110. "test message",
  111. "[test error] ('test message',)",
  112. ),
  113. (
  114. "poweroff",
  115. "org.freedesktop.DBus.Error.InteractiveAuthorizationRequired",
  116. "Interactive authentication required.",
  117. """interactive authorization required
  118. create /etc/polkit-1/rules.d/50-systemctl-mqtt.rules and insert the following rule:
  119. polkit.addRule(function(action, subject) {
  120. if(action.id === "org.freedesktop.login1.power-off" && subject.user === "{{username}}") {
  121. return polkit.Result.YES;
  122. }
  123. });
  124. """.replace("{{username}}", getpass.getuser()),
  125. ),
  126. (
  127. "reboot",
  128. "org.freedesktop.DBus.Error.InteractiveAuthorizationRequired",
  129. "Interactive authentication required.",
  130. """interactive authorization required
  131. create /etc/polkit-1/rules.d/50-systemctl-mqtt.rules and insert the following rule:
  132. polkit.addRule(function(action, subject) {
  133. if(action.id === "org.freedesktop.login1.reboot" && subject.user === "{{username}}") {
  134. return polkit.Result.YES;
  135. }
  136. });
  137. """.replace("{{username}}", getpass.getuser()),
  138. ),
  139. ],
  140. )
  141. def test__schedule_shutdown_fail(
  142. caplog, action: str, error_name: str, error_message: str, log_message: str
  143. ) -> None:
  144. login_manager_mock = unittest.mock.MagicMock()
  145. login_manager_mock.ScheduleShutdown.side_effect = DBusErrorResponseMock(
  146. name=error_name,
  147. data=(error_message,),
  148. )
  149. login_manager_mock.ListInhibitors.return_value = ([],)
  150. with unittest.mock.patch(
  151. "systemctl_mqtt._dbus.login_manager.get_login_manager_proxy",
  152. return_value=login_manager_mock,
  153. ), caplog.at_level(logging.DEBUG):
  154. systemctl_mqtt._dbus.login_manager.schedule_shutdown(
  155. action=action, delay=datetime.timedelta(seconds=21)
  156. )
  157. login_manager_mock.ScheduleShutdown.assert_called_once()
  158. assert len(caplog.records) == 3
  159. assert caplog.records[0].levelno == logging.INFO
  160. assert caplog.records[0].message.startswith(f"scheduling {action} for ")
  161. assert caplog.records[1].levelno == logging.ERROR
  162. assert caplog.records[1].message == f"failed to schedule {action}: {log_message}"
  163. assert "inhibitor" in caplog.records[2].message
  164. @pytest.mark.parametrize("action", ["poweroff"])
  165. @pytest.mark.parametrize(
  166. ("error_name", "error_message", "log_message"),
  167. [
  168. (
  169. "org.freedesktop.DBus.Error.InteractiveAuthorizationRequired",
  170. "Interactive authentication required.",
  171. """interactive authorization required
  172. create /etc/polkit-1/rules.d/50-systemctl-mqtt.rules and insert the following rule:
  173. polkit.addRule(function(action, subject) {
  174. if(action.id === "org.freedesktop.login1.power-off" && subject.user === "USERNAME") {
  175. return polkit.Result.YES;
  176. }
  177. });
  178. """,
  179. ),
  180. ],
  181. )
  182. def test__schedule_shutdown_fail_no_username(
  183. caplog, action, error_name, error_message, log_message
  184. ):
  185. login_manager_mock = unittest.mock.MagicMock()
  186. login_manager_mock.ScheduleShutdown.side_effect = DBusErrorResponseMock(
  187. name=error_name,
  188. data=(error_message,),
  189. )
  190. login_manager_mock.ListInhibitors.return_value = ([],)
  191. with unittest.mock.patch(
  192. "systemctl_mqtt._dbus.login_manager.get_login_manager_proxy",
  193. return_value=login_manager_mock,
  194. ), unittest.mock.patch(
  195. "getpass.getuser", side_effect=OSError("No username set in the environment")
  196. ), caplog.at_level(
  197. logging.ERROR
  198. ):
  199. systemctl_mqtt._dbus.login_manager.schedule_shutdown(
  200. action=action, delay=datetime.timedelta(seconds=21)
  201. )
  202. login_manager_mock.ScheduleShutdown.assert_called_once()
  203. assert len(caplog.records) == 1
  204. assert caplog.records[0].levelno == logging.ERROR
  205. assert caplog.records[0].message == f"failed to schedule {action}: {log_message}"
  206. def test_suspend(caplog):
  207. login_manager_mock = unittest.mock.MagicMock()
  208. with unittest.mock.patch(
  209. "systemctl_mqtt._dbus.login_manager.get_login_manager_proxy",
  210. return_value=login_manager_mock,
  211. ), caplog.at_level(logging.INFO):
  212. systemctl_mqtt._dbus.login_manager.suspend()
  213. login_manager_mock.Suspend.assert_called_once_with(interactive=False)
  214. assert len(caplog.records) == 1
  215. assert caplog.records[0].levelno == logging.INFO
  216. assert caplog.records[0].message == "suspending system"
  217. def test_lock_all_sessions(caplog):
  218. login_manager_mock = unittest.mock.MagicMock()
  219. with unittest.mock.patch(
  220. "systemctl_mqtt._dbus.login_manager.get_login_manager_proxy",
  221. return_value=login_manager_mock,
  222. ), caplog.at_level(logging.INFO):
  223. systemctl_mqtt._dbus.login_manager.lock_all_sessions()
  224. login_manager_mock.LockSessions.assert_called_once_with()
  225. assert len(caplog.records) == 1
  226. assert caplog.records[0].levelno == logging.INFO
  227. assert caplog.records[0].message == "instruct all sessions to activate screen locks"
  228. @pytest.mark.parametrize(
  229. ("error_name", "error_message", "log_message"),
  230. [
  231. (
  232. "test error",
  233. "test message",
  234. "[test error] ('test message',)",
  235. ),
  236. (
  237. "org.freedesktop.DBus.Error.InteractiveAuthorizationRequired",
  238. "Interactive authentication required.",
  239. """interactive authorization required
  240. create /etc/polkit-1/rules.d/50-systemctl-mqtt.rules and insert the following rule:
  241. polkit.addRule(function(action, subject) {
  242. if(action.id === "org.freedesktop.login1.lock-sessions" && subject.user === "{{username}}") {
  243. return polkit.Result.YES;
  244. }
  245. });
  246. """.replace("{{username}}", getpass.getuser()),
  247. ),
  248. ],
  249. )
  250. def test_lock_all_sessions_fail(
  251. caplog: pytest.LogCaptureFixture,
  252. error_name: str,
  253. error_message: str,
  254. log_message: str,
  255. ) -> None:
  256. login_manager_mock = unittest.mock.MagicMock()
  257. login_manager_mock.LockSessions.side_effect = DBusErrorResponseMock(
  258. name=error_name, data=(error_message,)
  259. )
  260. with unittest.mock.patch(
  261. "systemctl_mqtt._dbus.login_manager.get_login_manager_proxy",
  262. return_value=login_manager_mock,
  263. ), caplog.at_level(logging.ERROR):
  264. systemctl_mqtt._dbus.login_manager.lock_all_sessions()
  265. login_manager_mock.LockSessions.assert_called_once()
  266. assert len(caplog.records) == 1
  267. assert caplog.records[0].levelno == logging.ERROR
  268. assert caplog.records[0].message == f"failed to lock all sessions: {log_message}"
  269. async def _get_unit_path_mock( # pylint: disable=unused-argument
  270. *, service_manager: jeepney.io.asyncio.Proxy, unit_name: str
  271. ) -> str:
  272. return "/org/freedesktop/systemd1/unit/" + unit_name
  273. @pytest.mark.asyncio
  274. @pytest.mark.parametrize(
  275. "monitored_system_unit_names", [[], ["foo.service", "bar.service"]]
  276. )
  277. async def test__dbus_signal_loop(monitored_system_unit_names: list[str]) -> None:
  278. # pylint: disable=too-many-locals,too-many-arguments
  279. state_mock = unittest.mock.AsyncMock()
  280. with unittest.mock.patch(
  281. "jeepney.io.asyncio.open_dbus_router",
  282. ) as open_dbus_router_mock, unittest.mock.patch(
  283. "systemctl_mqtt._get_unit_path", _get_unit_path_mock
  284. ), unittest.mock.patch(
  285. "systemctl_mqtt._dbus_signal_loop_unit"
  286. ) as dbus_signal_loop_unit_mock:
  287. async with open_dbus_router_mock() as dbus_router_mock:
  288. pass
  289. add_match_reply = unittest.mock.Mock()
  290. add_match_reply.body = ()
  291. dbus_router_mock.send_and_get_reply.return_value = add_match_reply
  292. msg_queue: asyncio.Queue[jeepney.low_level.Message] = asyncio.Queue()
  293. await msg_queue.put(jeepney.low_level.Message(header=None, body=(False,)))
  294. await msg_queue.put(jeepney.low_level.Message(header=None, body=(True,)))
  295. await msg_queue.put(jeepney.low_level.Message(header=None, body=(False,)))
  296. dbus_router_mock.filter = unittest.mock.MagicMock()
  297. dbus_router_mock.filter.return_value.__enter__.return_value = msg_queue
  298. state_mock.monitored_system_unit_names = monitored_system_unit_names
  299. # asyncio.TaskGroup added in python3.11
  300. loop_task = asyncio.create_task(
  301. systemctl_mqtt._dbus_signal_loop(
  302. state=state_mock, mqtt_client=unittest.mock.MagicMock()
  303. )
  304. )
  305. async def _abort_after_msg_queue():
  306. await msg_queue.join()
  307. loop_task.cancel()
  308. with pytest.raises(asyncio.exceptions.CancelledError):
  309. await asyncio.gather(*(loop_task, _abort_after_msg_queue()))
  310. assert unittest.mock.call(bus="SYSTEM") in open_dbus_router_mock.call_args_list
  311. dbus_router_mock.filter.assert_called_once()
  312. (filter_match_rule,) = dbus_router_mock.filter.call_args[0]
  313. assert (
  314. filter_match_rule.header_fields["interface"] == "org.freedesktop.login1.Manager"
  315. )
  316. assert filter_match_rule.header_fields["member"] == "PrepareForShutdown"
  317. add_match_msg = dbus_router_mock.send_and_get_reply.call_args[0][0]
  318. assert (
  319. add_match_msg.header.fields[jeepney.low_level.HeaderFields.member] == "AddMatch"
  320. )
  321. assert add_match_msg.body == (
  322. "interface='org.freedesktop.login1.Manager',member='PrepareForShutdown'"
  323. ",path='/org/freedesktop/login1',type='signal'",
  324. )
  325. assert [
  326. c[1]["active"] for c in state_mock.preparing_for_shutdown_handler.call_args_list
  327. ] == [False, True, False]
  328. assert not any(args for args, _ in dbus_signal_loop_unit_mock.await_args_list)
  329. dbus_signal_loop_unit_kwargs = [
  330. kwargs for _, kwargs in dbus_signal_loop_unit_mock.await_args_list
  331. ]
  332. assert [(a["unit_name"], a["unit_path"]) for a in dbus_signal_loop_unit_kwargs] == [
  333. (n, f"/org/freedesktop/systemd1/unit/{n}") for n in monitored_system_unit_names
  334. ]
  335. def _mock_get_active_state_reply(state: str) -> unittest.mock.MagicMock:
  336. reply_mock = unittest.mock.MagicMock()
  337. reply_mock.body = (("s", state),)
  338. return reply_mock
  339. @pytest.mark.asyncio
  340. async def test__dbus_signal_loop_unit() -> None:
  341. state = systemctl_mqtt._State(
  342. mqtt_topic_prefix="prefix",
  343. homeassistant_discovery_prefix="unused",
  344. homeassistant_discovery_object_id="unused",
  345. poweroff_delay=datetime.timedelta(),
  346. monitored_system_unit_names=[],
  347. controlled_system_unit_names=[],
  348. )
  349. mqtt_client_mock = unittest.mock.AsyncMock()
  350. dbus_router_mock = unittest.mock.AsyncMock()
  351. bus_proxy_mock = unittest.mock.AsyncMock()
  352. bus_proxy_mock.AddMatch.return_value = ()
  353. get_active_state_reply_mock = unittest.mock.MagicMock()
  354. get_active_state_reply_mock.body = (("s", "active"),)
  355. states = [
  356. "active",
  357. "deactivating",
  358. "inactive",
  359. "inactive",
  360. "activating",
  361. "active",
  362. "active",
  363. "active",
  364. "inactive",
  365. ]
  366. dbus_router_mock.send_and_get_reply.side_effect = [
  367. _mock_get_active_state_reply(s) for s in states
  368. ]
  369. msg_queue: asyncio.Queue[jeepney.low_level.Message] = asyncio.Queue()
  370. for _ in range(len(states) - 1):
  371. await msg_queue.put(jeepney.low_level.Message(header=None, body=()))
  372. dbus_router_mock.filter = unittest.mock.MagicMock()
  373. dbus_router_mock.filter.return_value.__enter__.return_value = msg_queue
  374. loop_task = asyncio.create_task(
  375. systemctl_mqtt._dbus_signal_loop_unit(
  376. state=state,
  377. mqtt_client=mqtt_client_mock,
  378. dbus_router=dbus_router_mock,
  379. bus_proxy=bus_proxy_mock,
  380. unit_name="foo.service",
  381. unit_path="/org/freedesktop/systemd1/unit/whatever_2eservice",
  382. )
  383. )
  384. async def _abort_after_msg_queue():
  385. await msg_queue.join()
  386. loop_task.cancel()
  387. with pytest.raises(asyncio.exceptions.CancelledError):
  388. await asyncio.gather(*(loop_task, _abort_after_msg_queue()))
  389. bus_proxy_mock.AddMatch.assert_awaited_once()
  390. (match_rule,), add_match_kwargs = bus_proxy_mock.AddMatch.await_args
  391. assert match_rule.header_fields["interface"] == "org.freedesktop.DBus.Properties"
  392. assert match_rule.header_fields["member"] == "PropertiesChanged"
  393. assert not add_match_kwargs
  394. assert mqtt_client_mock.publish.await_args_list == [
  395. unittest.mock.call(
  396. topic="prefix/unit/system/foo.service/active-state", payload=s
  397. )
  398. for s in [ # consecutive duplicates filtered
  399. "active",
  400. "deactivating",
  401. "inactive",
  402. "activating",
  403. "active",
  404. "inactive",
  405. ]
  406. ]