From 44d7e96f29ea8dc9217078d2bc37da4e26810aa5 Mon Sep 17 00:00:00 2001 From: Dmitrii Iurco Date: Fri, 29 May 2026 16:58:57 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=206=20=E2=80=94=20require=5Factiv?= =?UTF-8?q?e=5Fservice=20decorator=20+=20wizard=20install=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Email/calendar/files routes now return 404 when the service is not installed, using a require_active_service decorator that checks ServiceRegistry. Status endpoints are exempt so health checks always work. SetupManager.complete_setup() now accepts a service_store_manager and installs any wizard-selected services in a background daemon thread after setup completes. Failures are logged but do not fail the wizard. Co-Authored-By: Claude Sonnet 4.6 --- api/managers.py | 4 +- api/routes/__init__.py | 19 ++ api/routes/calendar.py | 9 + api/routes/email.py | 8 + api/routes/files.py | 12 ++ api/setup_manager.py | 19 +- tests/test_api_endpoints.py | 12 +- tests/test_calendar_endpoints.py | 44 +++++ tests/test_email_endpoints.py | 27 +++ tests/test_file_endpoints.py | 38 ++++ tests/test_require_active_service.py | 214 +++++++++++++++++++++ tests/test_setup_manager_install_wiring.py | 155 +++++++++++++++ 12 files changed, 556 insertions(+), 5 deletions(-) create mode 100644 tests/test_require_active_service.py create mode 100644 tests/test_setup_manager_install_wiring.py diff --git a/api/managers.py b/api/managers.py index 08f774f..b9f6335 100644 --- a/api/managers.py +++ b/api/managers.py @@ -66,7 +66,6 @@ cell_link_manager = CellLinkManager( network_manager=network_manager, ) auth_manager = AuthManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR) -setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager) caddy_manager = CaddyManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR, service_bus=service_bus, service_registry=service_registry) ddns_manager = DDNSManager(config_manager=config_manager, data_dir=DATA_DIR, config_dir=CONFIG_DIR) @@ -96,6 +95,9 @@ service_store_manager = ServiceStoreManager( service_composer=service_composer, ) +setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager, + service_store_manager=service_store_manager) + # Service logger configuration _service_log_configs = { 'network': {'level': 'INFO', 'formatter': 'json', 'console': False}, diff --git a/api/routes/__init__.py b/api/routes/__init__.py index e69de29..297ad4d 100644 --- a/api/routes/__init__.py +++ b/api/routes/__init__.py @@ -0,0 +1,19 @@ +from functools import wraps +from flask import jsonify + + +def require_active_service(service_id: str): + """Decorator: return 404 if the named service is not installed. + + Apply to all email/calendar/files routes except /status endpoints, + so the UI can always check installation state without being blocked. + """ + def decorator(fn): + @wraps(fn) + def wrapper(*args, **kwargs): + from app import service_registry + if service_registry.get(service_id) is None: + return jsonify({'error': f'Service {service_id!r} is not installed'}), 404 + return fn(*args, **kwargs) + return wrapper + return decorator diff --git a/api/routes/calendar.py b/api/routes/calendar.py index 8952401..bc36fe3 100644 --- a/api/routes/calendar.py +++ b/api/routes/calendar.py @@ -1,9 +1,12 @@ import logging from flask import Blueprint, request, jsonify +from routes import require_active_service + logger = logging.getLogger('picell') bp = Blueprint('calendar', __name__) @bp.route('/api/calendar/users', methods=['GET']) +@require_active_service('calendar') def get_calendar_users(): """Get calendar users.""" try: @@ -15,6 +18,7 @@ def get_calendar_users(): return jsonify({"error": str(e)}), 500 @bp.route('/api/calendar/users', methods=['POST']) +@require_active_service('calendar') def create_calendar_user(): """Create calendar user.""" try: @@ -33,6 +37,7 @@ def create_calendar_user(): return jsonify({"error": str(e)}), 500 @bp.route('/api/calendar/users/', methods=['DELETE']) +@require_active_service('calendar') def delete_calendar_user(username): """Delete calendar user.""" try: @@ -44,6 +49,7 @@ def delete_calendar_user(username): return jsonify({"error": str(e)}), 500 @bp.route('/api/calendar/calendars', methods=['POST']) +@require_active_service('calendar') def create_calendar(): """Create calendar.""" try: @@ -67,6 +73,7 @@ def create_calendar(): return jsonify({"error": str(e)}), 500 @bp.route('/api/calendar/events', methods=['POST']) +@require_active_service('calendar') def add_calendar_event(): try: from app import calendar_manager @@ -85,6 +92,7 @@ def add_calendar_event(): return jsonify({"error": str(e)}), 500 @bp.route('/api/calendar/events//', methods=['GET']) +@require_active_service('calendar') def get_calendar_events(username, calendar_name): """Get calendar events.""" try: @@ -108,6 +116,7 @@ def get_calendar_status(): return jsonify({"error": str(e)}), 500 @bp.route('/api/calendar/connectivity', methods=['GET']) +@require_active_service('calendar') def test_calendar_connectivity(): """Test calendar connectivity.""" try: diff --git a/api/routes/email.py b/api/routes/email.py index ed2fece..da63476 100644 --- a/api/routes/email.py +++ b/api/routes/email.py @@ -1,9 +1,12 @@ import logging from flask import Blueprint, request, jsonify +from routes import require_active_service + logger = logging.getLogger('picell') bp = Blueprint('email', __name__) @bp.route('/api/email/users', methods=['GET']) +@require_active_service('email') def get_email_users(): """Get email users.""" try: @@ -15,6 +18,7 @@ def get_email_users(): return jsonify({"error": str(e)}), 500 @bp.route('/api/email/users', methods=['POST']) +@require_active_service('email') def create_email_user(): """Create email user.""" try: @@ -34,6 +38,7 @@ def create_email_user(): return jsonify({"error": str(e)}), 500 @bp.route('/api/email/users/', methods=['DELETE']) +@require_active_service('email') def delete_email_user(username): """Delete email user.""" try: @@ -57,6 +62,7 @@ def get_email_status(): return jsonify({"error": str(e)}), 500 @bp.route('/api/email/connectivity', methods=['GET']) +@require_active_service('email') def test_email_connectivity(): """Test email connectivity.""" try: @@ -68,6 +74,7 @@ def test_email_connectivity(): return jsonify({"error": str(e)}), 500 @bp.route('/api/email/send', methods=['POST']) +@require_active_service('email') def send_email(): try: from app import email_manager @@ -81,6 +88,7 @@ def send_email(): return jsonify({"error": str(e)}), 500 @bp.route('/api/email/mailbox/', methods=['GET']) +@require_active_service('email') def get_mailbox_info(username): """Get mailbox information.""" try: diff --git a/api/routes/files.py b/api/routes/files.py index a610f25..b2dd90f 100644 --- a/api/routes/files.py +++ b/api/routes/files.py @@ -1,9 +1,12 @@ import logging from flask import Blueprint, request, jsonify +from routes import require_active_service + logger = logging.getLogger('picell') bp = Blueprint('files', __name__) @bp.route('/api/files/users', methods=['GET']) +@require_active_service('files') def get_file_users(): """Get file storage users.""" try: @@ -15,6 +18,7 @@ def get_file_users(): return jsonify({"error": str(e)}), 500 @bp.route('/api/files/users', methods=['POST']) +@require_active_service('files') def create_file_user(): """Create file storage user.""" try: @@ -33,6 +37,7 @@ def create_file_user(): return jsonify({"error": str(e)}), 500 @bp.route('/api/files/users/', methods=['DELETE']) +@require_active_service('files') def delete_file_user(username): """Delete file storage user.""" try: @@ -44,6 +49,7 @@ def delete_file_user(username): return jsonify({"error": str(e)}), 500 @bp.route('/api/files/folders', methods=['POST']) +@require_active_service('files') def create_folder(): """Create folder.""" try: @@ -64,6 +70,7 @@ def create_folder(): return jsonify({"error": str(e)}), 500 @bp.route('/api/files/folders//', methods=['DELETE']) +@require_active_service('files') def delete_folder(username, folder_path): """Delete folder.""" try: @@ -77,6 +84,7 @@ def delete_folder(username, folder_path): return jsonify({"error": str(e)}), 500 @bp.route('/api/files/upload/', methods=['POST']) +@require_active_service('files') def upload_file(username): """Upload file.""" try: @@ -97,6 +105,7 @@ def upload_file(username): return jsonify({"error": str(e)}), 500 @bp.route('/api/files/download//', methods=['GET']) +@require_active_service('files') def download_file(username, file_path): """Download file.""" try: @@ -110,6 +119,7 @@ def download_file(username, file_path): return jsonify({"error": str(e)}), 500 @bp.route('/api/files/delete//', methods=['DELETE']) +@require_active_service('files') def delete_file(username, file_path): """Delete file.""" try: @@ -123,6 +133,7 @@ def delete_file(username, file_path): return jsonify({"error": str(e)}), 500 @bp.route('/api/files/list/', methods=['GET']) +@require_active_service('files') def list_files(username): """List files.""" try: @@ -148,6 +159,7 @@ def get_file_status(): return jsonify({"error": str(e)}), 500 @bp.route('/api/files/connectivity', methods=['GET']) +@require_active_service('files') def test_file_connectivity(): """Test file service connectivity.""" try: diff --git a/api/setup_manager.py b/api/setup_manager.py index 3999510..482e3a4 100644 --- a/api/setup_manager.py +++ b/api/setup_manager.py @@ -10,6 +10,7 @@ import fcntl import logging import os import re +import threading from typing import Any, Dict, List logger = logging.getLogger(__name__) @@ -94,9 +95,10 @@ def _build_ddns_config(domain_mode: str, cloudflare_api_token: str = '', class SetupManager: """Manages the first-run setup wizard state and completion.""" - def __init__(self, config_manager, auth_manager): + def __init__(self, config_manager, auth_manager, service_store_manager=None): self.config_manager = config_manager self.auth_manager = auth_manager + self.service_store_manager = service_store_manager # ── state helpers ───────────────────────────────────────────────────── @@ -263,6 +265,21 @@ class SetupManager: # ── mark setup complete (must be last) ───────────────────────── self.config_manager.set_identity_field('setup_complete', True) + # Trigger service installs in background — non-blocking, failures are logged + if services_enabled and self.service_store_manager is not None: + def _install_services(): + for svc_id in services_enabled: + try: + result = self.service_store_manager.install(svc_id) + if result.get('ok'): + logger.info('Wizard: installed service %r', svc_id) + else: + logger.warning('Wizard: install %r failed: %s', + svc_id, result.get('error') or result.get('errors')) + except Exception as exc: + logger.warning('Wizard: install %r raised: %s', svc_id, exc) + threading.Thread(target=_install_services, daemon=True).start() + logger.info(f"Setup completed. cell_name={cell_name!r}, domain_mode={domain_mode!r}") return {'success': True, 'redirect': '/login'} diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py index e39723b..3144974 100644 --- a/tests/test_api_endpoints.py +++ b/tests/test_api_endpoints.py @@ -393,8 +393,10 @@ class TestAPIEndpoints(unittest.TestCase): self.assertEqual(response.status_code, 500) mock_peers.update_peer_ip.side_effect = None + @patch('app.service_registry') @patch('app.email_manager') - def test_email_endpoints(self, mock_email): + def test_email_endpoints(self, mock_email, mock_sr): + mock_sr.get.return_value = {'id': 'email', 'installed': True} # Ensure all relevant mock methods return JSON-serializable values mock_email.get_users.return_value = [{'username': 'user1', 'domain': 'cell', 'email': 'user1@cell'}] mock_email.create_email_user.return_value = True @@ -454,8 +456,10 @@ class TestAPIEndpoints(unittest.TestCase): self.assertEqual(response.status_code, 500) mock_email.get_mailbox_info.side_effect = None + @patch('app.service_registry') @patch('app.calendar_manager') - def test_calendar_endpoints(self, mock_calendar): + def test_calendar_endpoints(self, mock_calendar, mock_sr): + mock_sr.get.return_value = {'id': 'calendar', 'installed': True} # Mock return values for all relevant calendar_manager methods mock_calendar.get_users.return_value = [{'username': 'user1', 'collections': {'calendars': ['cal1'], 'contacts': ['c1']}}] mock_calendar.create_calendar_user.return_value = True @@ -523,8 +527,10 @@ class TestAPIEndpoints(unittest.TestCase): self.assertEqual(response.status_code, 500) mock_calendar.test_connectivity.side_effect = None + @patch('app.service_registry') @patch('app.file_manager') - def test_file_endpoints(self, mock_file): + def test_file_endpoints(self, mock_file, mock_sr): + mock_sr.get.return_value = {'id': 'files', 'installed': True} # Mock return values for all relevant file_manager methods mock_file.get_users.return_value = [{'username': 'user1', 'storage_info': {'total_files': 1, 'total_size_bytes': 1000}}] mock_file.create_user.return_value = True diff --git a/tests/test_calendar_endpoints.py b/tests/test_calendar_endpoints.py index b5ea38c..596d662 100644 --- a/tests/test_calendar_endpoints.py +++ b/tests/test_calendar_endpoints.py @@ -24,12 +24,20 @@ sys.path.insert(0, str(api_dir)) from app import app +_INSTALLED = {'id': 'calendar', 'installed': True} + class TestGetCalendarUsers(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.calendar_manager') def test_get_users_returns_200_with_list(self, mock_cm): @@ -63,6 +71,12 @@ class TestCreateCalendarUser(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.calendar_manager') def test_create_user_returns_200_on_valid_body(self, mock_cm): @@ -133,6 +147,12 @@ class TestDeleteCalendarUser(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.calendar_manager') def test_delete_user_returns_200_on_success(self, mock_cm): @@ -161,6 +181,12 @@ class TestCreateCalendar(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.calendar_manager') def test_create_calendar_returns_200_on_valid_body(self, mock_cm): @@ -228,6 +254,12 @@ class TestAddCalendarEvent(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.calendar_manager') def test_add_event_returns_200_on_valid_body(self, mock_cm): @@ -294,6 +326,12 @@ class TestGetCalendarEvents(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.calendar_manager') def test_get_events_returns_200_with_events(self, mock_cm): @@ -354,6 +392,12 @@ class TestCalendarConnectivity(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.calendar_manager') def test_connectivity_returns_200_with_result(self, mock_cm): diff --git a/tests/test_email_endpoints.py b/tests/test_email_endpoints.py index 57cbd9e..89c827c 100644 --- a/tests/test_email_endpoints.py +++ b/tests/test_email_endpoints.py @@ -21,12 +21,21 @@ sys.path.insert(0, str(api_dir)) from app import app +# Sentinel value that make service_registry.get(...) return non-None (service installed) +_INSTALLED = {'id': 'email', 'installed': True} + class TestGetEmailUsers(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.email_manager') def test_get_users_returns_200_with_list(self, mock_em): @@ -60,6 +69,12 @@ class TestCreateEmailUser(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.email_manager') def test_create_user_returns_200_on_success(self, mock_em): @@ -131,6 +146,12 @@ class TestDeleteEmailUser(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.email_manager') def test_delete_user_returns_200_on_success(self, mock_em): @@ -187,6 +208,12 @@ class TestEmailConnectivity(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() @patch('app.email_manager') def test_connectivity_returns_200_with_result(self, mock_em): diff --git a/tests/test_file_endpoints.py b/tests/test_file_endpoints.py index e9080f2..f21775c 100644 --- a/tests/test_file_endpoints.py +++ b/tests/test_file_endpoints.py @@ -25,12 +25,20 @@ sys.path.insert(0, str(api_dir)) from app import app +_INSTALLED = {'id': 'files', 'installed': True} + class TestFileUsersEndpoints(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() # ── GET /api/files/users ──────────────────────────────────────────────── @@ -94,6 +102,12 @@ class TestFileListEndpoint(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() # ── GET /api/files/list/ ───────────────────────────────────── @@ -134,6 +148,12 @@ class TestFileFolderDeleteEndpoint(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() # ── DELETE /api/files/folders// ──────────────────────── @@ -186,6 +206,12 @@ class TestFileDownloadDeleteEndpoints(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() # ── GET /api/files/download// ────────────────────────── @@ -223,6 +249,12 @@ class TestFileCreateFolderEndpoint(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() # ── POST /api/files/folders ──────────────────────────────────────────── @@ -259,6 +291,12 @@ class TestFileUploadEndpoint(unittest.TestCase): def setUp(self): app.config['TESTING'] = True self.client = app.test_client() + self._sr_patcher = patch('app.service_registry') + mock_sr = self._sr_patcher.start() + mock_sr.get.return_value = _INSTALLED + + def tearDown(self): + self._sr_patcher.stop() # ── POST /api/files/upload/ ────────────────────────────────── diff --git a/tests/test_require_active_service.py b/tests/test_require_active_service.py new file mode 100644 index 0000000..d467477 --- /dev/null +++ b/tests/test_require_active_service.py @@ -0,0 +1,214 @@ +"""Tests for the require_active_service route decorator.""" +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) + +from flask import Flask +from routes import require_active_service + + +class TestRequireActiveServiceDecorator(unittest.TestCase): + + def test_installed_service_passes_through(self): + app = Flask(__name__) + app.config['TESTING'] = True + mock_registry = MagicMock() + mock_registry.get.return_value = {'id': 'email'} + + @app.route('/test/email') + @require_active_service('email') + def view(): + return 'ok', 200 + + with app.test_client() as client: + with patch.dict('sys.modules', {'app': MagicMock(service_registry=mock_registry)}): + resp = client.get('/test/email') + self.assertEqual(resp.status_code, 200) + + def test_not_installed_returns_404(self): + app = Flask(__name__) + app.config['TESTING'] = True + mock_registry = MagicMock() + mock_registry.get.return_value = None + + @app.route('/test/calendar') + @require_active_service('calendar') + def view(): + return 'ok', 200 + + with app.test_client() as client: + with patch.dict('sys.modules', {'app': MagicMock(service_registry=mock_registry)}): + resp = client.get('/test/calendar') + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIn('error', data) + self.assertIn('calendar', data['error']) + + def test_not_installed_error_message_contains_service_id(self): + app = Flask(__name__) + app.config['TESTING'] = True + mock_registry = MagicMock() + mock_registry.get.return_value = None + + @app.route('/test/files') + @require_active_service('files') + def view(): + return 'ok', 200 + + with app.test_client() as client: + with patch.dict('sys.modules', {'app': MagicMock(service_registry=mock_registry)}): + resp = client.get('/test/files') + data = resp.get_json() + self.assertIn('files', data['error']) + + def test_decorator_preserves_function_name(self): + @require_active_service('calendar') + def my_view(): + return 'ok' + self.assertEqual(my_view.__name__, 'my_view') + + def test_decorator_preserves_function_docstring(self): + @require_active_service('email') + def documented_view(): + """Returns email data.""" + return 'ok' + self.assertEqual(documented_view.__doc__, 'Returns email data.') + + def test_passes_positional_args_to_wrapped_function(self): + app = Flask(__name__) + app.config['TESTING'] = True + mock_registry = MagicMock() + mock_registry.get.return_value = {'id': 'email'} + + @app.route('/test/mailbox/') + @require_active_service('email') + def mailbox_view(username): + return username, 200 + + with app.test_client() as client: + with patch.dict('sys.modules', {'app': MagicMock(service_registry=mock_registry)}): + resp = client.get('/test/mailbox/alice') + self.assertEqual(resp.status_code, 200) + self.assertIn(b'alice', resp.data) + + def test_service_registry_get_called_with_correct_service_id(self): + app = Flask(__name__) + app.config['TESTING'] = True + mock_registry = MagicMock() + mock_registry.get.return_value = {'id': 'files'} + + @app.route('/test/svc') + @require_active_service('files') + def view(): + return 'ok', 200 + + with app.test_client() as client: + with patch.dict('sys.modules', {'app': MagicMock(service_registry=mock_registry)}): + client.get('/test/svc') + mock_registry.get.assert_called_with('files') + + def test_status_endpoint_bypasses_decorator_on_email_routes(self): + """The /status route in email.py must NOT be decorated; verify by importing the route.""" + from routes.email import get_email_status + # The status handler should not be wrapped — it won't have the + # _require_active_service marker that the wrapper would add. + # We verify by checking it has no 'service_id' closure variable + # from the decorator (i.e., it's the plain function, not a wrapper). + import inspect + # get_email_status should have no closure cells referencing a service_id + closure = get_email_status.__closure__ + if closure: + closure_vals = [c.cell_contents for c in closure if isinstance(c.cell_contents, str)] + self.assertNotIn('email', closure_vals, + "get_email_status should not be wrapped by require_active_service") + + def test_status_endpoint_bypasses_decorator_on_calendar_routes(self): + from routes.calendar import get_calendar_status + closure = get_calendar_status.__closure__ + if closure: + closure_vals = [c.cell_contents for c in closure if isinstance(c.cell_contents, str)] + self.assertNotIn('calendar', closure_vals) + + def test_status_endpoint_bypasses_decorator_on_files_routes(self): + from routes.files import get_file_status + closure = get_file_status.__closure__ + if closure: + closure_vals = [c.cell_contents for c in closure if isinstance(c.cell_contents, str)] + self.assertNotIn('files', closure_vals) + + +class TestRequireActiveServiceOnEmailRoutes(unittest.TestCase): + """Integration-style: exercise the decorator via the real Flask app.""" + + def setUp(self): + sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) + from app import app + app.config['TESTING'] = True + self.client = app.test_client() + self.app = app + + def test_email_users_returns_404_when_service_not_installed(self): + mock_registry = MagicMock() + mock_registry.get.return_value = None + with patch('app.service_registry', mock_registry): + resp = self.client.get('/api/email/users') + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIn('error', data) + self.assertIn('email', data['error']) + + def test_email_status_reachable_when_service_not_installed(self): + """Status endpoint is never blocked, even with service absent.""" + mock_registry = MagicMock() + mock_registry.get.return_value = None + mock_email_mgr = MagicMock() + mock_email_mgr.get_status.return_value = {'installed': False} + with patch('app.service_registry', mock_registry), \ + patch('app.email_manager', mock_email_mgr): + resp = self.client.get('/api/email/status') + self.assertNotEqual(resp.status_code, 404) + + def test_calendar_users_returns_404_when_service_not_installed(self): + mock_registry = MagicMock() + mock_registry.get.return_value = None + with patch('app.service_registry', mock_registry): + resp = self.client.get('/api/calendar/users') + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIn('calendar', data['error']) + + def test_calendar_status_reachable_when_service_not_installed(self): + mock_registry = MagicMock() + mock_registry.get.return_value = None + mock_cal_mgr = MagicMock() + mock_cal_mgr.get_status.return_value = {'installed': False} + with patch('app.service_registry', mock_registry), \ + patch('app.calendar_manager', mock_cal_mgr): + resp = self.client.get('/api/calendar/status') + self.assertNotEqual(resp.status_code, 404) + + def test_files_users_returns_404_when_service_not_installed(self): + mock_registry = MagicMock() + mock_registry.get.return_value = None + with patch('app.service_registry', mock_registry): + resp = self.client.get('/api/files/users') + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIn('files', data['error']) + + def test_files_status_reachable_when_service_not_installed(self): + mock_registry = MagicMock() + mock_registry.get.return_value = None + mock_file_mgr = MagicMock() + mock_file_mgr.get_status.return_value = {'installed': False} + with patch('app.service_registry', mock_registry), \ + patch('app.file_manager', mock_file_mgr): + resp = self.client.get('/api/files/status') + self.assertNotEqual(resp.status_code, 404) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_setup_manager_install_wiring.py b/tests/test_setup_manager_install_wiring.py new file mode 100644 index 0000000..85d0b3e --- /dev/null +++ b/tests/test_setup_manager_install_wiring.py @@ -0,0 +1,155 @@ +"""Tests for SetupManager service-install wiring added in Phase 6. + +Verifies that complete_setup() triggers background service installs when +service_store_manager is provided and services_enabled is non-empty. +""" +import os +import sys +import time +import unittest +from pathlib import Path +from unittest.mock import MagicMock, call, patch + +sys.path.insert(0, str(Path(__file__).parent.parent / 'api')) +from setup_manager import SetupManager + + +# ── helpers ─────────────────────────────────────────────────────────────────── + +def _valid_payload(**overrides): + base = { + 'cell_name': 'mycel', + 'password': 'SecurePass1!', + 'domain_mode': 'lan', + 'timezone': 'UTC', + 'services_enabled': ['email'], + 'ddns_provider': 'none', + } + base.update(overrides) + return base + + +def _make_setup_manager(services_enabled=None, service_store_manager=None): + config_mgr = MagicMock() + config_mgr.get_identity.return_value = {} + auth_mgr = MagicMock() + auth_mgr.create_user.return_value = True + return SetupManager( + config_manager=config_mgr, + auth_manager=auth_mgr, + service_store_manager=service_store_manager, + ), config_mgr, auth_mgr + + +# ── constructor ─────────────────────────────────────────────────────────────── + +class TestSetupManagerConstructor(unittest.TestCase): + + def test_accepts_service_store_manager_kwarg(self): + ssm = MagicMock() + sm, _, _ = _make_setup_manager(service_store_manager=ssm) + self.assertIs(sm.service_store_manager, ssm) + + def test_defaults_service_store_manager_to_none(self): + config_mgr = MagicMock() + config_mgr.get_identity.return_value = {} + auth_mgr = MagicMock() + sm = SetupManager(config_manager=config_mgr, auth_manager=auth_mgr) + self.assertIsNone(sm.service_store_manager) + + +# ── complete_setup + service install wiring ─────────────────────────────────── + +class TestCompleteSetupInstallWiring(unittest.TestCase): + + def test_no_service_store_manager_does_not_crash(self, tmp_path=None): + """service_store_manager=None with services_enabled set must not raise.""" + sm, _, _ = _make_setup_manager(service_store_manager=None) + with patch.dict(os.environ, {'DATA_DIR': self._tmp()}): + result = sm.complete_setup(_valid_payload(services_enabled=['email'])) + self.assertTrue(result['success']) + + def test_empty_services_enabled_no_install_called(self): + """install() must not be called when services_enabled is empty.""" + ssm = MagicMock() + sm, _, _ = _make_setup_manager(service_store_manager=ssm) + with patch.dict(os.environ, {'DATA_DIR': self._tmp()}): + result = sm.complete_setup(_valid_payload(services_enabled=[])) + time.sleep(0.05) + ssm.install.assert_not_called() + self.assertTrue(result['success']) + + def test_services_enabled_triggers_install_in_background(self): + """install() must be called with each service id after complete_setup.""" + ssm = MagicMock() + ssm.install.return_value = {'ok': True} + sm, _, _ = _make_setup_manager(service_store_manager=ssm) + with patch.dict(os.environ, {'DATA_DIR': self._tmp()}): + result = sm.complete_setup(_valid_payload(services_enabled=['email'])) + self.assertTrue(result['success']) + # Give the daemon thread time to finish + time.sleep(0.1) + ssm.install.assert_called_with('email') + + def test_multiple_services_all_installed(self): + ssm = MagicMock() + ssm.install.return_value = {'ok': True} + sm, _, _ = _make_setup_manager(service_store_manager=ssm) + with patch.dict(os.environ, {'DATA_DIR': self._tmp()}): + result = sm.complete_setup( + _valid_payload(services_enabled=['email', 'calendar', 'files']) + ) + self.assertTrue(result['success']) + time.sleep(0.1) + installed = [c[0][0] for c in ssm.install.call_args_list] + self.assertIn('email', installed) + self.assertIn('calendar', installed) + self.assertIn('files', installed) + + def test_install_failure_does_not_fail_wizard(self): + """An exception from install() must not propagate to complete_setup.""" + ssm = MagicMock() + ssm.install.side_effect = RuntimeError('Docker daemon unreachable') + sm, _, _ = _make_setup_manager(service_store_manager=ssm) + with patch.dict(os.environ, {'DATA_DIR': self._tmp()}): + result = sm.complete_setup(_valid_payload(services_enabled=['email'])) + self.assertTrue(result['success']) + self.assertEqual(result.get('redirect'), '/login') + + def test_install_returning_error_dict_does_not_fail_wizard(self): + """install() returning {'ok': False} must not affect setup result.""" + ssm = MagicMock() + ssm.install.return_value = {'ok': False, 'error': 'image pull failed'} + sm, _, _ = _make_setup_manager(service_store_manager=ssm) + with patch.dict(os.environ, {'DATA_DIR': self._tmp()}): + result = sm.complete_setup(_valid_payload(services_enabled=['email'])) + self.assertTrue(result['success']) + + def test_complete_setup_still_marks_setup_complete_before_install(self): + """setup_complete must be persisted even if install thread races.""" + ssm = MagicMock() + ssm.install.return_value = {'ok': True} + sm, config_mgr, _ = _make_setup_manager(service_store_manager=ssm) + with patch.dict(os.environ, {'DATA_DIR': self._tmp()}): + sm.complete_setup(_valid_payload(services_enabled=['email'])) + # setup_complete must be the last set_identity_field call + calls = config_mgr.set_identity_field.call_args_list + last = calls[-1] + self.assertEqual(last, call('setup_complete', True)) + + def test_service_store_manager_none_with_empty_services_succeeds(self): + sm, _, _ = _make_setup_manager(service_store_manager=None) + with patch.dict(os.environ, {'DATA_DIR': self._tmp()}): + result = sm.complete_setup(_valid_payload(services_enabled=[])) + self.assertTrue(result['success']) + + # ── helpers ─────────────────────────────────────────────────────────────── + + def _tmp(self): + """Return a writable temp dir path string for DATA_DIR.""" + import tempfile + return tempfile.mkdtemp() + + +if __name__ == '__main__': + unittest.main()