""" Container management integration tests. Covers: - GET /api/containers — list, shape, all expected containers present - POST /api/containers//restart — non-critical container; verify recovery - GET /api/containers//logs — returns log lines - GET /api/containers//stats — returns stats dict - Negative: non-existent container name → error response (not 5xx crash) All container endpoints require a local request; tests hit localhost so the access-control check passes. Run with: pytest tests/integration/test_containers.py -v """ import time import pytest import requests import sys import os sys.path.insert(0, os.path.dirname(__file__)) from conftest import API_BASE, _resolve_admin_pass # A non-critical container safe to restart during testing. # cell-ntp has no write-side effects and recovers in seconds. _SAFE_TO_RESTART = 'cell-ntp' # A container that definitely does not exist. _NONEXISTENT = 'cell-does-not-exist-xyz' _S = None @pytest.fixture(scope='module', autouse=True) def _auth_session(): global _S _S = requests.Session() _S.headers['Content-Type'] = 'application/json' r = _S.post(f"{API_BASE}/api/auth/login", json={'username': 'admin', 'password': _resolve_admin_pass()}) assert r.status_code == 200, f"Login failed: {{r.text}}" def get(path, **kw): return _S.get(f"{API_BASE}{path}", **kw) def post(path, **kw): return _S.post(f"{API_BASE}{path}", **kw) # Skip the entire module if the container endpoint is access-denied. # This happens when the running API image pre-dates the cell_net check in # is_local_request(). Run `make update` to rebuild and re-enable these tests. def _containers_accessible(): try: return _S.get(f"{API_BASE}/api/containers", timeout=3).status_code != 403 except Exception: return False pytestmark = pytest.mark.skipif( not _containers_accessible(), reason="Container endpoints return 403 — run `make update` to deploy current API image", ) # --------------------------------------------------------------------------- # GET /api/containers # --------------------------------------------------------------------------- class TestListContainers: def test_list_containers_returns_200(self): r = get('/api/containers') assert r.status_code == 200 def test_list_containers_returns_list(self): data = get('/api/containers').json() assert isinstance(data, list) assert len(data) > 0, "Expected at least one container in the list" def test_each_container_has_name_field(self): data = get('/api/containers').json() for c in data: assert 'name' in c, f"Container entry missing 'name': {c}" def test_each_container_has_status_field(self): data = get('/api/containers').json() for c in data: assert 'status' in c, f"Container entry missing 'status': {c}" def test_safe_to_restart_container_is_present(self): data = get('/api/containers').json() names = {c['name'] for c in data} assert _SAFE_TO_RESTART in names, ( f"Expected container '{_SAFE_TO_RESTART}' in list; found: {names}" ) def test_safe_to_restart_container_is_running(self): data = get('/api/containers').json() container = next((c for c in data if c['name'] == _SAFE_TO_RESTART), None) assert container is not None assert container['status'] == 'running', ( f"Container '{_SAFE_TO_RESTART}' is not running: {container['status']}" ) # --------------------------------------------------------------------------- # POST /api/containers//restart # --------------------------------------------------------------------------- class TestRestartContainer: def test_restart_safe_container_returns_200(self): r = post(f'/api/containers/{_SAFE_TO_RESTART}/restart') assert r.status_code == 200 def test_restart_safe_container_response_has_restarted_key(self): r = post(f'/api/containers/{_SAFE_TO_RESTART}/restart') assert r.status_code == 200 data = r.json() assert 'restarted' in data, f"Response missing 'restarted' key: {data}" def test_restart_safe_container_reports_success(self): r = post(f'/api/containers/{_SAFE_TO_RESTART}/restart') assert r.status_code == 200 assert r.json().get('restarted') is True def test_container_recovers_after_restart(self): """After a restart the container should be running within ~15 seconds.""" r = post(f'/api/containers/{_SAFE_TO_RESTART}/restart') assert r.status_code == 200 deadline = time.time() + 20 while time.time() < deadline: containers = get('/api/containers').json() container = next((c for c in containers if c['name'] == _SAFE_TO_RESTART), None) if container and container.get('status') == 'running': return time.sleep(2) pytest.fail( f"Container '{_SAFE_TO_RESTART}' did not return to 'running' within 20 s" ) def test_restart_nonexistent_container_does_not_return_200(self): """Restarting a container that doesn't exist should not silently succeed.""" r = post(f'/api/containers/{_NONEXISTENT}/restart') # The API may return 404, 400, or 500 for an unknown container — anything # but a 200 with restarted=True is acceptable. if r.status_code == 200: assert r.json().get('restarted') is not True, ( "restart of non-existent container should not claim restarted=True" ) # --------------------------------------------------------------------------- # GET /api/containers//logs # --------------------------------------------------------------------------- class TestContainerLogs: def test_get_logs_returns_200(self): r = get(f'/api/containers/{_SAFE_TO_RESTART}/logs') assert r.status_code == 200 def test_get_logs_has_logs_key(self): data = get(f'/api/containers/{_SAFE_TO_RESTART}/logs').json() assert 'logs' in data, f"Response missing 'logs' key: {data}" def test_get_logs_logs_is_string_or_list(self): logs = get(f'/api/containers/{_SAFE_TO_RESTART}/logs').json()['logs'] assert isinstance(logs, (str, list)), ( f"'logs' should be a string or list, got {type(logs)}" ) def test_get_logs_tail_param_respected(self): """tail=5 should return at most 5 lines (if log output is a list).""" data = get(f'/api/containers/{_SAFE_TO_RESTART}/logs', params={'tail': 5}).json() assert 'logs' in data logs = data['logs'] if isinstance(logs, list): assert len(logs) <= 5, f"Expected ≤5 log lines with tail=5, got {len(logs)}" def test_get_logs_nonexistent_container_returns_error(self): r = get(f'/api/containers/{_NONEXISTENT}/logs') # Should be 404/500 with an error body, not 200 with empty logs if r.status_code == 200: data = r.json() assert 'error' in data or not data.get('logs'), ( "Expected error for non-existent container logs, got successful response" ) else: assert r.status_code in (404, 500) # --------------------------------------------------------------------------- # GET /api/containers//stats # --------------------------------------------------------------------------- class TestContainerStats: def test_get_stats_returns_200(self): r = get(f'/api/containers/{_SAFE_TO_RESTART}/stats') assert r.status_code == 200 def test_get_stats_returns_dict(self): data = get(f'/api/containers/{_SAFE_TO_RESTART}/stats').json() assert isinstance(data, dict) def test_get_stats_nonexistent_container_does_not_crash(self): r = get(f'/api/containers/{_NONEXISTENT}/stats') # Any response other than an unhandled exception is acceptable assert r.status_code in (200, 404, 500) r.json() # must still be valid JSON