feat: port conflict validation, autosave on Apply, extended integration tests
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>
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user