fc3cfc9741
- 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>
213 lines
8.0 KiB
Python
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
|