Files
pic/tests/test_routes_services_catalog.py
T
roof aa1e5c41ec
Unit Tests / test (push) Successful in 12m6s
test: raise coverage 68.7% -> ~80.4%; add ~250 tests for new egress/DDNS/network paths
Coverage was below acceptable levels and several newly-added code paths
(sshuttle egress, proxy egress, DDNS provider stubs, DNS overview route,
peer-registry provisioning) had zero test coverage.

~250 new unit tests are added across 16 new test files. Existing test files
are updated to match refactored interfaces (DHCP removed, constants
introduced, network_manager restructured). .coveragerc is added to pin the
source mapping and the 70% floor so regressions are caught at commit time.

tests/test_enhanced_api.py was previously living in api/ (wrong location)
and is moved to tests/ where it belongs.

Integration test files are updated to remove references to DHCP endpoints
and add coverage for the new DNS overview and DDNS sync endpoints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 09:03:39 -04:00

1044 lines
45 KiB
Python

"""
Tests for routes/services.py:
- /api/services/catalog (list, get, status, restart, reconfigure)
- /api/services/catalog/<id>/accounts (list, provision, deprovision, credentials)
- /api/services/bus/* (status, events, start, stop, restart)
- /api/logs/* endpoints
"""
import sys
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
import app as app_module
from app import app
@pytest.fixture
def client():
app.config['TESTING'] = True
with app.test_client() as c:
yield c
def _make_registry(services):
reg = MagicMock()
reg.list_all = MagicMock(return_value=services)
reg.list_active = MagicMock(return_value=services)
reg.get = MagicMock(side_effect=lambda sid: next(
(s for s in services if s['id'] == sid), None))
return reg
def _make_service(sid, kind='builtin'):
return {'id': sid, 'name': sid.title(), 'kind': kind,
'subdomain': sid, 'capabilities': {}}
# ---------------------------------------------------------------------------
# GET /api/services/catalog
# ---------------------------------------------------------------------------
class TestGetServicesCatalog:
def test_returns_200(self, client):
reg = _make_registry([])
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/catalog')
assert resp.status_code == 200
def test_returns_services_list(self, client):
reg = _make_registry([_make_service('email')])
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/catalog')
data = json.loads(resp.data)
assert 'services' in data
assert len(data['services']) == 1
def test_returns_empty_when_no_services(self, client):
reg = _make_registry([])
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/catalog')
data = json.loads(resp.data)
assert data['services'] == []
def test_500_on_exception(self, client):
reg = MagicMock()
reg.list_all.side_effect = Exception('registry error')
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/catalog')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# GET /api/services/catalog/<service_id>
# ---------------------------------------------------------------------------
class TestGetServiceCatalogEntry:
def test_returns_200_for_known_service(self, client):
reg = _make_registry([_make_service('email')])
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/catalog/email')
assert resp.status_code == 200
def test_returns_service_data(self, client):
reg = _make_registry([_make_service('email')])
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/catalog/email')
data = json.loads(resp.data)
assert data['id'] == 'email'
def test_returns_404_for_unknown_service(self, client):
reg = _make_registry([])
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/catalog/nonexistent')
assert resp.status_code == 404
def test_404_includes_error_message(self, client):
reg = _make_registry([])
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/catalog/nonexistent')
data = json.loads(resp.data)
assert 'error' in data
def test_500_on_exception(self, client):
reg = MagicMock()
reg.get.side_effect = Exception('registry error')
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/catalog/email')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# GET /api/services/catalog/<service_id>/status
# ---------------------------------------------------------------------------
class TestGetServiceContainerStatus:
def test_returns_200(self, client):
svc = _make_service('email')
reg = _make_registry([svc])
composer = MagicMock()
composer.status_service.return_value = {'running': True, 'containers': []}
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.get('/api/services/catalog/email/status')
assert resp.status_code == 200
def test_returns_404_for_unknown_service(self, client):
reg = _make_registry([])
composer = MagicMock()
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.get('/api/services/catalog/nonexistent/status')
assert resp.status_code == 404
def test_400_on_value_error(self, client):
svc = _make_service('email')
reg = _make_registry([svc])
composer = MagicMock()
composer.status_service.side_effect = ValueError('bad service')
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.get('/api/services/catalog/email/status')
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# POST /api/services/catalog/<service_id>/restart
# ---------------------------------------------------------------------------
class TestRestartServiceContainers:
def test_returns_200_on_success(self, client):
svc = _make_service('email')
reg = _make_registry([svc])
composer = MagicMock()
composer.restart_service.return_value = {'ok': True, 'stdout': ''}
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.post('/api/services/catalog/email/restart')
assert resp.status_code == 200
def test_returns_404_for_unknown_service(self, client):
reg = _make_registry([])
composer = MagicMock()
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.post('/api/services/catalog/nonexistent/restart')
assert resp.status_code == 404
def test_returns_500_when_restart_fails(self, client):
svc = _make_service('email')
reg = _make_registry([svc])
composer = MagicMock()
composer.restart_service.return_value = {'ok': False, 'stderr': 'container error'}
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.post('/api/services/catalog/email/restart')
assert resp.status_code == 500
def test_response_includes_message_on_success(self, client):
svc = _make_service('email')
reg = _make_registry([svc])
composer = MagicMock()
composer.restart_service.return_value = {'ok': True}
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.post('/api/services/catalog/email/restart')
data = json.loads(resp.data)
assert 'message' in data
# ---------------------------------------------------------------------------
# POST /api/services/catalog/<service_id>/reconfigure
# ---------------------------------------------------------------------------
class TestReconfigureService:
def test_404_for_unknown_service(self, client):
reg = _make_registry([])
composer = MagicMock()
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.post('/api/services/catalog/nonexistent/reconfigure')
assert resp.status_code == 404
def test_400_for_builtin_service(self, client):
svc = _make_service('wireguard', kind='builtin')
reg = _make_registry([svc])
composer = MagicMock()
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.post('/api/services/catalog/wireguard/reconfigure')
assert resp.status_code == 400
data = json.loads(resp.data)
assert 'Builtins' in data['error']
def test_400_when_no_compose_file(self, client):
svc = _make_service('myapp', kind='store')
reg = _make_registry([svc])
composer = MagicMock()
composer.has_compose_file.return_value = False
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.post('/api/services/catalog/myapp/reconfigure')
assert resp.status_code == 400
def test_200_on_success(self, client):
svc = _make_service('myapp', kind='store')
reg = _make_registry([svc])
composer = MagicMock()
composer.has_compose_file.return_value = True
composer.up.return_value = {'ok': True, 'stdout': ''}
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.post('/api/services/catalog/myapp/reconfigure')
assert resp.status_code == 200
def test_500_when_up_fails(self, client):
svc = _make_service('myapp', kind='store')
reg = _make_registry([svc])
composer = MagicMock()
composer.has_compose_file.return_value = True
composer.up.return_value = {'ok': False, 'stderr': 'error'}
with patch.object(app_module, 'service_registry', reg), \
patch.object(app_module, 'service_composer', composer):
resp = client.post('/api/services/catalog/myapp/reconfigure')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# GET /api/services/catalog/<id>/accounts
# ---------------------------------------------------------------------------
class TestListServiceAccounts:
def test_returns_200(self, client):
am = MagicMock()
am.list_accounts.return_value = ['alice', 'bob']
with patch.object(app_module, 'account_manager', am):
resp = client.get('/api/services/catalog/email/accounts')
assert resp.status_code == 200
def test_returns_accounts_list(self, client):
am = MagicMock()
am.list_accounts.return_value = ['alice']
with patch.object(app_module, 'account_manager', am):
resp = client.get('/api/services/catalog/email/accounts')
data = json.loads(resp.data)
assert data['accounts'] == ['alice']
assert data['service_id'] == 'email'
def test_500_on_exception(self, client):
am = MagicMock()
am.list_accounts.side_effect = Exception('fail')
with patch.object(app_module, 'account_manager', am):
resp = client.get('/api/services/catalog/email/accounts')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# POST /api/services/catalog/<id>/accounts
# ---------------------------------------------------------------------------
class TestProvisionServiceAccount:
def test_returns_201_on_success(self, client):
am = MagicMock()
am.provision.return_value = None
with patch.object(app_module, 'account_manager', am):
resp = client.post('/api/services/catalog/email/accounts',
data=json.dumps({'username': 'alice'}),
content_type='application/json')
assert resp.status_code == 201
def test_400_when_username_missing(self, client):
am = MagicMock()
with patch.object(app_module, 'account_manager', am):
resp = client.post('/api/services/catalog/email/accounts',
data=json.dumps({}),
content_type='application/json')
assert resp.status_code == 400
def test_400_on_value_error(self, client):
am = MagicMock()
am.provision.side_effect = ValueError('user already exists')
with patch.object(app_module, 'account_manager', am):
resp = client.post('/api/services/catalog/email/accounts',
data=json.dumps({'username': 'alice'}),
content_type='application/json')
assert resp.status_code == 400
def test_500_on_runtime_error(self, client):
am = MagicMock()
am.provision.side_effect = RuntimeError('docker exec failed')
with patch.object(app_module, 'account_manager', am):
resp = client.post('/api/services/catalog/email/accounts',
data=json.dumps({'username': 'alice'}),
content_type='application/json')
assert resp.status_code == 500
def test_response_shape(self, client):
am = MagicMock()
am.provision.return_value = None
with patch.object(app_module, 'account_manager', am):
resp = client.post('/api/services/catalog/email/accounts',
data=json.dumps({'username': 'alice'}),
content_type='application/json')
data = json.loads(resp.data)
assert data['service_id'] == 'email'
assert data['username'] == 'alice'
assert data['provisioned'] is True
# ---------------------------------------------------------------------------
# DELETE /api/services/catalog/<id>/accounts/<username>
# ---------------------------------------------------------------------------
class TestDeprovisionServiceAccount:
def test_returns_200_on_success(self, client):
am = MagicMock()
am.deprovision.return_value = True
with patch.object(app_module, 'account_manager', am):
resp = client.delete('/api/services/catalog/email/accounts/alice')
assert resp.status_code == 200
def test_returns_500_when_deprovision_fails(self, client):
am = MagicMock()
am.deprovision.return_value = False
with patch.object(app_module, 'account_manager', am):
resp = client.delete('/api/services/catalog/email/accounts/alice')
assert resp.status_code == 500
def test_400_on_value_error(self, client):
am = MagicMock()
am.deprovision.side_effect = ValueError('not found')
with patch.object(app_module, 'account_manager', am):
resp = client.delete('/api/services/catalog/email/accounts/alice')
assert resp.status_code == 400
# ---------------------------------------------------------------------------
# GET /api/services/catalog/<id>/accounts/<username>/credentials
# ---------------------------------------------------------------------------
class TestGetServiceAccountCredentials:
def test_returns_200_with_creds(self, client):
am = MagicMock()
am.get_credentials.return_value = {'password': 'secret'}
with patch.object(app_module, 'account_manager', am):
resp = client.get('/api/services/catalog/email/accounts/alice/credentials')
assert resp.status_code == 200
def test_returns_404_when_not_provisioned(self, client):
am = MagicMock()
am.get_credentials.return_value = None
with patch.object(app_module, 'account_manager', am):
resp = client.get('/api/services/catalog/email/accounts/alice/credentials')
assert resp.status_code == 404
def test_returns_creds_in_response(self, client):
am = MagicMock()
am.get_credentials.return_value = {'password': 'mypass'}
with patch.object(app_module, 'account_manager', am):
resp = client.get('/api/services/catalog/email/accounts/alice/credentials')
data = json.loads(resp.data)
assert data['service_id'] == 'email'
assert data['username'] == 'alice'
assert 'password' in data
# ---------------------------------------------------------------------------
# GET /api/services/bus/status
# ---------------------------------------------------------------------------
class TestServiceBusStatus:
def test_returns_200(self, client):
mock_sb = MagicMock()
mock_sb.get_service_status_summary.return_value = {}
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.get('/api/services/bus/status')
assert resp.status_code == 200
def test_500_on_exception(self, client):
mock_sb = MagicMock()
mock_sb.get_service_status_summary.side_effect = Exception('fail')
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.get('/api/services/bus/status')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# POST /api/services/bus/services/<name>/start
# ---------------------------------------------------------------------------
class TestServiceBusStart:
def test_returns_200_on_success(self, client):
mock_sb = MagicMock()
mock_sb.orchestrate_service_start.return_value = True
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.post('/api/services/bus/services/network/start')
assert resp.status_code == 200
def test_returns_500_on_failure(self, client):
mock_sb = MagicMock()
mock_sb.orchestrate_service_start.return_value = False
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.post('/api/services/bus/services/network/start')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# POST /api/services/bus/services/<name>/stop
# ---------------------------------------------------------------------------
class TestServiceBusStop:
def test_returns_200_on_success(self, client):
mock_sb = MagicMock()
mock_sb.orchestrate_service_stop.return_value = True
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.post('/api/services/bus/services/network/stop')
assert resp.status_code == 200
def test_returns_500_on_failure(self, client):
mock_sb = MagicMock()
mock_sb.orchestrate_service_stop.return_value = False
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.post('/api/services/bus/services/network/stop')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# POST /api/services/bus/services/<name>/restart
# ---------------------------------------------------------------------------
class TestServiceBusRestart:
def test_returns_200_on_success(self, client):
mock_sb = MagicMock()
mock_sb.orchestrate_service_restart.return_value = True
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.post('/api/services/bus/services/email/restart')
assert resp.status_code == 200
def test_returns_500_on_failure(self, client):
mock_sb = MagicMock()
mock_sb.orchestrate_service_restart.return_value = False
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.post('/api/services/bus/services/email/restart')
assert resp.status_code == 500
def test_500_on_exception(self, client):
mock_sb = MagicMock()
mock_sb.orchestrate_service_restart.side_effect = Exception('bus error')
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.post('/api/services/bus/services/email/restart')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# GET /api/services/bus/events
# ---------------------------------------------------------------------------
class TestServiceBusEvents:
def test_returns_200(self, client):
mock_sb = MagicMock()
mock_sb.get_event_history.return_value = []
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.get('/api/services/bus/events')
assert resp.status_code == 200
def test_returns_list(self, client):
mock_sb = MagicMock()
mock_sb.get_event_history.return_value = []
with patch.object(app_module, 'service_bus', mock_sb):
resp = client.get('/api/services/bus/events')
data = json.loads(resp.data)
assert isinstance(data, list)
# ---------------------------------------------------------------------------
# GET /api/logs/services/<service>
# ---------------------------------------------------------------------------
class TestGetServiceLogs:
def test_returns_200(self, client):
mock_lm = MagicMock()
mock_lm.get_service_logs.return_value = ['line1', 'line2']
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/services/network')
assert resp.status_code == 200
def test_returns_log_lines(self, client):
mock_lm = MagicMock()
mock_lm.get_service_logs.return_value = ['line1']
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/services/network')
data = json.loads(resp.data)
assert 'logs' in data
assert data['service'] == 'network'
def test_500_on_exception(self, client):
mock_lm = MagicMock()
mock_lm.get_service_logs.side_effect = Exception('fail')
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/services/network')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# POST /api/logs/search
# ---------------------------------------------------------------------------
class TestSearchLogs:
def test_returns_200(self, client):
mock_lm = MagicMock()
mock_lm.search_logs.return_value = []
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.post('/api/logs/search',
data=json.dumps({'query': 'error'}),
content_type='application/json')
assert resp.status_code == 200
def test_returns_results(self, client):
mock_lm = MagicMock()
mock_lm.search_logs.return_value = [{'message': 'error occurred'}]
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.post('/api/logs/search',
data=json.dumps({'query': 'error'}),
content_type='application/json')
data = json.loads(resp.data)
assert 'results' in data
assert 'count' in data
assert data['count'] == 1
# ---------------------------------------------------------------------------
# GET /api/logs/statistics
# ---------------------------------------------------------------------------
class TestLogStatistics:
def test_returns_200(self, client):
mock_lm = MagicMock()
mock_lm.get_log_statistics.return_value = {'total': 100}
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/statistics')
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# POST /api/logs/rotate
# ---------------------------------------------------------------------------
class TestRotateLogs:
def test_returns_200(self, client):
mock_lm = MagicMock()
mock_lm.rotate_logs.return_value = None
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.post('/api/logs/rotate',
data=json.dumps({}),
content_type='application/json')
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# GET /api/logs/files
# ---------------------------------------------------------------------------
class TestGetLogFiles:
def test_returns_200(self, client):
mock_lm = MagicMock()
mock_lm.get_all_log_file_infos.return_value = []
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/files')
assert resp.status_code == 200
def test_returns_file_list(self, client):
mock_lm = MagicMock()
mock_lm.get_all_log_file_infos.return_value = [
{'file': 'network.log', 'size': 1024, 'backup': False}
]
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/files')
data = json.loads(resp.data)
assert len(data) == 1
assert data[0]['file'] == 'network.log'
def test_500_on_exception(self, client):
mock_lm = MagicMock()
mock_lm.get_all_log_file_infos.side_effect = Exception('disk error')
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/files')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# GET /api/logs/verbosity
# ---------------------------------------------------------------------------
class TestGetLogVerbosity:
def test_returns_200(self, client):
mock_lm = MagicMock()
mock_lm.get_service_levels.return_value = {'network': 'INFO'}
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/verbosity')
assert resp.status_code == 200
def test_returns_service_levels(self, client):
mock_lm = MagicMock()
mock_lm.get_service_levels.return_value = {'network': 'DEBUG', 'email': 'INFO'}
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/verbosity')
data = json.loads(resp.data)
assert data['network'] == 'DEBUG'
assert data['email'] == 'INFO'
def test_500_on_exception(self, client):
mock_lm = MagicMock()
mock_lm.get_service_levels.side_effect = Exception('fail')
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/verbosity')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# PUT /api/logs/verbosity
# ---------------------------------------------------------------------------
class TestSetLogVerbosity:
def test_returns_200(self, client):
import tempfile, os
mock_lm = MagicMock()
mock_lm.set_service_level.return_value = None
mock_lm.get_service_levels.return_value = {'network': 'DEBUG'}
with patch.object(app_module, 'log_manager', mock_lm):
with tempfile.TemporaryDirectory() as tmpdir:
with patch.dict(os.environ, {'CONFIG_DIR': tmpdir}):
resp = client.put('/api/logs/verbosity',
data=json.dumps({'network': 'DEBUG'}),
content_type='application/json')
assert resp.status_code == 200
def test_calls_set_service_level(self, client):
import tempfile, os
mock_lm = MagicMock()
mock_lm.get_service_levels.return_value = {}
with patch.object(app_module, 'log_manager', mock_lm):
with tempfile.TemporaryDirectory() as tmpdir:
with patch.dict(os.environ, {'CONFIG_DIR': tmpdir}):
client.put('/api/logs/verbosity',
data=json.dumps({'network': 'DEBUG'}),
content_type='application/json')
mock_lm.set_service_level.assert_called_with('network', 'DEBUG')
def test_500_on_exception(self, client):
mock_lm = MagicMock()
mock_lm.set_service_level.side_effect = Exception('fail')
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.put('/api/logs/verbosity',
data=json.dumps({'network': 'DEBUG'}),
content_type='application/json')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# GET /api/services/active
# ---------------------------------------------------------------------------
class TestGetActiveServices:
def test_returns_200(self, client):
reg = _make_registry([_make_service('email')])
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/active')
assert resp.status_code == 200
def test_returns_active_services_list(self, client):
reg = _make_registry([
{'id': 'email', 'name': 'Email', 'subdomain': 'mail', 'capabilities': {'smtp': True}},
])
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/active')
data = json.loads(resp.data)
assert isinstance(data, list)
assert len(data) == 1
assert data[0]['id'] == 'email'
def test_500_on_exception(self, client):
reg = MagicMock()
reg.list_active.side_effect = Exception('error')
with patch.object(app_module, 'service_registry', reg):
resp = client.get('/api/services/active')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# GET /api/services/status
# ---------------------------------------------------------------------------
class TestGetAllServicesStatus:
def test_returns_200(self, client):
mock_sbus = MagicMock()
mock_sbus.list_services.return_value = []
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.get('/api/services/status')
assert resp.status_code == 200
def test_returns_status_for_each_service(self, client):
mock_svc = MagicMock()
mock_svc.get_status.return_value = {
'status': 'online', 'running': True, 'timestamp': '2026-01-01T00:00:00'
}
mock_sbus = MagicMock()
mock_sbus.list_services.return_value = ['network']
mock_sbus.get_service.return_value = mock_svc
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.get('/api/services/status')
data = json.loads(resp.data)
assert 'network' in data
def test_service_exception_sets_error_in_status(self, client):
mock_svc = MagicMock()
mock_svc.get_status.side_effect = Exception('service down')
mock_sbus = MagicMock()
mock_sbus.list_services.return_value = ['email']
mock_sbus.get_service.return_value = mock_svc
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.get('/api/services/status')
data = json.loads(resp.data)
assert 'email' in data
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# GET /api/services/connectivity
# ---------------------------------------------------------------------------
class TestTestAllServicesConnectivity:
def test_returns_200(self, client):
mock_sbus = MagicMock()
mock_sbus.list_services.return_value = []
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.get('/api/services/connectivity')
assert resp.status_code == 200
def test_includes_connectivity_result(self, client):
mock_svc = MagicMock()
mock_svc.test_connectivity.return_value = {'success': True}
mock_sbus = MagicMock()
mock_sbus.list_services.return_value = ['network']
mock_sbus.get_service.return_value = mock_svc
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.get('/api/services/connectivity')
data = json.loads(resp.data)
assert 'network' in data
def test_500_on_bus_exception(self, client):
mock_sbus = MagicMock()
mock_sbus.list_services.side_effect = Exception('bus fail')
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.get('/api/services/connectivity')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# GET /api/logs (backend logs endpoint)
# ---------------------------------------------------------------------------
class TestGetBackendLogs:
def test_returns_404_when_log_file_missing(self, client):
# The default state: picell.log does not exist in the api/ directory
import os
import routes.services as svc_routes
# If the log file doesn't exist, it should return 404
original = os.path.exists
def fake_exists(path):
if 'picell.log' in path:
return False
return original(path)
with patch('routes.services.os.path.exists', side_effect=fake_exists):
resp = client.get('/api/logs')
assert resp.status_code == 404
# ---------------------------------------------------------------------------
# POST /api/logs/export
# ---------------------------------------------------------------------------
class TestExportLogsEndpoint:
def test_returns_200(self, client):
mock_lm = MagicMock()
mock_lm.export_logs.return_value = '[]'
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.post('/api/logs/export',
data=json.dumps({'format': 'json'}),
content_type='application/json')
assert resp.status_code == 200
def test_returns_logs_and_format(self, client):
mock_lm = MagicMock()
mock_lm.export_logs.return_value = '[{"level":"INFO"}]'
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.post('/api/logs/export',
data=json.dumps({'format': 'json'}),
content_type='application/json')
data = json.loads(resp.data)
assert 'logs' in data
assert data['format'] == 'json'
def test_500_on_exception(self, client):
mock_lm = MagicMock()
mock_lm.export_logs.side_effect = Exception('export failed')
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.post('/api/logs/export',
data=json.dumps({'format': 'json'}),
content_type='application/json')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# GET /api/services/status — service-specific branches (wireguard, email, etc.)
# ---------------------------------------------------------------------------
class TestGetAllServicesStatusServiceBranches:
"""Cover the elif branches in get_all_services_status for each service type."""
def _status_for_service(self, client, service_name, raw_status):
mock_svc = MagicMock()
mock_svc.get_status.return_value = dict(
{'status': 'online', 'running': True, 'timestamp': '2026-01-01T00:00:00'},
**raw_status
)
mock_sbus = MagicMock()
mock_sbus.list_services.return_value = [service_name]
mock_sbus.get_service.return_value = mock_svc
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.get('/api/services/status')
return json.loads(resp.data)
def test_wireguard_branch_includes_peers_count(self, client):
data = self._status_for_service(client, 'wireguard',
{'peers_count': 3, 'interface': 'wg0'})
assert data['wireguard'].get('peers_count') == 3
assert data['wireguard'].get('interface') == 'wg0'
def test_email_branch_includes_users_count(self, client):
data = self._status_for_service(client, 'email',
{'users_count': 5, 'domain': 'cell.local'})
assert data['email'].get('users_count') == 5
assert data['email'].get('domain') == 'cell.local'
def test_calendar_branch_includes_calendars_count(self, client):
data = self._status_for_service(client, 'calendar',
{'users_count': 2, 'calendars_count': 4})
assert data['calendar'].get('calendars_count') == 4
def test_files_branch_includes_storage_used(self, client):
data = self._status_for_service(client, 'files',
{'users_count': 1, 'total_storage_used': {'used': 100}})
assert data['files'].get('storage_used') == {'used': 100}
def test_routing_branch_includes_nat_rules_count(self, client):
data = self._status_for_service(client, 'routing',
{'nat_rules_count': 2, 'peer_routes_count': 1,
'firewall_rules_count': 3})
assert data['routing'].get('nat_rules_count') == 2
def test_vault_branch_includes_certificates_count(self, client):
data = self._status_for_service(client, 'vault',
{'certificates_count': 5, 'trusted_keys_count': 2})
assert data['vault'].get('certificates_count') == 5
def test_non_dict_status_uses_string_fallback(self, client):
"""When get_status returns a non-dict, it gets stored as a string+bool."""
mock_svc = MagicMock()
mock_svc.get_status.return_value = 'running'
mock_sbus = MagicMock()
mock_sbus.list_services.return_value = ['network']
mock_sbus.get_service.return_value = mock_svc
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.get('/api/services/status')
data = json.loads(resp.data)
assert 'network' in data
# ---------------------------------------------------------------------------
# Error paths in catalog service management endpoints
# ---------------------------------------------------------------------------
class TestServiceCatalogErrorPaths:
"""Cover ValueError/Exception branches in catalog management endpoints."""
def test_get_service_container_status_value_error_returns_400(self, client):
mock_reg = MagicMock()
mock_reg.get.return_value = {'id': 'email', 'kind': 'builtin'}
mock_composer = MagicMock()
mock_composer.status_service.side_effect = ValueError('bad request')
with patch.object(app_module, 'service_registry', mock_reg), \
patch.object(app_module, 'service_composer', mock_composer):
resp = client.get('/api/services/catalog/email/status')
assert resp.status_code == 400
def test_restart_service_containers_value_error_returns_400(self, client):
mock_reg = MagicMock()
mock_reg.get.return_value = {'id': 'email', 'kind': 'builtin'}
mock_composer = MagicMock()
mock_composer.restart_service.side_effect = ValueError('bad service')
with patch.object(app_module, 'service_registry', mock_reg), \
patch.object(app_module, 'service_composer', mock_composer):
resp = client.post('/api/services/catalog/email/restart')
assert resp.status_code == 400
def test_restart_service_containers_exception_returns_500(self, client):
mock_reg = MagicMock()
mock_reg.get.return_value = {'id': 'email', 'kind': 'builtin'}
mock_composer = MagicMock()
mock_composer.restart_service.side_effect = Exception('docker down')
with patch.object(app_module, 'service_registry', mock_reg), \
patch.object(app_module, 'service_composer', mock_composer):
resp = client.post('/api/services/catalog/email/restart')
assert resp.status_code == 500
def test_reconfigure_service_value_error_returns_400(self, client):
mock_reg = MagicMock()
mock_reg.get.return_value = {'id': 'myapp', 'kind': 'store'}
mock_composer = MagicMock()
mock_composer.has_compose_file.return_value = True
mock_composer.up.side_effect = ValueError('invalid config')
with patch.object(app_module, 'service_registry', mock_reg), \
patch.object(app_module, 'service_composer', mock_composer):
resp = client.post('/api/services/catalog/myapp/reconfigure')
assert resp.status_code == 400
def test_reconfigure_service_exception_returns_500(self, client):
mock_reg = MagicMock()
mock_reg.get.return_value = {'id': 'myapp', 'kind': 'store'}
mock_composer = MagicMock()
mock_composer.has_compose_file.return_value = True
mock_composer.up.side_effect = Exception('compose fail')
with patch.object(app_module, 'service_registry', mock_reg), \
patch.object(app_module, 'service_composer', mock_composer):
resp = client.post('/api/services/catalog/myapp/reconfigure')
assert resp.status_code == 500
def test_provision_service_account_exception_returns_500(self, client):
mock_am = MagicMock()
mock_am.provision.side_effect = Exception('db failure')
with patch.object(app_module, 'account_manager', mock_am):
resp = client.post('/api/services/catalog/email/accounts',
data=json.dumps({'username': 'alice'}),
content_type='application/json')
assert resp.status_code == 500
def test_deprovision_service_account_value_error_returns_400(self, client):
mock_am = MagicMock()
mock_am.deprovision.side_effect = ValueError('user not found')
with patch.object(app_module, 'account_manager', mock_am):
resp = client.delete('/api/services/catalog/email/accounts/alice')
assert resp.status_code == 400
def test_deprovision_service_account_exception_returns_500(self, client):
mock_am = MagicMock()
mock_am.deprovision.side_effect = Exception('db down')
with patch.object(app_module, 'account_manager', mock_am):
resp = client.delete('/api/services/catalog/email/accounts/alice')
assert resp.status_code == 500
def test_get_service_account_credentials_exception_returns_500(self, client):
mock_am = MagicMock()
mock_am.get_credentials.side_effect = Exception('db fail')
with patch.object(app_module, 'account_manager', mock_am):
resp = client.get('/api/services/catalog/email/accounts/alice/credentials')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# Exception paths for bus event/start/stop and log endpoints
# ---------------------------------------------------------------------------
class TestServiceBusEndpointExceptions:
"""Cover exception paths for service bus and log endpoints."""
def test_get_service_bus_events_exception_returns_500(self, client):
mock_sbus = MagicMock()
mock_sbus.get_event_history.side_effect = Exception('bus crash')
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.get('/api/services/bus/events')
assert resp.status_code == 500
def test_start_service_exception_returns_500(self, client):
mock_sbus = MagicMock()
mock_sbus.orchestrate_service_start.side_effect = Exception('crash')
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.post('/api/services/bus/services/email/start')
assert resp.status_code == 500
def test_stop_service_exception_returns_500(self, client):
mock_sbus = MagicMock()
mock_sbus.orchestrate_service_stop.side_effect = Exception('crash')
with patch.object(app_module, 'service_bus', mock_sbus):
resp = client.post('/api/services/bus/services/email/stop')
assert resp.status_code == 500
def test_search_logs_exception_returns_500(self, client):
mock_lm = MagicMock()
mock_lm.search_logs.side_effect = Exception('search fail')
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.post('/api/logs/search',
data='{}',
content_type='application/json')
assert resp.status_code == 500
def test_get_log_statistics_exception_returns_500(self, client):
mock_lm = MagicMock()
mock_lm.get_log_statistics.side_effect = Exception('stats fail')
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.get('/api/logs/statistics')
assert resp.status_code == 500
def test_rotate_logs_exception_returns_500(self, client):
mock_lm = MagicMock()
mock_lm.rotate_logs.side_effect = Exception('rotate fail')
with patch.object(app_module, 'log_manager', mock_lm):
resp = client.post('/api/logs/rotate',
data='{}',
content_type='application/json')
assert resp.status_code == 500