fix: expand bgp pipeline and stabilize backend tests
This commit is contained in:
@@ -1,10 +1,72 @@
|
||||
"""Tests for BGP observability helpers."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from app.api.v1.bgp import BGP_SOURCES
|
||||
from app.core.security import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
from app.services.bgp_detectors import detect_mass_withdrawal_anomalies
|
||||
from app.services.collectors.bgp_common import (
|
||||
create_bgp_anomalies_for_batch,
|
||||
save_bgp_observations_for_batch,
|
||||
)
|
||||
from app.services.bgp_enrichment import enrich_bgp_events_for_batch, extract_bgp_network_fields
|
||||
from app.services.bgp_incidents import create_bgp_incidents_for_anomalies
|
||||
from app.models.bgp_anomaly import BGPAnomaly
|
||||
from app.models.collected_data import CollectedData
|
||||
from app.models.bgp_incident import BGPIncident
|
||||
from app.models.bgp_observation import BGPObservation
|
||||
from app.models.user import User
|
||||
from app.services.collectors.bgp_common import normalize_bgp_event
|
||||
from app.services.collectors.bgpstream import BGPStreamBackfillCollector
|
||||
|
||||
|
||||
class _FakeScalarResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def all(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeResult:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
return _FakeScalarResult(self._rows)
|
||||
|
||||
def fetchall(self):
|
||||
return self._rows
|
||||
|
||||
|
||||
class _FakeAsyncSession:
|
||||
def __init__(self, results, gets=None):
|
||||
self._results = list(results)
|
||||
self._gets = gets or {}
|
||||
self.added = []
|
||||
self.commits = 0
|
||||
|
||||
async def execute(self, _stmt):
|
||||
if not self._results:
|
||||
return _FakeResult([])
|
||||
return _FakeResult(self._results.pop(0))
|
||||
|
||||
async def get(self, model, item_id):
|
||||
return self._gets.get((model, item_id))
|
||||
|
||||
def add(self, item):
|
||||
self.added.append(item)
|
||||
|
||||
async def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
|
||||
def test_normalize_bgp_event_from_live_payload():
|
||||
event = normalize_bgp_event(
|
||||
{
|
||||
@@ -30,6 +92,25 @@ def test_normalize_bgp_event_from_live_payload():
|
||||
assert event["metadata"]["is_more_specific"] is False
|
||||
|
||||
|
||||
def test_normalize_bgp_event_uses_peer_and_community_fallbacks():
|
||||
event = normalize_bgp_event(
|
||||
{
|
||||
"collector": "rrc00",
|
||||
"peer_asn": "3333",
|
||||
"peer": "2405:a640::50",
|
||||
"type": "UPDATE",
|
||||
"prefix": "2401:2260::/32",
|
||||
"path": [3333, 15412, 9304, 151650],
|
||||
"community": [[15412, 603], [3333, 100]],
|
||||
"timestamp": "2026-03-27T06:07:18.470000+00:00",
|
||||
},
|
||||
project="ris-live",
|
||||
)
|
||||
|
||||
assert event["metadata"]["peer_ip"] == "2405:a640::50"
|
||||
assert event["metadata"]["communities"] == [[15412, 603], [3333, 100]]
|
||||
|
||||
|
||||
def test_bgpstream_transform_preserves_broker_record():
|
||||
collector = BGPStreamBackfillCollector()
|
||||
transformed = collector.transform(
|
||||
@@ -72,3 +153,413 @@ def test_bgp_anomaly_to_dict():
|
||||
assert data["anomaly_type"] == "origin_change"
|
||||
assert data["new_origin_asn"] == 64497
|
||||
assert data["evidence"]["previous_origins"] == [64496]
|
||||
|
||||
|
||||
def test_bgp_observation_to_dict():
|
||||
observation = BGPObservation(
|
||||
source="ris_live_bgp",
|
||||
ingest_batch_id="ris_live_bgp:1:1",
|
||||
source_event_id="evt-1",
|
||||
collector="rrc00",
|
||||
peer_asn=3333,
|
||||
peer_ip="2001:db8::1",
|
||||
prefix="203.0.113.0/24",
|
||||
event_type="announcement",
|
||||
as_path=[3333, 64500, 64496],
|
||||
origin_asn=64496,
|
||||
next_hop="2001:db8::2",
|
||||
communities=["3333:100"],
|
||||
collector_geo={"city": "Amsterdam", "country": "Netherlands"},
|
||||
raw_payload={"raw": "deadbeef"},
|
||||
)
|
||||
|
||||
data = observation.to_dict()
|
||||
assert data["source"] == "ris_live_bgp"
|
||||
assert data["collector"] == "rrc00"
|
||||
assert data["event_type"] == "announcement"
|
||||
assert data["as_path"] == [3333, 64500, 64496]
|
||||
assert data["collector_geo"]["city"] == "Amsterdam"
|
||||
|
||||
|
||||
def test_extract_bgp_network_fields():
|
||||
ipv4 = extract_bgp_network_fields("203.0.113.0/24")
|
||||
assert ipv4["prefix_family"] == "ipv4"
|
||||
assert ipv4["prefix_length"] == 24
|
||||
assert ipv4["prefix_supernet"] == "203.0.0.0/16"
|
||||
assert ipv4["is_more_specific"] is False
|
||||
|
||||
ipv6 = extract_bgp_network_fields("2001:db8:1::/48")
|
||||
assert ipv6["prefix_family"] == "ipv6"
|
||||
assert ipv6["prefix_length"] == 48
|
||||
assert ipv6["prefix_supernet"] == "2001:db8::/32"
|
||||
assert ipv6["is_more_specific"] is False
|
||||
|
||||
|
||||
def test_detect_mass_withdrawal_anomalies():
|
||||
events = [
|
||||
{
|
||||
"metadata": {
|
||||
"prefix": "203.0.113.0/24",
|
||||
"origin_asn": 64496,
|
||||
"event_type": "withdrawal",
|
||||
}
|
||||
}
|
||||
for _ in range(3)
|
||||
]
|
||||
|
||||
anomalies = detect_mass_withdrawal_anomalies(
|
||||
source="ris_live_bgp",
|
||||
snapshot_id=1,
|
||||
task_id=2,
|
||||
events=events,
|
||||
)
|
||||
|
||||
assert len(anomalies) == 1
|
||||
assert anomalies[0].anomaly_type == "mass_withdrawal"
|
||||
assert anomalies[0].prefix == "203.0.113.0/24"
|
||||
|
||||
|
||||
def test_bgp_incident_to_dict():
|
||||
incident = BGPIncident(
|
||||
source="ris_live_bgp",
|
||||
incident_key="origin_change:203.0.113.0/24:64497",
|
||||
incident_type="origin_change",
|
||||
title="Origin Change incident on 203.0.113.0/24",
|
||||
summary="Grouped incident summary",
|
||||
severity="critical",
|
||||
status="active",
|
||||
confidence=0.91,
|
||||
affected_prefixes=["203.0.113.0/24"],
|
||||
affected_asns=[64496, 64497],
|
||||
affected_collectors=["rrc00", "rrc01"],
|
||||
affected_regions=[{"country": "Netherlands", "city": "Amsterdam"}],
|
||||
evidence_refs=["origin_change:203.0.113.0/24:64497"],
|
||||
)
|
||||
|
||||
data = incident.to_dict()
|
||||
assert data["incident_type"] == "origin_change"
|
||||
assert data["affected_prefixes"] == ["203.0.113.0/24"]
|
||||
assert data["affected_collectors"] == ["rrc00", "rrc01"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enrich_bgp_events_for_batch_adds_profiles_and_prefix_scope():
|
||||
historical_observation = BGPObservation(
|
||||
source="ris_live_bgp",
|
||||
collector="rrc01",
|
||||
prefix="203.0.113.0/24",
|
||||
origin_asn=64496,
|
||||
observed_at=datetime(2026, 3, 28, 0, 0, tzinfo=UTC),
|
||||
collector_geo={
|
||||
"country": "United Kingdom",
|
||||
"city": "London",
|
||||
"latitude": 51.5072,
|
||||
"longitude": -0.1276,
|
||||
},
|
||||
event_type="announcement",
|
||||
)
|
||||
peeringdb_record = CollectedData(
|
||||
source="peeringdb_network",
|
||||
name="ExampleNet",
|
||||
extra_data={
|
||||
"asn": 64497,
|
||||
"country": "NL",
|
||||
"city": "Amsterdam",
|
||||
"info_type": "Content",
|
||||
"ix_count": 3,
|
||||
"url": "https://example.net",
|
||||
},
|
||||
)
|
||||
peeringdb_record.id = 99
|
||||
|
||||
db = _FakeAsyncSession([[historical_observation], [peeringdb_record]])
|
||||
events = [
|
||||
{
|
||||
"metadata": {
|
||||
"prefix": "203.0.113.0/24",
|
||||
"origin_asn": 64497,
|
||||
"new_origin_asn": None,
|
||||
"collector": "rrc00",
|
||||
"collector_location": {
|
||||
"country": "Netherlands",
|
||||
"city": "Amsterdam",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
},
|
||||
"as_path": [3333, 64497, 64497],
|
||||
"timestamp": "2026-03-30T10:00:00Z",
|
||||
},
|
||||
"reference_date": "2026-03-30T10:00:00Z",
|
||||
}
|
||||
]
|
||||
|
||||
enriched = await enrich_bgp_events_for_batch(db, source="ris_live_bgp", events=events)
|
||||
|
||||
enrichment = enriched[0]["metadata"]["enrichment"]
|
||||
assert enrichment["path_prepending"] is True
|
||||
assert enrichment["is_new_origin_for_prefix"] is True
|
||||
assert enrichment["rpki_validation"]["status"] == "unknown"
|
||||
assert enrichment["origin_asn_profile"]["name"] == "ExampleNet"
|
||||
assert enrichment["prefix_scope"]["countries"] == ["Netherlands", "United Kingdom"]
|
||||
assert enrichment["prefix_scope"]["cities"] == ["Amsterdam", "London"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_bgp_incidents_for_anomalies_aggregates_regions_and_collectors():
|
||||
db = _FakeAsyncSession([[]])
|
||||
anomaly = BGPAnomaly(
|
||||
source="ris_live_bgp",
|
||||
anomaly_type="origin_change",
|
||||
severity="critical",
|
||||
status="active",
|
||||
entity_key="origin_change:203.0.113.0/24:64497",
|
||||
prefix="203.0.113.0/24",
|
||||
origin_asn=64496,
|
||||
new_origin_asn=64497,
|
||||
summary="Origin ASN changed",
|
||||
confidence=0.9,
|
||||
evidence={
|
||||
"impacted_regions": [
|
||||
{
|
||||
"collector": "rrc00",
|
||||
"country": "Netherlands",
|
||||
"city": "Amsterdam",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
created = await create_bgp_incidents_for_anomalies(
|
||||
db,
|
||||
source="ris_live_bgp",
|
||||
snapshot_id=1,
|
||||
task_id=2,
|
||||
anomalies=[anomaly],
|
||||
)
|
||||
|
||||
assert created == 1
|
||||
assert db.commits == 1
|
||||
assert len(db.added) == 1
|
||||
incident = db.added[0]
|
||||
assert incident.incident_type == "origin_change"
|
||||
assert incident.affected_collectors == ["rrc00"]
|
||||
assert incident.affected_regions[0]["city"] == "Amsterdam"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_bgp_observations_for_batch_adds_rows():
|
||||
db = _FakeAsyncSession([])
|
||||
events = [
|
||||
{
|
||||
"source_id": "evt-1",
|
||||
"description": "rrc00 observed announcement for 203.0.113.0/24",
|
||||
"reference_date": "2026-03-30T10:00:00Z",
|
||||
"metadata": {
|
||||
"collector": "rrc00",
|
||||
"peer_asn": 3333,
|
||||
"peer_ip": "2001:db8::1",
|
||||
"prefix": "203.0.113.0/24",
|
||||
"event_type": "announcement",
|
||||
"as_path": [3333, 64500, 64496],
|
||||
"origin_asn": 64496,
|
||||
"next_hop": "2001:db8::2",
|
||||
"communities": ["3333:100"],
|
||||
"timestamp": "2026-03-30T10:00:00Z",
|
||||
"collector_location": {"city": "Amsterdam", "country": "Netherlands"},
|
||||
"raw_message": {"raw": "deadbeef"},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
created = await save_bgp_observations_for_batch(
|
||||
db,
|
||||
source="ris_live_bgp",
|
||||
snapshot_id=1,
|
||||
task_id=2,
|
||||
events=events,
|
||||
)
|
||||
|
||||
assert created == 1
|
||||
assert db.commits == 1
|
||||
assert len(db.added) == 1
|
||||
observation = db.added[0]
|
||||
assert observation.ingest_batch_id == "ris_live_bgp:2:1"
|
||||
assert observation.collector == "rrc00"
|
||||
assert observation.prefix == "203.0.113.0/24"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_bgp_anomalies_for_batch_calls_incident_aggregation():
|
||||
previous_record = CollectedData(
|
||||
source="ris_live_bgp",
|
||||
extra_data={"prefix": "203.0.113.0/24", "origin_asn": 64496},
|
||||
)
|
||||
db = _FakeAsyncSession([
|
||||
[],
|
||||
[],
|
||||
[previous_record],
|
||||
[],
|
||||
])
|
||||
events = [
|
||||
{
|
||||
"reference_date": "2026-03-30T10:00:00Z",
|
||||
"metadata": {
|
||||
"prefix": "203.0.113.0/24",
|
||||
"origin_asn": 64497,
|
||||
"collector": "rrc00",
|
||||
"collector_location": {
|
||||
"country": "Netherlands",
|
||||
"city": "Amsterdam",
|
||||
"latitude": 52.3676,
|
||||
"longitude": 4.9041,
|
||||
},
|
||||
"as_path": [3333, 64497],
|
||||
"event_type": "announcement",
|
||||
"timestamp": "2026-03-30T10:00:00Z",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
with patch(
|
||||
"app.services.collectors.bgp_common.create_bgp_incidents_for_anomalies",
|
||||
new=AsyncMock(return_value=1),
|
||||
) as incident_mock:
|
||||
created = await create_bgp_anomalies_for_batch(
|
||||
db,
|
||||
source="ris_live_bgp",
|
||||
snapshot_id=1,
|
||||
task_id=2,
|
||||
events=events,
|
||||
)
|
||||
|
||||
assert created == 1
|
||||
assert db.commits == 1
|
||||
assert len(db.added) == 1
|
||||
anomaly = db.added[0]
|
||||
assert anomaly.anomaly_type == "origin_change"
|
||||
assert anomaly.prefix == "203.0.113.0/24"
|
||||
incident_mock.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_bgp_anomalies_for_batch_skips_existing_entity_keys():
|
||||
previous_record = CollectedData(
|
||||
source="ris_live_bgp",
|
||||
extra_data={"prefix": "203.0.113.0/24", "origin_asn": 64496},
|
||||
)
|
||||
existing_key = ("origin_change:203.0.113.0/24:64497",)
|
||||
db = _FakeAsyncSession([
|
||||
[],
|
||||
[],
|
||||
[previous_record],
|
||||
[existing_key],
|
||||
])
|
||||
events = [
|
||||
{
|
||||
"reference_date": "2026-03-30T10:00:00Z",
|
||||
"metadata": {
|
||||
"prefix": "203.0.113.0/24",
|
||||
"origin_asn": 64497,
|
||||
"collector": "rrc00",
|
||||
"collector_location": {"country": "Netherlands", "city": "Amsterdam"},
|
||||
"as_path": [3333, 64497],
|
||||
"event_type": "announcement",
|
||||
"timestamp": "2026-03-30T10:00:00Z",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
with patch(
|
||||
"app.services.collectors.bgp_common.create_bgp_incidents_for_anomalies",
|
||||
new=AsyncMock(return_value=0),
|
||||
) as incident_mock:
|
||||
created = await create_bgp_anomalies_for_batch(
|
||||
db,
|
||||
source="ris_live_bgp",
|
||||
snapshot_id=1,
|
||||
task_id=2,
|
||||
events=events,
|
||||
)
|
||||
|
||||
assert created == 0
|
||||
assert len(db.added) == 0
|
||||
incident_mock.assert_not_awaited()
|
||||
|
||||
|
||||
async def _bgp_test_client(db_session):
|
||||
async def override_get_db():
|
||||
yield db_session
|
||||
|
||||
def override_get_current_user():
|
||||
return User(id=1, username="testuser", email="test@example.com", password_hash="x", role="admin")
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[get_current_user] = override_get_current_user
|
||||
transport = ASGITransport(app=app)
|
||||
client = AsyncClient(transport=transport, base_url="http://test")
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bgp_events_api_lists_observations():
|
||||
observation = BGPObservation(
|
||||
id=1,
|
||||
source=BGP_SOURCES[0],
|
||||
collector="rrc00",
|
||||
peer_asn=3333,
|
||||
prefix="203.0.113.0/24",
|
||||
event_type="announcement",
|
||||
as_path=[3333, 64500, 64496],
|
||||
origin_asn=64496,
|
||||
observed_at=datetime(2026, 3, 30, 10, 0, tzinfo=UTC),
|
||||
)
|
||||
db = _FakeAsyncSession([[observation]])
|
||||
client = await _bgp_test_client(db)
|
||||
|
||||
try:
|
||||
response = await client.get("/api/v1/bgp/events")
|
||||
finally:
|
||||
await client.aclose()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["total"] == 1
|
||||
assert payload["data"][0]["collector"] == "rrc00"
|
||||
assert payload["data"][0]["prefix"] == "203.0.113.0/24"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bgp_incidents_api_returns_incident():
|
||||
incident = BGPIncident(
|
||||
id=7,
|
||||
source="ris_live_bgp",
|
||||
incident_key="origin_change:203.0.113.0/24:64497",
|
||||
incident_type="origin_change",
|
||||
title="Origin Change incident on 203.0.113.0/24",
|
||||
summary="Grouped incident summary",
|
||||
severity="critical",
|
||||
status="active",
|
||||
confidence=0.91,
|
||||
affected_prefixes=["203.0.113.0/24"],
|
||||
affected_collectors=["rrc00"],
|
||||
)
|
||||
db = _FakeAsyncSession(
|
||||
[[incident]],
|
||||
gets={(BGPIncident, 7): incident},
|
||||
)
|
||||
client = await _bgp_test_client(db)
|
||||
|
||||
try:
|
||||
list_response = await client.get("/api/v1/bgp/incidents")
|
||||
detail_response = await client.get("/api/v1/bgp/incidents/7")
|
||||
finally:
|
||||
await client.aclose()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()["total"] == 1
|
||||
assert detail_response.status_code == 200
|
||||
assert detail_response.json()["incident_type"] == "origin_change"
|
||||
|
||||
Reference in New Issue
Block a user