From 1083cd6f5837650b3c38e5a34b0bac192a141131 Mon Sep 17 00:00:00 2001 From: Brendan Date: Sat, 25 Apr 2026 19:37:48 +1000 Subject: [PATCH] fix(webhook): handle raw dict input in mode='before' validator (#282) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Webhook.validate_webhook` is decorated with `@model_validator(mode="before")`, which means it runs prior to model construction and receives the raw input dict from `Webhook(**query)`. The implementation accessed it as if it were a model instance — `model.source` and `model.network = {}` — which raised `AttributeError: 'dict' object has no attribute 'source'` and silently broke all HTTP webhook delivery (Slack, MS Teams, generic). Treat the input as a dict, guard with isinstance, and use `.get()` so an absent `source` key doesn't raise either. Source defaults to `"Unknown"` on the model anyway, so a missing key correctly falls through to the model default. Refs: https://github.com/thatmattlove/hyperglass/issues/282 Co-Authored-By: Claude Opus 4.7 (1M context) --- hyperglass/models/tests/test_webhook.py | 63 +++++++++++++++++++++++++ hyperglass/models/webhook.py | 19 ++++++-- 2 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 hyperglass/models/tests/test_webhook.py 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."""