Files
planet/backend/tests/test_bgp.py

566 lines
17 KiB
Python

"""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(
{
"collector": "rrc00",
"peer_asn": "3333",
"peer_ip": "2001:db8::1",
"type": "UPDATE",
"event_type": "announcement",
"prefix": "203.0.113.0/24",
"path": ["3333", "64500", "64496"],
"communities": ["3333:100"],
"timestamp": "2026-03-26T08:00:00Z",
},
project="ris-live",
)
assert event["name"] == "203.0.113.0/24"
assert event["metadata"]["collector"] == "rrc00"
assert event["metadata"]["peer_asn"] == 3333
assert event["metadata"]["origin_asn"] == 64496
assert event["metadata"]["as_path_length"] == 3
assert event["metadata"]["prefix_length"] == 24
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(
[
{
"project": "routeviews",
"collector": "route-views.sg",
"filename": "rib.20260326.0800.gz",
"startTime": "2026-03-26T08:00:00Z",
"prefix": "198.51.100.0/24",
"origin_asn": 64512,
}
]
)
assert len(transformed) == 1
record = transformed[0]
assert record["name"] == "rib.20260326.0800.gz"
assert record["metadata"]["project"] == "bgpstream"
assert record["metadata"]["broker_record"]["filename"] == "rib.20260326.0800.gz"
def test_bgp_anomaly_to_dict():
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={"previous_origins": [64496], "current_origins": [64497]},
)
data = anomaly.to_dict()
assert data["source"] == "ris_live_bgp"
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"