POST /api/v1/settings persisted the new status check interval and echoed it back, but never rescheduled the running job: reschedule_status_checks() is defined in app/core/scheduler.py and has no caller anywhere. The UI and scan_config.json show the new value while the scheduler keeps firing at the old one until the backend restarts. On our install this went unnoticed for three weeks: 3600s was configured but 30s stayed in effect, so ~120 nodes were pinged 120x more often than intended with no visible symptom. Also add ge=10 to interval_seconds, mirroring the floor already enforced by reschedule_status_checks(). Without it a value below 10 is written to scan_config.json first and only then raises, which surfaces as a 500 and leaves the invalid value to be reloaded on the next boot. Both new tests fail without the fix: the first with AttributeError (the route does not import the function), the second with 200 instead of 422. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
258 lines
9.8 KiB
Python
258 lines
9.8 KiB
Python
"""Tests for GET/POST /api/v1/settings."""
|
|
import json
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from app.core.config import Settings
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_settings_requires_auth(client: AsyncClient):
|
|
res = await client.get("/api/v1/settings")
|
|
assert res.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_settings_returns_interval(client: AsyncClient, headers):
|
|
res = await client.get("/api/v1/settings", headers=headers)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert "interval_seconds" in data
|
|
assert isinstance(data["interval_seconds"], int)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_settings_saves_interval(client: AsyncClient, headers):
|
|
with patch("app.api.routes.settings.settings") as mock_settings:
|
|
mock_settings.status_checker_interval = 60
|
|
mock_settings.save_overrides = lambda: None
|
|
res = await client.post(
|
|
"/api/v1/settings",
|
|
json={"interval_seconds": 120},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
assert res.json()["interval_seconds"] == 120
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_settings_reschedules_status_checks(client: AsyncClient, headers):
|
|
"""The new interval must reach the running scheduler, not just the config
|
|
file: otherwise the UI and scan_config.json show the new value while the
|
|
job keeps firing at the old one until the backend restarts."""
|
|
with patch("app.api.routes.settings.settings") as mock_settings, \
|
|
patch("app.api.routes.settings.reschedule_status_checks") as mock_reschedule:
|
|
mock_settings.save_overrides = lambda: None
|
|
res = await client.post(
|
|
"/api/v1/settings",
|
|
json={"interval_seconds": 120},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
mock_reschedule.assert_called_once_with(120)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_settings_rejects_too_short_status_interval(client: AsyncClient, headers):
|
|
res = await client.post(
|
|
"/api/v1/settings",
|
|
json={"interval_seconds": 5},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 422
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_settings_requires_auth(client: AsyncClient):
|
|
res = await client.post("/api/v1/settings", json={"interval_seconds": 30})
|
|
assert res.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_settings_returns_service_check_fields(client: AsyncClient, headers):
|
|
res = await client.get("/api/v1/settings", headers=headers)
|
|
data = res.json()
|
|
assert "service_check_enabled" in data
|
|
assert "service_check_interval" in data
|
|
assert isinstance(data["service_check_enabled"], bool)
|
|
assert isinstance(data["service_check_interval"], int)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_settings_saves_service_check_fields(client: AsyncClient, headers):
|
|
with patch("app.api.routes.settings.settings") as mock_settings:
|
|
mock_settings.save_overrides = lambda: None
|
|
res = await client.post(
|
|
"/api/v1/settings",
|
|
json={
|
|
"interval_seconds": 60,
|
|
"service_check_enabled": True,
|
|
"service_check_interval": 600,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
body = res.json()
|
|
assert body["service_check_enabled"] is True
|
|
assert body["service_check_interval"] == 600
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_settings_rejects_too_short_service_interval(client: AsyncClient, headers):
|
|
res = await client.post(
|
|
"/api/v1/settings",
|
|
json={"interval_seconds": 60, "service_check_enabled": True, "service_check_interval": 5},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 422
|
|
|
|
|
|
def test_proxmox_connection_config_is_env_only_never_from_overrides(tmp_path):
|
|
"""Connection config (host/port/verify_tls) is env-only: a stale value in
|
|
scan_config.json must be ignored so it can never clobber the env. Only the
|
|
auto-sync activation is read back."""
|
|
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
|
s.proxmox_host = "pve.local" # as if set from env
|
|
s.proxmox_port = 8006
|
|
s.proxmox_verify_tls = True
|
|
(tmp_path / "scan_config.json").write_text(json.dumps({
|
|
"proxmox_host": "stale-host",
|
|
"proxmox_port": 9999,
|
|
"proxmox_verify_tls": False,
|
|
"proxmox_sync_enabled": True,
|
|
"proxmox_sync_interval": 600,
|
|
}))
|
|
s.load_overrides()
|
|
# Connection config untouched by the file (env values survive).
|
|
assert s.proxmox_host == "pve.local"
|
|
assert s.proxmox_port == 8006
|
|
assert s.proxmox_verify_tls is True
|
|
# Auto-sync activation is the only thing loaded.
|
|
assert s.proxmox_sync_enabled is True
|
|
assert s.proxmox_sync_interval == 600
|
|
|
|
|
|
def test_save_overrides_omits_proxmox_connection_config(tmp_path):
|
|
"""save_overrides must never write host/port/verify_tls (nor the token) —
|
|
only the sync activation. This is what prevents the dual source of truth."""
|
|
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
|
s.proxmox_host = "pve.local"
|
|
s.proxmox_sync_enabled = True
|
|
s.proxmox_sync_interval = 900
|
|
s.save_overrides()
|
|
written = json.loads((tmp_path / "scan_config.json").read_text())
|
|
assert "proxmox_host" not in written
|
|
assert "proxmox_port" not in written
|
|
assert "proxmox_verify_tls" not in written
|
|
assert "proxmox_token_id" not in written
|
|
assert "proxmox_token_secret" not in written
|
|
assert written["proxmox_sync_enabled"] is True
|
|
assert written["proxmox_sync_interval"] == 900
|
|
|
|
|
|
def test_mesh_connection_config_is_env_only_never_from_overrides(tmp_path):
|
|
"""Zigbee/Z-Wave MQTT connection config (host/port/credentials/topic/tls) is
|
|
env-only: stale values in scan_config.json must be ignored. Only the
|
|
auto-sync activation is read back."""
|
|
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
|
s.zigbee_mqtt_host = "broker.local"
|
|
s.zwave_mqtt_host = "broker.local"
|
|
(tmp_path / "scan_config.json").write_text(json.dumps({
|
|
"zigbee_mqtt_host": "stale", "zigbee_mqtt_password": "stale",
|
|
"zigbee_sync_enabled": True, "zigbee_sync_interval": 600,
|
|
"zwave_mqtt_host": "stale", "zwave_mqtt_password": "stale",
|
|
"zwave_sync_enabled": True, "zwave_sync_interval": 700,
|
|
}))
|
|
s.load_overrides()
|
|
assert s.zigbee_mqtt_host == "broker.local"
|
|
assert s.zwave_mqtt_host == "broker.local"
|
|
assert s.zigbee_sync_enabled is True
|
|
assert s.zigbee_sync_interval == 600
|
|
assert s.zwave_sync_enabled is True
|
|
assert s.zwave_sync_interval == 700
|
|
|
|
|
|
def test_save_overrides_omits_mesh_credentials(tmp_path):
|
|
"""save_overrides must never write MQTT host/credentials — only the sync
|
|
activation. Prevents the dual source of truth and leaking secrets to disk."""
|
|
s = Settings(secret_key="x", sqlite_path=str(tmp_path / "homelab.db"))
|
|
s.zigbee_mqtt_host = "broker.local"
|
|
s.zigbee_mqtt_password = "secret"
|
|
s.zigbee_sync_enabled = True
|
|
s.zigbee_sync_interval = 900
|
|
s.zwave_mqtt_password = "secret"
|
|
s.zwave_sync_interval = 1200
|
|
s.save_overrides()
|
|
raw = (tmp_path / "scan_config.json").read_text()
|
|
assert "secret" not in raw
|
|
written = json.loads(raw)
|
|
assert "zigbee_mqtt_host" not in written
|
|
assert "zigbee_mqtt_password" not in written
|
|
assert "zwave_mqtt_password" not in written
|
|
assert written["zigbee_sync_enabled"] is True
|
|
assert written["zigbee_sync_interval"] == 900
|
|
assert written["zwave_sync_interval"] == 1200
|
|
|
|
|
|
def _valid_oidc_settings(**overrides):
|
|
values = {
|
|
"secret_key": "test-secret-key-that-is-at-least-32-bytes",
|
|
"auth_mode": "oidc",
|
|
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
|
|
"oidc_client_id": "homelable",
|
|
"oidc_client_secret": "client-secret",
|
|
"oidc_redirect_uri": "https://homelable.example/api/v1/auth/oidc/callback",
|
|
"cors_origins": ["https://homelable.example"],
|
|
}
|
|
values.update(overrides)
|
|
return Settings(_env_file=None, **values)
|
|
|
|
|
|
def test_oidc_mode_requires_complete_client_configuration():
|
|
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL.*OIDC_CLIENT_ID.*OIDC_CLIENT_SECRET.*OIDC_REDIRECT_URI"):
|
|
Settings(secret_key="test-secret", auth_mode="oidc", _env_file=None)
|
|
|
|
|
|
def test_oidc_mode_requires_openid_scope():
|
|
with pytest.raises(ValueError, match="OIDC_SCOPES must contain 'openid'"):
|
|
_valid_oidc_settings(oidc_scopes="profile email")
|
|
|
|
|
|
def test_oidc_mode_requires_strong_session_secret():
|
|
with pytest.raises(ValueError, match="SECRET_KEY must be at least 32 bytes"):
|
|
_valid_oidc_settings(secret_key="too-short")
|
|
|
|
|
|
def test_oidc_secure_cookie_requires_https_callback():
|
|
with pytest.raises(ValueError, match="must use HTTPS"):
|
|
_valid_oidc_settings(oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback")
|
|
|
|
|
|
def test_oidc_secure_cookie_requires_https_discovery():
|
|
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL must use HTTPS"):
|
|
_valid_oidc_settings(
|
|
oidc_discovery_url="http://idp.local/application/o/homelable/.well-known/openid-configuration"
|
|
)
|
|
|
|
|
|
def test_oidc_insecure_dev_mode_allows_http_urls():
|
|
configured = _valid_oidc_settings(
|
|
oidc_cookie_secure=False,
|
|
oidc_discovery_url="http://idp.local/.well-known/openid-configuration",
|
|
oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback",
|
|
)
|
|
assert configured.oidc_cookie_secure is False
|
|
|
|
|
|
def test_oidc_mode_rejects_wildcard_cors():
|
|
with pytest.raises(ValueError, match="CORS_ORIGINS cannot contain"):
|
|
_valid_oidc_settings(cors_origins=["*"])
|
|
|
|
|
|
def test_valid_oidc_configuration_is_accepted():
|
|
configured = _valid_oidc_settings()
|
|
assert configured.auth_mode == "oidc"
|
|
assert configured.oidc_cookie_secure is True
|