"""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}