| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453 |
- # -*- coding:utf-8 -*-
- """
- Unit tests for ddns.scheduler.launchd module
- @author: NewFuture
- """
- import os
- import platform
- import sys
- from __init__ import patch, unittest
- from ddns.scheduler.launchd import LaunchdScheduler
- from ddns.util.try_run import try_run
- # Handle builtins import for Python 2/3 compatibility
- if sys.version_info[0] >= 3:
- builtins_module = "builtins"
- permission_error = PermissionError
- else:
- # Python 2
- builtins_module = "__builtin__"
- permission_error = OSError
- class TestLaunchdScheduler(unittest.TestCase):
- """Test cases for LaunchdScheduler class"""
- def setUp(self):
- """Set up test fixtures"""
- self.scheduler = LaunchdScheduler()
- def test_service_name_property(self):
- """Test service name constant"""
- expected_name = "cc.newfuture.ddns"
- self.assertEqual(self.scheduler.LABEL, expected_name)
- def test_plist_path_property(self):
- """Test plist path property"""
- expected_path = os.path.expanduser("~/Library/LaunchAgents/cc.newfuture.ddns.plist")
- self.assertEqual(self.scheduler._get_plist_path(), expected_path)
- def test_get_status_loaded_enabled(self):
- """Test get_status when service is loaded and enabled"""
- # Mock plist file exists and has content
- plist_content = """<?xml version="1.0" encoding="UTF-8"?>
- <plist version="1.0">
- <dict>
- <key>Label</key>
- <string>cc.newfuture.ddns</string>
- <key>StartInterval</key>
- <integer>300</integer>
- </dict>
- </plist>"""
- with patch("os.path.exists", return_value=True), patch(
- "ddns.scheduler.launchd.read_file_safely", return_value=plist_content
- ), patch("ddns.scheduler.launchd.try_run") as mock_run_command:
- # Mock launchctl list to return service is loaded - need to include the full label
- mock_run_command.return_value = "PID\tStatus\tLabel\n123\t0\tcc.newfuture.ddns\n456\t0\tcom.apple.other"
- status = self.scheduler.get_status()
- expected_status = {
- "scheduler": "launchd",
- "installed": True,
- "enabled": True,
- "interval": 5, # 300 seconds / 60 = 5 minutes
- }
- self.assertEqual(status["scheduler"], expected_status["scheduler"])
- self.assertEqual(status["installed"], expected_status["installed"])
- self.assertEqual(status["enabled"], expected_status["enabled"])
- self.assertEqual(status["interval"], expected_status["interval"])
- def test_get_status_not_loaded(self):
- """Test get_status when service is not loaded"""
- # Mock plist file doesn't exist
- with patch("os.path.exists", return_value=False), patch(
- "ddns.scheduler.launchd.read_file_safely", return_value=None
- ):
- status = self.scheduler.get_status()
- # When not installed, only basic keys are returned
- self.assertEqual(status["scheduler"], "launchd")
- self.assertEqual(status["installed"], False)
- # enabled and interval keys are not returned when not installed
- self.assertNotIn("enabled", status)
- self.assertNotIn("interval", status)
- @patch("os.path.exists")
- def test_is_installed_true(self, mock_exists):
- """Test is_installed returns True when plist file exists"""
- mock_exists.return_value = True
- result = self.scheduler.is_installed()
- self.assertTrue(result)
- @patch("os.path.exists")
- def test_is_installed_false(self, mock_exists):
- """Test is_installed returns False when plist file doesn't exist"""
- mock_exists.return_value = False
- result = self.scheduler.is_installed()
- self.assertFalse(result)
- @patch("ddns.scheduler.launchd.write_file")
- def test_install_with_sudo_fallback(self, mock_write_file):
- """Test install with sudo fallback for permission issues"""
- mock_write_file.return_value = None # write_file succeeds
- with patch("ddns.scheduler.launchd.try_run", return_value="loaded successfully"):
- ddns_args = {"dns": "debug", "ipv4": ["test.com"]}
- result = self.scheduler.install(5, ddns_args)
- self.assertTrue(result)
- @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific test")
- def test_launchctl_with_sudo_retry(self):
- """Test launchctl command with automatic sudo retry on permission error"""
- with patch("ddns.scheduler.launchd.try_run") as mock_run_cmd:
- # Test that launchctl operations use try_run directly
- mock_run_cmd.return_value = "success"
- # Test enable which uses launchctl load
- plist_path = self.scheduler._get_plist_path()
- with patch("os.path.exists", return_value=True):
- result = self.scheduler.enable()
- self.assertTrue(result)
- # Verify the call was made with the expected command and logger
- mock_run_cmd.assert_called_with(["launchctl", "load", plist_path], logger=self.scheduler.logger)
- @patch("os.path.exists")
- @patch("os.remove")
- def test_uninstall_success(self, mock_remove, mock_exists):
- """Test successful uninstall"""
- mock_exists.return_value = True
- with patch("ddns.scheduler.launchd.try_run", return_value="unloaded successfully"):
- result = self.scheduler.uninstall()
- self.assertTrue(result)
- mock_remove.assert_called_once()
- @patch("os.path.exists")
- @patch("os.remove")
- def test_uninstall_with_permission_handling(self, mock_remove, mock_exists):
- """Test uninstall handles permission errors gracefully"""
- mock_exists.return_value = True
- # Mock file removal failure - use appropriate error type for Python version
- mock_remove.side_effect = permission_error("Permission denied")
- with patch("ddns.scheduler.launchd.try_run", return_value="") as mock_run:
- result = self.scheduler.uninstall()
- # Should handle permission error gracefully and still return True
- self.assertTrue(result)
- # Should attempt to unload the service
- mock_run.assert_called_once()
- # Should attempt to remove the file
- mock_remove.assert_called_once()
- def test_enable_success(self):
- """Test successful enable"""
- with patch("os.path.exists", return_value=True):
- with patch("ddns.scheduler.launchd.try_run", return_value="loaded successfully"):
- result = self.scheduler.enable()
- self.assertTrue(result)
- def test_disable_success(self):
- """Test successful disable"""
- with patch("ddns.scheduler.launchd.try_run", return_value="unloaded successfully"):
- result = self.scheduler.disable()
- self.assertTrue(result)
- def test_build_ddns_command(self):
- """Test _build_ddns_command functionality"""
- ddns_args = {"dns": "debug", "ipv4": ["test.example.com"], "debug": True}
- command = self.scheduler._build_ddns_command(ddns_args)
- self.assertIsInstance(command, list)
- command_str = " ".join(command)
- self.assertIn("debug", command_str)
- self.assertIn("test.example.com", command_str)
- @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific test")
- def test_real_launchctl_availability(self):
- """Test if launchctl is available on macOS systems"""
- try:
- # Test launchctl availability by trying to run it
- result = try_run(["launchctl", "version"])
- # launchctl is available if result is not None
- if result is None:
- self.skipTest("launchctl not available")
- except (OSError, Exception):
- self.skipTest("launchctl not found on this system")
- @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific test")
- def test_permission_check_methods(self):
- """Test permission checking for launchd operations"""
- # Test if we can write to LaunchAgents directory
- agents_dir = os.path.expanduser("~/Library/LaunchAgents")
- can_write = os.access(agents_dir, os.W_OK) if os.path.exists(agents_dir) else False
- # For system-wide daemons (/Library/LaunchDaemons), we'd typically need sudo
- daemon_dir = "/Library/LaunchDaemons"
- daemon_write = os.access(daemon_dir, os.W_OK) if os.path.exists(daemon_dir) else False
- # If we can't write to system locations, we should be able to use sudo
- if not daemon_write:
- try:
- try_run(["sudo", "--version"])
- sudo_available = True
- except Exception:
- sudo_available = False
- # sudo should be available on macOS systems
- if not sudo_available:
- self.skipTest("sudo not available for elevated permissions")
- # User agents directory should generally be writable or sudo should be available
- if os.path.exists(agents_dir):
- try:
- try_run(["sudo", "--version"])
- sudo_available = True
- except Exception:
- sudo_available = False
- self.assertTrue(
- can_write or sudo_available, "Should be able to write to user LaunchAgents or have sudo access"
- )
- @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific test")
- def test_real_launchd_integration(self):
- """Test real launchd integration with actual system calls"""
- # Test launchctl availability by trying to run it directly
- try:
- result = try_run(["launchctl", "version"])
- if result is None:
- self.skipTest("launchctl not available on this system")
- except (OSError, Exception):
- self.skipTest("launchctl not available on this system")
- # Test real launchctl version call
- version_result = try_run(["launchctl", "version"])
- # On a real macOS system, this should work
- self.assertTrue(version_result is None or isinstance(version_result, str))
- # Test real status check
- status = self.scheduler.get_status()
- self.assertIsInstance(status, dict)
- self.assertEqual(status["scheduler"], "launchd")
- self.assertIsInstance(status["installed"], bool)
- # Test launchctl list (read-only operation)
- list_result = try_run(["launchctl", "list"])
- # This might return None or string based on system state
- self.assertTrue(list_result is None or isinstance(list_result, str))
- @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific test")
- def test_real_scheduler_methods_safe(self):
- """Test real scheduler methods that don't modify system state"""
- # Test launchctl availability by trying to run it directly
- try:
- result = try_run(["launchctl", "version"])
- if result is None:
- self.skipTest("launchctl not available on this system")
- except (OSError, Exception):
- self.skipTest("launchctl not available on this system")
- # Test is_installed (safe read-only operation)
- installed = self.scheduler.is_installed()
- self.assertIsInstance(installed, bool)
- # Test build command
- ddns_args = {"dns": "debug", "ipv4": ["test.example.com"]}
- command = self.scheduler._build_ddns_command(ddns_args)
- self.assertIsInstance(command, list)
- command_str = " ".join(command)
- self.assertIn("python", command_str.lower())
- # Test get status (safe read-only operation)
- status = self.scheduler.get_status()
- basic_keys = ["scheduler", "installed"]
- for key in basic_keys:
- self.assertIn(key, status)
- # enabled and interval are only present when service is installed
- if status["installed"]:
- optional_keys = ["enabled", "interval"]
- for key in optional_keys:
- self.assertIn(key, status)
- # Test plist path generation
- plist_path = self.scheduler._get_plist_path()
- self.assertIsInstance(plist_path, str)
- self.assertTrue(plist_path.endswith(".plist"))
- self.assertIn("LaunchAgents", plist_path)
- # Test enable/disable without actual installation (should handle gracefully)
- enable_result = self.scheduler.enable()
- self.assertIsInstance(enable_result, bool)
- disable_result = self.scheduler.disable()
- self.assertIsInstance(disable_result, bool)
- def _setup_real_launchd_test(self):
- """
- Helper method to set up real launchd tests with common functionality
- Returns: (original_label, test_service_label)
- """
- # Check if launchctl is available first
- try:
- result = try_run(["launchctl", "version"])
- if result is None:
- self.skipTest("launchctl not available on this system")
- except (OSError, Exception):
- self.skipTest("launchctl not available on this system")
- # Use a unique test service label to avoid conflicts
- original_label = self.scheduler.LABEL
- import time
- test_service_label = "cc.newfuture.ddns.test.{}".format(int(time.time()))
- self.scheduler.LABEL = test_service_label # type: ignore
- return original_label, test_service_label
- def _cleanup_real_launchd_test(self, original_label, test_service_label):
- """
- Helper method to clean up real launchd tests
- """
- try:
- # Remove any test services
- if self.scheduler.is_installed():
- self.scheduler.uninstall()
- except Exception:
- pass
- # Restore original service label
- self.scheduler.LABEL = original_label
- # Final cleanup - ensure test service is removed
- try:
- self.scheduler.LABEL = test_service_label
- if self.scheduler.is_installed():
- self.scheduler.uninstall()
- except Exception:
- pass
- # Restore original label
- self.scheduler.LABEL = original_label
- @unittest.skipUnless(platform.system().lower() == "darwin", "macOS-specific integration test")
- def test_real_lifecycle_comprehensive(self):
- """
- Comprehensive real-life integration test covering all lifecycle scenarios
- This combines install/enable/disable/uninstall, error handling, and permission scenarios
- WARNING: This test modifies system state and should only run on test systems
- """
- if platform.system().lower() != "darwin":
- self.skipTest("macOS-specific integration test")
- original_label, test_service_label = self._setup_real_launchd_test()
- try:
- # ===== PHASE 1: Clean state and error handling =====
- if self.scheduler.is_installed():
- self.scheduler.uninstall()
- # Test operations on non-existent service
- self.assertFalse(self.scheduler.enable(), "Enable should fail for non-existent service")
- # Verify initial state
- initial_status = self.scheduler.get_status()
- self.assertEqual(initial_status["scheduler"], "launchd")
- self.assertFalse(initial_status["installed"], "Service should not be installed initially")
- # ===== PHASE 2: Installation and validation =====
- ddns_args = {
- "dns": "debug",
- "ipv4": ["test-comprehensive.example.com"],
- "config": ["config.json"],
- "ttl": 300,
- }
- install_result = self.scheduler.install(interval=5, ddns_args=ddns_args)
- self.assertTrue(install_result, "Installation should succeed")
- # Verify installation
- post_install_status = self.scheduler.get_status()
- self.assertTrue(post_install_status["installed"], "Service should be installed")
- self.assertTrue(post_install_status["enabled"], "Service should be enabled")
- self.assertEqual(post_install_status["interval"], 5, "Interval should match")
- # Verify plist file exists and is readable
- plist_path = self.scheduler._get_plist_path()
- self.assertTrue(os.path.exists(plist_path), "Plist file should exist after installation")
- self.assertTrue(os.access(plist_path, os.R_OK), "Plist file should be readable")
- # Validate plist content
- with open(plist_path, "r") as f:
- content = f.read()
- self.assertIn(test_service_label, content, "Plist should contain correct service label")
- self.assertIn("StartInterval", content, "Plist should contain StartInterval")
- # ===== PHASE 3: Disable/Enable cycle =====
- disable_result = self.scheduler.disable()
- self.assertTrue(disable_result, "Disable should succeed")
- post_disable_status = self.scheduler.get_status()
- self.assertTrue(post_disable_status["installed"], "Should still be installed after disable")
- self.assertFalse(post_disable_status["enabled"], "Should be disabled")
- enable_result = self.scheduler.enable()
- self.assertTrue(enable_result, "Enable should succeed")
- post_enable_status = self.scheduler.get_status()
- self.assertTrue(post_enable_status["installed"], "Should still be installed after enable")
- self.assertTrue(post_enable_status["enabled"], "Should be enabled")
- # ===== PHASE 4: Duplicate installation and permission test =====
- duplicate_install = self.scheduler.install(interval=5, ddns_args=ddns_args)
- self.assertIsInstance(duplicate_install, bool, "Duplicate install should return boolean")
- status_after_duplicate = self.scheduler.get_status()
- self.assertTrue(status_after_duplicate["installed"], "Should remain installed after duplicate")
- # Test LaunchAgents directory accessibility if needed
- agents_dir = os.path.expanduser("~/Library/LaunchAgents")
- if os.path.exists(agents_dir) and os.access(agents_dir, os.W_OK):
- # Test file creation/removal
- test_file = os.path.join(agents_dir, "test_write_access.tmp")
- try:
- with open(test_file, "w") as f:
- f.write("test")
- self.assertTrue(os.path.exists(test_file), "Should be able to create test file")
- os.remove(test_file)
- self.assertFalse(os.path.exists(test_file), "Should be able to remove test file")
- except (OSError, IOError):
- pass # Permission test failed, but not critical
- # ===== PHASE 5: Uninstall and verification =====
- uninstall_result = self.scheduler.uninstall()
- self.assertTrue(uninstall_result, "Uninstall should succeed")
- final_status = self.scheduler.get_status()
- self.assertFalse(final_status["installed"], "Should not be installed after uninstall")
- self.assertFalse(self.scheduler.is_installed(), "is_installed() should return False")
- # Verify plist file is removed
- self.assertFalse(os.path.exists(plist_path), "Plist file should be removed after uninstall")
- finally:
- self._cleanup_real_launchd_test(original_label, test_service_label)
- if __name__ == "__main__":
- unittest.main()
|