Files
FabledSteward/tests/plugins/snmp/test_service.py
T
bvandeusenandClaude Opus 5.5 d065cafd11
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m38s
CI / integration (push) Successful in 1m41s
CI / publish (push) Successful in 1m31s
feat(snmp): NAS presets for Synology and Asustor, reading every disk and volume (#5985, #5986)
A preset can now read a vendor's tables: one row per disk, volume, fan or
sensor. The scheduled poll discovers the rows by walking the device: on its
first poll, after a save, and then hourly, and only while it answers. Each row
becomes readings named by the device's own labels ("Disk 1 status"). Only the
columns a row answered are asked for.

A vendor status, whether a code (Synology) or a word (Asustor), is a state
reading. It is stored as a level (0 OK, 1 busy, 2 warning, 3 failed), so one
alert rule covers any vendor, and shown in the device's own word.

- Synology: system, power and fan status and temperature; per disk: status,
  health, temperature and bad sectors; per volume: RAID state (scrubbing,
  parity check, rebuild, degraded), free space and size.
- Asustor: per disk: status, SMART and temperature; per volume: status, free
  space and size; CPU and system temperature, free memory and fans.
  From ADM's ASUSTOR-DISK-MIB and ASUSTOR-SYSTEM-MIB.
- Linux server: plus each physical disk's 1-minute busy % (UCD-DISKIO-MIB).

Migration snmp_002_table_rows adds tables, tables_at and texts to
snmp_devices. Milestone 530.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 22:40:14 -04:00

134 lines
6.4 KiB
Python

"""The SNMP device form, the row it becomes, and what a page says about the
newest poll (milestone 491)."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
import pytest
from werkzeug.datastructures import MultiDict
from plugins.snmp.readings import Reading
from plugins.snmp.service import (FormError, RateKeeper, credentials, device_state, form_credentials,
form_values, parse_form, readings_for)
from steward.core.crypto import decrypt_secret, init_crypto, is_encrypted
def setup_function():
init_crypto("unit-test-secret-key")
def _form(**fields):
presets = fields.pop("presets", ["system"])
md = MultiDict({"name": "core-switch", "address": "192.168.1.2", "port": "161", "version": "2c", **fields})
for p in presets:
md.add("presets", p)
return md
def test_a_device_is_stored_with_its_community_encrypted():
values = parse_form(_form(community="s3cret", interfaces="1-2", presets=["system", "interfaces"]))
assert values["name"] == "core-switch" and values["port"] == 161 and values["version"] == "2c"
assert values["presets"] == ["system", "interfaces"] and values["interfaces"] == "1-2"
assert is_encrypted(values["community"]) and decrypt_secret(values["community"]) == "s3cret"
assert values["v3_user"] == "" and values["host_id"] is None
def test_a_blank_community_is_public_on_a_new_device_and_kept_on_an_old_one():
assert decrypt_secret(parse_form(_form())["community"]) == "public"
existing = NS(community="enc:kept", v3_auth_key="", v3_priv_key="")
assert parse_form(_form(), existing)["community"] == "enc:kept"
@pytest.mark.parametrize("fields, says", [
({"name": ""}, "name"),
({"address": ""}, "address"),
({"port": "99999"}, "port"),
({"version": "4"}, "version"),
({"presets": []}, "at least one"),
({"presets": ["interfaces"]}, "interface numbers"),
({"custom": "1.3.6 Bad-Name"}, "Line 1"),
({"version": "3"}, "user name"),
({"version": "3", "v3_user": "mon", "v3_auth": "none", "v3_priv": "aes128"}, "Privacy needs authentication"),
({"version": "3", "v3_user": "mon", "v3_auth": "sha", "v3_priv": "none"}, "needs its key"),
({"version": "3", "v3_user": "mon", "v3_auth": "sha", "v3_auth_key": "short", "v3_priv": "none"},
"at least 8"),
])
def test_a_form_that_cannot_be_saved_says_what_to_fix(fields, says):
with pytest.raises(FormError, match=says):
parse_form(_form(**fields))
def test_a_v3_device_keeps_its_user_and_encrypted_keys_and_no_community():
values = parse_form(_form(version="3", v3_user="mon", v3_auth="sha256", v3_auth_key="authpass1",
v3_priv="aes128", v3_priv_key="privpass1"))
assert values["community"] == "" and values["v3_user"] == "mon"
assert (values["v3_auth"], values["v3_priv"]) == ("sha256", "aes128")
assert decrypt_secret(values["v3_auth_key"]) == "authpass1" and decrypt_secret(values["v3_priv_key"]) == "privpass1"
row = NS(name="x", **{k: values[k] for k in ("version", "community", "v3_user", "v3_auth",
"v3_auth_key", "v3_priv", "v3_priv_key")})
creds = credentials(row)
assert (creds.user, creds.auth_key, creds.priv_key) == ("mon", "authpass1", "privpass1")
def test_the_edit_form_never_carries_a_secret_back():
device = NS(name="ups", address="10.0.0.9", port=161, version="2c", v3_user="", v3_auth="none",
v3_priv="none", presets=["ups"], interfaces="", custom="", host_id=None,
community="enc:x", v3_auth_key="", v3_priv_key="")
v = form_values(device)
assert v["has_community"] and not v["has_auth_key"]
assert "enc:x" not in str(v)
def test_test_uses_what_is_typed_and_falls_back_to_the_stored_secret():
from steward.core.crypto import encrypt_secret
existing = NS(community=encrypt_secret("stored"), v3_auth_key="", v3_priv_key="")
address, port, creds = form_credentials(_form(port="1161"), existing)
assert (address, port, creds.community) == ("192.168.1.2", 1161, "stored")
assert form_credentials(_form(community="typed"), existing)[2].community == "typed"
assert form_credentials(_form(), None)[2].community == "public"
def test_a_nas_preset_alone_is_a_choice_though_its_rows_come_with_the_first_poll():
assert parse_form(_form(presets=["asustor"]))["presets"] == ["asustor"]
with pytest.raises(FormError, match="Choose at least one preset"):
parse_form(_form(presets=[]))
def test_a_device_is_asked_for_its_stored_rows_or_the_ones_just_found():
row = NS(presets=["synology"], interfaces="", custom="", version="2c",
tables={"synology_volumes": [{"index": "0", "label": "Volume 1", "columns": ["status"]}]})
assert "raid0_status" in [r.key for r in readings_for(row)]
found = {"synology_disks": [{"index": "0", "label": "Disk 1", "columns": ["temp"]}]}
keys = [r.key for r in readings_for(row, found)]
assert "disk0_temp" in keys and "raid0_status" not in keys
def test_a_row_that_no_longer_parses_is_asked_for_nothing():
row = NS(presets=["system"], interfaces="eth0", custom="", version="2c")
assert readings_for(row) == []
def _row(**kw):
return NS(**{"last_polled_at": None, "last_error": None, **kw})
def test_the_newest_poll_decides_what_the_page_says():
now = datetime(2026, 10, 8, 15, 0, tzinfo=timezone.utc)
recent = now - timedelta(seconds=30)
assert device_state(_row(), 60, now).reach == "pending"
assert device_state(_row(last_polled_at=recent), 60, now).reach == "up"
down = device_state(_row(last_polled_at=recent, last_error="No reply from x"), 60, now)
assert (down.reach, down.says) == ("down", "No reply from x")
assert device_state(_row(last_polled_at=now - timedelta(minutes=5)), 60, now).reach == "stale"
def test_a_counter_becomes_a_rate_and_a_reset_is_skipped():
keeper = RateKeeper()
rx = Reading("1.3", "if1_in", "Interface 1 in", "octets")
t0 = datetime(2026, 10, 8, 15, 0, tzinfo=timezone.utc)
assert keeper.rate("d1", rx, 1_000, t0) is None # nothing to compare yet
assert keeper.rate("d1", rx, 7_000, t0 + timedelta(seconds=60)) == 800.0 # 100 B/s as bit/s
assert keeper.rate("d1", rx, 50, t0 + timedelta(seconds=120)) is None # rebooted or wrapped
assert keeper.rate("d1", rx, 650, t0 + timedelta(seconds=180)) == 80.0
keeper.forget("d1")
assert keeper.rate("d1", rx, 9_999, t0 + timedelta(seconds=240)) is None