Files
FabledSteward/tests/plugins/snmp/test_mcp_tools.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

50 lines
2.2 KiB
Python

"""The snmp plugin's MCP tools (#5840): which devices the overview names, what
a device row carries (never its community or v3 keys), and the tools it
registers."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace as NS
import pytest
import plugins.snmp as snmp_plugin
from plugins.snmp import mcp_tools as t
from steward.models.users import UserRole
NOW = datetime(2026, 10, 15, 12, 0, tzinfo=timezone.utc)
def _device(name, *, polled=timedelta(seconds=30), error=None):
return NS(id=f"id-{name}", name=name, address="10.0.0.9", port=161, version="2c",
community="s3cret", v3_user="", v3_auth_key="authkey", v3_priv_key="privkey",
presets=[], interfaces="", custom=None, host_id=None,
last_polled_at=NOW - polled if polled else None, last_ok_at=NOW - timedelta(hours=1),
last_error=error, missing=[], tables={}, texts={})
def test_only_a_device_whose_poll_failed_is_an_overview_finding():
views = [t._device_view(d, {}, NOW, 60) | {"_ok_at": d.last_ok_at} for d in (
_device("ups", error="Timed out"), _device("switch"),
_device("printer", polled=timedelta(days=1), error="Timed out"), _device("nas", polled=None))]
[f] = t.device_findings(views)
assert f["kind"] == "snmp_device_down" and f["subject"] == "ups" and "Timed out" in f["summary"]
assert f["next"] == 'get_snmp_device(device="ups")'
def test_a_device_row_never_carries_its_secrets():
row = t._device_view(_device("ups"), {}, NOW, 60)
text = repr(row)
assert "s3cret" not in text and "authkey" not in text and "privkey" not in text
assert row["reach"] == "up" and row["device_id"] == "id-ups"
def test_the_plugin_registers_its_tools_for_viewers():
got = {}
snmp_plugin.register_mcp_tools(lambda fn, role, read_only: got.update({fn.__name__: (role, read_only)}))
assert got == {name: (UserRole.viewer, True) for name in ("list_snmp_devices", "get_snmp_device")}
assert callable(snmp_plugin.mcp_overview)
async def test_list_snmp_devices_refuses_an_unknown_reach():
with pytest.raises(ValueError, match="reach must be one of"):
await t.list_snmp_devices(reach="broken")