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>
651 lines
29 KiB
Python
651 lines
29 KiB
Python
"""
|
|
Tests for app.py: health_history (deque), health monitor logic,
|
|
connectivity endpoints, caddy endpoints, egress endpoints,
|
|
and before-request hooks (enforce_setup/enforce_auth/check_csrf).
|
|
"""
|
|
import sys
|
|
from pathlib import Path
|
|
import json
|
|
from collections import deque
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
|
|
|
|
import app as app_module
|
|
from app import app
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_app_state():
|
|
"""Reset global mutable state between tests."""
|
|
orig_running = app_module.health_monitor_running
|
|
orig_counters = dict(app_module.service_alert_counters)
|
|
app.config['TESTING'] = True
|
|
yield
|
|
app_module.health_monitor_running = orig_running
|
|
app_module.service_alert_counters = orig_counters
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
app.config['TESTING'] = True
|
|
with app.test_client() as c:
|
|
yield c
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# health_history is a deque (not a list)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestHealthHistoryIsDeque:
|
|
def test_health_history_is_deque(self):
|
|
assert isinstance(app_module.health_history, deque)
|
|
|
|
def test_health_history_has_maxlen(self):
|
|
assert app_module.health_history.maxlen == app_module.HEALTH_HISTORY_SIZE
|
|
|
|
def test_health_history_appendleft_works(self):
|
|
"""appendleft (used in health_monitor_loop) should work on a deque."""
|
|
hh = app_module.health_history
|
|
entry = {'timestamp': '2026-01-01T00:00:00', 'alerts': []}
|
|
hh.appendleft(entry)
|
|
assert hh[0] == entry
|
|
|
|
def test_health_history_maxlen_evicts_old_entries(self):
|
|
hh = deque(maxlen=3)
|
|
for i in range(5):
|
|
hh.appendleft({'n': i})
|
|
assert len(hh) == 3
|
|
# Most recent is first
|
|
assert hh[0]['n'] == 4
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/health/history
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGetHealthHistory:
|
|
def test_returns_200(self, client):
|
|
with patch.object(app_module, 'health_history', deque(maxlen=100)):
|
|
resp = client.get('/api/health/history')
|
|
assert resp.status_code == 200
|
|
|
|
def test_returns_list(self, client):
|
|
with patch.object(app_module, 'health_history', deque(maxlen=100)):
|
|
resp = client.get('/api/health/history')
|
|
data = json.loads(resp.data)
|
|
assert isinstance(data, list)
|
|
|
|
def test_returns_stored_entries(self, client):
|
|
hh = deque(maxlen=100)
|
|
hh.appendleft({'timestamp': 't1', 'alerts': []})
|
|
hh.appendleft({'timestamp': 't2', 'alerts': []})
|
|
with patch.object(app_module, 'health_history', hh):
|
|
resp = client.get('/api/health/history')
|
|
data = json.loads(resp.data)
|
|
assert len(data) == 2
|
|
|
|
def test_returns_empty_when_no_history(self, client):
|
|
with patch.object(app_module, 'health_history', deque(maxlen=100)):
|
|
resp = client.get('/api/health/history')
|
|
assert json.loads(resp.data) == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# POST /api/health/history/clear
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestClearHealthHistory:
|
|
def test_clear_returns_200(self, client):
|
|
hh = deque(maxlen=100)
|
|
hh.appendleft({'entry': 1})
|
|
with patch.object(app_module, 'health_history', hh):
|
|
resp = client.post('/api/health/history/clear')
|
|
assert resp.status_code == 200
|
|
|
|
def test_clear_empties_history(self, client):
|
|
hh = deque(maxlen=100)
|
|
hh.appendleft({'entry': 1})
|
|
with patch.object(app_module, 'health_history', hh):
|
|
client.post('/api/health/history/clear')
|
|
assert len(hh) == 0
|
|
|
|
def test_clear_resets_alert_counters(self, client):
|
|
app_module.service_alert_counters['network'] = 5
|
|
hh = deque(maxlen=100)
|
|
with patch.object(app_module, 'health_history', hh):
|
|
client.post('/api/health/history/clear')
|
|
assert app_module.service_alert_counters == {}
|
|
|
|
def test_clear_response_has_message(self, client):
|
|
hh = deque(maxlen=100)
|
|
with patch.object(app_module, 'health_history', hh):
|
|
resp = client.post('/api/health/history/clear')
|
|
data = json.loads(resp.data)
|
|
assert 'message' in data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# perform_health_check alerting logic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPerformHealthCheck:
|
|
def test_healthy_service_resets_counter(self):
|
|
app_module.service_alert_counters['network'] = 2
|
|
mock_service_bus = MagicMock()
|
|
mock_service_bus.list_services.return_value = ['network']
|
|
network_svc = MagicMock()
|
|
network_svc.health_check.return_value = {'running': True}
|
|
mock_service_bus.get_service.return_value = network_svc
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.get_installed_services.return_value = []
|
|
with patch.object(app_module, 'service_bus', mock_service_bus), \
|
|
patch.object(app_module, 'config_manager', mock_cfg), \
|
|
app.app_context():
|
|
result = app_module.perform_health_check()
|
|
assert app_module.service_alert_counters.get('network', 0) == 0
|
|
assert 'network' in result
|
|
|
|
def test_unhealthy_service_with_error_key_increments_counter(self):
|
|
"""Services that raise an exception get recorded with an 'error' key,
|
|
which the alerting logic recognises as unhealthy."""
|
|
app_module.service_alert_counters = {}
|
|
mock_service_bus = MagicMock()
|
|
mock_service_bus.list_services.return_value = ['network']
|
|
mock_service_bus.publish_event = MagicMock()
|
|
network_svc = MagicMock()
|
|
# Raise so the result gets {'error': ..., 'status': 'offline'}
|
|
network_svc.health_check.side_effect = Exception('container down')
|
|
mock_service_bus.get_service.return_value = network_svc
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.get_installed_services.return_value = []
|
|
with patch.object(app_module, 'service_bus', mock_service_bus), \
|
|
patch.object(app_module, 'config_manager', mock_cfg), \
|
|
app.app_context():
|
|
app_module.perform_health_check()
|
|
# With an 'error' key and no 'running' key, healthy=False → counter increments
|
|
assert app_module.service_alert_counters.get('network', 0) == 1
|
|
|
|
def test_alert_triggered_at_threshold(self):
|
|
"""Counter reaching HEALTH_ALERT_THRESHOLD emits an alert."""
|
|
app_module.service_alert_counters = {'network': app_module.HEALTH_ALERT_THRESHOLD - 1}
|
|
mock_service_bus = MagicMock()
|
|
mock_service_bus.list_services.return_value = ['network']
|
|
mock_service_bus.publish_event = MagicMock()
|
|
network_svc = MagicMock()
|
|
# Use exception path to guarantee healthy=False
|
|
network_svc.health_check.side_effect = Exception('container down')
|
|
mock_service_bus.get_service.return_value = network_svc
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.get_installed_services.return_value = []
|
|
with patch.object(app_module, 'service_bus', mock_service_bus), \
|
|
patch.object(app_module, 'config_manager', mock_cfg), \
|
|
app.app_context():
|
|
result = app_module.perform_health_check()
|
|
# Alert should be in result['alerts']
|
|
assert len(result['alerts']) >= 1
|
|
assert any('network' in a for a in result['alerts'])
|
|
|
|
def test_optional_store_services_skipped_when_not_installed(self):
|
|
mock_service_bus = MagicMock()
|
|
mock_service_bus.list_services.return_value = ['email_manager']
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.get_installed_services.return_value = [] # email not installed
|
|
with patch.object(app_module, 'service_bus', mock_service_bus), \
|
|
patch.object(app_module, 'config_manager', mock_cfg), \
|
|
app.app_context():
|
|
result = app_module.perform_health_check()
|
|
# email_manager should not appear in result (was skipped)
|
|
assert 'email_manager' not in result
|
|
|
|
def test_optional_store_service_checked_when_installed(self):
|
|
mock_service_bus = MagicMock()
|
|
mock_service_bus.list_services.return_value = ['email_manager']
|
|
mock_service_bus.publish_event = MagicMock()
|
|
email_svc = MagicMock()
|
|
email_svc.health_check.return_value = {'running': True}
|
|
mock_service_bus.get_service.return_value = email_svc
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.get_installed_services.return_value = ['email'] # email installed
|
|
with patch.object(app_module, 'service_bus', mock_service_bus), \
|
|
patch.object(app_module, 'config_manager', mock_cfg), \
|
|
app.app_context():
|
|
result = app_module.perform_health_check()
|
|
assert 'email_manager' in result
|
|
|
|
def test_service_without_health_check_falls_back_to_get_status(self):
|
|
mock_service_bus = MagicMock()
|
|
mock_service_bus.list_services.return_value = ['routing']
|
|
svc = MagicMock(spec=[]) # no health_check attribute
|
|
svc.get_status = MagicMock(return_value={'running': True})
|
|
mock_service_bus.get_service.return_value = svc
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.get_installed_services.return_value = []
|
|
with patch.object(app_module, 'service_bus', mock_service_bus), \
|
|
patch.object(app_module, 'config_manager', mock_cfg), \
|
|
app.app_context():
|
|
result = app_module.perform_health_check()
|
|
assert 'routing' in result
|
|
|
|
def test_service_exception_recorded_as_error(self):
|
|
mock_service_bus = MagicMock()
|
|
mock_service_bus.list_services.return_value = ['vault']
|
|
svc = MagicMock()
|
|
svc.health_check.side_effect = Exception('vault down')
|
|
mock_service_bus.get_service.return_value = svc
|
|
mock_cfg = MagicMock()
|
|
mock_cfg.get_installed_services.return_value = []
|
|
with patch.object(app_module, 'service_bus', mock_service_bus), \
|
|
patch.object(app_module, 'config_manager', mock_cfg), \
|
|
app.app_context():
|
|
result = app_module.perform_health_check()
|
|
assert 'error' in result.get('vault', {})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/connectivity/status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestConnectivityEndpoints:
|
|
def test_connectivity_status_200(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_status.return_value = {'exits': [], 'peers': {}}
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.get('/api/connectivity/status')
|
|
assert resp.status_code == 200
|
|
|
|
def test_connectivity_status_shape(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_status.return_value = {'exits': [], 'peers': {}}
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.get('/api/connectivity/status')
|
|
data = json.loads(resp.data)
|
|
assert 'exits' in data
|
|
|
|
def test_connectivity_status_500_on_exception(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_status.side_effect = Exception('fail')
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.get('/api/connectivity/status')
|
|
assert resp.status_code == 500
|
|
|
|
def test_connectivity_list_exits_200(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.list_exits.return_value = []
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.get('/api/connectivity/exits')
|
|
assert resp.status_code == 200
|
|
|
|
def test_connectivity_list_exits_shape(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.list_exits.return_value = [{'type': 'wireguard_ext', 'name': 'exit1'}]
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.get('/api/connectivity/exits')
|
|
data = json.loads(resp.data)
|
|
assert 'exits' in data
|
|
assert len(data['exits']) == 1
|
|
|
|
def test_connectivity_upload_wireguard_missing_conf_text(self, client):
|
|
resp = client.post('/api/connectivity/exits/wireguard',
|
|
data=json.dumps({}), content_type='application/json')
|
|
assert resp.status_code == 400
|
|
data = json.loads(resp.data)
|
|
assert 'error' in data
|
|
|
|
def test_connectivity_upload_wireguard_empty_conf_text(self, client):
|
|
resp = client.post('/api/connectivity/exits/wireguard',
|
|
data=json.dumps({'conf_text': ' '}),
|
|
content_type='application/json')
|
|
assert resp.status_code == 400
|
|
|
|
def test_connectivity_upload_wireguard_success(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.upload_wireguard_ext.return_value = {'ok': True, 'message': 'Uploaded'}
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.post('/api/connectivity/exits/wireguard',
|
|
data=json.dumps({'conf_text': '[Interface]\nPrivateKey = abc\n'}),
|
|
content_type='application/json')
|
|
assert resp.status_code == 200
|
|
|
|
def test_connectivity_upload_wireguard_failure(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.upload_wireguard_ext.return_value = {'ok': False, 'error': 'bad config'}
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.post('/api/connectivity/exits/wireguard',
|
|
data=json.dumps({'conf_text': '[Interface]\nPrivateKey = abc\n'}),
|
|
content_type='application/json')
|
|
assert resp.status_code == 400
|
|
|
|
def test_connectivity_upload_openvpn_missing_ovpn_text(self, client):
|
|
resp = client.post('/api/connectivity/exits/openvpn',
|
|
data=json.dumps({}), content_type='application/json')
|
|
assert resp.status_code == 400
|
|
|
|
def test_connectivity_upload_openvpn_success(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.upload_openvpn.return_value = {'ok': True}
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.post('/api/connectivity/exits/openvpn',
|
|
data=json.dumps({'ovpn_text': 'client\ndev tun\n'}),
|
|
content_type='application/json')
|
|
assert resp.status_code == 200
|
|
|
|
def test_connectivity_apply_routes_200(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.apply_routes.return_value = {'ok': True, 'applied': 0}
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.post('/api/connectivity/exits/apply',
|
|
content_type='application/json')
|
|
assert resp.status_code == 200
|
|
|
|
def test_connectivity_set_peer_exit_missing_exit_via(self, client):
|
|
resp = client.put('/api/connectivity/peers/alice/exit',
|
|
data=json.dumps({}), content_type='application/json')
|
|
assert resp.status_code == 400
|
|
|
|
def test_connectivity_set_peer_exit_success(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.set_peer_exit.return_value = {'ok': True}
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.put('/api/connectivity/peers/alice/exit',
|
|
data=json.dumps({'exit_via': 'wireguard_ext'}),
|
|
content_type='application/json')
|
|
assert resp.status_code == 200
|
|
|
|
def test_connectivity_set_peer_exit_failure(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.set_peer_exit.return_value = {'ok': False, 'error': 'not found'}
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.put('/api/connectivity/peers/alice/exit',
|
|
data=json.dumps({'exit_via': 'wireguard_ext'}),
|
|
content_type='application/json')
|
|
assert resp.status_code == 400
|
|
|
|
def test_connectivity_get_peer_exits_200(self, client):
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_peer_exits.return_value = {'alice': 'wireguard_ext'}
|
|
with patch.object(app_module, 'connectivity_manager', mock_cm):
|
|
resp = client.get('/api/connectivity/peers')
|
|
assert resp.status_code == 200
|
|
data = json.loads(resp.data)
|
|
assert 'peers' in data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/caddy/cert-status and POST /api/caddy/cert-renew
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCaddyEndpoints:
|
|
def test_caddy_cert_status_200(self, client):
|
|
mock_caddy = MagicMock()
|
|
mock_caddy.get_cert_status_fresh.return_value = {'status': 'valid', 'days_remaining': 60}
|
|
with patch.object(app_module, 'caddy_manager', mock_caddy):
|
|
resp = client.get('/api/caddy/cert-status')
|
|
assert resp.status_code == 200
|
|
|
|
def test_caddy_cert_status_shape(self, client):
|
|
mock_caddy = MagicMock()
|
|
mock_caddy.get_cert_status_fresh.return_value = {'status': 'internal'}
|
|
with patch.object(app_module, 'caddy_manager', mock_caddy):
|
|
resp = client.get('/api/caddy/cert-status')
|
|
data = json.loads(resp.data)
|
|
assert 'status' in data
|
|
|
|
def test_caddy_cert_status_500_on_exception(self, client):
|
|
mock_caddy = MagicMock()
|
|
mock_caddy.get_cert_status_fresh.side_effect = Exception('Caddy unreachable')
|
|
with patch.object(app_module, 'caddy_manager', mock_caddy):
|
|
resp = client.get('/api/caddy/cert-status')
|
|
assert resp.status_code == 500
|
|
|
|
def test_caddy_cert_renew_success(self, client):
|
|
mock_caddy = MagicMock()
|
|
mock_caddy.renew_cert.return_value = {'ok': True, 'status': 'pending'}
|
|
with patch.object(app_module, 'caddy_manager', mock_caddy):
|
|
resp = client.post('/api/caddy/cert-renew',
|
|
content_type='application/json')
|
|
assert resp.status_code == 200
|
|
|
|
def test_caddy_cert_renew_failure(self, client):
|
|
mock_caddy = MagicMock()
|
|
mock_caddy.renew_cert.return_value = {'ok': False, 'error': 'LAN mode'}
|
|
with patch.object(app_module, 'caddy_manager', mock_caddy):
|
|
resp = client.post('/api/caddy/cert-renew',
|
|
content_type='application/json')
|
|
assert resp.status_code == 400
|
|
|
|
def test_caddy_cert_renew_500_on_exception(self, client):
|
|
mock_caddy = MagicMock()
|
|
mock_caddy.renew_cert.side_effect = Exception('fail')
|
|
with patch.object(app_module, 'caddy_manager', mock_caddy):
|
|
resp = client.post('/api/caddy/cert-renew',
|
|
content_type='application/json')
|
|
assert resp.status_code == 500
|
|
|
|
def test_caddy_upload_custom_cert_missing_fields(self, client):
|
|
resp = client.post('/api/caddy/custom-cert',
|
|
data=json.dumps({}), content_type='application/json')
|
|
assert resp.status_code == 400
|
|
|
|
def test_caddy_upload_custom_cert_success(self, client):
|
|
mock_caddy = MagicMock()
|
|
mock_caddy.upload_custom_cert.return_value = {'ok': True}
|
|
with patch.object(app_module, 'caddy_manager', mock_caddy):
|
|
resp = client.post('/api/caddy/custom-cert',
|
|
data=json.dumps({'cert_pem': 'CERT', 'key_pem': 'KEY'}),
|
|
content_type='application/json')
|
|
assert resp.status_code == 200
|
|
|
|
def test_caddy_upload_custom_cert_failure(self, client):
|
|
mock_caddy = MagicMock()
|
|
mock_caddy.upload_custom_cert.return_value = {'ok': False, 'error': 'invalid cert'}
|
|
with patch.object(app_module, 'caddy_manager', mock_caddy):
|
|
resp = client.post('/api/caddy/custom-cert',
|
|
data=json.dumps({'cert_pem': 'BAD', 'key_pem': 'BAD'}),
|
|
content_type='application/json')
|
|
assert resp.status_code == 422
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/egress/status and PUT /api/egress/services/<id>/exit
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEgressEndpoints:
|
|
def test_egress_status_200(self, client):
|
|
mock_egress = MagicMock()
|
|
mock_egress.get_status.return_value = {'services': {}}
|
|
with patch('app.egress_manager', mock_egress, create=True):
|
|
resp = client.get('/api/egress/status')
|
|
assert resp.status_code == 200
|
|
|
|
def test_egress_status_500_on_exception(self, client):
|
|
mock_egress = MagicMock()
|
|
mock_egress.get_status.side_effect = Exception('fail')
|
|
with patch('app.egress_manager', mock_egress, create=True):
|
|
resp = client.get('/api/egress/status')
|
|
assert resp.status_code == 500
|
|
|
|
def test_egress_set_service_exit_missing_exit_type(self, client):
|
|
mock_egress = MagicMock()
|
|
with patch('app.egress_manager', mock_egress, create=True):
|
|
resp = client.put('/api/egress/services/email/exit',
|
|
data=json.dumps({}), content_type='application/json')
|
|
assert resp.status_code == 400
|
|
|
|
def test_egress_set_service_exit_success(self, client):
|
|
mock_egress = MagicMock()
|
|
mock_egress.set_service_exit.return_value = {'ok': True}
|
|
with patch('app.egress_manager', mock_egress, create=True):
|
|
resp = client.put('/api/egress/services/email/exit',
|
|
data=json.dumps({'exit_type': 'wireguard_ext'}),
|
|
content_type='application/json')
|
|
assert resp.status_code == 200
|
|
|
|
def test_egress_set_service_exit_failure(self, client):
|
|
mock_egress = MagicMock()
|
|
mock_egress.set_service_exit.return_value = {'ok': False, 'error': 'not found'}
|
|
with patch('app.egress_manager', mock_egress, create=True):
|
|
resp = client.put('/api/egress/services/email/exit',
|
|
data=json.dumps({'exit_type': 'wireguard_ext'}),
|
|
content_type='application/json')
|
|
assert resp.status_code == 400
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# enforce_setup hook: returns 428 when setup is not complete
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEnforceSetupHook:
|
|
def test_428_when_setup_incomplete(self):
|
|
"""Without TESTING=True, API requests are blocked if setup is not done."""
|
|
app.config['TESTING'] = False
|
|
mock_setup = MagicMock()
|
|
mock_setup.is_setup_complete.return_value = False
|
|
try:
|
|
with patch.object(app_module, 'setup_manager', mock_setup):
|
|
with app.test_client() as c:
|
|
resp = c.get('/api/status')
|
|
assert resp.status_code == 428
|
|
data = json.loads(resp.data)
|
|
assert 'redirect' in data
|
|
finally:
|
|
app.config['TESTING'] = True
|
|
|
|
def test_setup_route_passes_when_incomplete(self):
|
|
"""Setup routes always pass through regardless of setup status."""
|
|
app.config['TESTING'] = False
|
|
mock_setup = MagicMock()
|
|
mock_setup.is_setup_complete.return_value = False
|
|
try:
|
|
with patch.object(app_module, 'setup_manager', mock_setup):
|
|
with app.test_client() as c:
|
|
resp = c.get('/api/setup/status')
|
|
# Should NOT be 428
|
|
assert resp.status_code != 428
|
|
finally:
|
|
app.config['TESTING'] = True
|
|
|
|
def test_health_passes_when_incomplete(self):
|
|
"""The /health endpoint always passes through."""
|
|
app.config['TESTING'] = False
|
|
mock_setup = MagicMock()
|
|
mock_setup.is_setup_complete.return_value = False
|
|
try:
|
|
with patch.object(app_module, 'setup_manager', mock_setup):
|
|
with app.test_client() as c:
|
|
resp = c.get('/health')
|
|
assert resp.status_code == 200
|
|
finally:
|
|
app.config['TESTING'] = True
|
|
|
|
def test_setup_complete_passes_through(self):
|
|
"""All routes pass through when setup is complete."""
|
|
app.config['TESTING'] = False
|
|
mock_setup = MagicMock()
|
|
mock_setup.is_setup_complete.return_value = True
|
|
mock_auth = MagicMock()
|
|
mock_auth.list_users.return_value = []
|
|
try:
|
|
with patch.object(app_module, 'setup_manager', mock_setup), \
|
|
patch.object(app_module, 'auth_manager', mock_auth):
|
|
with app.test_client() as c:
|
|
resp = c.get('/api/status')
|
|
assert resp.status_code != 428
|
|
finally:
|
|
app.config['TESTING'] = True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# enforce_auth hook: 503 when users file exists but is empty
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEnforceAuthHook:
|
|
def test_503_when_users_file_empty_and_readable(self, tmp_path):
|
|
"""Returns 503 when users file exists + readable but has no accounts."""
|
|
import tempfile, os
|
|
app.config['TESTING'] = False
|
|
users_file = tmp_path / 'auth_users.json'
|
|
users_file.write_text('[]') # file exists but no accounts
|
|
|
|
from auth_manager import AuthManager
|
|
real_auth = MagicMock(spec=AuthManager)
|
|
real_auth.list_users.return_value = []
|
|
real_auth._users_file = str(users_file)
|
|
|
|
mock_setup = MagicMock()
|
|
mock_setup.is_setup_complete.return_value = True
|
|
try:
|
|
with patch.object(app_module, 'auth_manager', real_auth), \
|
|
patch.object(app_module, 'setup_manager', mock_setup):
|
|
with app.test_client() as c:
|
|
resp = c.get('/api/status')
|
|
assert resp.status_code == 503
|
|
data = json.loads(resp.data)
|
|
assert 'error' in data
|
|
finally:
|
|
app.config['TESTING'] = True
|
|
|
|
def test_401_when_no_session_and_users_exist(self, tmp_path):
|
|
"""Returns 401 when users exist but no session cookie is set."""
|
|
app.config['TESTING'] = False
|
|
users_file = tmp_path / 'auth_users.json'
|
|
# Users file doesn't exist — no file means enforcement
|
|
# is bypassed. Use a file that DOES have a user.
|
|
import json as _json
|
|
users_file.write_text(_json.dumps([{'username': 'admin', 'role': 'admin'}]))
|
|
|
|
from auth_manager import AuthManager
|
|
real_auth = MagicMock(spec=AuthManager)
|
|
real_auth.list_users.return_value = [{'username': 'admin', 'role': 'admin'}]
|
|
real_auth._users_file = str(users_file)
|
|
|
|
mock_setup = MagicMock()
|
|
mock_setup.is_setup_complete.return_value = True
|
|
try:
|
|
with patch.object(app_module, 'auth_manager', real_auth), \
|
|
patch.object(app_module, 'setup_manager', mock_setup):
|
|
with app.test_client() as c:
|
|
# No login — no session
|
|
resp = c.get('/api/status')
|
|
assert resp.status_code == 401
|
|
finally:
|
|
app.config['TESTING'] = True
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /api/status
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestGetCellStatus:
|
|
def test_returns_200(self, client):
|
|
mock_sb = MagicMock()
|
|
mock_sb.list_services.return_value = []
|
|
mock_pr = MagicMock()
|
|
mock_pr.list_peers.return_value = []
|
|
mock_cm = MagicMock()
|
|
mock_cm.configs = {'_identity': {'cell_name': 'test', 'domain': 'cell'}}
|
|
mock_cm.get_effective_domain.return_value = 'cell'
|
|
with patch.object(app_module, 'service_bus', mock_sb), \
|
|
patch.object(app_module, 'peer_registry', mock_pr), \
|
|
patch.object(app_module, 'config_manager', mock_cm):
|
|
resp = client.get('/api/status')
|
|
assert resp.status_code == 200
|
|
|
|
def test_status_includes_expected_keys(self, client):
|
|
mock_sb = MagicMock()
|
|
mock_sb.list_services.return_value = []
|
|
mock_pr = MagicMock()
|
|
mock_pr.list_peers.return_value = []
|
|
mock_cm = MagicMock()
|
|
mock_cm.configs = {'_identity': {'cell_name': 'test', 'domain': 'cell'}}
|
|
mock_cm.get_effective_domain.return_value = 'cell'
|
|
with patch.object(app_module, 'service_bus', mock_sb), \
|
|
patch.object(app_module, 'peer_registry', mock_pr), \
|
|
patch.object(app_module, 'config_manager', mock_cm):
|
|
resp = client.get('/api/status')
|
|
data = json.loads(resp.data)
|
|
for key in ('cell_name', 'domain', 'uptime', 'peers_count', 'services'):
|
|
assert key in data, f"Missing key: {key}"
|