768571f2b7
Port conflict validation: - api/port_registry.py: detect_conflicts() checks all service sections for shared port values - api/app.py: returns HTTP 409 on port conflict after existing range validation - webui/src/pages/Settings.jsx: JS-side detectPortConflicts() with useMemo shows inline conflict errors and blocks Save before the request is made; catch blocks surface server error messages (including 409) instead of generic fallbacks Config autosave on Apply: - webui/src/contexts/DraftConfigContext.jsx: new context; Settings registers flush callbacks per section; App calls flushAll() before applyPending() when any section is dirty - webui/src/App.jsx: wraps tree with DraftConfigProvider, handleApply shows 'saving' banner state and awaits flushAll() - webui/src/pages/Settings.jsx: registers identity + per-service flushers; propagates dirty state into context via setDirty; uses refs to avoid stale closures Extended integration test coverage (114 new tests): - tests/integration/test_config_api.py: GET/PUT config, export, import, backup lifecycle - tests/integration/test_network_services.py: DNS records + DHCP reservations CRUD - tests/integration/test_containers.py: list, restart, logs, stats; recovery polling - tests/integration/test_negative_scenarios.py: error-path coverage for all endpoints - tests/test_port_conflicts.py: 20 unit tests for port_registry.detect_conflicts() Pre-commit hook updated to skip tests/integration/ (live-stack tests require a running stack and must be run explicitly via `make test-integration`). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
201 lines
7.6 KiB
Python
201 lines
7.6 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
|
|
|
|
# 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'
|
|
|
|
|
|
def get(path, **kw):
|
|
return requests.get(f"{API_BASE}{path}", **kw)
|
|
|
|
|
|
def post(path, **kw):
|
|
return requests.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 requests.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
|