test_scheduler_systemd.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. # -*- coding:utf-8 -*-
  2. """
  3. Unit tests for ddns.scheduler.systemd module
  4. @author: NewFuture
  5. """
  6. import os
  7. import platform
  8. from __init__ import patch, unittest
  9. from ddns.scheduler.systemd import SystemdScheduler
  10. from ddns.util.try_run import try_run
  11. class TestSystemdScheduler(unittest.TestCase):
  12. """Test cases for SystemdScheduler class"""
  13. def setUp(self):
  14. """Set up test fixtures"""
  15. self.scheduler = SystemdScheduler()
  16. def test_service_name_property(self):
  17. """Test service name constant"""
  18. self.assertEqual(self.scheduler.SERVICE_NAME, "ddns.service")
  19. def test_timer_name_property(self):
  20. """Test timer name constant"""
  21. self.assertEqual(self.scheduler.TIMER_NAME, "ddns.timer")
  22. @patch("os.path.exists")
  23. def test_is_installed_true(self, mock_exists):
  24. """Test is_installed returns True when service exists"""
  25. mock_exists.return_value = True
  26. result = self.scheduler.is_installed()
  27. self.assertTrue(result)
  28. @patch("os.path.exists")
  29. def test_is_installed_false(self, mock_exists):
  30. """Test is_installed returns False when service doesn't exist"""
  31. mock_exists.return_value = False
  32. result = self.scheduler.is_installed()
  33. self.assertFalse(result)
  34. @patch("subprocess.check_output")
  35. @patch("ddns.scheduler.systemd.read_file_safely")
  36. @patch("os.path.exists")
  37. def test_get_status_success(self, mock_exists, mock_read_file, mock_check_output):
  38. """Test get_status with proper file reading"""
  39. mock_exists.return_value = True
  40. # Mock read_file_safely to return content for timer file and service file
  41. def mock_read_side_effect(file_path):
  42. if "ddns.timer" in file_path:
  43. return "OnUnitActiveSec=5m\n"
  44. elif "ddns.service" in file_path:
  45. return "ExecStart=/usr/bin/python3 -m ddns\n"
  46. return ""
  47. mock_read_file.side_effect = mock_read_side_effect
  48. # Mock subprocess.check_output to return "enabled" status
  49. mock_check_output.return_value = "enabled"
  50. status = self.scheduler.get_status()
  51. self.assertEqual(status["scheduler"], "systemd")
  52. self.assertTrue(status["installed"])
  53. self.assertTrue(status["enabled"])
  54. self.assertEqual(status["interval"], 5)
  55. @patch("ddns.scheduler.systemd.write_file")
  56. @patch.object(SystemdScheduler, "_systemctl")
  57. def test_install_with_sudo_fallback(self, mock_systemctl, mock_write_file):
  58. """Test install with sudo fallback for permission issues"""
  59. # Mock successful file writing and systemctl calls
  60. mock_write_file.return_value = None # write_file doesn't return anything
  61. mock_systemctl.side_effect = [True, True, True] # daemon-reload, enable, start all succeed
  62. ddns_args = {"dns": "debug", "ipv4": ["test.com"]}
  63. result = self.scheduler.install(5, ddns_args)
  64. self.assertTrue(result)
  65. # Verify that write_file was called twice (service and timer files)
  66. self.assertEqual(mock_write_file.call_count, 2)
  67. # Verify systemctl was called 3 times (daemon-reload, enable, start)
  68. self.assertEqual(mock_systemctl.call_count, 3)
  69. def test_systemctl_basic_functionality(self):
  70. """Test systemctl command basic functionality"""
  71. # Test that systemctl calls try_run and returns appropriate boolean
  72. with patch("ddns.scheduler.systemd.try_run") as mock_run_cmd:
  73. # Test success case
  74. mock_run_cmd.return_value = "success"
  75. result = self.scheduler._systemctl("enable", "ddns.timer")
  76. self.assertTrue(result)
  77. mock_run_cmd.assert_called_with(["systemctl", "enable", "ddns.timer"], logger=self.scheduler.logger)
  78. # Test failure case
  79. mock_run_cmd.return_value = None
  80. result = self.scheduler._systemctl("enable", "ddns.timer")
  81. self.assertFalse(result)
  82. @patch("os.remove")
  83. @patch.object(SystemdScheduler, "_systemctl")
  84. def test_uninstall_with_permission_handling(self, mock_systemctl, mock_remove):
  85. """Test uninstall with proper permission handling"""
  86. mock_systemctl.return_value = True # disable() succeeds
  87. # Mock successful file removal
  88. mock_remove.return_value = None
  89. result = self.scheduler.uninstall()
  90. self.assertTrue(result)
  91. # Verify both service and timer files are removed
  92. self.assertEqual(mock_remove.call_count, 2)
  93. def test_build_ddns_command(self):
  94. """Test _build_ddns_command functionality"""
  95. ddns_args = {"dns": "debug", "ipv4": ["test.example.com"], "debug": True}
  96. command = self.scheduler._build_ddns_command(ddns_args)
  97. self.assertIsInstance(command, list)
  98. command_str = " ".join(command)
  99. self.assertIn("debug", command_str)
  100. self.assertIn("test.example.com", command_str)
  101. @unittest.skipUnless(platform.system().lower() == "linux", "Linux-specific test")
  102. def test_real_systemctl_availability(self):
  103. """Test if systemctl is available on Linux systems"""
  104. # Check if systemctl command is available
  105. try:
  106. from ddns.util.try_run import try_run
  107. systemctl_result = try_run(["systemctl", "--version"])
  108. if not systemctl_result:
  109. self.skipTest("systemctl not available on this system")
  110. except Exception:
  111. self.skipTest("systemctl not available on this system")
  112. # Test both regular and sudo access
  113. self.scheduler._systemctl("--version")
  114. # Test if we have sudo access (don't actually run sudo commands in tests)
  115. try:
  116. sudo_result = try_run(["sudo", "--version"])
  117. if sudo_result:
  118. # Just verify sudo is available for fallback
  119. self.assertIsNotNone(sudo_result)
  120. except Exception:
  121. # sudo not available, skip test
  122. self.skipTest("sudo not available for elevated permissions")
  123. @unittest.skipUnless(platform.system().lower() == "linux", "Linux-specific test")
  124. def test_permission_check_methods(self):
  125. """Test permission checking for systemd operations"""
  126. # Test if we can write to systemd directory
  127. systemd_dir = "/etc/systemd/system"
  128. can_write = os.access(systemd_dir, os.W_OK) if os.path.exists(systemd_dir) else False
  129. # If we can't write directly, we should be able to use sudo
  130. if not can_write:
  131. try:
  132. sudo_result = try_run(["sudo", "--version"])
  133. self.assertIsNotNone(sudo_result, "sudo should be available for elevated permissions")
  134. except Exception:
  135. self.skipTest("sudo not available for elevated permissions")
  136. @unittest.skipUnless(platform.system().lower() == "linux", "Linux-specific test")
  137. def test_real_systemd_integration(self):
  138. """Test real systemd integration with actual system calls"""
  139. # Check if systemctl command is available
  140. try:
  141. systemctl_result = try_run(["systemctl", "--version"])
  142. if not systemctl_result:
  143. self.skipTest("systemctl not available on this system")
  144. except Exception:
  145. self.skipTest("systemctl not available on this system")
  146. # Test real systemctl version call
  147. version_result = self.scheduler._systemctl("--version")
  148. # On a real Linux system with systemd, this should work
  149. # We don't assert the result since it may vary based on permissions
  150. self.assertIsInstance(version_result, bool)
  151. # Test real status check for a non-existent service
  152. status = self.scheduler.get_status()
  153. self.assertIsInstance(status, dict)
  154. self.assertEqual(status["scheduler"], "systemd")
  155. self.assertIsInstance(status["installed"], bool)
  156. # Test if daemon-reload works (read-only operation)
  157. daemon_reload_result = self.scheduler._systemctl("daemon-reload")
  158. # This might fail due to permissions, but shouldn't crash
  159. self.assertIsInstance(daemon_reload_result, bool)
  160. @unittest.skipUnless(platform.system().lower() == "linux", "Linux-specific test")
  161. def test_real_scheduler_methods_safe(self):
  162. """Test real scheduler methods that don't modify system state"""
  163. # Check if systemctl command is available
  164. try:
  165. systemctl_result = try_run(["systemctl", "--version"])
  166. if not systemctl_result:
  167. self.skipTest("systemctl not available on this system")
  168. except Exception:
  169. self.skipTest("systemctl not available on this system")
  170. # Test is_installed (safe read-only operation)
  171. installed = self.scheduler.is_installed()
  172. self.assertIsInstance(installed, bool)
  173. # Test build command
  174. ddns_args = {"dns": "debug", "ipv4": ["test.example.com"]}
  175. command = self.scheduler._build_ddns_command(ddns_args)
  176. self.assertIsInstance(command, list)
  177. command_str = " ".join(command)
  178. self.assertIn("python", command_str.lower())
  179. # Test get status (safe read-only operation)
  180. status = self.scheduler.get_status()
  181. # Basic keys should always be present
  182. basic_required_keys = ["scheduler", "installed"]
  183. for key in basic_required_keys:
  184. self.assertIn(key, status)
  185. # If service is installed, additional keys should be present
  186. if status.get("installed", False):
  187. additional_keys = ["enabled", "interval"]
  188. for key in additional_keys:
  189. self.assertIn(key, status)
  190. # Test enable/disable without actual installation (should handle gracefully)
  191. enable_result = self.scheduler.enable()
  192. self.assertIsInstance(enable_result, bool)
  193. disable_result = self.scheduler.disable()
  194. self.assertIsInstance(disable_result, bool)
  195. @unittest.skipUnless(platform.system().lower() == "linux", "Linux-specific test")
  196. def test_real_systemd_lifecycle_operations(self):
  197. """Test real systemd lifecycle operations: install -> enable -> disable -> uninstall"""
  198. # Check if systemctl command is available
  199. try:
  200. systemctl_result = try_run(["systemctl", "--version"])
  201. if not systemctl_result:
  202. self.skipTest("systemctl not available on this system")
  203. except Exception:
  204. self.skipTest("systemctl not available on this system")
  205. # Test arguments for DDNS
  206. ddns_args = {"dns": "debug", "ipv4": ["test.example.com"], "interval": 10}
  207. # Store original state
  208. original_installed = self.scheduler.is_installed()
  209. self.scheduler.get_status() if original_installed else None
  210. try:
  211. # Test 1: Install operation
  212. install_result = self.scheduler.install(10, ddns_args)
  213. self.assertIsInstance(install_result, bool)
  214. # After install, service should be installed (regardless of permissions)
  215. post_install_status = self.scheduler.get_status()
  216. self.assertIsInstance(post_install_status, dict)
  217. self.assertEqual(post_install_status["scheduler"], "systemd")
  218. self.assertIsInstance(post_install_status["installed"], bool)
  219. # If installation succeeded, test enable/disable
  220. if install_result and post_install_status.get("installed", False):
  221. # Test 2: Enable operation
  222. enable_result = self.scheduler.enable()
  223. self.assertIsInstance(enable_result, bool)
  224. # Check status after enable attempt
  225. post_enable_status = self.scheduler.get_status()
  226. self.assertIsInstance(post_enable_status, dict)
  227. self.assertIn("enabled", post_enable_status)
  228. # Test 3: Disable operation
  229. disable_result = self.scheduler.disable()
  230. self.assertIsInstance(disable_result, bool)
  231. # Check status after disable attempt
  232. post_disable_status = self.scheduler.get_status()
  233. self.assertIsInstance(post_disable_status, dict)
  234. self.assertIn("enabled", post_disable_status)
  235. # Test 4: Uninstall operation
  236. uninstall_result = self.scheduler.uninstall()
  237. self.assertIsInstance(uninstall_result, bool)
  238. # Check status after uninstall attempt
  239. post_uninstall_status = self.scheduler.get_status()
  240. self.assertIsInstance(post_uninstall_status, dict)
  241. # After uninstall, installed should be False (if uninstall succeeded)
  242. if uninstall_result:
  243. self.assertFalse(post_uninstall_status.get("installed", True))
  244. else:
  245. self.skipTest("Install failed due to permissions - cannot test lifecycle")
  246. except Exception as e:
  247. # If we get permission errors, that's expected in test environment
  248. if "Permission denied" in str(e) or "Interactive authentication required" in str(e):
  249. self.skipTest("Insufficient permissions for systemd operations")
  250. else:
  251. # Re-raise unexpected exceptions
  252. raise
  253. finally:
  254. # Cleanup: Try to restore original state
  255. try:
  256. if original_installed:
  257. # If it was originally installed, try to restore
  258. if not self.scheduler.is_installed():
  259. # Try to reinstall with original settings if we have them
  260. self.scheduler.install(10, ddns_args)
  261. else:
  262. # If it wasn't originally installed, try to uninstall
  263. if self.scheduler.is_installed():
  264. self.scheduler.uninstall()
  265. except Exception:
  266. # Cleanup failures are not critical for tests
  267. pass
  268. @unittest.skipUnless(platform.system().lower() == "linux", "Linux-specific test")
  269. def test_real_systemd_status_consistency(self):
  270. """Test that systemd status reporting is consistent across operations"""
  271. # Check if systemctl command is available
  272. try:
  273. systemctl_result = try_run(["systemctl", "--version"])
  274. if not systemctl_result:
  275. self.skipTest("systemctl not available on this system")
  276. except Exception:
  277. self.skipTest("systemctl not available on this system")
  278. # Get initial status
  279. initial_status = self.scheduler.get_status()
  280. self.assertIsInstance(initial_status, dict)
  281. self.assertEqual(initial_status["scheduler"], "systemd")
  282. self.assertIn("installed", initial_status)
  283. # Test is_installed consistency
  284. installed_check = self.scheduler.is_installed()
  285. self.assertEqual(installed_check, initial_status["installed"])
  286. # If installed, check that additional status fields are present
  287. if initial_status.get("installed", False):
  288. required_keys = ["enabled", "interval"]
  289. for key in required_keys:
  290. self.assertIn(key, initial_status, "Key '{}' should be present when service is installed".format(key))
  291. # Test that repeated status calls are consistent
  292. second_status = self.scheduler.get_status()
  293. self.assertEqual(initial_status["scheduler"], second_status["scheduler"])
  294. self.assertEqual(initial_status["installed"], second_status["installed"])
  295. # If both report as installed, other fields should also match
  296. if initial_status.get("installed", False) and second_status.get("installed", False):
  297. for key in ["enabled", "interval"]:
  298. if key in initial_status and key in second_status:
  299. self.assertEqual(
  300. initial_status[key],
  301. second_status[key],
  302. "Status field '{}' should be consistent between calls".format(key),
  303. )
  304. if __name__ == "__main__":
  305. unittest.main()