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>
363 lines
14 KiB
Python
363 lines
14 KiB
Python
"""
|
|
Apply-propagation integration tests.
|
|
|
|
Verifies the full save → pending → apply → verify lifecycle:
|
|
|
|
1. GET /api/config/pending — reports needs_restart
|
|
2. PUT /api/config — a port change marks pending
|
|
3. DELETE /api/config/pending — discard clears the flag
|
|
4. POST /api/config/apply — apply returns 200, clears pending,
|
|
restarts only the affected container
|
|
5. After restart, GET /api/config reflects the saved change
|
|
|
|
Routes used (confirmed in api/app.py):
|
|
GET /api/config/pending — {needs_restart, changed_at, changes, containers}
|
|
DELETE /api/config/pending — discard without restart
|
|
POST /api/config/apply — trigger restart; returns {message, restart_in_progress}
|
|
GET /health — {status: "healthy"} (used to poll for recovery)
|
|
|
|
Why calendar.port?
|
|
------------------
|
|
Changing calendar.port is the safest apply-test trigger because:
|
|
- It falls under the _PORT_CHANGE_MAP in app.py and therefore sets
|
|
needs_restart=true pointing only at the ['radicale'] container.
|
|
- The API container itself is NOT in that list, so the Flask process
|
|
stays up during apply — no connection gap to handle.
|
|
- cell_name / domain changes are applied immediately to DNS and do NOT
|
|
set needs_restart, so they cannot be used to exercise the pending path.
|
|
|
|
Run with: pytest tests/integration/test_apply_propagation.py -v
|
|
"""
|
|
import time
|
|
import sys
|
|
import os
|
|
|
|
import pytest
|
|
import requests
|
|
from requests.exceptions import ConnectionError, Timeout
|
|
|
|
sys.path.insert(0, os.path.dirname(__file__))
|
|
from conftest import API_BASE, _resolve_admin_pass
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_POLL_INTERVAL = 2 # seconds between health polls
|
|
_HEALTH_TIMEOUT = 90 # max seconds to wait for healthy after apply
|
|
|
|
# Two distinct valid calendar ports used as before/after values.
|
|
# Neither conflicts with any other default service port.
|
|
_CAL_PORT_A = 5232 # the standard Radicale default
|
|
_CAL_PORT_B = 5233 # an alternate safe value used as the "changed" state
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_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 put(path, **kw):
|
|
return _S.put(f"{API_BASE}{path}", **kw)
|
|
|
|
|
|
def post(path, **kw):
|
|
return _S.post(f"{API_BASE}{path}", **kw)
|
|
|
|
|
|
def delete(path, **kw):
|
|
return _S.delete(f"{API_BASE}{path}", **kw)
|
|
|
|
|
|
def wait_for_healthy(timeout: int = _HEALTH_TIMEOUT) -> bool:
|
|
"""
|
|
Poll GET /health until it returns {"status": "healthy"} or timeout expires.
|
|
Connection errors are swallowed so the loop survives a container restart.
|
|
Returns True if healthy within timeout, False otherwise.
|
|
"""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
r = requests.get(f"{API_BASE}/health", timeout=5)
|
|
if r.status_code == 200 and r.json().get("status") == "healthy":
|
|
return True
|
|
except (ConnectionError, Timeout):
|
|
pass # API may be momentarily unreachable — keep trying
|
|
time.sleep(_POLL_INTERVAL)
|
|
return False
|
|
|
|
|
|
def pending_state() -> dict:
|
|
"""Return the current /api/config/pending response body."""
|
|
return get("/api/config/pending").json()
|
|
|
|
|
|
def current_calendar_port() -> int:
|
|
"""Read calendar.port from the live config."""
|
|
cfg = get("/api/config").json()
|
|
svc = cfg.get("service_configs", {}).get("calendar", {})
|
|
return int(svc.get("port", _CAL_PORT_A))
|
|
|
|
|
|
def set_calendar_port(port: int) -> requests.Response:
|
|
"""PUT calendar.port and return the response."""
|
|
return put("/api/config", json={"calendar": {"port": port}})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestPendingState (no container restarts)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestPendingState:
|
|
"""Tests that verify pending-state semantics without triggering an apply."""
|
|
|
|
def test_pending_starts_false(self):
|
|
"""
|
|
After discarding any stale pending changes, GET /api/config/pending
|
|
must return needs_restart=false.
|
|
"""
|
|
delete("/api/config/pending")
|
|
data = pending_state()
|
|
assert data["needs_restart"] is False, (
|
|
f"Expected needs_restart=false at baseline, got: {data}"
|
|
)
|
|
|
|
def test_save_config_sets_pending(self):
|
|
"""
|
|
PUT /api/config with a changed calendar.port must flip needs_restart
|
|
to true. The change is discarded (not applied) so no restart occurs.
|
|
"""
|
|
original_port = current_calendar_port()
|
|
# Pick whichever alternate port is not currently in use.
|
|
new_port = _CAL_PORT_B if original_port == _CAL_PORT_A else _CAL_PORT_A
|
|
|
|
# Start from a clean state.
|
|
delete("/api/config/pending")
|
|
assert pending_state()["needs_restart"] is False, "Could not clear pending state"
|
|
|
|
try:
|
|
r = set_calendar_port(new_port)
|
|
assert r.status_code == 200, f"PUT /api/config failed: {r.text}"
|
|
|
|
data = pending_state()
|
|
assert data["needs_restart"] is True, (
|
|
f"Expected needs_restart=true after port change, got: {data}"
|
|
)
|
|
assert isinstance(data["changes"], list)
|
|
assert len(data["changes"]) > 0, (
|
|
"changes list is empty even though needs_restart is true"
|
|
)
|
|
# The pending restart should be scoped to the radicale container.
|
|
assert "radicale" in data.get("containers", []) or data.get("containers") == ["*"], (
|
|
f"Expected 'radicale' in pending containers, got: {data.get('containers')}"
|
|
)
|
|
finally:
|
|
set_calendar_port(original_port)
|
|
delete("/api/config/pending")
|
|
|
|
def test_discard_clears_pending(self):
|
|
"""
|
|
DELETE /api/config/pending after a config change must reset
|
|
needs_restart to false and empty the changes list without
|
|
restarting any containers.
|
|
"""
|
|
original_port = current_calendar_port()
|
|
new_port = _CAL_PORT_B if original_port == _CAL_PORT_A else _CAL_PORT_A
|
|
|
|
delete("/api/config/pending") # start clean
|
|
|
|
try:
|
|
r = set_calendar_port(new_port)
|
|
assert r.status_code == 200, f"PUT /api/config failed: {r.text}"
|
|
assert pending_state()["needs_restart"] is True, (
|
|
"needs_restart not set after port change — cannot test discard"
|
|
)
|
|
|
|
dr = delete("/api/config/pending")
|
|
assert dr.status_code == 200, (
|
|
f"DELETE /api/config/pending returned {dr.status_code}: {dr.text}"
|
|
)
|
|
body = dr.json()
|
|
assert "message" in body, f"Discard response missing 'message': {body}"
|
|
|
|
data = pending_state()
|
|
assert data["needs_restart"] is False, (
|
|
f"needs_restart still true after discard: {data}"
|
|
)
|
|
assert data["changes"] == [], (
|
|
f"changes list not empty after discard: {data}"
|
|
)
|
|
finally:
|
|
set_calendar_port(original_port)
|
|
delete("/api/config/pending")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TestApplyAndVerify (triggers actual container restart)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestApplyAndVerify:
|
|
"""
|
|
Tests that call POST /api/config/apply.
|
|
|
|
Because a calendar.port change only restarts the 'radicale' container
|
|
(not the API container), the API stays up throughout. wait_for_healthy()
|
|
is still called after each apply to confirm full readiness before making
|
|
assertions.
|
|
|
|
Every test restores the original calendar port in a finally block.
|
|
"""
|
|
|
|
def test_apply_endpoint_exists(self):
|
|
"""
|
|
POST /api/config/apply must return 200 with a 'message' key even
|
|
when there is nothing pending (documented no-op behaviour).
|
|
"""
|
|
delete("/api/config/pending")
|
|
|
|
r = post("/api/config/apply")
|
|
assert r.status_code == 200, (
|
|
f"Expected 200 from POST /api/config/apply (no-op), "
|
|
f"got {r.status_code}: {r.text}"
|
|
)
|
|
body = r.json()
|
|
assert "message" in body, f"Response body missing 'message' key: {body}"
|
|
|
|
def test_apply_clears_pending(self):
|
|
"""
|
|
After saving a config change and calling POST /api/config/apply,
|
|
GET /api/config/pending must return needs_restart=false.
|
|
|
|
app.py clears the pending flag synchronously before spawning the
|
|
restart thread, so the flag is cleared as soon as the apply HTTP
|
|
response is received — regardless of when containers finish starting.
|
|
"""
|
|
original_port = current_calendar_port()
|
|
new_port = _CAL_PORT_B if original_port == _CAL_PORT_A else _CAL_PORT_A
|
|
|
|
delete("/api/config/pending")
|
|
|
|
try:
|
|
r = set_calendar_port(new_port)
|
|
assert r.status_code == 200, f"PUT /api/config failed: {r.text}"
|
|
assert pending_state()["needs_restart"] is True, (
|
|
"needs_restart not set — cannot verify that apply clears it"
|
|
)
|
|
|
|
ar = post("/api/config/apply")
|
|
assert ar.status_code == 200, (
|
|
f"POST /api/config/apply returned {ar.status_code}: {ar.text}"
|
|
)
|
|
apply_body = ar.json()
|
|
assert "message" in apply_body, (
|
|
f"Apply response missing 'message': {apply_body}"
|
|
)
|
|
|
|
# Wait for the API to confirm healthy (radicale restart in background).
|
|
recovered = wait_for_healthy()
|
|
assert recovered, (
|
|
f"API did not return healthy within {_HEALTH_TIMEOUT}s after apply"
|
|
)
|
|
|
|
data = pending_state()
|
|
assert data["needs_restart"] is False, (
|
|
f"needs_restart still true after apply + recovery: {data}"
|
|
)
|
|
|
|
finally:
|
|
set_calendar_port(original_port)
|
|
delete("/api/config/pending")
|
|
wait_for_healthy(_HEALTH_TIMEOUT)
|
|
|
|
def test_cell_name_change_persists_after_apply(self):
|
|
"""
|
|
Full lifecycle: change calendar.port → apply → wait for healthy →
|
|
GET /api/config must return the new port value.
|
|
|
|
This confirms that the configuration written to disk before apply
|
|
survives the container restart cycle and is not rolled back.
|
|
cell_name is used as a secondary check: because cell_name changes
|
|
are applied immediately to DNS (not via pending), we verify it was
|
|
not inadvertently cleared by the apply path.
|
|
"""
|
|
original_port = current_calendar_port()
|
|
original_cfg = get("/api/config").json()
|
|
original_name = original_cfg["cell_name"]
|
|
|
|
new_port = _CAL_PORT_B if original_port == _CAL_PORT_A else _CAL_PORT_A
|
|
|
|
delete("/api/config/pending")
|
|
|
|
try:
|
|
# 1. Save a new calendar port.
|
|
r = set_calendar_port(new_port)
|
|
assert r.status_code == 200, f"PUT /api/config failed: {r.text}"
|
|
|
|
# 2. Confirm config is saved before apply.
|
|
saved_port = current_calendar_port()
|
|
assert saved_port == new_port, (
|
|
f"calendar.port not saved before apply: got {saved_port}"
|
|
)
|
|
|
|
# 3. Confirm pending is set.
|
|
assert pending_state()["needs_restart"] is True, (
|
|
"needs_restart not set after calendar.port change"
|
|
)
|
|
|
|
# 4. Apply.
|
|
ar = post("/api/config/apply")
|
|
assert ar.status_code == 200, (
|
|
f"POST /api/config/apply returned {ar.status_code}: {ar.text}"
|
|
)
|
|
apply_body = ar.json()
|
|
assert "message" in apply_body, (
|
|
f"Apply response missing 'message': {apply_body}"
|
|
)
|
|
|
|
# 5. Wait for healthy.
|
|
recovered = wait_for_healthy()
|
|
assert recovered, (
|
|
f"API did not return healthy within {_HEALTH_TIMEOUT}s after apply"
|
|
)
|
|
|
|
# 6. Verify the port change persists in the running config.
|
|
post_apply_port = current_calendar_port()
|
|
assert post_apply_port == new_port, (
|
|
f"calendar.port reverted after apply: "
|
|
f"expected {new_port}, got {post_apply_port}"
|
|
)
|
|
|
|
# 7. Confirm that cell_name was not inadvertently cleared.
|
|
post_apply_cfg = get("/api/config").json()
|
|
assert post_apply_cfg["cell_name"] == original_name, (
|
|
f"cell_name changed unexpectedly during apply: "
|
|
f"expected {original_name!r}, got {post_apply_cfg['cell_name']!r}"
|
|
)
|
|
|
|
# 8. Confirm pending is cleared.
|
|
assert pending_state()["needs_restart"] is False, (
|
|
"needs_restart still true after apply + recovery"
|
|
)
|
|
|
|
finally:
|
|
# Restore the original calendar port.
|
|
set_calendar_port(original_port)
|
|
post("/api/config/apply")
|
|
wait_for_healthy(_HEALTH_TIMEOUT)
|
|
delete("/api/config/pending")
|