diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ecf61ed --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +"""Tests package.""" diff --git a/tests/test_detector.py b/tests/test_detector.py new file mode 100644 index 0000000..161ea32 --- /dev/null +++ b/tests/test_detector.py @@ -0,0 +1,273 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for ``core.detector.DjangoProjectDetector``. + +Tests both modern modular layout (``config/wsgi.py``) and traditional +layout (``/wsgi.py``) to ensure the auto-detection engine works +correctly for all Django project structures. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +# Ensure project root is importable +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from core.detector import DjangoProjectDetector +from core.models import DatabaseEngine, ProjectLayout + + +class TestDjangoProjectDetector(unittest.TestCase): + """Test suite for the Django project detector.""" + + def setUp(self) -> None: + self.detector = DjangoProjectDetector() + + # ────────────────────────────────────────────────────────────────── + # Layout detection + # ────────────────────────────────────────────────────────────────── + + def test_modular_layout_detected(self) -> None: + """config/wsgi.py should be detected as MODULAR layout.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "config").mkdir() + (web_dir / "config" / "wsgi.py").write_text( + 'import os\n' + 'os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")\n' + 'from django.core.wsgi import get_wsgi_application\n' + 'application = get_wsgi_application()\n' + ) + (web_dir / "config" / "settings.py").write_text( + 'STATIC_ROOT = BASE_DIR / "staticfiles"\n' + 'DATABASES = {"default": {"ENGINE": "django.db.backends.sqlite3"}}\n' + ) + # Create a fake venv + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("#!/usr/bin/env python3\n") + + info = self.detector.detect(web_dir) + + self.assertEqual(info.layout, ProjectLayout.MODULAR) + self.assertEqual(info.wsgi_module, "config.wsgi:application") + self.assertEqual(info.settings_module, "config.settings") + self.assertTrue(info.is_valid) + + def test_traditional_layout_detected(self) -> None: + """/wsgi.py should be detected as TRADITIONAL layout.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "myproject").mkdir() + (web_dir / "myproject" / "wsgi.py").write_text( + 'import os\n' + 'os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")\n' + 'from django.core.wsgi import get_wsgi_application\n' + 'application = get_wsgi_application()\n' + ) + (web_dir / "myproject" / "settings.py").write_text( + 'STATIC_ROOT = BASE_DIR / "static"\n' + 'DATABASES = {"default": {"ENGINE": "django.db.backends.postgresql"}}\n' + ) + # Create a fake venv + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("#!/usr/bin/env python3\n") + + info = self.detector.detect(web_dir) + + self.assertEqual(info.layout, ProjectLayout.TRADITIONAL) + self.assertEqual(info.wsgi_module, "myproject.wsgi:application") + self.assertTrue(info.is_valid) + + def test_traditional_layout_with_hint(self) -> None: + """When a project hint is provided, it should be preferred.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "zamzam").mkdir() + (web_dir / "zamzam" / "wsgi.py").write_text("application = None\n") + (web_dir / "zamzam" / "settings.py").write_text("") + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("") + + info = self.detector.detect(web_dir, project_hint="zamzam") + + self.assertEqual(info.layout, ProjectLayout.TRADITIONAL) + self.assertEqual(info.wsgi_module, "zamzam.wsgi:application") + + def test_flat_layout_detected(self) -> None: + """wsgi.py at root should be detected as FLAT layout.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "wsgi.py").write_text( + 'import os\n' + 'os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")\n' + 'from django.core.wsgi import get_wsgi_application\n' + 'application = get_wsgi_application()\n' + ) + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("") + + info = self.detector.detect(web_dir) + + self.assertEqual(info.layout, ProjectLayout.FLAT) + self.assertEqual(info.wsgi_module, "wsgi:application") + + def test_no_wsgi_returns_unknown(self) -> None: + """Missing wsgi.py should result in UNKNOWN layout with errors.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + info = self.detector.detect(web_dir) + + self.assertEqual(info.layout, ProjectLayout.UNKNOWN) + self.assertFalse(info.is_valid) + self.assertTrue(len(info.errors) > 0) + + def test_modular_takes_priority_over_traditional(self) -> None: + """If both config/wsgi.py and myproject/wsgi.py exist, MODULAR wins.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "config").mkdir() + (web_dir / "config" / "wsgi.py").write_text("application = None\n") + (web_dir / "config" / "settings.py").write_text("") + (web_dir / "myproject").mkdir() + (web_dir / "myproject" / "wsgi.py").write_text("application = None\n") + + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("") + + info = self.detector.detect(web_dir) + self.assertEqual(info.layout, ProjectLayout.MODULAR) + + # ────────────────────────────────────────────────────────────────── + # Virtual environment detection + # ────────────────────────────────────────────────────────────────── + + def test_venv_in_public_html(self) -> None: + """Should find venv inside public_html.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "config").mkdir() + (web_dir / "config" / "wsgi.py").write_text("") + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("") + + info = self.detector.detect(web_dir) + self.assertIsNotNone(info.venv_path) + self.assertEqual(info.venv_path.name, "venv") + + def test_dotvenv_detected(self) -> None: + """Should find .venv as an alternative.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "config").mkdir() + (web_dir / "config" / "wsgi.py").write_text("") + venv_bin = web_dir / ".venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("") + + info = self.detector.detect(web_dir) + self.assertIsNotNone(info.venv_path) + self.assertEqual(info.venv_path.name, ".venv") + + # ────────────────────────────────────────────────────────────────── + # Settings parsing + # ────────────────────────────────────────────────────────────────── + + def test_static_root_extracted(self) -> None: + """STATIC_ROOT value should be extracted from settings.py.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "config").mkdir() + (web_dir / "config" / "wsgi.py").write_text("") + (web_dir / "config" / "settings.py").write_text( + "STATIC_ROOT = BASE_DIR / 'my_static_output'\n" + ) + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("") + + info = self.detector.detect(web_dir) + self.assertEqual(info.static_root_name, "my_static_output") + + def test_database_engine_sqlite(self) -> None: + """Should detect SQLite database engine.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "config").mkdir() + (web_dir / "config" / "wsgi.py").write_text("") + (web_dir / "config" / "settings.py").write_text( + "DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3'}}\n" + ) + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("") + + info = self.detector.detect(web_dir) + self.assertEqual(info.database_engine, DatabaseEngine.SQLITE) + + def test_database_engine_postgresql(self) -> None: + """Should detect PostgreSQL database engine.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "config").mkdir() + (web_dir / "config" / "wsgi.py").write_text("") + (web_dir / "config" / "settings.py").write_text( + "DATABASES = {'default': {'ENGINE': 'django.db.backends.postgresql'}}\n" + ) + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("") + + info = self.detector.detect(web_dir) + self.assertEqual(info.database_engine, DatabaseEngine.POSTGRESQL) + + # ────────────────────────────────────────────────────────────────── + # Dot-env and requirements detection + # ────────────────────────────────────────────────────────────────── + + def test_dotenv_detected(self) -> None: + """Should detect presence of .env file.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "config").mkdir() + (web_dir / "config" / "wsgi.py").write_text("") + (web_dir / ".env").write_text("DEBUG=True\n") + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("") + + info = self.detector.detect(web_dir) + self.assertTrue(info.has_dot_env) + + def test_requirements_detected(self) -> None: + """Should detect presence of requirements.txt.""" + with tempfile.TemporaryDirectory() as tmpdir: + web_dir = Path(tmpdir) + (web_dir / "config").mkdir() + (web_dir / "config" / "wsgi.py").write_text("") + (web_dir / "requirements.txt").write_text("django\ngunicorn\n") + venv_bin = web_dir / "venv" / "bin" + venv_bin.mkdir(parents=True) + (venv_bin / "python").write_text("") + + info = self.detector.detect(web_dir) + self.assertTrue(info.has_requirements) + + def test_nonexistent_directory(self) -> None: + """Should handle nonexistent directory gracefully.""" + info = self.detector.detect(Path("/tmp/does_not_exist_xyz")) + self.assertFalse(info.is_valid) + self.assertTrue(len(info.errors) > 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service_generator.py b/tests/test_service_generator.py new file mode 100644 index 0000000..7f8fbdc --- /dev/null +++ b/tests/test_service_generator.py @@ -0,0 +1,146 @@ +# -*- coding: utf-8 -*- +""" +Unit tests for ``core.service_generator.SystemdServiceGenerator``. + +Validates that the generated systemd service content includes: +- Correct WSGI module reference +- ``--umask 007`` flag +- Dynamic worker count +- EnvironmentFile for .env projects +- Security hardening directives +""" + +import os +import sys +import unittest +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from core.models import ( + DatabaseEngine, + DjangoProjectInfo, + DomainConfig, + ProjectLayout, + ServiceConfig, +) +from core.service_generator import SystemdServiceGenerator + + +class TestSystemdServiceGenerator(unittest.TestCase): + """Test suite for the systemd service generator.""" + + def setUp(self) -> None: + self.generator = SystemdServiceGenerator() + self.dc = DomainConfig(domain="example.com", user="amin") + + def _make_project_info(self, **kwargs) -> DjangoProjectInfo: + defaults = dict( + layout=ProjectLayout.MODULAR, + wsgi_module="config.wsgi:application", + venv_path=Path("/home/amin/web/example.com/public_html/venv"), + settings_module="config.settings", + static_root_name="staticfiles", + database_engine=DatabaseEngine.SQLITE, + has_dot_env=False, + has_requirements=True, + ) + defaults.update(kwargs) + return DjangoProjectInfo(**defaults) + + # ────────────────────────────────────────────────────────────────── + # Content validation + # ────────────────────────────────────────────────────────────────── + + def test_contains_wsgi_module(self) -> None: + """Output should contain the correct WSGI module.""" + pi = self._make_project_info() + config = ServiceConfig(domain_config=self.dc, project_info=pi) + content = self.generator.generate(config) + self.assertIn("config.wsgi:application", content) + + def test_contains_umask(self) -> None: + """Output must include --umask 007.""" + pi = self._make_project_info() + config = ServiceConfig(domain_config=self.dc, project_info=pi) + content = self.generator.generate(config) + self.assertIn("--umask 007", content) + + def test_contains_protect_system(self) -> None: + """Output should include systemd security hardening.""" + pi = self._make_project_info() + config = ServiceConfig(domain_config=self.dc, project_info=pi) + content = self.generator.generate(config) + self.assertIn("ProtectSystem=full", content) + self.assertIn("PrivateTmp=true", content) + self.assertIn("NoNewPrivileges=true", content) + + def test_dynamic_worker_count(self) -> None: + """Workers should be (2 * CPU) + 1 when not specified.""" + pi = self._make_project_info() + config = ServiceConfig(domain_config=self.dc, project_info=pi) + expected_workers = (2 * (os.cpu_count() or 1)) + 1 + self.assertEqual(config.workers, expected_workers) + content = self.generator.generate(config) + self.assertIn(f"--workers {expected_workers}", content) + + def test_custom_worker_count(self) -> None: + """Explicit worker count should override dynamic calculation.""" + pi = self._make_project_info() + config = ServiceConfig(domain_config=self.dc, project_info=pi, workers=4) + self.assertEqual(config.workers, 4) + content = self.generator.generate(config) + self.assertIn("--workers 4", content) + + def test_env_file_included_when_present(self) -> None: + """EnvironmentFile should be set if .env exists.""" + pi = self._make_project_info(has_dot_env=True) + config = ServiceConfig(domain_config=self.dc, project_info=pi) + content = self.generator.generate(config) + self.assertIn("EnvironmentFile=", content) + + def test_env_file_commented_when_absent(self) -> None: + """EnvironmentFile should be commented out if .env is missing.""" + pi = self._make_project_info(has_dot_env=False) + config = ServiceConfig(domain_config=self.dc, project_info=pi) + content = self.generator.generate(config) + self.assertIn("# No .env found", content) + self.assertNotIn("EnvironmentFile=", content) + + def test_traditional_layout_wsgi(self) -> None: + """Traditional layout should use .wsgi:application.""" + pi = self._make_project_info( + layout=ProjectLayout.TRADITIONAL, + wsgi_module="zamzam.wsgi:application", + ) + config = ServiceConfig(domain_config=self.dc, project_info=pi) + content = self.generator.generate(config) + self.assertIn("zamzam.wsgi:application", content) + + def test_socket_path_in_output(self) -> None: + """Socket path should match the canonical path from DomainConfig.""" + pi = self._make_project_info() + config = ServiceConfig(domain_config=self.dc, project_info=pi) + content = self.generator.generate(config) + self.assertIn(str(self.dc.socket_path), content) + + def test_domain_in_description(self) -> None: + """Service description should include the domain name.""" + pi = self._make_project_info() + config = ServiceConfig(domain_config=self.dc, project_info=pi) + content = self.generator.generate(config) + self.assertIn("example.com", content) + + def test_correct_user_and_group(self) -> None: + """User and Group should be correctly set.""" + pi = self._make_project_info() + config = ServiceConfig(domain_config=self.dc, project_info=pi) + content = self.generator.generate(config) + self.assertIn("User=amin", content) + self.assertIn("Group=www-data", content) + + +if __name__ == "__main__": + unittest.main()