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
263 lines
10 KiB
Python
263 lines
10 KiB
Python
"""Scan API routes: trigger, pending list, canvas-count correlation, timestamps, config."""
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from httpx import AsyncClient
|
|
|
|
from app.db.models import PendingDevice
|
|
from tests.scan.helpers import _add_design, _node
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_scan_requires_auth(client: AsyncClient):
|
|
res = await client.post("/api/v1/scan/trigger")
|
|
assert res.status_code == 401
|
|
|
|
|
|
@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.settings") as mock_settings,
|
|
):
|
|
mock_settings.scanner_ranges = ["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
|
|
|
|
|
|
@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"
|
|
# No matching node → not on any canvas.
|
|
assert data[0]["canvas_count"] == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_canvas_count_matches_ip_in_comma_list(client, headers, db_session, pending_device):
|
|
# Node.ip holds several comma-separated addresses (IPv6 added first). The
|
|
# device scanned as the plain IPv4 must still correlate (issue #258).
|
|
d1 = await _add_design(db_session, "Home")
|
|
db_session.add(_node(d1, ip="fe80::1, 192.168.1.100"))
|
|
await db_session.commit()
|
|
|
|
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
|
assert data[0]["canvas_count"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_canvas_count_correlates_by_mac(client, headers, db_session, pending_device):
|
|
# Node's ip differs entirely (user edited it) but the MAC still matches:
|
|
# the device is on the canvas (issue #258, MAC is the stable identifier).
|
|
d1 = await _add_design(db_session, "Home")
|
|
db_session.add(_node(d1, ip="10.9.9.9", mac="aa:bb:cc:dd:ee:ff"))
|
|
await db_session.commit()
|
|
|
|
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()
|
|
assert data[0]["canvas_count"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_canvas_count_counts_distinct_designs_by_ip(client, headers, db_session, pending_device):
|
|
# Same IP placed on two different canvases → canvas_count == 2.
|
|
d1 = await _add_design(db_session, "Home")
|
|
d2 = await _add_design(db_session, "Lab")
|
|
db_session.add(_node(d1, ip="192.168.1.100"))
|
|
db_session.add(_node(d2, ip="192.168.1.100"))
|
|
await db_session.commit()
|
|
|
|
res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
data = res.json()
|
|
assert len(data) == 1
|
|
assert data[0]["canvas_count"] == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_canvas_count_correlates_by_ieee(client, headers, db_session):
|
|
device = PendingDevice(
|
|
id=str(uuid.uuid4()), ieee_address="0x00124b001", discovery_source="zigbee",
|
|
suggested_type="zigbee_enddevice", services=[], status="pending",
|
|
)
|
|
db_session.add(device)
|
|
d1 = await _add_design(db_session, "Zigbee")
|
|
db_session.add(_node(d1, ieee="0x00124b001"))
|
|
await db_session.commit()
|
|
|
|
res = await client.get("/api/v1/scan/pending", headers=headers)
|
|
by_id = {d["id"]: d for d in res.json()}
|
|
assert by_id[device.id]["canvas_count"] == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_device_without_node_has_null_node_timestamps(client, headers, pending_device):
|
|
# No matching canvas node → node_* timestamps are all null; the device still
|
|
# carries its own discovered_at for the "Discovered" fallback on the tile.
|
|
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
|
assert data["discovered_at"] is not None
|
|
assert data["node_created_at"] is None
|
|
assert data["node_last_scan"] is None
|
|
assert data["node_last_modified"] is None
|
|
assert data["node_last_seen"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_device_exposes_linked_node_timestamps(client, headers, db_session, pending_device):
|
|
d1 = await _add_design(db_session, "Home")
|
|
node = _node(d1, ip="192.168.1.100")
|
|
node.last_scan = datetime(2026, 6, 1, 8, 30, tzinfo=timezone.utc)
|
|
node.last_seen = datetime(2026, 6, 25, 9, 15, tzinfo=timezone.utc)
|
|
db_session.add(node)
|
|
await db_session.commit()
|
|
|
|
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
|
assert data["node_created_at"] is not None # defaulted on insert
|
|
assert data["node_last_modified"] is not None # updated_at defaulted on insert
|
|
assert data["node_last_scan"].startswith("2026-06-01")
|
|
assert data["node_last_seen"].startswith("2026-06-25")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_node_timestamps_aggregate_across_matches(client, headers, db_session, pending_device):
|
|
# Two canvas nodes share the device IP: created_at takes the OLDEST,
|
|
# last_scan takes the NEWEST.
|
|
d1 = await _add_design(db_session, "Home")
|
|
d2 = await _add_design(db_session, "Lab")
|
|
older = _node(d1, ip="192.168.1.100")
|
|
older.created_at = datetime(2026, 1, 1, 0, 0, tzinfo=timezone.utc)
|
|
older.last_scan = datetime(2026, 3, 1, 0, 0, tzinfo=timezone.utc)
|
|
newer = _node(d2, ip="192.168.1.100")
|
|
newer.created_at = datetime(2026, 5, 1, 0, 0, tzinfo=timezone.utc)
|
|
newer.last_scan = datetime(2026, 6, 1, 0, 0, tzinfo=timezone.utc)
|
|
db_session.add_all([older, newer])
|
|
await db_session.commit()
|
|
|
|
data = (await client.get("/api/v1/scan/pending", headers=headers)).json()[0]
|
|
assert data["node_created_at"].startswith("2026-01-01") # oldest
|
|
assert data["node_last_scan"].startswith("2026-06-01") # newest
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_deep_scan_falls_back_to_settings():
|
|
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
|
|
|
|
with patch("app.api.routes.scan.settings") as mock_settings:
|
|
mock_settings.scanner_http_ranges = ["7000-7100"]
|
|
mock_settings.scanner_http_probe_enabled = True
|
|
mock_settings.scanner_http_verify_tls = False
|
|
# Empty payload → all values come from settings defaults
|
|
ds = _resolve_deep_scan(TriggerScanRequest())
|
|
assert ds.http_ranges == ["7000-7100"]
|
|
assert ds.http_probe_enabled is True
|
|
assert ds.verify_tls is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_deep_scan_override_wins():
|
|
from app.api.routes.scan import TriggerScanRequest, _resolve_deep_scan
|
|
|
|
with patch("app.api.routes.scan.settings") as mock_settings:
|
|
mock_settings.scanner_http_ranges = []
|
|
mock_settings.scanner_http_probe_enabled = False
|
|
mock_settings.scanner_http_verify_tls = False
|
|
ds = _resolve_deep_scan(
|
|
TriggerScanRequest(http_ranges=["9000"], http_probe_enabled=True, verify_tls=True)
|
|
)
|
|
assert ds.http_ranges == ["9000"]
|
|
assert ds.http_probe_enabled is True
|
|
assert ds.verify_tls is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_scan_passes_deep_scan_options(client: AsyncClient, headers):
|
|
captured = {}
|
|
|
|
async def fake_bg(run_id, ranges, deep_scan):
|
|
captured["deep_scan"] = deep_scan
|
|
|
|
with (
|
|
patch("app.api.routes.scan._background_scan", new=fake_bg),
|
|
patch("app.api.routes.scan.settings") as mock_settings,
|
|
):
|
|
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
|
mock_settings.scanner_http_ranges = []
|
|
mock_settings.scanner_http_probe_enabled = False
|
|
mock_settings.scanner_http_verify_tls = False
|
|
res = await client.post(
|
|
"/api/v1/scan/trigger",
|
|
json={"http_probe_enabled": True, "http_ranges": ["8000-8100"]},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
assert captured["deep_scan"].http_probe_enabled is True
|
|
assert captured["deep_scan"].http_ranges == ["8000-8100"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_scan_rejects_invalid_port_range(client: AsyncClient, headers):
|
|
with patch("app.api.routes.scan.settings") as mock_settings:
|
|
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
|
res = await client.post(
|
|
"/api/v1/scan/trigger",
|
|
json={"http_ranges": ["70000-80000"]},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 422
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_scan_config_includes_deep_scan(client: AsyncClient, headers):
|
|
with patch("app.api.routes.scan.settings") as mock_settings:
|
|
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
|
mock_settings.scanner_http_ranges = ["8000-8100"]
|
|
mock_settings.scanner_http_probe_enabled = True
|
|
mock_settings.scanner_http_verify_tls = False
|
|
res = await client.get("/api/v1/scan/config", headers=headers)
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["http_ranges"] == ["8000-8100"]
|
|
assert data["http_probe_enabled"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_scan_config_persists_deep_scan(client: AsyncClient, headers):
|
|
saved = {}
|
|
|
|
with patch("app.api.routes.scan.settings") as mock_settings:
|
|
mock_settings.scanner_ranges = ["192.168.1.0/24"]
|
|
mock_settings.scanner_http_ranges = []
|
|
mock_settings.scanner_http_probe_enabled = False
|
|
mock_settings.scanner_http_verify_tls = False
|
|
mock_settings.save_overrides = lambda: saved.update(
|
|
http_ranges=mock_settings.scanner_http_ranges,
|
|
probe=mock_settings.scanner_http_probe_enabled,
|
|
)
|
|
res = await client.post(
|
|
"/api/v1/scan/config",
|
|
json={
|
|
"ranges": ["192.168.1.0/24"],
|
|
"http_ranges": ["9000-9100"],
|
|
"http_probe_enabled": True,
|
|
"verify_tls": True,
|
|
},
|
|
headers=headers,
|
|
)
|
|
assert res.status_code == 200
|
|
assert saved == {"http_ranges": ["9000-9100"], "probe": True}
|