b513fa6f4e
Break the 1897-line test_scan.py into topic modules under tests/scan/ (routes, approve, properties, run) sharing fixtures via a package conftest.py and pure builders via helpers.py. Same 80 tests, no behavior change; full suite 627 passed. ha-relevant: no
432 lines
15 KiB
Python
432 lines
15 KiB
Python
"""run_scan service persistence, _background_scan lifecycle, stop/cancel."""
|
|
import uuid
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.db.models import Node, PendingDevice, ScanRun
|
|
from app.services.scanner import _cancelled_runs, request_cancel, run_scan
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_scan_marks_run_failed_on_exception(mem_db):
|
|
"""If run_scan() raises, the ScanRun must transition running → failed and the
|
|
session rollback path must execute without a follow-on exception."""
|
|
from app.api.routes.scan import _background_scan
|
|
|
|
async with mem_db() as session:
|
|
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
|
session.add(run)
|
|
await session.commit()
|
|
run_id = run.id
|
|
|
|
with (
|
|
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
|
patch(
|
|
"app.api.routes.scan.run_scan",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("boom"),
|
|
),
|
|
):
|
|
await _background_scan(run_id, ["10.0.0.0/24"])
|
|
|
|
async with mem_db() as session:
|
|
refreshed = await session.get(ScanRun, run_id)
|
|
assert refreshed is not None
|
|
assert refreshed.status == "failed"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_scan_leaves_non_running_status_alone(mem_db):
|
|
"""If the run was already stopped/cancelled before run_scan failed, _background_scan
|
|
must NOT overwrite that terminal status with 'failed'."""
|
|
from app.api.routes.scan import _background_scan
|
|
|
|
async with mem_db() as session:
|
|
run = ScanRun(status="cancelled", ranges=["10.0.0.0/24"])
|
|
session.add(run)
|
|
await session.commit()
|
|
run_id = run.id
|
|
|
|
with (
|
|
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
|
patch(
|
|
"app.api.routes.scan.run_scan",
|
|
new_callable=AsyncMock,
|
|
side_effect=RuntimeError("boom"),
|
|
),
|
|
):
|
|
await _background_scan(run_id, ["10.0.0.0/24"])
|
|
|
|
async with mem_db() as session:
|
|
refreshed = await session.get(ScanRun, run_id)
|
|
assert refreshed is not None
|
|
assert refreshed.status == "cancelled"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_background_scan_success_path_invokes_run_scan(mem_db):
|
|
from app.api.routes.scan import _background_scan
|
|
|
|
async with mem_db() as session:
|
|
run = ScanRun(status="running", ranges=["10.0.0.0/24"])
|
|
session.add(run)
|
|
await session.commit()
|
|
run_id = run.id
|
|
|
|
with (
|
|
patch("app.api.routes.scan.AsyncSessionLocal", mem_db),
|
|
patch("app.api.routes.scan.run_scan", new_callable=AsyncMock) as mock_run_scan,
|
|
):
|
|
from app.services.scanner import DeepScanOptions
|
|
await _background_scan(run_id, ["10.0.0.0/24"], DeepScanOptions())
|
|
mock_run_scan.assert_awaited_once()
|
|
|
|
|
|
@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() == []
|
|
|
|
|
|
# --- run_scan: re-scan updates existing pending devices ---
|
|
|
|
MOCK_HOST = {
|
|
"ip": "192.168.1.50",
|
|
"mac": "aa:bb:cc:dd:ee:ff",
|
|
"hostname": "myhost.lan",
|
|
"os": "Linux",
|
|
"open_ports": [{"port": 8096, "protocol": "tcp", "banner": "Jellyfin"}],
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_scan_creates_new_pending_device(db_session: AsyncSession):
|
|
run_id = str(uuid.uuid4())
|
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
db_session.add(run)
|
|
await db_session.commit()
|
|
|
|
with (
|
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
):
|
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
|
|
result = await db_session.execute(
|
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
|
)
|
|
device = result.scalar_one_or_none()
|
|
assert device is not None
|
|
assert device.hostname == "myhost.lan"
|
|
assert any(s["port"] == 8096 for s in device.services)
|
|
assert device.suggested_type == "server"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_scan_keeps_stale_pending_for_canvas_nodes(db_session: AsyncSession):
|
|
"""Pending devices whose IP is already on a canvas are NOT purged — they stay
|
|
in the inventory and are surfaced with an "In N canvas" badge."""
|
|
node = Node(
|
|
id=str(uuid.uuid4()),
|
|
label="Existing Server",
|
|
type="server",
|
|
ip="192.168.1.50",
|
|
status="online",
|
|
services=[],
|
|
pos_x=0.0,
|
|
pos_y=0.0,
|
|
)
|
|
stale = PendingDevice(
|
|
id=str(uuid.uuid4()),
|
|
ip="192.168.1.50",
|
|
mac=None,
|
|
hostname=None,
|
|
os=None,
|
|
services=[],
|
|
suggested_type="generic",
|
|
status="pending",
|
|
)
|
|
db_session.add(node)
|
|
db_session.add(stale)
|
|
await db_session.commit()
|
|
|
|
run_id = str(uuid.uuid4())
|
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
db_session.add(run)
|
|
await db_session.commit()
|
|
|
|
with (
|
|
patch("app.services.scanner._nmap_scan", return_value=[]),
|
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
):
|
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
|
|
result = await db_session.execute(
|
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
|
)
|
|
assert result.scalar_one_or_none() is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_scan_records_ip_already_in_canvas(db_session: AsyncSession):
|
|
"""A scanned IP that already exists as a canvas Node still produces a pending
|
|
device (no longer suppressed)."""
|
|
node = Node(
|
|
id=str(uuid.uuid4()),
|
|
label="Existing Server",
|
|
type="server",
|
|
ip="192.168.1.50",
|
|
status="online",
|
|
services=[],
|
|
pos_x=0.0,
|
|
pos_y=0.0,
|
|
)
|
|
db_session.add(node)
|
|
await db_session.commit()
|
|
|
|
run_id = str(uuid.uuid4())
|
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
db_session.add(run)
|
|
await db_session.commit()
|
|
|
|
with (
|
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
):
|
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
|
|
result = await db_session.execute(
|
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
|
)
|
|
device = result.scalar_one_or_none()
|
|
assert device is not None
|
|
assert device.status == "pending"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_scan_refreshes_approved_device_without_duplicating(db_session: AsyncSession):
|
|
"""Re-scanning an already-approved device updates its row in place instead of
|
|
spawning a fresh pending duplicate, and keeps it approved."""
|
|
approved = PendingDevice(
|
|
id=str(uuid.uuid4()), ip="192.168.1.50", mac=None, hostname="old",
|
|
os=None, services=[], suggested_type="server", status="approved",
|
|
)
|
|
db_session.add(approved)
|
|
run_id = str(uuid.uuid4())
|
|
db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
|
|
await db_session.commit()
|
|
|
|
with (
|
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
):
|
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
|
|
rows = (await db_session.execute(
|
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
|
)).scalars().all()
|
|
assert len(rows) == 1
|
|
assert rows[0].status == "approved"
|
|
assert rows[0].hostname == "myhost.lan" # refreshed from the scan
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_scan_collapses_existing_duplicate_rows(db_session: AsyncSession):
|
|
"""Pre-existing duplicate inventory rows for one IP are collapsed to a single
|
|
row at scan start, even if the device is not re-discovered."""
|
|
for status in ("approved", "pending", "pending"):
|
|
db_session.add(PendingDevice(
|
|
id=str(uuid.uuid4()), ip="192.168.1.77", mac=None, hostname=None,
|
|
os=None, services=[], suggested_type="server", status=status,
|
|
))
|
|
run_id = str(uuid.uuid4())
|
|
db_session.add(ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"]))
|
|
await db_session.commit()
|
|
|
|
with (
|
|
patch("app.services.scanner._nmap_scan", return_value=[]),
|
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
):
|
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
|
|
rows = (await db_session.execute(
|
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.77")
|
|
)).scalars().all()
|
|
assert len(rows) == 1
|
|
assert rows[0].status == "approved" # approved row is the one kept
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_scan_skips_hidden_device(db_session: AsyncSession):
|
|
"""Devices previously hidden by the user must not re-appear in pending on re-scan."""
|
|
hidden = PendingDevice(
|
|
id=str(uuid.uuid4()),
|
|
ip="192.168.1.50",
|
|
mac=None,
|
|
hostname=None,
|
|
os=None,
|
|
services=[],
|
|
suggested_type="generic",
|
|
status="hidden",
|
|
)
|
|
db_session.add(hidden)
|
|
await db_session.commit()
|
|
|
|
run_id = str(uuid.uuid4())
|
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
db_session.add(run)
|
|
await db_session.commit()
|
|
|
|
with (
|
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
):
|
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
|
|
result = await db_session.execute(
|
|
select(PendingDevice).where(
|
|
PendingDevice.ip == "192.168.1.50",
|
|
PendingDevice.status == "pending",
|
|
)
|
|
)
|
|
assert result.scalar_one_or_none() is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_scan_requires_auth(client: AsyncClient):
|
|
res = await client.post("/api/v1/scan/fake-id/stop")
|
|
assert res.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_scan_not_found(client: AsyncClient, headers):
|
|
import uuid as _uuid
|
|
res = await client.post(f"/api/v1/scan/{_uuid.uuid4()}/stop", headers=headers)
|
|
assert res.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_scan_not_running(client: AsyncClient, headers, db_session: AsyncSession):
|
|
run = ScanRun(id=str(uuid.uuid4()), status="done", ranges=["192.168.1.0/24"])
|
|
db_session.add(run)
|
|
await db_session.commit()
|
|
|
|
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
|
assert res.status_code == 409
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_scan_success(client: AsyncClient, headers, db_session: AsyncSession):
|
|
run = ScanRun(id=str(uuid.uuid4()), status="running", ranges=["192.168.1.0/24"])
|
|
db_session.add(run)
|
|
await db_session.commit()
|
|
|
|
res = await client.post(f"/api/v1/scan/{run.id}/stop", headers=headers)
|
|
assert res.status_code == 200
|
|
assert res.json() == {"stopping": True}
|
|
# run_id added to cancel set
|
|
assert run.id in _cancelled_runs
|
|
# status flipped eagerly so the UI reacts without waiting for a checkpoint
|
|
await db_session.refresh(run)
|
|
assert run.status == "cancelled"
|
|
assert run.finished_at is not None
|
|
# cleanup for other tests
|
|
_cancelled_runs.discard(run.id)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_scan_cancelled_marks_status(db_session: AsyncSession):
|
|
"""When cancel is requested before the scan starts, status becomes 'cancelled'."""
|
|
run_id = str(uuid.uuid4())
|
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
db_session.add(run)
|
|
await db_session.commit()
|
|
|
|
request_cancel(run_id)
|
|
|
|
with (
|
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]) as mock_nmap,
|
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
):
|
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
# nmap should not have been called — cancelled before first range
|
|
mock_nmap.assert_not_called()
|
|
|
|
await db_session.refresh(run)
|
|
assert run.status == "cancelled"
|
|
assert run.finished_at is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_scan_cancelled_mid_scan_skips_remaining_cidrs(db_session: AsyncSession):
|
|
"""Cancel flag set after first CIDR is started prevents processing of the second CIDR."""
|
|
run_id = str(uuid.uuid4())
|
|
run = ScanRun(id=run_id, status="running", ranges=["10.0.0.0/24", "10.0.1.0/24"])
|
|
db_session.add(run)
|
|
await db_session.commit()
|
|
|
|
call_count = 0
|
|
|
|
def nmap_side_effect(target: str, port_spec: str | None = None, run_id: str | None = None):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
# Signal cancellation after the first CIDR scan completes
|
|
if call_count == 1:
|
|
request_cancel(run_id)
|
|
return []
|
|
|
|
with (
|
|
patch("app.services.scanner._nmap_scan", side_effect=nmap_side_effect),
|
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
):
|
|
await run_scan(["10.0.0.0/24", "10.0.1.0/24"], db_session, run_id)
|
|
|
|
assert call_count == 1 # second CIDR was skipped
|
|
await db_session.refresh(run)
|
|
assert run.status == "cancelled"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_scan_updates_existing_pending_device(db_session: AsyncSession):
|
|
"""Re-scanning the same IP updates services instead of creating a duplicate."""
|
|
# Pre-existing pending device with no services
|
|
existing = PendingDevice(
|
|
id=str(uuid.uuid4()),
|
|
ip="192.168.1.50",
|
|
mac=None,
|
|
hostname=None,
|
|
os=None,
|
|
services=[],
|
|
suggested_type="generic",
|
|
status="pending",
|
|
)
|
|
db_session.add(existing)
|
|
await db_session.commit()
|
|
|
|
run_id = str(uuid.uuid4())
|
|
run = ScanRun(id=run_id, status="running", ranges=["192.168.1.0/24"])
|
|
db_session.add(run)
|
|
await db_session.commit()
|
|
|
|
with (
|
|
patch("app.services.scanner._nmap_scan", return_value=[MOCK_HOST]),
|
|
patch("app.api.routes.status.broadcast_scan_update", new_callable=AsyncMock),
|
|
):
|
|
await run_scan(["192.168.1.0/24"], db_session, run_id)
|
|
|
|
# Should still be only one device
|
|
result = await db_session.execute(
|
|
select(PendingDevice).where(PendingDevice.ip == "192.168.1.50")
|
|
)
|
|
devices = list(result.scalars().all())
|
|
assert len(devices) == 1
|
|
device = devices[0]
|
|
# Services and hostname should be updated
|
|
assert device.hostname == "myhost.lan"
|
|
assert any(s["port"] == 8096 for s in device.services)
|