test_scheduler_launchd.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453
  1. # -*- coding:utf-8 -*-
  2. """
  3. Unit tests for ddns.scheduler.launchd module
  4. @author: NewFuture
  5. """
  6. import os
  7. import platform
  8. import sys
  9. from __init__ import patch, unittest
  10. from ddns.scheduler.launchd import LaunchdScheduler
  11. from ddns.util.try_run import try_run
  12. # Handle builtins import for Python 2/3 compatibility
  13. if sys.version_info[0] >= 3:
  14. builtins_module = "builtins"
  15. permission_error = PermissionError
  16. else:
  17. # Python 2
  18. builtins_module = "__builtin__"
  19. permission_error = OSError
  20. class TestLaunchdScheduler(unittest.TestCase):
  21. """Test cases for LaunchdScheduler class"""
  22. def setUp(self):
  23. """Set up test fixtures"""
  24. self.scheduler = LaunchdScheduler()
  25. def test_service_name_property(self):
  26. """Test service name constant"""
  27. expected_name = "cc.newfuture.ddns"
  28. self.assertEqual(self.scheduler.LABEL, expected_name)
  29. def test_plist_path_property(self):
  30. """Test plist path property"""
  31. expected_path = os.path.expanduser("~/Library/LaunchAgents/cc.newfuture.ddns.plist")
  32. self.assertEqual(self.scheduler._get_plist_path(), expected_path)
  33. def test_get_status_loaded_enabled(self):
  34. """Test get_status when service is loaded and enabled"""
  35. # Mock plist file exists and has content
  36. plist_content = """<?xml version="1.0" encoding="UTF-8"?>
  37. <plist version="1.0">
  38. <dict>
  39. <key>Label</key>
  40. <string>cc.newfuture.ddns</string>
  41. <key>StartInterval</key>
  42. <integer>300</integer>
  43. </dict>
  44. </plist>"""
  45. with patch("os.path.exists", return_value=True), patch(
  46. "ddns.scheduler.launchd.read_file_safely", return_value=plist_content
  47. ), patch("ddns.scheduler.launchd.try_run") as mock_run_command:
  48. # Mock launchctl list to return service is loaded - need to include the full label
  49. mock_run_command.return_value = "PID\tStatus\tLabel\n123\t0\tcc.newfuture.ddns\n456\t0\tcom.apple.other"
  50. status = self.scheduler.get_status()
  51. expected_status = {
  52. "scheduler": "launchd",
  53. "installed": True,
  54. "enabled": True,
  55. "interval": 5, # 300 seconds / 60 = 5 minutes
  56. }
  57. self.assertEqual(status["scheduler"], expected_status["scheduler"])
  58. self.assertEqual(status["installed"], expected_status["installed"])
  59. self.assertEqual(status["enabled"], expected_status["enabled"])
  60. self.assertEqual(status["interval"], expected_status["interval"])
  61. def test_get_status_not_loaded(self):
  62. """Test get_status when service is not loaded"""
  63. # Mock plist file doesn't exist
  64. with patch("os.path.exists", return_value=False), patch(
  65. "ddns.scheduler.launchd.read_file_safely", return_value=None
  66. ):
  67. status = self.scheduler.get_status()
  68. # When not installed, only basic keys are returned
  69. self.assertEqual(status["scheduler"], "launchd")
  70. self.assertEqual(status["installed"], False)
  71. # enabled and interval keys are not returned when not installed
  72. self.assertNotIn("enabled", status)
  73. self.assertNotIn("interval", status)
  74. @patch("os.path.exists")
  75. def test_is_installed_true(self, mock_exists):
  76. """Test is_installed returns True when plist file exists"""
  77. mock_exists.return_value = True
  78. result = self.scheduler.is_installed()
  79. self.assertTrue(result)
  80. @patch("os.path.exists")
  81. def test_is_installed_false(self, mock_exists):
  82. """Test is_installed returns False when plist file doesn't exist"""
  83. mock_exists.return_value = False
  84. result = self.scheduler.is_installed()
  85. self.assertFalse(result)
  86. @patch("ddns.scheduler.launchd.write_file")
  87. def test_install_with_sudo_fallback(self, mock_write_file):
  88. """Test install with sudo fallback for permission issues"""
  89. mock_write_file.return_value = None # write_file succeeds
  90. with patch("ddns.scheduler.launchd.try_run", return_value="loaded successfully"):
  91. ddns_args = {"dns": "debug", "ipv4": ["test.com"]}
  92. result = self.scheduler.install(5, ddns_args)
  93. self.assertTrue(result)
  94. @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific test")
  95. def test_launchctl_with_sudo_retry(self):
  96. """Test launchctl command with automatic sudo retry on permission error"""
  97. with patch("ddns.scheduler.launchd.try_run") as mock_run_cmd:
  98. # Test that launchctl operations use try_run directly
  99. mock_run_cmd.return_value = "success"
  100. # Test enable which uses launchctl load
  101. plist_path = self.scheduler._get_plist_path()
  102. with patch("os.path.exists", return_value=True):
  103. result = self.scheduler.enable()
  104. self.assertTrue(result)
  105. # Verify the call was made with the expected command and logger
  106. mock_run_cmd.assert_called_with(["launchctl", "load", plist_path], logger=self.scheduler.logger)
  107. @patch("os.path.exists")
  108. @patch("os.remove")
  109. def test_uninstall_success(self, mock_remove, mock_exists):
  110. """Test successful uninstall"""
  111. mock_exists.return_value = True
  112. with patch("ddns.scheduler.launchd.try_run", return_value="unloaded successfully"):
  113. result = self.scheduler.uninstall()
  114. self.assertTrue(result)
  115. mock_remove.assert_called_once()
  116. @patch("os.path.exists")
  117. @patch("os.remove")
  118. def test_uninstall_with_permission_handling(self, mock_remove, mock_exists):
  119. """Test uninstall handles permission errors gracefully"""
  120. mock_exists.return_value = True
  121. # Mock file removal failure - use appropriate error type for Python version
  122. mock_remove.side_effect = permission_error("Permission denied")
  123. with patch("ddns.scheduler.launchd.try_run", return_value="") as mock_run:
  124. result = self.scheduler.uninstall()
  125. # Should handle permission error gracefully and still return True
  126. self.assertTrue(result)
  127. # Should attempt to unload the service
  128. mock_run.assert_called_once()
  129. # Should attempt to remove the file
  130. mock_remove.assert_called_once()
  131. def test_enable_success(self):
  132. """Test successful enable"""
  133. with patch("os.path.exists", return_value=True):
  134. with patch("ddns.scheduler.launchd.try_run", return_value="loaded successfully"):
  135. result = self.scheduler.enable()
  136. self.assertTrue(result)
  137. def test_disable_success(self):
  138. """Test successful disable"""
  139. with patch("ddns.scheduler.launchd.try_run", return_value="unloaded successfully"):
  140. result = self.scheduler.disable()
  141. self.assertTrue(result)
  142. def test_build_ddns_command(self):
  143. """Test _build_ddns_command functionality"""
  144. ddns_args = {"dns": "debug", "ipv4": ["test.example.com"], "debug": True}
  145. command = self.scheduler._build_ddns_command(ddns_args)
  146. self.assertIsInstance(command, list)
  147. command_str = " ".join(command)
  148. self.assertIn("debug", command_str)
  149. self.assertIn("test.example.com", command_str)
  150. @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific test")
  151. def test_real_launchctl_availability(self):
  152. """Test if launchctl is available on macOS systems"""
  153. try:
  154. # Test launchctl availability by trying to run it
  155. result = try_run(["launchctl", "version"])
  156. # launchctl is available if result is not None
  157. if result is None:
  158. self.skipTest("launchctl not available")
  159. except (OSError, Exception):
  160. self.skipTest("launchctl not found on this system")
  161. @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific test")
  162. def test_permission_check_methods(self):
  163. """Test permission checking for launchd operations"""
  164. # Test if we can write to LaunchAgents directory
  165. agents_dir = os.path.expanduser("~/Library/LaunchAgents")
  166. can_write = os.access(agents_dir, os.W_OK) if os.path.exists(agents_dir) else False
  167. # For system-wide daemons (/Library/LaunchDaemons), we'd typically need sudo
  168. daemon_dir = "/Library/LaunchDaemons"
  169. daemon_write = os.access(daemon_dir, os.W_OK) if os.path.exists(daemon_dir) else False
  170. # If we can't write to system locations, we should be able to use sudo
  171. if not daemon_write:
  172. try:
  173. try_run(["sudo", "--version"])
  174. sudo_available = True
  175. except Exception:
  176. sudo_available = False
  177. # sudo should be available on macOS systems
  178. if not sudo_available:
  179. self.skipTest("sudo not available for elevated permissions")
  180. # User agents directory should generally be writable or sudo should be available
  181. if os.path.exists(agents_dir):
  182. try:
  183. try_run(["sudo", "--version"])
  184. sudo_available = True
  185. except Exception:
  186. sudo_available = False
  187. self.assertTrue(
  188. can_write or sudo_available, "Should be able to write to user LaunchAgents or have sudo access"
  189. )
  190. @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific test")
  191. def test_real_launchd_integration(self):
  192. """Test real launchd integration with actual system calls"""
  193. # Test launchctl availability by trying to run it directly
  194. try:
  195. result = try_run(["launchctl", "version"])
  196. if result is None:
  197. self.skipTest("launchctl not available on this system")
  198. except (OSError, Exception):
  199. self.skipTest("launchctl not available on this system")
  200. # Test real launchctl version call
  201. version_result = try_run(["launchctl", "version"])
  202. # On a real macOS system, this should work
  203. self.assertTrue(version_result is None or isinstance(version_result, str))
  204. # Test real status check
  205. status = self.scheduler.get_status()
  206. self.assertIsInstance(status, dict)
  207. self.assertEqual(status["scheduler"], "launchd")
  208. self.assertIsInstance(status["installed"], bool)
  209. # Test launchctl list (read-only operation)
  210. list_result = try_run(["launchctl", "list"])
  211. # This might return None or string based on system state
  212. self.assertTrue(list_result is None or isinstance(list_result, str))
  213. @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific test")
  214. def test_real_scheduler_methods_safe(self):
  215. """Test real scheduler methods that don't modify system state"""
  216. # Test launchctl availability by trying to run it directly
  217. try:
  218. result = try_run(["launchctl", "version"])
  219. if result is None:
  220. self.skipTest("launchctl not available on this system")
  221. except (OSError, Exception):
  222. self.skipTest("launchctl not available on this system")
  223. # Test is_installed (safe read-only operation)
  224. installed = self.scheduler.is_installed()
  225. self.assertIsInstance(installed, bool)
  226. # Test build command
  227. ddns_args = {"dns": "debug", "ipv4": ["test.example.com"]}
  228. command = self.scheduler._build_ddns_command(ddns_args)
  229. self.assertIsInstance(command, list)
  230. command_str = " ".join(command)
  231. self.assertIn("python", command_str.lower())
  232. # Test get status (safe read-only operation)
  233. status = self.scheduler.get_status()
  234. basic_keys = ["scheduler", "installed"]
  235. for key in basic_keys:
  236. self.assertIn(key, status)
  237. # enabled and interval are only present when service is installed
  238. if status["installed"]:
  239. optional_keys = ["enabled", "interval"]
  240. for key in optional_keys:
  241. self.assertIn(key, status)
  242. # Test plist path generation
  243. plist_path = self.scheduler._get_plist_path()
  244. self.assertIsInstance(plist_path, str)
  245. self.assertTrue(plist_path.endswith(".plist"))
  246. self.assertIn("LaunchAgents", plist_path)
  247. # Test enable/disable without actual installation (should handle gracefully)
  248. enable_result = self.scheduler.enable()
  249. self.assertIsInstance(enable_result, bool)
  250. disable_result = self.scheduler.disable()
  251. self.assertIsInstance(disable_result, bool)
  252. def _setup_real_launchd_test(self):
  253. """
  254. Helper method to set up real launchd tests with common functionality
  255. Returns: (original_label, test_service_label)
  256. """
  257. # Check if launchctl is available first
  258. try:
  259. result = try_run(["launchctl", "version"])
  260. if result is None:
  261. self.skipTest("launchctl not available on this system")
  262. except (OSError, Exception):
  263. self.skipTest("launchctl not available on this system")
  264. # Use a unique test service label to avoid conflicts
  265. original_label = self.scheduler.LABEL
  266. import time
  267. test_service_label = "cc.newfuture.ddns.test.{}".format(int(time.time()))
  268. self.scheduler.LABEL = test_service_label # type: ignore
  269. return original_label, test_service_label
  270. def _cleanup_real_launchd_test(self, original_label, test_service_label):
  271. """
  272. Helper method to clean up real launchd tests
  273. """
  274. try:
  275. # Remove any test services
  276. if self.scheduler.is_installed():
  277. self.scheduler.uninstall()
  278. except Exception:
  279. pass
  280. # Restore original service label
  281. self.scheduler.LABEL = original_label
  282. # Final cleanup - ensure test service is removed
  283. try:
  284. self.scheduler.LABEL = test_service_label
  285. if self.scheduler.is_installed():
  286. self.scheduler.uninstall()
  287. except Exception:
  288. pass
  289. # Restore original label
  290. self.scheduler.LABEL = original_label
  291. @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific integration test")
  292. def test_real_lifecycle_comprehensive(self):
  293. """
  294. Comprehensive real-life integration test covering all lifecycle scenarios
  295. This combines install/enable/disable/uninstall, error handling, and permission scenarios
  296. WARNING: This test modifies system state and should only run on test systems
  297. """
  298. if platform.system().lower() != "darwin":
  299. self.skipTest("macOS-specific integration test")
  300. original_label, test_service_label = self._setup_real_launchd_test()
  301. try:
  302. # ===== PHASE 1: Clean state and error handling =====
  303. if self.scheduler.is_installed():
  304. self.scheduler.uninstall()
  305. # Test operations on non-existent service
  306. self.assertFalse(self.scheduler.enable(), "Enable should fail for non-existent service")
  307. # Verify initial state
  308. initial_status = self.scheduler.get_status()
  309. self.assertEqual(initial_status["scheduler"], "launchd")
  310. self.assertFalse(initial_status["installed"], "Service should not be installed initially")
  311. # ===== PHASE 2: Installation and validation =====
  312. ddns_args = {
  313. "dns": "debug",
  314. "ipv4": ["test-comprehensive.example.com"],
  315. "config": ["config.json"],
  316. "ttl": 300,
  317. }
  318. install_result = self.scheduler.install(interval=5, ddns_args=ddns_args)
  319. self.assertTrue(install_result, "Installation should succeed")
  320. # Verify installation
  321. post_install_status = self.scheduler.get_status()
  322. self.assertTrue(post_install_status["installed"], "Service should be installed")
  323. self.assertTrue(post_install_status["enabled"], "Service should be enabled")
  324. self.assertEqual(post_install_status["interval"], 5, "Interval should match")
  325. # Verify plist file exists and is readable
  326. plist_path = self.scheduler._get_plist_path()
  327. self.assertTrue(os.path.exists(plist_path), "Plist file should exist after installation")
  328. self.assertTrue(os.access(plist_path, os.R_OK), "Plist file should be readable")
  329. # Validate plist content
  330. with open(plist_path, "r") as f:
  331. content = f.read()
  332. self.assertIn(test_service_label, content, "Plist should contain correct service label")
  333. self.assertIn("StartInterval", content, "Plist should contain StartInterval")
  334. # ===== PHASE 3: Disable/Enable cycle =====
  335. disable_result = self.scheduler.disable()
  336. self.assertTrue(disable_result, "Disable should succeed")
  337. post_disable_status = self.scheduler.get_status()
  338. self.assertTrue(post_disable_status["installed"], "Should still be installed after disable")
  339. self.assertFalse(post_disable_status["enabled"], "Should be disabled")
  340. enable_result = self.scheduler.enable()
  341. self.assertTrue(enable_result, "Enable should succeed")
  342. post_enable_status = self.scheduler.get_status()
  343. self.assertTrue(post_enable_status["installed"], "Should still be installed after enable")
  344. self.assertTrue(post_enable_status["enabled"], "Should be enabled")
  345. # ===== PHASE 4: Duplicate installation and permission test =====
  346. duplicate_install = self.scheduler.install(interval=5, ddns_args=ddns_args)
  347. self.assertIsInstance(duplicate_install, bool, "Duplicate install should return boolean")
  348. status_after_duplicate = self.scheduler.get_status()
  349. self.assertTrue(status_after_duplicate["installed"], "Should remain installed after duplicate")
  350. # Test LaunchAgents directory accessibility if needed
  351. agents_dir = os.path.expanduser("~/Library/LaunchAgents")
  352. if os.path.exists(agents_dir) and os.access(agents_dir, os.W_OK):
  353. # Test file creation/removal
  354. test_file = os.path.join(agents_dir, "test_write_access.tmp")
  355. try:
  356. with open(test_file, "w") as f:
  357. f.write("test")
  358. self.assertTrue(os.path.exists(test_file), "Should be able to create test file")
  359. os.remove(test_file)
  360. self.assertFalse(os.path.exists(test_file), "Should be able to remove test file")
  361. except (OSError, IOError):
  362. pass # Permission test failed, but not critical
  363. # ===== PHASE 5: Uninstall and verification =====
  364. uninstall_result = self.scheduler.uninstall()
  365. self.assertTrue(uninstall_result, "Uninstall should succeed")
  366. final_status = self.scheduler.get_status()
  367. self.assertFalse(final_status["installed"], "Should not be installed after uninstall")
  368. self.assertFalse(self.scheduler.is_installed(), "is_installed() should return False")
  369. # Verify plist file is removed
  370. self.assertFalse(os.path.exists(plist_path), "Plist file should be removed after uninstall")
  371. finally:
  372. self._cleanup_real_launchd_test(original_label, test_service_label)
  373. if __name__ == "__main__":
  374. unittest.main()