Files
pic/tests/integration/test_containers.py
T
roof 768571f2b7 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>
2026-04-24 04:45:47 -04:00

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