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:
parent
0e816fb966
commit
9413797a51
2 changed files with 183 additions and 0 deletions
125
tests/test_metrics_schema.py
Normal file
125
tests/test_metrics_schema.py
Normal 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
58
tests/test_settings.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue