feat: Phase 3 & 4 — monitoring, discovery, polish, deployment
Phase 3 — Discovery & Monitoring: - Network scanner: nmap wrapper + mock fallback, fingerprint service (35 signatures) - Status checker: ping/http/https/tcp/ssh/prometheus/health per-node checks - APScheduler: status checks every 60s, WebSocket broadcast - WebSocket /ws/status: live node status updates to frontend - Sidebar panels: Pending Devices, Hidden Devices, Scan History - Auth token persisted to localStorage (survive page refresh) - 24 new backend tests (scan flow + status_checker) Phase 4 — Polish & Deployment: - Auto-layout: Dagre hierarchical TB via Toolbar button - Export PNG: html-to-image download via Toolbar button - Scan config modal: CIDR ranges + check interval, GET/POST /api/v1/scan/config - Dockerfile.backend (Python 3.13 slim + nmap), Dockerfile.frontend (nginx) - docker-compose.yml with data volume and NET_RAW cap for ping - scripts/lxc-install.sh: Proxmox VE systemd bootstrap - README.md: quick-start, config reference, stack overview
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
"""Tests for scan routes: trigger, pending devices, approve/hide/ignore."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.db.models import PendingDevice
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def headers(client: AsyncClient):
|
||||
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
|
||||
token = res.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def pending_device(db_session):
|
||||
import uuid
|
||||
device = PendingDevice(
|
||||
id=str(uuid.uuid4()),
|
||||
ip="192.168.1.100",
|
||||
mac="aa:bb:cc:dd:ee:ff",
|
||||
hostname="my-server",
|
||||
os="Linux",
|
||||
services=[{"port": 22, "name": "ssh"}],
|
||||
suggested_type="server",
|
||||
status="pending",
|
||||
)
|
||||
db_session.add(device)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
# --- Trigger scan ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_scan_requires_auth(client: AsyncClient):
|
||||
res = await client.post("/api/v1/scan/trigger")
|
||||
# FastAPI's OAuth2PasswordBearer returns 403 when no token is provided
|
||||
assert res.status_code in (401, 403)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_scan_creates_run(client: AsyncClient, headers):
|
||||
with (
|
||||
patch("app.api.routes.scan._background_scan", new_callable=AsyncMock),
|
||||
patch("app.api.routes.scan._load_ranges", return_value=["192.168.1.0/24"]),
|
||||
):
|
||||
res = await client.post("/api/v1/scan/trigger", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["status"] == "running"
|
||||
assert data["ranges"] == ["192.168.1.0/24"]
|
||||
assert "id" in data
|
||||
|
||||
|
||||
# --- Pending devices ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_empty(client: AsyncClient, headers):
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_pending_returns_device(client: AsyncClient, headers, pending_device):
|
||||
res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["ip"] == "192.168.1.100"
|
||||
assert data[0]["hostname"] == "my-server"
|
||||
|
||||
|
||||
# --- Approve device ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_device(client: AsyncClient, headers, pending_device):
|
||||
node_payload = {
|
||||
"label": "My Server",
|
||||
"type": "server",
|
||||
"ip": "192.168.1.100",
|
||||
"hostname": "my-server",
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
}
|
||||
res = await client.post(
|
||||
f"/api/v1/scan/pending/{pending_device.id}/approve",
|
||||
json=node_payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
data = res.json()
|
||||
assert data["approved"] is True
|
||||
assert "node_id" in data
|
||||
|
||||
# Device should no longer appear in pending list
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_approve_nonexistent_device(client: AsyncClient, headers):
|
||||
node_payload = {
|
||||
"label": "Ghost",
|
||||
"type": "generic",
|
||||
"ip": "10.0.0.1",
|
||||
"status": "unknown",
|
||||
"services": [],
|
||||
}
|
||||
res = await client.post(
|
||||
"/api/v1/scan/pending/nonexistent-id/approve",
|
||||
json=node_payload,
|
||||
headers=headers,
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["approved"] is False
|
||||
|
||||
|
||||
# --- Hide device ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hide_device(client: AsyncClient, headers, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["hidden"] is True
|
||||
|
||||
# Should no longer appear in pending
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
|
||||
# Should appear in hidden
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert len(hidden_res.json()) == 1
|
||||
|
||||
|
||||
# --- Ignore device ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ignore_device(client: AsyncClient, headers, pending_device):
|
||||
res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/ignore", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["ignored"] is True
|
||||
|
||||
# Device should be gone from both pending and hidden
|
||||
pending_res = await client.get("/api/v1/scan/pending", headers=headers)
|
||||
assert pending_res.json() == []
|
||||
hidden_res = await client.get("/api/v1/scan/hidden", headers=headers)
|
||||
assert hidden_res.json() == []
|
||||
|
||||
|
||||
# --- Scan runs ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_runs_empty(client: AsyncClient, headers):
|
||||
res = await client.get("/api/v1/scan/runs", headers=headers)
|
||||
assert res.status_code == 200
|
||||
assert res.json() == []
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Tests for status_checker service: each check method."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.status_checker import _tcp_connect, check_node
|
||||
|
||||
# --- check_node dispatcher ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_unknown_without_host():
|
||||
result = await check_node("ping", None, None)
|
||||
assert result["status"] == "unknown"
|
||||
assert result["response_time_ms"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_ping_online():
|
||||
with patch("app.services.status_checker._ping", new_callable=AsyncMock, return_value=True):
|
||||
result = await check_node("ping", None, "192.168.1.1")
|
||||
assert result["status"] == "online"
|
||||
assert result["response_time_ms"] is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_ping_offline():
|
||||
with patch("app.services.status_checker._ping", new_callable=AsyncMock, return_value=False):
|
||||
result = await check_node("ping", None, "192.168.1.1")
|
||||
assert result["status"] == "offline"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_http_online():
|
||||
with patch("app.services.status_checker._http_get", new_callable=AsyncMock, return_value=True):
|
||||
result = await check_node("http", "192.168.1.1:8080", None)
|
||||
assert result["status"] == "online"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_http_prepends_scheme():
|
||||
"""If target doesn't start with http, http:// is prepended."""
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
await check_node("http", "192.168.1.1:8080", None)
|
||||
|
||||
assert captured["url"].startswith("http://")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_https_uses_verify():
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["verify"] = verify
|
||||
return True
|
||||
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
await check_node("https", "https://myserver", None)
|
||||
|
||||
assert captured["verify"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_ssh():
|
||||
with patch("app.services.status_checker._tcp_connect", new_callable=AsyncMock, return_value=True) as mock_tcp:
|
||||
result = await check_node("ssh", None, "192.168.1.5")
|
||||
mock_tcp.assert_called_once_with("192.168.1.5", 22)
|
||||
assert result["status"] == "online"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_tcp_parses_port():
|
||||
captured = {}
|
||||
|
||||
async def fake_tcp(host, port):
|
||||
captured["host"] = host
|
||||
captured["port"] = port
|
||||
return True
|
||||
|
||||
with patch("app.services.status_checker._tcp_connect", side_effect=fake_tcp):
|
||||
await check_node("tcp", "192.168.1.10:9090", None)
|
||||
|
||||
assert captured["host"] == "192.168.1.10"
|
||||
assert captured["port"] == 9090
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_prometheus_appends_metrics():
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
await check_node("prometheus", "192.168.1.10:9090", None)
|
||||
|
||||
assert "/metrics" in captured["url"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_health_appends_health():
|
||||
captured = {}
|
||||
|
||||
async def fake_http_get(url, verify=False):
|
||||
captured["url"] = url
|
||||
return True
|
||||
|
||||
with patch("app.services.status_checker._http_get", side_effect=fake_http_get):
|
||||
await check_node("health", "192.168.1.10:8080", None)
|
||||
|
||||
assert "/health" in captured["url"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_unknown_method_falls_back_to_ping():
|
||||
with patch("app.services.status_checker._ping", new_callable=AsyncMock, return_value=True) as mock_ping:
|
||||
result = await check_node("foobar", None, "10.0.0.1")
|
||||
mock_ping.assert_called_once()
|
||||
assert result["status"] == "online"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_node_exception_returns_offline():
|
||||
with patch("app.services.status_checker._ping", new_callable=AsyncMock, side_effect=RuntimeError("boom")):
|
||||
result = await check_node("ping", None, "10.0.0.1")
|
||||
assert result["status"] == "offline"
|
||||
assert result["response_time_ms"] is None
|
||||
|
||||
|
||||
# --- _tcp_connect ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tcp_connect_success():
|
||||
writer_mock = MagicMock()
|
||||
writer_mock.close = MagicMock()
|
||||
writer_mock.wait_closed = AsyncMock()
|
||||
with patch("asyncio.open_connection", new_callable=AsyncMock, return_value=(MagicMock(), writer_mock)):
|
||||
result = await _tcp_connect("192.168.1.1", 22)
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tcp_connect_timeout():
|
||||
async def timeout_open(*args, **kwargs):
|
||||
raise TimeoutError()
|
||||
|
||||
with patch("asyncio.open_connection", side_effect=timeout_open):
|
||||
result = await _tcp_connect("192.168.1.1", 22)
|
||||
assert result is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tcp_connect_os_error():
|
||||
with patch("asyncio.open_connection", new_callable=AsyncMock, side_effect=OSError("refused")):
|
||||
result = await _tcp_connect("192.168.1.1", 9999)
|
||||
assert result is False
|
||||
Reference in New Issue
Block a user