diff --git a/hyperglass/models/tests/test_webhook.py b/hyperglass/models/tests/test_webhook.py new file mode 100644 index 00000000..adf33a4d --- /dev/null +++ b/hyperglass/models/tests/test_webhook.py @@ -0,0 +1,63 @@ +"""Regression tests for the Webhook model validator. + +Tracks https://github.com/thatmattlove/hyperglass/issues/282 — Slack / MS Teams +/ generic HTTP logging silently failed because the `mode="before"` validator +treated its raw-dict input as a model instance. +""" + +# Standard Library +from datetime import datetime, timezone + +# Project +from hyperglass.models.webhook import Webhook + + +def _query(source: str, network: dict | None = None) -> dict: + return { + "query_location": "test", + "query_type": "bgp_route", + "query_target": "192.0.2.0/24", + "headers": {}, + "source": source, + "network": network if network is not None else {"prefix": "192.0.2.0/24", "asn": "65000"}, + "timestamp": datetime.now(timezone.utc), + } + + +def test_webhook_constructs_with_public_source(): + """The validator must not raise on a normal public-IP source.""" + hook = Webhook(**_query(source="203.0.113.7")) + assert hook.source == "203.0.113.7" + assert hook.network.prefix == "192.0.2.0/24" + assert hook.network.asn == "65000" + + +def test_webhook_resets_network_for_localhost_v4(): + """A 127.0.0.1 source should clear the network info to defaults.""" + hook = Webhook(**_query(source="127.0.0.1")) + assert hook.source == "127.0.0.1" + # WebhookNetwork defaults all fields to "Unknown" when given an empty dict. + assert hook.network.prefix == "Unknown" + assert hook.network.asn == "Unknown" + + +def test_webhook_resets_network_for_localhost_v6(): + """`::1` should also be treated as localhost.""" + hook = Webhook(**_query(source="::1")) + assert hook.network.prefix == "Unknown" + assert hook.network.asn == "Unknown" + + +def test_webhook_slack_payload_renders(): + """End-to-end: the Slack payload must build without raising.""" + hook = Webhook(**_query(source="203.0.113.7")) + payload = hook.slack() + assert payload["text"] + assert any("203.0.113.7" in str(block) for block in payload["blocks"]) + + +def test_webhook_msteams_payload_renders(): + """End-to-end: the MS Teams payload must build without raising.""" + hook = Webhook(**_query(source="203.0.113.7")) + payload = hook.msteams() + assert payload["@type"] == "MessageCard" diff --git a/hyperglass/models/webhook.py b/hyperglass/models/webhook.py index 2c831935..ad9385b1 100644 --- a/hyperglass/models/webhook.py +++ b/hyperglass/models/webhook.py @@ -58,11 +58,20 @@ class Webhook(HyperglassModel): timestamp: datetime @model_validator(mode="before") - def validate_webhook(cls, model: "Webhook") -> "Webhook": - """Reset network attributes if the source is localhost.""" - if model.source in ("127.0.0.1", "::1"): - model.network = {} - return model + @classmethod + def validate_webhook(cls, data: t.Any) -> t.Any: + """Reset network attributes if the source is localhost. + + `mode="before"` runs prior to model construction, so the input is the + raw mapping passed to `Webhook(**query)` (a dict). The previous + implementation accessed it as if it were a model instance + (`model.source`, `model.network = {}`), which raised + `AttributeError: 'dict' object has no attribute 'source'` and + silently broke Slack/MS Teams/generic webhook delivery (#282). + """ + if isinstance(data, dict) and data.get("source") in ("127.0.0.1", "::1"): + data["network"] = {} + return data def msteams(self) -> t.Dict[str, t.Any]: """Format the webhook data as a Microsoft Teams card."""