test: cover Settings validation and Metrics schema

34 new tests (89 total, still ~0.1s).

test_settings.py — exercises BackendSettings directly with _env_file=None
so the developer's local .env does not leak in:
  - default port ranges and invariants,
  - non-integer / out-of-range port rejection,
  - cpu_alert_th out-of-range rejection,
  - env override roundtrip,
  - extra="ignore" tolerates typos (regression: an unknown env var
    should not crash startup).

test_metrics_schema.py — black-box tests of parse_metrics() with each
case named after the attack it guards against:
  - happy path with full and partial (optional fields) payloads,
  - every required field individually missing,
  - every percentage field individually out of [0, 100],
  - extra="forbid" rejects smuggled keys (e.g. {"injected": "<!channel>"}),
  - unsafe device_id patterns (slashes, newlines, path traversal,
    65-char overflow, empty string),
  - invalid raw JSON,
  - NaN / Infinity / -Infinity which json.loads accepts but the
    schema (Field + the finite-value validator) rejects.
This commit is contained in:
authentik Default Admin 2026-05-17 17:48:45 +00:00
parent 0e816fb966
commit 9413797a51
2 changed files with 183 additions and 0 deletions

View file

@ -0,0 +1,125 @@
"""Tests for the Pydantic Metrics schema and parse_metrics() helper.
Each case names the attack/mistake it guards against. parse_metrics
returns None on rejection so the on_message callback can short-circuit
without try/except gymnastics.
"""
import json
import pytest
from backend import Metrics, parse_metrics
def _valid_payload(**overrides):
base = {
"device_id": "edge-01",
"timestamp": 1_700_000_000,
"cpu_percent": 42.0,
"mem_percent": 30.0,
"disk_percent": 20.0,
"gpu_percent": 10.0,
"agent_cpu_percent": 1.5,
"agent_mem_mb": 32.0,
}
base.update(overrides)
return base
# ----- happy path ------------------------------------------------------------
def test_accepts_full_well_formed_payload():
m = Metrics(**_valid_payload())
assert m.device_id == "edge-01"
assert m.cpu_percent == 42.0
def test_optional_fields_default_to_none():
payload = _valid_payload()
for k in ("gpu_percent", "agent_cpu_percent", "agent_mem_mb"):
payload.pop(k)
m = Metrics(**payload)
assert m.gpu_percent is None
assert m.agent_cpu_percent is None
assert m.agent_mem_mb is None
# ----- schema rejection ------------------------------------------------------
@pytest.mark.parametrize("field", ["device_id", "timestamp", "cpu_percent",
"mem_percent", "disk_percent"])
def test_rejects_missing_required_field(field):
payload = _valid_payload()
payload.pop(field)
raw = json.dumps(payload)
assert parse_metrics(raw) is None
@pytest.mark.parametrize("field,bad", [
("cpu_percent", 150),
("cpu_percent", -1),
("mem_percent", 101.5),
("disk_percent", -0.0001),
("gpu_percent", 200),
])
def test_rejects_percentage_out_of_range(field, bad):
raw = json.dumps(_valid_payload(**{field: bad}))
assert parse_metrics(raw) is None
def test_rejects_extra_fields():
# extra="forbid" prevents a malicious publisher from smuggling
# additional keys into device_state / Slack summary.
raw = json.dumps(_valid_payload(injected="<!channel>"))
assert parse_metrics(raw) is None
@pytest.mark.parametrize("bad_id", [
"device with space",
"device/01",
"../../etc/passwd",
"edge\n<!channel>",
"x" * 65,
"",
])
def test_rejects_unsafe_device_id(bad_id):
raw = json.dumps(_valid_payload(device_id=bad_id))
assert parse_metrics(raw) is None
def test_rejects_negative_timestamp():
raw = json.dumps(_valid_payload(timestamp=-1))
assert parse_metrics(raw) is None
# ----- raw JSON handling -----------------------------------------------------
def test_rejects_invalid_json():
assert parse_metrics("not really json") is None
assert parse_metrics("") is None
assert parse_metrics("{") is None
def test_rejects_nan_inf_via_raw_json():
# The json module accepts NaN/Infinity by default. Field(ge/le) blocks
# inf; the @field_validator blocks NaN.
assert parse_metrics('{"device_id":"a","timestamp":1,"cpu_percent":NaN,'
'"mem_percent":0,"disk_percent":0}') is None
assert parse_metrics('{"device_id":"a","timestamp":1,"cpu_percent":Infinity,'
'"mem_percent":0,"disk_percent":0}') is None
assert parse_metrics('{"device_id":"a","timestamp":1,"cpu_percent":-Infinity,'
'"mem_percent":0,"disk_percent":0}') is None
def test_returns_typed_metrics_instance():
raw = json.dumps(_valid_payload())
m = parse_metrics(raw)
assert isinstance(m, Metrics)
# confirm round-trip via model_dump preserves the payload shape
dumped = m.model_dump()
assert dumped["device_id"] == "edge-01"
assert dumped["cpu_percent"] == 42.0

58
tests/test_settings.py Normal file
View file

@ -0,0 +1,58 @@
"""Tests for typed configuration via pydantic-settings.
These tests exercise BackendSettings directly (not the module-level
``settings`` singleton) so they remain hermetic and do not depend on
the developer's environment.
"""
import pytest
from pydantic import ValidationError
from backend import BackendSettings
def test_defaults_are_sane():
s = BackendSettings(_env_file=None)
assert s.mqtt_port == 8883
assert s.ws_port == 6789
assert 0 <= s.cpu_alert_th <= 100
assert s.alert_cooldown >= 0
assert s.max_devices >= 1
assert s.mqtt_backoff_min <= s.mqtt_backoff_max
def test_rejects_non_integer_port(monkeypatch):
monkeypatch.setenv("WS_PORT", "not-a-number")
with pytest.raises(ValidationError):
BackendSettings(_env_file=None)
@pytest.mark.parametrize("port", [0, -1, 65536, 999999])
def test_rejects_port_out_of_range(monkeypatch, port):
monkeypatch.setenv("WS_PORT", str(port))
with pytest.raises(ValidationError):
BackendSettings(_env_file=None)
@pytest.mark.parametrize("th", [-1, 101, 200])
def test_rejects_cpu_threshold_out_of_range(monkeypatch, th):
monkeypatch.setenv("CPU_ALERT_TH", str(th))
with pytest.raises(ValidationError):
BackendSettings(_env_file=None)
def test_env_override(monkeypatch):
monkeypatch.setenv("MAX_DEVICES", "42")
monkeypatch.setenv("ALERT_COOLDOWN", "120")
s = BackendSettings(_env_file=None)
assert s.max_devices == 42
assert s.alert_cooldown == 120
def test_unknown_envvars_are_ignored(monkeypatch):
# extra="ignore" — typos like WS_HOSST should not break startup, only
# silently miss. (Catching typos is a job for a config schema doc,
# not the runtime; we just want to avoid hard crashes.)
monkeypatch.setenv("WS_HOSST", "0.0.0.0")
s = BackendSettings(_env_file=None)
assert s.ws_host == "0.0.0.0" # default still applies