323 lines
8.9 KiB
Python
323 lines
8.9 KiB
Python
"""Behavior tests for multi-packet fping execution and persistence."""
|
|
|
|
import asyncio
|
|
from datetime import datetime, timedelta
|
|
|
|
import pytest
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
|
|
from app.config import Settings
|
|
from app.models.device import Base, Device, DeviceTypeEnum
|
|
from app.models.ping_record import PingRecord
|
|
from app.services.fping_runner import (
|
|
ProbeResult,
|
|
parse_fping_count_output,
|
|
run_fping_count,
|
|
)
|
|
from app.services.pinger import Pinger
|
|
|
|
|
|
@pytest.fixture
|
|
async def db_session() -> AsyncSession:
|
|
"""Provide an isolated database for pinger transaction tests."""
|
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
|
try:
|
|
async with engine.begin() as connection:
|
|
await connection.run_sync(Base.metadata.create_all)
|
|
async with AsyncSession(engine, expire_on_commit=False) as session:
|
|
yield session
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
def test_parses_loss_and_average_rtt():
|
|
"""Numeric replies count as received packets and dashes count as loss."""
|
|
output = "10.0.0.8 : 12.4 - 12.6\n10.0.0.9 : - - -"
|
|
|
|
result = parse_fping_count_output(
|
|
output,
|
|
{"10.0.0.8", "10.0.0.9"},
|
|
3,
|
|
)
|
|
|
|
assert result["10.0.0.8"].received_count == 2
|
|
assert result["10.0.0.8"].average_rtt_ms == 12.5
|
|
assert result["10.0.0.8"].packet_loss_percent == pytest.approx(
|
|
33.3333333333
|
|
)
|
|
assert result["10.0.0.9"].packet_loss_percent == 100.0
|
|
|
|
|
|
def test_marks_missing_and_malformed_expected_output_invalid():
|
|
"""Incomplete fping output cannot be interpreted as device packet loss."""
|
|
output = "10.0.0.8 : 10.2 bad-token -"
|
|
|
|
result = parse_fping_count_output(
|
|
output,
|
|
{"10.0.0.8", "10.0.0.9"},
|
|
3,
|
|
)
|
|
|
|
assert result["10.0.0.8"].is_valid is False
|
|
assert result["10.0.0.8"].failure_reason == "malformed fping replies"
|
|
assert result["10.0.0.9"].is_valid is False
|
|
assert result["10.0.0.9"].failure_reason == "missing fping output"
|
|
|
|
|
|
def test_parses_ipv6_without_confusing_address_colons_for_delimiter():
|
|
"""An IPv6 address remains the result key when parsing the output delimiter."""
|
|
result = parse_fping_count_output(
|
|
"2001:db8::8 : 1.25 - 1.75",
|
|
{"2001:db8::8"},
|
|
3,
|
|
)
|
|
|
|
assert result["2001:db8::8"].is_valid is True
|
|
assert result["2001:db8::8"].received_count == 2
|
|
assert result["2001:db8::8"].average_rtt_ms == 1.5
|
|
|
|
|
|
async def test_runner_uses_argument_vector_and_parses_quiet_stderr(monkeypatch):
|
|
"""The runner passes IPs as argv entries and reads fping -C quiet output."""
|
|
observed = {}
|
|
|
|
class CompletedProcess:
|
|
returncode = 1
|
|
|
|
async def communicate(self):
|
|
return b"", b"10.0.0.8 : 4.2 - 4.4\n"
|
|
|
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
|
observed["args"] = args
|
|
observed["kwargs"] = kwargs
|
|
return CompletedProcess()
|
|
|
|
monkeypatch.setattr(
|
|
asyncio,
|
|
"create_subprocess_exec",
|
|
fake_create_subprocess_exec,
|
|
)
|
|
|
|
result = await run_fping_count(
|
|
["10.0.0.8"],
|
|
packets_per_round=3,
|
|
timeout_ms=800,
|
|
executable="/test/fping",
|
|
)
|
|
|
|
assert observed["args"] == (
|
|
"/test/fping",
|
|
"-C",
|
|
"3",
|
|
"-q",
|
|
"-t",
|
|
"800",
|
|
"10.0.0.8",
|
|
)
|
|
assert observed["kwargs"] == {
|
|
"stdout": asyncio.subprocess.PIPE,
|
|
"stderr": asyncio.subprocess.PIPE,
|
|
}
|
|
assert result["10.0.0.8"].received_count == 2
|
|
|
|
|
|
async def test_runner_failure_returns_invalid_result_instead_of_packet_loss(
|
|
monkeypatch,
|
|
):
|
|
"""A process communication failure remains an invalid probe aggregate."""
|
|
|
|
class BrokenProcess:
|
|
async def communicate(self):
|
|
raise RuntimeError("simulated process pipe failure")
|
|
|
|
async def fake_create_subprocess_exec(*args, **kwargs):
|
|
return BrokenProcess()
|
|
|
|
monkeypatch.setattr(
|
|
asyncio,
|
|
"create_subprocess_exec",
|
|
fake_create_subprocess_exec,
|
|
)
|
|
|
|
result = await run_fping_count(
|
|
["10.0.0.8"],
|
|
packets_per_round=3,
|
|
timeout_ms=800,
|
|
executable="/test/fping",
|
|
)
|
|
|
|
assert result["10.0.0.8"].is_valid is False
|
|
assert result["10.0.0.8"].failure_reason == (
|
|
"fping execution failed: RuntimeError"
|
|
)
|
|
|
|
|
|
async def test_pinger_persists_one_summary_for_each_enabled_device(
|
|
db_session,
|
|
monkeypatch,
|
|
):
|
|
"""One monitoring round writes exactly one aggregate per enabled device."""
|
|
first = Device(
|
|
name="edge-a",
|
|
ip="10.0.0.8",
|
|
device_type=DeviceTypeEnum.switch,
|
|
current_status="online",
|
|
is_enabled=True,
|
|
)
|
|
second = Device(
|
|
name="edge-b",
|
|
ip="10.0.0.9",
|
|
device_type=DeviceTypeEnum.switch,
|
|
current_status="online",
|
|
is_enabled=True,
|
|
)
|
|
disabled = Device(
|
|
name="edge-disabled",
|
|
ip="10.0.0.10",
|
|
device_type=DeviceTypeEnum.switch,
|
|
current_status="online",
|
|
is_enabled=False,
|
|
)
|
|
db_session.add_all([first, second, disabled])
|
|
await db_session.commit()
|
|
|
|
async def fake_run_fping_count(
|
|
ips,
|
|
packets_per_round,
|
|
timeout_ms,
|
|
executable,
|
|
):
|
|
assert set(ips) == {"10.0.0.8", "10.0.0.9"}
|
|
return {
|
|
"10.0.0.8": ProbeResult("10.0.0.8", 3, 2, 8.5, True),
|
|
"10.0.0.9": ProbeResult("10.0.0.9", 3, 3, 9.5, True),
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.pinger.run_fping_count",
|
|
fake_run_fping_count,
|
|
)
|
|
|
|
changes = await Pinger(Settings()).run_one_round(db_session)
|
|
records = list(
|
|
(
|
|
await db_session.execute(
|
|
select(PingRecord).order_by(PingRecord.device_id)
|
|
)
|
|
)
|
|
.scalars()
|
|
.all()
|
|
)
|
|
|
|
assert changes == []
|
|
assert len(records) == 2
|
|
assert [record.device_id for record in records] == [first.id, second.id]
|
|
assert records[0].sent_count == 3
|
|
assert records[0].received_count == 2
|
|
assert records[0].packet_loss_percent == pytest.approx(33.3333333333)
|
|
assert records[0].average_rtt_ms == 8.5
|
|
assert records[0].is_valid is True
|
|
|
|
|
|
async def test_pinger_persists_invalid_probe_without_changing_device_state(
|
|
db_session,
|
|
monkeypatch,
|
|
):
|
|
"""A runner failure remains observable but cannot mark a device offline."""
|
|
device = Device(
|
|
name="edge-a",
|
|
ip="10.0.0.8",
|
|
device_type=DeviceTypeEnum.switch,
|
|
current_status="online",
|
|
is_enabled=True,
|
|
)
|
|
db_session.add(device)
|
|
await db_session.commit()
|
|
|
|
async def fake_run_fping_count(*args, **kwargs):
|
|
return {
|
|
device.ip: ProbeResult(
|
|
device.ip,
|
|
3,
|
|
0,
|
|
None,
|
|
False,
|
|
"fping executable not found",
|
|
)
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.pinger.run_fping_count",
|
|
fake_run_fping_count,
|
|
)
|
|
|
|
changes = await Pinger(Settings()).run_one_round(db_session)
|
|
record = (
|
|
await db_session.execute(select(PingRecord).where(PingRecord.device_id == device.id))
|
|
).scalar_one()
|
|
|
|
assert changes == []
|
|
assert device.current_status == "online"
|
|
assert record.is_valid is False
|
|
assert record.packet_loss_percent is None
|
|
assert record.failure_reason == "fping executable not found"
|
|
|
|
|
|
async def test_pinger_evaluates_recent_valid_summaries_and_returns_change(
|
|
db_session,
|
|
monkeypatch,
|
|
):
|
|
"""A newly persisted full-loss summary completes the offline streak."""
|
|
device = Device(
|
|
name="edge-a",
|
|
ip="10.0.0.8",
|
|
device_type=DeviceTypeEnum.switch,
|
|
current_status="online",
|
|
is_enabled=True,
|
|
offline_consecutive_rounds=2,
|
|
)
|
|
db_session.add(device)
|
|
await db_session.flush()
|
|
db_session.add(
|
|
PingRecord(
|
|
device_id=device.id,
|
|
is_alive=False,
|
|
response_time_ms=None,
|
|
round_num=1,
|
|
sent_count=3,
|
|
received_count=0,
|
|
packet_loss_percent=100.0,
|
|
average_rtt_ms=None,
|
|
is_valid=True,
|
|
created_at=datetime.now() - timedelta(seconds=30),
|
|
)
|
|
)
|
|
await db_session.commit()
|
|
|
|
async def fake_run_fping_count(*args, **kwargs):
|
|
return {
|
|
device.ip: ProbeResult(
|
|
device.ip,
|
|
3,
|
|
0,
|
|
None,
|
|
True,
|
|
)
|
|
}
|
|
|
|
monkeypatch.setattr(
|
|
"app.services.pinger.run_fping_count",
|
|
fake_run_fping_count,
|
|
)
|
|
|
|
changes = await Pinger(Settings()).run_one_round(db_session)
|
|
|
|
assert len(changes) == 1
|
|
assert changes[0].device is device
|
|
assert changes[0].old_status == "online"
|
|
assert changes[0].new_status == "offline"
|
|
assert changes[0].event_type == "offline"
|
|
assert device.current_status == "offline"
|
|
assert device.last_offline_time is not None
|