Files
pic/tests/integration/test_apply_propagation.py
roof fc3cfc9741 Fix post-deploy auth issues: best-effort service provisioning, integration test auth, test mock corrections
- 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>
2026-04-25 15:42:03 -04:00

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")