Files
pic/tests/test_routes_containers.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

378 lines
16 KiB
Python

"""
Tests for routes/containers.py — container, image, and volume management endpoints.
All endpoints require is_local_request() to return True; non-local gets 403.
"""
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 local_client():
"""Flask test client that appears as local (loopback) request."""
app.config['TESTING'] = True
with app.test_client() as c:
# is_local_request is imported inside each route handler from app
with patch.object(app_module, 'is_local_request', return_value=True):
yield c
@pytest.fixture
def remote_client():
"""Flask test client that appears as a non-local (remote) request."""
app.config['TESTING'] = True
with app.test_client() as c:
with patch.object(app_module, 'is_local_request', return_value=False):
yield c
# ---------------------------------------------------------------------------
# GET /api/containers — requires local
# ---------------------------------------------------------------------------
class TestListContainers:
def test_returns_200_from_local(self, local_client):
mock_cm = MagicMock()
mock_cm.list_containers.return_value = [{'name': 'cell-dns', 'status': 'running'}]
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.get('/api/containers')
assert resp.status_code == 200
def test_returns_containers_list(self, local_client):
mock_cm = MagicMock()
mock_cm.list_containers.return_value = [{'name': 'cell-dns'}]
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.get('/api/containers')
data = json.loads(resp.data)
assert isinstance(data, list)
assert data[0]['name'] == 'cell-dns'
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.get('/api/containers')
assert resp.status_code == 403
def test_500_on_exception(self, local_client):
mock_cm = MagicMock()
mock_cm.list_containers.side_effect = Exception('docker error')
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.get('/api/containers')
assert resp.status_code == 500
# ---------------------------------------------------------------------------
# POST /api/containers/<name>/start
# ---------------------------------------------------------------------------
class TestStartContainer:
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.start_container.return_value = True
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/containers/cell-dns/start')
assert resp.status_code == 200
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.post('/api/containers/cell-dns/start')
assert resp.status_code == 403
def test_response_shape(self, local_client):
mock_cm = MagicMock()
mock_cm.start_container.return_value = True
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/containers/cell-dns/start')
data = json.loads(resp.data)
assert 'started' in data
# ---------------------------------------------------------------------------
# POST /api/containers/<name>/stop
# ---------------------------------------------------------------------------
class TestStopContainer:
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.stop_container.return_value = True
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/containers/cell-dns/stop')
assert resp.status_code == 200
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.post('/api/containers/cell-dns/stop')
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /api/containers/<name>/restart
# ---------------------------------------------------------------------------
class TestRestartContainer:
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.restart_container.return_value = True
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/containers/cell-dns/restart')
assert resp.status_code == 200
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.post('/api/containers/cell-dns/restart')
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# GET /api/containers/<name>/logs
# ---------------------------------------------------------------------------
class TestGetContainerLogs:
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.get_container_logs.return_value = ['line1', 'line2']
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.get('/api/containers/cell-dns/logs')
assert resp.status_code == 200
def test_returns_logs_in_response(self, local_client):
mock_cm = MagicMock()
mock_cm.get_container_logs.return_value = ['line1']
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.get('/api/containers/cell-dns/logs?tail=50')
data = json.loads(resp.data)
assert 'logs' in data
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.get('/api/containers/cell-dns/logs')
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# GET /api/containers/<name>/stats
# ---------------------------------------------------------------------------
class TestGetContainerStats:
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.get_container_stats.return_value = {'cpu': '5%', 'memory': '100MB'}
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.get('/api/containers/cell-dns/stats')
assert resp.status_code == 200
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.get('/api/containers/cell-dns/stats')
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /api/containers (create)
# ---------------------------------------------------------------------------
class TestCreateContainer:
def test_returns_400_when_image_missing(self, local_client):
resp = local_client.post('/api/containers',
data=json.dumps({'name': 'test'}),
content_type='application/json')
assert resp.status_code == 400
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.post('/api/containers',
data=json.dumps({'image': 'nginx'}),
content_type='application/json')
assert resp.status_code == 403
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.create_container.return_value = {'id': 'abc123', 'name': 'mycontainer'}
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/containers',
data=json.dumps({'image': 'nginx', 'name': 'mycontainer'}),
content_type='application/json')
assert resp.status_code == 200
def test_returns_500_when_result_has_error(self, local_client):
mock_cm = MagicMock()
mock_cm.create_container.return_value = {'error': 'image not found'}
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/containers',
data=json.dumps({'image': 'badimage'}),
content_type='application/json')
assert resp.status_code == 500
def test_volume_outside_allowed_path_returns_403(self, local_client):
"""Volume mounts outside the allowed directories are blocked."""
mock_cm = MagicMock()
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/containers',
data=json.dumps({
'image': 'nginx',
'volumes': {'/etc/passwd': '/mnt/passwd'}
}),
content_type='application/json')
assert resp.status_code == 403
def test_volume_in_allowed_path_passes(self, local_client):
"""Volume mounts under /tmp/ are permitted."""
mock_cm = MagicMock()
mock_cm.create_container.return_value = {'id': 'abc'}
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/containers',
data=json.dumps({
'image': 'nginx',
'volumes': {'/tmp/test': '/mnt/test'}
}),
content_type='application/json')
assert resp.status_code == 200
# ---------------------------------------------------------------------------
# DELETE /api/containers/<name>
# ---------------------------------------------------------------------------
class TestRemoveContainer:
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.remove_container.return_value = True
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.delete('/api/containers/mycontainer')
assert resp.status_code == 200
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.delete('/api/containers/mycontainer')
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# GET /api/images
# ---------------------------------------------------------------------------
class TestListImages:
def test_returns_200(self, local_client):
mock_cm = MagicMock()
mock_cm.list_images.return_value = [{'tag': 'nginx:latest'}]
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.get('/api/images')
assert resp.status_code == 200
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.get('/api/images')
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /api/images/pull
# ---------------------------------------------------------------------------
class TestPullImage:
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.pull_image.return_value = {'status': 'pulled'}
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/images/pull',
data=json.dumps({'image': 'nginx:latest'}),
content_type='application/json')
assert resp.status_code == 200
def test_returns_400_when_image_missing(self, local_client):
resp = local_client.post('/api/images/pull',
data=json.dumps({}),
content_type='application/json')
assert resp.status_code == 400
def test_returns_500_when_pull_fails(self, local_client):
mock_cm = MagicMock()
mock_cm.pull_image.return_value = {'error': 'not found'}
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/images/pull',
data=json.dumps({'image': 'badimage'}),
content_type='application/json')
assert resp.status_code == 500
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.post('/api/images/pull',
data=json.dumps({'image': 'nginx'}),
content_type='application/json')
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# DELETE /api/images/<image>
# ---------------------------------------------------------------------------
class TestRemoveImage:
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.remove_image.return_value = True
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.delete('/api/images/nginx:latest')
assert resp.status_code == 200
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.delete('/api/images/nginx:latest')
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# GET /api/volumes
# ---------------------------------------------------------------------------
class TestListVolumes:
def test_returns_200(self, local_client):
mock_cm = MagicMock()
mock_cm.list_volumes.return_value = []
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.get('/api/volumes')
assert resp.status_code == 200
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.get('/api/volumes')
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# POST /api/volumes (create)
# ---------------------------------------------------------------------------
class TestCreateVolume:
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.create_volume.return_value = {'name': 'myvolume'}
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.post('/api/volumes',
data=json.dumps({'name': 'myvolume'}),
content_type='application/json')
assert resp.status_code == 200
def test_returns_400_when_name_missing(self, local_client):
resp = local_client.post('/api/volumes',
data=json.dumps({}),
content_type='application/json')
assert resp.status_code == 400
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.post('/api/volumes',
data=json.dumps({'name': 'v'}),
content_type='application/json')
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# DELETE /api/volumes/<name>
# ---------------------------------------------------------------------------
class TestRemoveVolume:
def test_returns_200_on_success(self, local_client):
mock_cm = MagicMock()
mock_cm.remove_volume.return_value = True
with patch.object(app_module, 'container_manager', mock_cm):
resp = local_client.delete('/api/volumes/myvolume')
assert resp.status_code == 200
def test_returns_403_from_remote(self, remote_client):
resp = remote_client.delete('/api/volumes/myvolume')
assert resp.status_code == 403