Files
FabledSteward/tests/plugins/snmp/test_service.py
T
bvandeusenandClaude Opus 5.5 9ca2e38fa1
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 55s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped
feat(snmp): devices you add, test and read on the SNMP page (milestone 491)
The operator, reviewing SNMP on #5214: "this system looks incomplete like
there's something missing from it". The one device shown was plugin.yaml's
example (core-switch 192.168.1.1, community public), polled every minute;
devices could only be defined by editing YAML in the image; a failed poll was
logged at DEBUG and stored nowhere, so the page said "waiting for the next
poll" forever; v3 ran as v2c; counters showed as lifetime totals.

- Devices are rows (snmp_devices, migration snmp_001): address, port,
  v1/v2c community or a v3 user with auth/priv protocols and keys (secrets
  encrypted), presets plus custom readings, an optional host binding, and
  the newest poll's outcome. The YAML device list and its example are gone.
- The SNMP page adds, edits and deletes them (admin, audited, with
  get_audit_entities so the audit log names and links a device). Readings
  come from presets (system uptime, interface traffic by number, UPS-MIB,
  Linux net-snmp) and custom "OID name [unit]" lines.
- One GET per device with every OID, devices polled at once. The outcome is
  written to the row in words ("No reply from … within 5 s. Check the address
  and port, that SNMP is on, and the community"), with readings it lacks.
  v1's whole-request error drops that OID and asks again. SNMPv3 signs in
  with USM (MD5/SHA-1/SHA-2, DES/3DES/AES).
- Test asks the unsaved form's device for its name, description, uptime and
  interfaces by number. It is an admin's explicit action; every page reads
  only stored rows (System 178's ruling).
- Each reading has a unit; a counter is stored as a per-second rate (bytes as
  bit/s), a reset or wrap skipped. The page, widget and host panel say values
  in units and why a device is silent; the device page draws a chart per
  reading on a dated axis.

Tests: readings and presets, the form and its secrets, v3 checks, rates,
the poller against a stand-in pysnmp (one GET, v1 retry, timeout wording,
USM), the pages, and an integration test of the poll writing outcomes and
rates on Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:54:11 -04:00

119 lines
5.7 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_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