Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions hyperglass/models/tests/test_webhook.py
Original file line number Diff line number Diff line change
@@ -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"
19 changes: 14 additions & 5 deletions hyperglass/models/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Apr 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@model_validator methods elsewhere in this codebase are defined as plain methods (no @classmethod). Adding @classmethod here is inconsistent and can be brittle depending on how Pydantic wraps validators; consider dropping @classmethod and keeping the same (cls, data) signature so it matches the established pattern (e.g., DnsOverHttps.validate_dns, FRRPath.validate_path).

Suggested change
@classmethod

Copilot uses AI. Check for mistakes.
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."""
Expand Down