aa1e5c41ec
Unit Tests / test (push) Successful in 12m6s
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>
1044 lines
45 KiB
Python
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
|