Files
pic/tests/integration/test_containers.py
T
roof fc3cfc9741 Fix post-deploy auth issues: best-effort service provisioning, integration test auth, test mock corrections
- api/app.py: email/calendar/files provisioning now best-effort (non-fatal); fixed email_manager.create_email_user call to include domain argument
- tests/integration: added module-level auth sessions to all integration test files; added admin auth to api fixture and _resolve_admin_pass() helper; added TEST_PEER_PASSWORD constant; added password to peer creation calls
- tests/test_peer_provisioning.py: renamed rollback test to reflect new best-effort semantics (email failure no longer causes rollback)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 15:42:03 -04:00

213 lines
8.0 KiB
Python

"""
Container management integration tests.
Covers:
- GET /api/containers — list, shape, all expected containers present
- POST /api/containers/<name>/restart — non-critical container; verify recovery
- GET /api/containers/<name>/logs — returns log lines
- GET /api/containers/<name>/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/<name>/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/<name>/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/<name>/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