diff --git a/backend/tests/scan/__init__.py b/backend/tests/scan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/scan/conftest.py b/backend/tests/scan/conftest.py new file mode 100644 index 0000000..e366270 --- /dev/null +++ b/backend/tests/scan/conftest.py @@ -0,0 +1,84 @@ +"""Fixtures shared across the scan test modules.""" +import uuid + +import pytest + +from app.db.models import PendingDevice + + +@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 + + +@pytest.fixture +async def mem_db(): + from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine + + from app.db.database import Base + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + factory = async_sessionmaker(engine, expire_on_commit=False) + yield factory + await engine.dispose() + + +@pytest.fixture +async def two_pending_devices(db_session): + devices = [] + for i in range(2): + d = PendingDevice( + id=str(uuid.uuid4()), + ip=f"192.168.1.{10 + i}", + mac=None, + hostname=f"host-{i}", + os=None, + services=[], + suggested_type="generic", + status="pending", + ) + db_session.add(d) + devices.append(d) + await db_session.commit() + for d in devices: + await db_session.refresh(d) + return devices + + +@pytest.fixture +async def zigbee_pending_device(db_session): + device = PendingDevice( + id=str(uuid.uuid4()), + ip=None, + mac=None, + hostname=None, + friendly_name="bulb_1", + services=[], + suggested_type="zigbee_enddevice", + device_subtype="EndDevice", + ieee_address="0xABCDEF", + vendor="IKEA", + model="TRADFRI", + lqi=180, + status="pending", + discovery_source="zigbee", + ) + db_session.add(device) + await db_session.commit() + await db_session.refresh(device) + return device diff --git a/backend/tests/scan/helpers.py b/backend/tests/scan/helpers.py new file mode 100644 index 0000000..dd87ea3 --- /dev/null +++ b/backend/tests/scan/helpers.py @@ -0,0 +1,52 @@ +"""Shared builders for scan test suite (pure helpers, no fixtures).""" +import uuid + +from app.db.models import Design, Node, PendingDevice + + +async def _add_design(db_session, name: str) -> str: + design = Design(id=str(uuid.uuid4()), name=name) + db_session.add(design) + await db_session.commit() + return design.id + + +def _node(design_id: str, *, ip=None, ieee=None, mac=None) -> Node: + return Node( + id=str(uuid.uuid4()), label="n", type="server", status="online", + ip=ip, mac=mac, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0, + design_id=design_id, + ) + + +async def _seed_zigbee_pending_pair(db_session): + """Create a coordinator Node + a pending device + a link between them.""" + from app.db.models import Node, PendingDeviceLink + + coord = Node( + label="Coordinator", + type="zigbee_coordinator", + status="unknown", + ieee_address="0xCOORD", + ) + db_session.add(coord) + + pending = PendingDevice( + ieee_address="0xR1", + friendly_name="router_1", + suggested_type="zigbee_router", + device_subtype="Router", + status="pending", + discovery_source="zigbee", + ) + db_session.add(pending) + + db_session.add( + PendingDeviceLink( + source_ieee="0xCOORD", + target_ieee="0xR1", + discovery_source="zigbee", + ) + ) + await db_session.commit() + return coord, pending diff --git a/backend/tests/scan/test_scan_approve.py b/backend/tests/scan/test_scan_approve.py new file mode 100644 index 0000000..ae12cc5 --- /dev/null +++ b/backend/tests/scan/test_scan_approve.py @@ -0,0 +1,723 @@ +"""Approve / hide / restore / ignore / bulk device flows and conflict handling.""" +import uuid + +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from app.db.models import Design, Node, PendingDevice +from tests.scan.helpers import _add_design, _node + + +@pytest.mark.asyncio +async def test_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device): + # A node with no design_id is not "on a canvas". + db_session.add(_node(None, ip="192.168.1.100")) + await db_session.commit() + + res = await client.get("/api/v1/scan/pending", headers=headers) + assert res.json()[0]["canvas_count"] == 0 + + +@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 + + # Approved devices stay in the inventory (status != "hidden") so they keep + # showing with an "In N canvas" badge — they are no longer dropped. + pending_res = await client.get("/api/v1/scan/pending", headers=headers) + inventory = pending_res.json() + assert len(inventory) == 1 + assert inventory[0]["id"] == pending_device.id + assert inventory[0]["status"] == "approved" + + +@pytest.mark.asyncio +async def test_approve_device_conflicts_on_existing_ieee_same_design( + client: AsyncClient, headers, db_session +): + """Approving a device whose IEEE is already on the target design prompts the + user (409) instead of silently merging/replacing — same UX as ip/mac.""" + design = Design(name="d1") + db_session.add(design) + await db_session.flush() + existing = Node( + label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ", + services=[], design_id=design.id, + ) + db_session.add(existing) + device = PendingDevice( + id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice", + status="pending", discovery_source="zigbee", + ) + db_session.add(device) + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{device.id}/approve", + json={ + "label": "sensor", "type": "zigbee_enddevice", + "status": "online", "services": [], "design_id": design.id, + }, + headers=headers, + ) + assert res.status_code == 409 + detail = res.json()["detail"] + assert detail["duplicate"] is True + assert detail["existing_node_id"] == existing.id + assert detail["match"] == "ieee" + assert detail["value"] == "0xZZZ" + + # No second node created; device stays pending until the user decides. + nodes = ( + await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ")) + ).scalars().all() + assert len(nodes) == 1 + + +@pytest.mark.asyncio +async def test_approve_device_force_creates_duplicate_ieee( + client: AsyncClient, headers, db_session +): + """force=True lets the user place a second card for the same IEEE.""" + design = Design(name="d1") + db_session.add(design) + await db_session.flush() + db_session.add(Node( + label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ", + services=[], design_id=design.id, + )) + device = PendingDevice( + id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice", + status="pending", discovery_source="zigbee", + ) + db_session.add(device) + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{device.id}/approve", + json={ + "label": "sensor", "type": "zigbee_enddevice", "status": "online", + "services": [], "design_id": design.id, "force": True, + }, + headers=headers, + ) + assert res.status_code == 200 + nodes = ( + await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ")) + ).scalars().all() + assert len(nodes) == 2 + + +@pytest.mark.asyncio +async def test_approve_device_conflicts_on_existing_ip( + client: AsyncClient, headers, db_session, pending_device +): + """An ordinary host whose ip already sits on the target design is NOT + silently duplicated: the approve returns 409 with the existing node so the + UI can ask the user.""" + design = await _add_design(db_session, "Home") + existing = _node(design, ip="192.168.1.100") + db_session.add(existing) + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json={"label": "dup", "type": "server", "ip": "192.168.1.100", + "status": "unknown", "services": [], "design_id": design}, + headers=headers, + ) + assert res.status_code == 409 + detail = res.json()["detail"] + assert detail["duplicate"] is True + assert detail["existing_node_id"] == existing.id + assert detail["match"] == "ip" + assert detail["value"] == "192.168.1.100" + + # No node created, device left pending (user hasn't decided yet). + nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all() + assert len(nodes) == 1 + await db_session.refresh(pending_device) + assert pending_device.status == "pending" + + +@pytest.mark.asyncio +async def test_approve_device_conflicts_on_existing_mac( + client: AsyncClient, headers, db_session, pending_device +): + """MAC match (device re-IP'd via DHCP) also triggers the duplicate guard.""" + design = await _add_design(db_session, "Home") + existing = Node(id=str(uuid.uuid4()), label="n", type="server", status="online", + ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff", services=[], design_id=design) + db_session.add(existing) + await db_session.commit() + + # pending_device carries mac aa:bb:cc:dd:ee:ff but a different ip. + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json={"label": "dup", "type": "server", "ip": "192.168.1.55", + "mac": "aa:bb:cc:dd:ee:ff", "status": "unknown", "services": [], + "design_id": design}, + headers=headers, + ) + assert res.status_code == 409 + assert res.json()["detail"]["match"] == "mac" + + +@pytest.mark.asyncio +async def test_approve_device_conflicts_on_ip_in_comma_list( + client: AsyncClient, headers, db_session, pending_device +): + """The existing node's ip holds an IPv6 before the IPv4 the device scanned + as. Exact-string matching missed it (issue #258); per-token matching catches + the duplicate.""" + design = await _add_design(db_session, "Home") + existing = _node(design, ip="fe80::1, 192.168.1.100") + db_session.add(existing) + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json={"label": "dup", "type": "server", "ip": "192.168.1.100", + "status": "unknown", "services": [], "design_id": design}, + headers=headers, + ) + assert res.status_code == 409 + detail = res.json()["detail"] + assert detail["existing_node_id"] == existing.id + assert detail["match"] == "ip" + assert detail["value"] == "192.168.1.100" + + +@pytest.mark.asyncio +async def test_approve_device_no_conflict_on_ip_substring( + client: AsyncClient, headers, db_session, pending_device +): + """The ip guard must match whole addresses, not substrings: a node at + 10.0.0.40 is not a duplicate of a device at 10.0.0.4.""" + design = await _add_design(db_session, "Home") + db_session.add(_node(design, ip="10.0.0.40")) + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json={"label": "new", "type": "server", "ip": "10.0.0.4", + "mac": None, "status": "unknown", "services": [], "design_id": design}, + headers=headers, + ) + assert res.status_code == 200 + + +@pytest.mark.asyncio +async def test_approve_device_force_creates_duplicate( + client: AsyncClient, headers, db_session, pending_device +): + """force=True (user confirmed) bypasses the guard and creates the node.""" + design = await _add_design(db_session, "Home") + db_session.add(_node(design, ip="192.168.1.100")) + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json={"label": "dup", "type": "server", "ip": "192.168.1.100", + "status": "unknown", "services": [], "design_id": design, "force": True}, + headers=headers, + ) + assert res.status_code == 200 + nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all() + assert len(nodes) == 2 # duplicate deliberately created + + +@pytest.mark.asyncio +async def test_approve_device_allows_same_ip_on_other_design( + client: AsyncClient, headers, db_session, pending_device +): + """The guard is per-design: the same host on a different canvas is fine.""" + other = await _add_design(db_session, "Lab") + target = await _add_design(db_session, "Home") + db_session.add(_node(other, ip="192.168.1.100")) # exists on a DIFFERENT design + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json={"label": "ok", "type": "server", "ip": "192.168.1.100", + "status": "unknown", "services": [], "design_id": target}, + headers=headers, + ) + assert res.status_code == 200 + + +@pytest.mark.asyncio +async def test_approve_device_places_already_approved_on_another_design( + client: AsyncClient, headers, db_session +): + """A device already approved on ANOTHER canvas (global status="approved") + must still be placeable on a new design — status is global, canvas + membership is per-design (mirrors bulk_approve).""" + other = await _add_design(db_session, "Other") + target = await _add_design(db_session, "Network Topology") + # Device is on `other` already (its global status is "approved"). + db_session.add(_node(other, ieee="0x00158d0005292b83")) + device = PendingDevice( + id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83", + suggested_type="zigbee_enddevice", status="approved", + discovery_source="zigbee", + ) + db_session.add(device) + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{device.id}/approve", + json={"label": "sensor", "type": "zigbee_enddevice", "status": "online", + "services": [], "design_id": target}, + headers=headers, + ) + assert res.status_code == 200 + # A node now exists on the target design too (one per canvas). + nodes = ( + await db_session.execute( + select(Node).where(Node.ieee_address == "0x00158d0005292b83") + ) + ).scalars().all() + assert {n.design_id for n in nodes} == {other, target} + + +@pytest.mark.asyncio +async def test_approve_device_rejects_hidden(client: AsyncClient, headers, db_session, pending_device): + """A user-hidden device is not approvable via this endpoint.""" + pending_device.status = "hidden" + db_session.add(pending_device) + await db_session.commit() + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json={"label": "x", "type": "server", "status": "unknown", "services": []}, + headers=headers, + ) + assert res.status_code == 409 + + +@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 == 404 + + +@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 + + +@pytest.mark.asyncio +async def test_restore_device(client: AsyncClient, headers, pending_device): + # Hide first + await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers) + + # Restore + res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers) + assert res.status_code == 200 + assert res.json()["restored"] is True + + # Now back in pending, gone from hidden + pending_res = await client.get("/api/v1/scan/pending", headers=headers) + assert len(pending_res.json()) == 1 + hidden_res = await client.get("/api/v1/scan/hidden", headers=headers) + assert hidden_res.json() == [] + + +@pytest.mark.asyncio +async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device): + res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers) + assert res.status_code == 409 + + +@pytest.mark.asyncio +async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device): + # Hide + await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers) + + res = await client.post( + "/api/v1/scan/pending/bulk-restore", + headers=headers, + json={"device_ids": [pending_device.id]}, + ) + assert res.status_code == 200 + assert res.json()["restored"] == 1 + assert res.json()["skipped"] == 0 + + pending_res = await client.get("/api/v1/scan/pending", headers=headers) + assert len(pending_res.json()) == 1 + + +@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() == [] + + +@pytest.mark.asyncio +async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices): + ids = [d.id for d in two_pending_devices] + res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["approved"] == 2 + assert len(data["node_ids"]) == 2 + assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs" + assert len(data["device_ids"]) == 2 + assert data["skipped"] == 0 + # Approved devices stay in the inventory, now marked "approved". + pending_res = await client.get("/api/v1/scan/pending", headers=headers) + inventory = pending_res.json() + assert len(inventory) == 2 + assert all(d["status"] == "approved" for d in inventory) + + +@pytest.mark.asyncio +async def test_bulk_approve_places_already_approved_device_on_another_design( + client: AsyncClient, headers, db_session, two_pending_devices +): + """Regression: a device already approved (status='approved', e.g. placed on + another canvas) must still get a node on the design being approved onto. + + Previously bulk-approve filtered status=='pending', so selecting an + already-approved device created no node — the user saw fewer nodes than + they selected.""" + ids = [d.id for d in two_pending_devices] + design_a = await _add_design(db_session, "Canvas A") + design_b = await _add_design(db_session, "Canvas B") + + # Approve both onto design A. + res_a = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": ids, "design_id": design_a}, + headers=headers, + ) + assert res_a.json()["approved"] == 2 + + # Re-approve the same (now status='approved') devices onto design B. + res_b = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": ids, "design_id": design_b}, + headers=headers, + ) + data_b = res_b.json() + assert data_b["approved"] == 2, "already-approved devices must place onto the new canvas" + assert data_b["skipped"] == 0 + + # Two nodes now exist on each design. + from app.db.models import Node as NodeModel + nodes_b = ( + await db_session.execute(select(NodeModel).where(NodeModel.design_id == design_b)) + ).scalars().all() + assert len(nodes_b) == 2 + + +@pytest.mark.asyncio +async def test_bulk_approve_skips_device_already_on_target_design( + client: AsyncClient, headers, db_session, two_pending_devices +): + """A device already on the target canvas (same ip) is not placed twice.""" + ids = [d.id for d in two_pending_devices] + design = await _add_design(db_session, "Canvas") + # First device already sits on the canvas (matched by ip). + db_session.add(_node(design, ip="192.168.1.10")) + await db_session.commit() + + res = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": ids, "design_id": design}, + headers=headers, + ) + data = res.json() + assert data["approved"] == 1 # only the second device (192.168.1.11) + assert data["skipped"] == 1 + + from app.db.models import Node as NodeModel + nodes = ( + await db_session.execute(select(NodeModel).where(NodeModel.design_id == design)) + ).scalars().all() + # The pre-existing node plus the one newly approved — no duplicate for .10. + assert len(nodes) == 2 + assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"] + + +@pytest.mark.asyncio +async def test_bulk_approve_skips_device_matching_ip_in_comma_list( + client: AsyncClient, headers, db_session, two_pending_devices +): + """The on-canvas node's ip holds an IPv6 before the IPv4; the device scanned + as the plain IPv4 is still recognised as already placed (issue #258).""" + ids = [d.id for d in two_pending_devices] + design = await _add_design(db_session, "Canvas") + db_session.add(_node(design, ip="fe80::1, 192.168.1.10")) + await db_session.commit() + + res = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": ids, "design_id": design}, + headers=headers, + ) + data = res.json() + assert data["approved"] == 1 # only the second device (192.168.1.11) + assert data["skipped"] == 1 + assert data["skipped_devices"][0]["value"] == "192.168.1.10" + + +@pytest.mark.asyncio +async def test_bulk_approve_reports_skipped_devices( + client: AsyncClient, headers, db_session, two_pending_devices +): + """Bulk can't prompt per-device, so it reports each duplicate it skipped + (with the existing node id) instead of silently dropping it.""" + ids = [d.id for d in two_pending_devices] + design = await _add_design(db_session, "Canvas") + existing = _node(design, ip="192.168.1.10") + db_session.add(existing) + await db_session.commit() + + res = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": ids, "design_id": design}, + headers=headers, + ) + data = res.json() + assert data["approved"] == 1 + skipped = data["skipped_devices"] + assert len(skipped) == 1 + entry = skipped[0] + assert entry["match"] == "ip" + assert entry["value"] == "192.168.1.10" + assert entry["existing_node_id"] == existing.id + assert entry["device_id"] in ids + + +@pytest.mark.asyncio +async def test_approve_device_copies_mac_to_node_and_properties( + client: AsyncClient, headers, pending_device, db_session +): + """Approving a scanned device must carry its MAC onto the node + properties.""" + from sqlalchemy import select + + from app.db.models import Node as NodeModel + # Payload intentionally omits mac — it must come from the pending device. + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []}, + headers=headers, + ) + assert res.status_code == 200 + node = ( + await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100")) + ).scalar_one() + assert node.mac == "aa:bb:cc:dd:ee:ff" + mac_props = [p for p in node.properties if p["key"] == "MAC"] + assert mac_props == [ + {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} + ] + + +@pytest.mark.asyncio +async def test_bulk_approve_copies_mac_to_node_and_properties( + client: AsyncClient, headers, db_session +): + """Bulk approve must also propagate the scanned MAC to node + properties.""" + from sqlalchemy import select + + from app.db.models import Node as NodeModel + device = PendingDevice( + id=str(uuid.uuid4()), + ip="192.168.1.55", + mac="11:22:33:44:55:66", + hostname="host-mac", + services=[], + suggested_type="generic", + status="pending", + ) + db_session.add(device) + await db_session.commit() + + res = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": [device.id]}, + headers=headers, + ) + assert res.status_code == 200 + node = ( + await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55")) + ).scalar_one() + assert node.mac == "11:22:33:44:55:66" + mac_props = [p for p in node.properties if p["key"] == "MAC"] + assert mac_props == [ + {"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False} + ] + + +@pytest.mark.asyncio +async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session): + """Approved devices with an IP must default to ping; otherwise scheduler skips them.""" + from sqlalchemy import select + + from app.db.models import Node as NodeModel + ids = [d.id for d in two_pending_devices] + res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers) + assert res.status_code == 200 + nodes = (await db_session.execute(select(NodeModel))).scalars().all() + for n in nodes: + if n.ip: + assert n.check_method == "ping", f"node {n.id} created without check_method" + + +@pytest.mark.asyncio +async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session): + from sqlalchemy import select + + from app.db.models import Node as NodeModel + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []}, + headers=headers, + ) + assert res.status_code == 200 + node = (await db_session.execute(select(NodeModel))).scalars().first() + assert node is not None + assert node.check_method == "ping" + + +@pytest.mark.asyncio +async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices): + ids = [d.id for d in two_pending_devices] + # Approve first device individually first + await client.post( + f"/api/v1/scan/pending/{ids[0]}/approve", + json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []}, + headers=headers, + ) + # Bulk approve both — first one is already approved (not pending), should be skipped + res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["approved"] == 1 + assert data["skipped"] == 1 + + +@pytest.mark.asyncio +async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices): + ids = [d.id for d in two_pending_devices] + res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}) + assert res.status_code == 401 + + +@pytest.mark.asyncio +async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices): + ids = [d.id for d in two_pending_devices] + res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["hidden"] == 2 + assert data["skipped"] == 0 + # Should appear in hidden list + hidden_res = await client.get("/api/v1/scan/hidden", headers=headers) + assert len(hidden_res.json()) == 2 + + +@pytest.mark.asyncio +async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices): + ids = [d.id for d in two_pending_devices] + # Hide first device individually first + await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers) + # Bulk hide both — first is already hidden (not pending anymore) + res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers) + assert res.status_code == 200 + data = res.json() + assert data["hidden"] == 1 + assert data["skipped"] == 1 + + +@pytest.mark.asyncio +async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices): + ids = [d.id for d in two_pending_devices] + res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}) + assert res.status_code == 401 + + +@pytest.mark.asyncio +async def test_bulk_approve_targets_requested_design(client, headers, db_session): + """bulk-approve must place nodes on the design_id sent by the UI, not the + first design — otherwise approved devices land on the wrong canvas.""" + first = await _add_design(db_session, "Default") # first design (fallback) + active = await _add_design(db_session, "zwave") # the design the user is on + dev = PendingDevice( + id=str(uuid.uuid4()), + ieee_address="zwave-H-2", + friendly_name="Living Room Plug", + suggested_type="zwave_router", + device_subtype="Router", + vendor="Aeotec", + model="ZW096", + status="pending", + discovery_source="zwave", + ) + db_session.add(dev) + await db_session.commit() + + res = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": [dev.id], "design_id": active}, + headers=headers, + ) + assert res.status_code == 200 + assert res.json()["approved"] == 1 + + node = ( + await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2")) + ).scalar_one() + assert node.design_id == active + assert node.design_id != first + # Z-Wave device → online + Z-Wave property rows, no ICMP check. + assert node.status == "online" + assert node.check_method == "none" + assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"} diff --git a/backend/tests/scan/test_scan_properties.py b/backend/tests/scan/test_scan_properties.py new file mode 100644 index 0000000..8289472 --- /dev/null +++ b/backend/tests/scan/test_scan_properties.py @@ -0,0 +1,336 @@ +"""MAC-property builders and Zigbee/Z-Wave property population on approve.""" +import uuid + +import pytest +from httpx import AsyncClient +from sqlalchemy import select + +from app.db.models import Node, PendingDevice +from tests.scan.helpers import _add_design, _seed_zigbee_pending_pair + + +@pytest.mark.asyncio +async def test_approve_zigbee_device_populates_properties( + client: AsyncClient, headers, zigbee_pending_device, db_session +): + """Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties.""" + from sqlalchemy import select + + from app.db.models import Node as NodeModel + payload = { + "label": "bulb_1", + "type": "zigbee_enddevice", + "status": "online", + "services": [], + "check_method": "none", + } + res = await client.post( + f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve", + json=payload, + headers=headers, + ) + assert res.status_code == 200 + node = ( + await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF")) + ).scalar_one() + keys = {p["key"]: p["value"] for p in node.properties} + assert keys == { + "IEEE": "0xABCDEF", + "Vendor": "IKEA", + "Model": "TRADFRI", + "LQI": "180", + } + + +@pytest.mark.asyncio +async def test_bulk_approve_zigbee_populates_properties( + client: AsyncClient, headers, zigbee_pending_device, db_session +): + from sqlalchemy import select + + from app.db.models import Node as NodeModel + res = await client.post( + "/api/v1/scan/pending/bulk-approve", + json={"device_ids": [zigbee_pending_device.id]}, + headers=headers, + ) + assert res.status_code == 200 + node = ( + await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF")) + ).scalar_one() + keys = {p["key"]: p["value"] for p in node.properties} + assert keys["IEEE"] == "0xABCDEF" + assert keys["Vendor"] == "IKEA" + assert keys["Model"] == "TRADFRI" + assert keys["LQI"] == "180" + assert node.check_method == "none" + + +def test_build_mac_property_returns_hidden_row(): + from app.api.routes.scan import build_mac_property + + assert build_mac_property("aa:bb:cc:dd:ee:ff") == [ + {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} + ] + + +def test_build_mac_property_empty_when_no_mac(): + from app.api.routes.scan import build_mac_property + + assert build_mac_property(None) == [] + assert build_mac_property("") == [] + + +def test_merge_mac_property_appends_when_absent(): + from app.api.routes.scan import merge_mac_property + + existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}] + merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff") + assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged + # Existing prop preserved untouched. + assert existing[0] in merged + + +def test_merge_mac_property_idempotent_and_preserves_visibility(): + from app.api.routes.scan import merge_mac_property + + existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}] + merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff") + # No duplicate MAC row; user's visible=True choice kept. + macs = [p for p in merged if p["key"] == "MAC"] + assert len(macs) == 1 + assert macs[0]["visible"] is True + + +def test_merge_mac_property_noop_without_mac(): + from app.api.routes.scan import merge_mac_property + + existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}] + assert merge_mac_property(existing, None) == existing + + +@pytest.mark.asyncio +async def test_approve_device_does_not_duplicate_mac_property( + client: AsyncClient, headers, pending_device, db_session +): + """If the approve payload already carries a MAC prop, don't add a second one.""" + from sqlalchemy import select + + from app.db.models import Node as NodeModel + res = await client.post( + f"/api/v1/scan/pending/{pending_device.id}/approve", + json={ + "label": "My Server", + "type": "server", + "ip": "192.168.1.100", + "status": "unknown", + "services": [], + "properties": [ + {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True} + ], + }, + headers=headers, + ) + assert res.status_code == 200 + node = ( + await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100")) + ).scalar_one() + mac_props = [p for p in node.properties if p["key"] == "MAC"] + assert len(mac_props) == 1 + # User's visibility choice is preserved. + assert mac_props[0]["visible"] is True + + +@pytest.mark.asyncio +async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node( + client: AsyncClient, headers, db_session +): + from sqlalchemy import select + + from app.db.models import Edge + + coord, pending = await _seed_zigbee_pending_pair(db_session) + + res = await client.post( + f"/api/v1/scan/pending/{pending.id}/approve", + json={ + "label": "router_1", + "type": "zigbee_router", + "ip": None, + "status": "unknown", + "services": [], + }, + headers=headers, + ) + assert res.status_code == 200 + data = res.json() + assert data["approved"] is True + assert data["edges_created"] == 1 + + edges = (await db_session.execute(select(Edge))).scalars().all() + assert len(edges) == 1 + assert edges[0].source == coord.id + assert edges[0].target == data["node_id"] + assert edges[0].source_handle == "bottom" + # Bare side name (canonical stored form); renders at the top like before. + assert edges[0].target_handle == "top" + assert edges[0].type == "iot" + + +@pytest.mark.asyncio +async def test_approve_zigbee_skips_duplicate_edge( + client: AsyncClient, headers, db_session +): + """Re-running the resolution does not create a second edge for the same pair.""" + from sqlalchemy import select + + from app.db.models import Edge, PendingDevice, PendingDeviceLink + + coord, pending = await _seed_zigbee_pending_pair(db_session) + body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []} + await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers) + + # Simulate a second pending row + link between same coord and a new device, + # but keep an existing edge in place to verify dedupe also handles + # the swapped-direction case. + new_pending = PendingDevice( + ieee_address="0xR1B", + friendly_name="r1b", + suggested_type="zigbee_router", + status="pending", + discovery_source="zigbee", + ) + db_session.add(new_pending) + db_session.add( + PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee") + ) + await db_session.commit() + res = await client.post( + f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers + ) + assert res.json()["edges_created"] == 1 # only the new pair + edges = (await db_session.execute(select(Edge))).scalars().all() + assert len(edges) == 2 # original + new, no duplicate + + +@pytest.mark.asyncio +async def test_approve_zigbee_skips_when_other_endpoint_still_pending( + client: AsyncClient, headers, db_session +): + """Both endpoints pending → no edge yet, link row preserved for later.""" + from sqlalchemy import select + + from app.db.models import Edge, PendingDevice, PendingDeviceLink + + a = PendingDevice( + ieee_address="0xA", + friendly_name="a", + suggested_type="zigbee_router", + status="pending", + discovery_source="zigbee", + ) + b = PendingDevice( + ieee_address="0xB", + friendly_name="b", + suggested_type="zigbee_enddevice", + status="pending", + discovery_source="zigbee", + ) + db_session.add_all([a, b]) + db_session.add( + PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee") + ) + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{a.id}/approve", + json={ + "label": "a", + "type": "zigbee_router", + "ip": None, + "status": "unknown", + "services": [], + }, + headers=headers, + ) + assert res.status_code == 200 + assert res.json()["edges_created"] == 0 + + edges = (await db_session.execute(select(Edge))).scalars().all() + assert edges == [] + links = (await db_session.execute(select(PendingDeviceLink))).scalars().all() + assert len(links) == 1 # preserved for later resolution + + +@pytest.mark.asyncio +async def test_approve_zigbee_resolves_link_after_second_approval( + client: AsyncClient, headers, db_session +): + """First approval keeps link (other endpoint pending); second approval + creates the edge. The link row is retained afterwards so the same pair can + be re-approved onto another canvas — it's topology, wiped only on reimport.""" + from sqlalchemy import select + + from app.db.models import Edge, PendingDevice, PendingDeviceLink + + a = PendingDevice( + ieee_address="0xA", + friendly_name="a", + suggested_type="zigbee_router", + status="pending", + discovery_source="zigbee", + ) + b = PendingDevice( + ieee_address="0xB", + friendly_name="b", + suggested_type="zigbee_enddevice", + status="pending", + discovery_source="zigbee", + ) + db_session.add_all([a, b]) + db_session.add( + PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee") + ) + await db_session.commit() + + body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []} + await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers) + res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers) + assert res.json()["edges_created"] == 1 + + edges = (await db_session.execute(select(Edge))).scalars().all() + assert len(edges) == 1 + links = (await db_session.execute(select(PendingDeviceLink))).scalars().all() + assert len(links) == 1 # retained for re-approval onto other canvases + + +@pytest.mark.asyncio +async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session): + active = await _add_design(db_session, "zwave") + dev = PendingDevice( + id=str(uuid.uuid4()), + ieee_address="zwave-H-9", + friendly_name="Door Sensor", + suggested_type="zwave_enddevice", + vendor="Aeotec", + model="ZW120", + status="pending", + discovery_source="zwave", + ) + db_session.add(dev) + await db_session.commit() + + res = await client.post( + f"/api/v1/scan/pending/{dev.id}/approve", + json={"label": "Door Sensor", "type": "zwave_enddevice", "design_id": active}, + headers=headers, + ) + assert res.status_code == 200 + + node = ( + await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9")) + ).scalar_one() + assert node.design_id == active + assert node.status == "online" + assert node.check_method == "none" + assert any(p["key"] == "Z-Wave ID" for p in node.properties) diff --git a/backend/tests/scan/test_scan_routes.py b/backend/tests/scan/test_scan_routes.py new file mode 100644 index 0000000..401dcfb --- /dev/null +++ b/backend/tests/scan/test_scan_routes.py @@ -0,0 +1,262 @@ +"""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} diff --git a/backend/tests/scan/test_scan_run.py b/backend/tests/scan/test_scan_run.py new file mode 100644 index 0000000..0994265 --- /dev/null +++ b/backend/tests/scan/test_scan_run.py @@ -0,0 +1,431 @@ +"""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) diff --git a/backend/tests/test_scan.py b/backend/tests/test_scan.py deleted file mode 100644 index 11f7646..0000000 --- a/backend/tests/test_scan.py +++ /dev/null @@ -1,1890 +0,0 @@ -"""Tests for scan routes: trigger, pending devices, approve/hide/ignore, stop.""" -import uuid -from datetime import datetime, timezone -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 Design, Node, PendingDevice, ScanRun -from app.services.scanner import _cancelled_runs, request_cancel, run_scan - - -@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 - - -# --- _background_scan error handling --- - -@pytest.fixture -async def mem_db(): - from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - - from app.db.database import Base - engine = create_async_engine("sqlite+aiosqlite:///:memory:") - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - factory = async_sessionmaker(engine, expire_on_commit=False) - yield factory - await engine.dispose() - - -@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() - - -# --- Trigger scan --- - -@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 - - -# --- 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" - # No matching node → not on any canvas. - assert data[0]["canvas_count"] == 0 - - -# --- Canvas-presence correlation (canvas_count) --- - -async def _add_design(db_session, name: str) -> str: - design = Design(id=str(uuid.uuid4()), name=name) - db_session.add(design) - await db_session.commit() - return design.id - - -def _node(design_id: str, *, ip=None, ieee=None, mac=None) -> Node: - return Node( - id=str(uuid.uuid4()), label="n", type="server", status="online", - ip=ip, mac=mac, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0, - design_id=design_id, - ) - - -@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_canvas_count_ignores_nodes_without_design(client, headers, db_session, pending_device): - # A node with no design_id is not "on a canvas". - db_session.add(_node(None, ip="192.168.1.100")) - await db_session.commit() - - res = await client.get("/api/v1/scan/pending", headers=headers) - assert res.json()[0]["canvas_count"] == 0 - - -# --- Linked-node timestamps on the inventory response --- - -@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 - - -# --- 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 - - # Approved devices stay in the inventory (status != "hidden") so they keep - # showing with an "In N canvas" badge — they are no longer dropped. - pending_res = await client.get("/api/v1/scan/pending", headers=headers) - inventory = pending_res.json() - assert len(inventory) == 1 - assert inventory[0]["id"] == pending_device.id - assert inventory[0]["status"] == "approved" - - -@pytest.mark.asyncio -async def test_approve_device_conflicts_on_existing_ieee_same_design( - client: AsyncClient, headers, db_session -): - """Approving a device whose IEEE is already on the target design prompts the - user (409) instead of silently merging/replacing — same UX as ip/mac.""" - design = Design(name="d1") - db_session.add(design) - await db_session.flush() - existing = Node( - label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ", - services=[], design_id=design.id, - ) - db_session.add(existing) - device = PendingDevice( - id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice", - status="pending", discovery_source="zigbee", - ) - db_session.add(device) - await db_session.commit() - - res = await client.post( - f"/api/v1/scan/pending/{device.id}/approve", - json={ - "label": "sensor", "type": "zigbee_enddevice", - "status": "online", "services": [], "design_id": design.id, - }, - headers=headers, - ) - assert res.status_code == 409 - detail = res.json()["detail"] - assert detail["duplicate"] is True - assert detail["existing_node_id"] == existing.id - assert detail["match"] == "ieee" - assert detail["value"] == "0xZZZ" - - # No second node created; device stays pending until the user decides. - nodes = ( - await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ")) - ).scalars().all() - assert len(nodes) == 1 - - -@pytest.mark.asyncio -async def test_approve_device_force_creates_duplicate_ieee( - client: AsyncClient, headers, db_session -): - """force=True lets the user place a second card for the same IEEE.""" - design = Design(name="d1") - db_session.add(design) - await db_session.flush() - db_session.add(Node( - label="sensor", type="zigbee_enddevice", ieee_address="0xZZZ", - services=[], design_id=design.id, - )) - device = PendingDevice( - id=str(uuid.uuid4()), ieee_address="0xZZZ", suggested_type="zigbee_enddevice", - status="pending", discovery_source="zigbee", - ) - db_session.add(device) - await db_session.commit() - - res = await client.post( - f"/api/v1/scan/pending/{device.id}/approve", - json={ - "label": "sensor", "type": "zigbee_enddevice", "status": "online", - "services": [], "design_id": design.id, "force": True, - }, - headers=headers, - ) - assert res.status_code == 200 - nodes = ( - await db_session.execute(select(Node).where(Node.ieee_address == "0xZZZ")) - ).scalars().all() - assert len(nodes) == 2 - - -@pytest.mark.asyncio -async def test_approve_device_conflicts_on_existing_ip( - client: AsyncClient, headers, db_session, pending_device -): - """An ordinary host whose ip already sits on the target design is NOT - silently duplicated: the approve returns 409 with the existing node so the - UI can ask the user.""" - design = await _add_design(db_session, "Home") - existing = _node(design, ip="192.168.1.100") - db_session.add(existing) - await db_session.commit() - - res = await client.post( - f"/api/v1/scan/pending/{pending_device.id}/approve", - json={"label": "dup", "type": "server", "ip": "192.168.1.100", - "status": "unknown", "services": [], "design_id": design}, - headers=headers, - ) - assert res.status_code == 409 - detail = res.json()["detail"] - assert detail["duplicate"] is True - assert detail["existing_node_id"] == existing.id - assert detail["match"] == "ip" - assert detail["value"] == "192.168.1.100" - - # No node created, device left pending (user hasn't decided yet). - nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all() - assert len(nodes) == 1 - await db_session.refresh(pending_device) - assert pending_device.status == "pending" - - -@pytest.mark.asyncio -async def test_approve_device_conflicts_on_existing_mac( - client: AsyncClient, headers, db_session, pending_device -): - """MAC match (device re-IP'd via DHCP) also triggers the duplicate guard.""" - design = await _add_design(db_session, "Home") - existing = Node(id=str(uuid.uuid4()), label="n", type="server", status="online", - ip="10.0.0.9", mac="aa:bb:cc:dd:ee:ff", services=[], design_id=design) - db_session.add(existing) - await db_session.commit() - - # pending_device carries mac aa:bb:cc:dd:ee:ff but a different ip. - res = await client.post( - f"/api/v1/scan/pending/{pending_device.id}/approve", - json={"label": "dup", "type": "server", "ip": "192.168.1.55", - "mac": "aa:bb:cc:dd:ee:ff", "status": "unknown", "services": [], - "design_id": design}, - headers=headers, - ) - assert res.status_code == 409 - assert res.json()["detail"]["match"] == "mac" - - -@pytest.mark.asyncio -async def test_approve_device_conflicts_on_ip_in_comma_list( - client: AsyncClient, headers, db_session, pending_device -): - """The existing node's ip holds an IPv6 before the IPv4 the device scanned - as. Exact-string matching missed it (issue #258); per-token matching catches - the duplicate.""" - design = await _add_design(db_session, "Home") - existing = _node(design, ip="fe80::1, 192.168.1.100") - db_session.add(existing) - await db_session.commit() - - res = await client.post( - f"/api/v1/scan/pending/{pending_device.id}/approve", - json={"label": "dup", "type": "server", "ip": "192.168.1.100", - "status": "unknown", "services": [], "design_id": design}, - headers=headers, - ) - assert res.status_code == 409 - detail = res.json()["detail"] - assert detail["existing_node_id"] == existing.id - assert detail["match"] == "ip" - assert detail["value"] == "192.168.1.100" - - -@pytest.mark.asyncio -async def test_approve_device_no_conflict_on_ip_substring( - client: AsyncClient, headers, db_session, pending_device -): - """The ip guard must match whole addresses, not substrings: a node at - 10.0.0.40 is not a duplicate of a device at 10.0.0.4.""" - design = await _add_design(db_session, "Home") - db_session.add(_node(design, ip="10.0.0.40")) - await db_session.commit() - - res = await client.post( - f"/api/v1/scan/pending/{pending_device.id}/approve", - json={"label": "new", "type": "server", "ip": "10.0.0.4", - "mac": None, "status": "unknown", "services": [], "design_id": design}, - headers=headers, - ) - assert res.status_code == 200 - - -@pytest.mark.asyncio -async def test_approve_device_force_creates_duplicate( - client: AsyncClient, headers, db_session, pending_device -): - """force=True (user confirmed) bypasses the guard and creates the node.""" - design = await _add_design(db_session, "Home") - db_session.add(_node(design, ip="192.168.1.100")) - await db_session.commit() - - res = await client.post( - f"/api/v1/scan/pending/{pending_device.id}/approve", - json={"label": "dup", "type": "server", "ip": "192.168.1.100", - "status": "unknown", "services": [], "design_id": design, "force": True}, - headers=headers, - ) - assert res.status_code == 200 - nodes = (await db_session.execute(select(Node).where(Node.design_id == design))).scalars().all() - assert len(nodes) == 2 # duplicate deliberately created - - -@pytest.mark.asyncio -async def test_approve_device_allows_same_ip_on_other_design( - client: AsyncClient, headers, db_session, pending_device -): - """The guard is per-design: the same host on a different canvas is fine.""" - other = await _add_design(db_session, "Lab") - target = await _add_design(db_session, "Home") - db_session.add(_node(other, ip="192.168.1.100")) # exists on a DIFFERENT design - await db_session.commit() - - res = await client.post( - f"/api/v1/scan/pending/{pending_device.id}/approve", - json={"label": "ok", "type": "server", "ip": "192.168.1.100", - "status": "unknown", "services": [], "design_id": target}, - headers=headers, - ) - assert res.status_code == 200 - - -@pytest.mark.asyncio -async def test_approve_device_places_already_approved_on_another_design( - client: AsyncClient, headers, db_session -): - """A device already approved on ANOTHER canvas (global status="approved") - must still be placeable on a new design — status is global, canvas - membership is per-design (mirrors bulk_approve).""" - other = await _add_design(db_session, "Other") - target = await _add_design(db_session, "Network Topology") - # Device is on `other` already (its global status is "approved"). - db_session.add(_node(other, ieee="0x00158d0005292b83")) - device = PendingDevice( - id=str(uuid.uuid4()), ieee_address="0x00158d0005292b83", - suggested_type="zigbee_enddevice", status="approved", - discovery_source="zigbee", - ) - db_session.add(device) - await db_session.commit() - - res = await client.post( - f"/api/v1/scan/pending/{device.id}/approve", - json={"label": "sensor", "type": "zigbee_enddevice", "status": "online", - "services": [], "design_id": target}, - headers=headers, - ) - assert res.status_code == 200 - # A node now exists on the target design too (one per canvas). - nodes = ( - await db_session.execute( - select(Node).where(Node.ieee_address == "0x00158d0005292b83") - ) - ).scalars().all() - assert {n.design_id for n in nodes} == {other, target} - - -@pytest.mark.asyncio -async def test_approve_device_rejects_hidden(client: AsyncClient, headers, db_session, pending_device): - """A user-hidden device is not approvable via this endpoint.""" - pending_device.status = "hidden" - db_session.add(pending_device) - await db_session.commit() - res = await client.post( - f"/api/v1/scan/pending/{pending_device.id}/approve", - json={"label": "x", "type": "server", "status": "unknown", "services": []}, - headers=headers, - ) - assert res.status_code == 409 - - -@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 == 404 - - -# --- 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 - - -# --- Restore hidden device --- - -@pytest.mark.asyncio -async def test_restore_device(client: AsyncClient, headers, pending_device): - # Hide first - await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers) - - # Restore - res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers) - assert res.status_code == 200 - assert res.json()["restored"] is True - - # Now back in pending, gone from hidden - pending_res = await client.get("/api/v1/scan/pending", headers=headers) - assert len(pending_res.json()) == 1 - hidden_res = await client.get("/api/v1/scan/hidden", headers=headers) - assert hidden_res.json() == [] - - -@pytest.mark.asyncio -async def test_restore_device_rejects_non_hidden(client: AsyncClient, headers, pending_device): - res = await client.post(f"/api/v1/scan/pending/{pending_device.id}/restore", headers=headers) - assert res.status_code == 409 - - -@pytest.mark.asyncio -async def test_bulk_restore_devices(client: AsyncClient, headers, pending_device): - # Hide - await client.post(f"/api/v1/scan/pending/{pending_device.id}/hide", headers=headers) - - res = await client.post( - "/api/v1/scan/pending/bulk-restore", - headers=headers, - json={"device_ids": [pending_device.id]}, - ) - assert res.status_code == 200 - assert res.json()["restored"] == 1 - assert res.json()["skipped"] == 0 - - pending_res = await client.get("/api/v1/scan/pending", headers=headers) - assert len(pending_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() == [] - - -# --- 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 - - -# --- Stop scan --- - -@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) - - -# --- run_scan cancellation --- - -@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) - - -# --- Bulk approve --- - -@pytest.fixture -async def two_pending_devices(db_session): - devices = [] - for i in range(2): - d = PendingDevice( - id=str(uuid.uuid4()), - ip=f"192.168.1.{10 + i}", - mac=None, - hostname=f"host-{i}", - os=None, - services=[], - suggested_type="generic", - status="pending", - ) - db_session.add(d) - devices.append(d) - await db_session.commit() - for d in devices: - await db_session.refresh(d) - return devices - - -@pytest.mark.asyncio -async def test_bulk_approve_approves_devices(client: AsyncClient, headers, two_pending_devices): - ids = [d.id for d in two_pending_devices] - res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers) - assert res.status_code == 200 - data = res.json() - assert data["approved"] == 2 - assert len(data["node_ids"]) == 2 - assert all(nid is not None for nid in data["node_ids"]), "node_ids must be non-null UUIDs" - assert len(data["device_ids"]) == 2 - assert data["skipped"] == 0 - # Approved devices stay in the inventory, now marked "approved". - pending_res = await client.get("/api/v1/scan/pending", headers=headers) - inventory = pending_res.json() - assert len(inventory) == 2 - assert all(d["status"] == "approved" for d in inventory) - - -@pytest.mark.asyncio -async def test_bulk_approve_places_already_approved_device_on_another_design( - client: AsyncClient, headers, db_session, two_pending_devices -): - """Regression: a device already approved (status='approved', e.g. placed on - another canvas) must still get a node on the design being approved onto. - - Previously bulk-approve filtered status=='pending', so selecting an - already-approved device created no node — the user saw fewer nodes than - they selected.""" - ids = [d.id for d in two_pending_devices] - design_a = await _add_design(db_session, "Canvas A") - design_b = await _add_design(db_session, "Canvas B") - - # Approve both onto design A. - res_a = await client.post( - "/api/v1/scan/pending/bulk-approve", - json={"device_ids": ids, "design_id": design_a}, - headers=headers, - ) - assert res_a.json()["approved"] == 2 - - # Re-approve the same (now status='approved') devices onto design B. - res_b = await client.post( - "/api/v1/scan/pending/bulk-approve", - json={"device_ids": ids, "design_id": design_b}, - headers=headers, - ) - data_b = res_b.json() - assert data_b["approved"] == 2, "already-approved devices must place onto the new canvas" - assert data_b["skipped"] == 0 - - # Two nodes now exist on each design. - from app.db.models import Node as NodeModel - nodes_b = ( - await db_session.execute(select(NodeModel).where(NodeModel.design_id == design_b)) - ).scalars().all() - assert len(nodes_b) == 2 - - -@pytest.mark.asyncio -async def test_bulk_approve_skips_device_already_on_target_design( - client: AsyncClient, headers, db_session, two_pending_devices -): - """A device already on the target canvas (same ip) is not placed twice.""" - ids = [d.id for d in two_pending_devices] - design = await _add_design(db_session, "Canvas") - # First device already sits on the canvas (matched by ip). - db_session.add(_node(design, ip="192.168.1.10")) - await db_session.commit() - - res = await client.post( - "/api/v1/scan/pending/bulk-approve", - json={"device_ids": ids, "design_id": design}, - headers=headers, - ) - data = res.json() - assert data["approved"] == 1 # only the second device (192.168.1.11) - assert data["skipped"] == 1 - - from app.db.models import Node as NodeModel - nodes = ( - await db_session.execute(select(NodeModel).where(NodeModel.design_id == design)) - ).scalars().all() - # The pre-existing node plus the one newly approved — no duplicate for .10. - assert len(nodes) == 2 - assert sorted(n.ip for n in nodes) == ["192.168.1.10", "192.168.1.11"] - - -@pytest.mark.asyncio -async def test_bulk_approve_skips_device_matching_ip_in_comma_list( - client: AsyncClient, headers, db_session, two_pending_devices -): - """The on-canvas node's ip holds an IPv6 before the IPv4; the device scanned - as the plain IPv4 is still recognised as already placed (issue #258).""" - ids = [d.id for d in two_pending_devices] - design = await _add_design(db_session, "Canvas") - db_session.add(_node(design, ip="fe80::1, 192.168.1.10")) - await db_session.commit() - - res = await client.post( - "/api/v1/scan/pending/bulk-approve", - json={"device_ids": ids, "design_id": design}, - headers=headers, - ) - data = res.json() - assert data["approved"] == 1 # only the second device (192.168.1.11) - assert data["skipped"] == 1 - assert data["skipped_devices"][0]["value"] == "192.168.1.10" - - -@pytest.mark.asyncio -async def test_bulk_approve_reports_skipped_devices( - client: AsyncClient, headers, db_session, two_pending_devices -): - """Bulk can't prompt per-device, so it reports each duplicate it skipped - (with the existing node id) instead of silently dropping it.""" - ids = [d.id for d in two_pending_devices] - design = await _add_design(db_session, "Canvas") - existing = _node(design, ip="192.168.1.10") - db_session.add(existing) - await db_session.commit() - - res = await client.post( - "/api/v1/scan/pending/bulk-approve", - json={"device_ids": ids, "design_id": design}, - headers=headers, - ) - data = res.json() - assert data["approved"] == 1 - skipped = data["skipped_devices"] - assert len(skipped) == 1 - entry = skipped[0] - assert entry["match"] == "ip" - assert entry["value"] == "192.168.1.10" - assert entry["existing_node_id"] == existing.id - assert entry["device_id"] in ids - - -@pytest.fixture -async def zigbee_pending_device(db_session): - device = PendingDevice( - id=str(uuid.uuid4()), - ip=None, - mac=None, - hostname=None, - friendly_name="bulb_1", - services=[], - suggested_type="zigbee_enddevice", - device_subtype="EndDevice", - ieee_address="0xABCDEF", - vendor="IKEA", - model="TRADFRI", - lqi=180, - status="pending", - discovery_source="zigbee", - ) - db_session.add(device) - await db_session.commit() - await db_session.refresh(device) - return device - - -@pytest.mark.asyncio -async def test_approve_zigbee_device_populates_properties( - client: AsyncClient, headers, zigbee_pending_device, db_session -): - """Approving a zigbee device must populate IEEE/Vendor/Model/LQI in properties.""" - from sqlalchemy import select - - from app.db.models import Node as NodeModel - payload = { - "label": "bulb_1", - "type": "zigbee_enddevice", - "status": "online", - "services": [], - "check_method": "none", - } - res = await client.post( - f"/api/v1/scan/pending/{zigbee_pending_device.id}/approve", - json=payload, - headers=headers, - ) - assert res.status_code == 200 - node = ( - await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF")) - ).scalar_one() - keys = {p["key"]: p["value"] for p in node.properties} - assert keys == { - "IEEE": "0xABCDEF", - "Vendor": "IKEA", - "Model": "TRADFRI", - "LQI": "180", - } - - -@pytest.mark.asyncio -async def test_bulk_approve_zigbee_populates_properties( - client: AsyncClient, headers, zigbee_pending_device, db_session -): - from sqlalchemy import select - - from app.db.models import Node as NodeModel - res = await client.post( - "/api/v1/scan/pending/bulk-approve", - json={"device_ids": [zigbee_pending_device.id]}, - headers=headers, - ) - assert res.status_code == 200 - node = ( - await db_session.execute(select(NodeModel).where(NodeModel.ieee_address == "0xABCDEF")) - ).scalar_one() - keys = {p["key"]: p["value"] for p in node.properties} - assert keys["IEEE"] == "0xABCDEF" - assert keys["Vendor"] == "IKEA" - assert keys["Model"] == "TRADFRI" - assert keys["LQI"] == "180" - assert node.check_method == "none" - - -# --- MAC address propagation on approve (issue #168) --- - -def test_build_mac_property_returns_hidden_row(): - from app.api.routes.scan import build_mac_property - - assert build_mac_property("aa:bb:cc:dd:ee:ff") == [ - {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} - ] - - -def test_build_mac_property_empty_when_no_mac(): - from app.api.routes.scan import build_mac_property - - assert build_mac_property(None) == [] - assert build_mac_property("") == [] - - -def test_merge_mac_property_appends_when_absent(): - from app.api.routes.scan import merge_mac_property - - existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}] - merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff") - assert {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} in merged - # Existing prop preserved untouched. - assert existing[0] in merged - - -def test_merge_mac_property_idempotent_and_preserves_visibility(): - from app.api.routes.scan import merge_mac_property - - existing = [{"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True}] - merged = merge_mac_property(existing, "aa:bb:cc:dd:ee:ff") - # No duplicate MAC row; user's visible=True choice kept. - macs = [p for p in merged if p["key"] == "MAC"] - assert len(macs) == 1 - assert macs[0]["visible"] is True - - -def test_merge_mac_property_noop_without_mac(): - from app.api.routes.scan import merge_mac_property - - existing = [{"key": "Custom", "value": "x", "icon": None, "visible": True}] - assert merge_mac_property(existing, None) == existing - - -@pytest.mark.asyncio -async def test_approve_device_copies_mac_to_node_and_properties( - client: AsyncClient, headers, pending_device, db_session -): - """Approving a scanned device must carry its MAC onto the node + properties.""" - from sqlalchemy import select - - from app.db.models import Node as NodeModel - # Payload intentionally omits mac — it must come from the pending device. - res = await client.post( - f"/api/v1/scan/pending/{pending_device.id}/approve", - json={"label": "My Server", "type": "server", "ip": "192.168.1.100", "status": "unknown", "services": []}, - headers=headers, - ) - assert res.status_code == 200 - node = ( - await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100")) - ).scalar_one() - assert node.mac == "aa:bb:cc:dd:ee:ff" - mac_props = [p for p in node.properties if p["key"] == "MAC"] - assert mac_props == [ - {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": False} - ] - - -@pytest.mark.asyncio -async def test_approve_device_does_not_duplicate_mac_property( - client: AsyncClient, headers, pending_device, db_session -): - """If the approve payload already carries a MAC prop, don't add a second one.""" - from sqlalchemy import select - - from app.db.models import Node as NodeModel - res = await client.post( - f"/api/v1/scan/pending/{pending_device.id}/approve", - json={ - "label": "My Server", - "type": "server", - "ip": "192.168.1.100", - "status": "unknown", - "services": [], - "properties": [ - {"key": "MAC", "value": "aa:bb:cc:dd:ee:ff", "icon": None, "visible": True} - ], - }, - headers=headers, - ) - assert res.status_code == 200 - node = ( - await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.100")) - ).scalar_one() - mac_props = [p for p in node.properties if p["key"] == "MAC"] - assert len(mac_props) == 1 - # User's visibility choice is preserved. - assert mac_props[0]["visible"] is True - - -@pytest.mark.asyncio -async def test_bulk_approve_copies_mac_to_node_and_properties( - client: AsyncClient, headers, db_session -): - """Bulk approve must also propagate the scanned MAC to node + properties.""" - from sqlalchemy import select - - from app.db.models import Node as NodeModel - device = PendingDevice( - id=str(uuid.uuid4()), - ip="192.168.1.55", - mac="11:22:33:44:55:66", - hostname="host-mac", - services=[], - suggested_type="generic", - status="pending", - ) - db_session.add(device) - await db_session.commit() - - res = await client.post( - "/api/v1/scan/pending/bulk-approve", - json={"device_ids": [device.id]}, - headers=headers, - ) - assert res.status_code == 200 - node = ( - await db_session.execute(select(NodeModel).where(NodeModel.ip == "192.168.1.55")) - ).scalar_one() - assert node.mac == "11:22:33:44:55:66" - mac_props = [p for p in node.properties if p["key"] == "MAC"] - assert mac_props == [ - {"key": "MAC", "value": "11:22:33:44:55:66", "icon": None, "visible": False} - ] - - -@pytest.mark.asyncio -async def test_bulk_approve_sets_default_check_method(client: AsyncClient, headers, two_pending_devices, db_session): - """Approved devices with an IP must default to ping; otherwise scheduler skips them.""" - from sqlalchemy import select - - from app.db.models import Node as NodeModel - ids = [d.id for d in two_pending_devices] - res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers) - assert res.status_code == 200 - nodes = (await db_session.execute(select(NodeModel))).scalars().all() - for n in nodes: - if n.ip: - assert n.check_method == "ping", f"node {n.id} created without check_method" - - -@pytest.mark.asyncio -async def test_approve_device_sets_default_check_method(client: AsyncClient, headers, pending_device, db_session): - from sqlalchemy import select - - from app.db.models import Node as NodeModel - res = await client.post( - f"/api/v1/scan/pending/{pending_device.id}/approve", - json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []}, - headers=headers, - ) - assert res.status_code == 200 - node = (await db_session.execute(select(NodeModel))).scalars().first() - assert node is not None - assert node.check_method == "ping" - - -@pytest.mark.asyncio -async def test_bulk_approve_skips_already_approved(client: AsyncClient, headers, two_pending_devices): - ids = [d.id for d in two_pending_devices] - # Approve first device individually first - await client.post( - f"/api/v1/scan/pending/{ids[0]}/approve", - json={"label": "h", "type": "generic", "ip": "192.168.1.10", "status": "unknown", "services": []}, - headers=headers, - ) - # Bulk approve both — first one is already approved (not pending), should be skipped - res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}, headers=headers) - assert res.status_code == 200 - data = res.json() - assert data["approved"] == 1 - assert data["skipped"] == 1 - - -@pytest.mark.asyncio -async def test_bulk_approve_requires_auth(client: AsyncClient, two_pending_devices): - ids = [d.id for d in two_pending_devices] - res = await client.post("/api/v1/scan/pending/bulk-approve", json={"device_ids": ids}) - assert res.status_code == 401 - - -# --- Bulk hide --- - -@pytest.mark.asyncio -async def test_bulk_hide_hides_devices(client: AsyncClient, headers, two_pending_devices): - ids = [d.id for d in two_pending_devices] - res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers) - assert res.status_code == 200 - data = res.json() - assert data["hidden"] == 2 - assert data["skipped"] == 0 - # Should appear in hidden list - hidden_res = await client.get("/api/v1/scan/hidden", headers=headers) - assert len(hidden_res.json()) == 2 - - -@pytest.mark.asyncio -async def test_bulk_hide_skips_non_pending(client: AsyncClient, headers, two_pending_devices): - ids = [d.id for d in two_pending_devices] - # Hide first device individually first - await client.post(f"/api/v1/scan/pending/{ids[0]}/hide", headers=headers) - # Bulk hide both — first is already hidden (not pending anymore) - res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}, headers=headers) - assert res.status_code == 200 - data = res.json() - assert data["hidden"] == 1 - assert data["skipped"] == 1 - - -@pytest.mark.asyncio -async def test_bulk_hide_requires_auth(client: AsyncClient, two_pending_devices): - ids = [d.id for d in two_pending_devices] - res = await client.post("/api/v1/scan/pending/bulk-hide", json={"device_ids": ids}) - assert res.status_code == 401 - - -# --------------------------------------------------------------------------- -# Approve auto-creates Edges from pending_device_links (Zigbee flow) -# --------------------------------------------------------------------------- - - -async def _seed_zigbee_pending_pair(db_session): - """Create a coordinator Node + a pending device + a link between them.""" - from app.db.models import Node, PendingDevice, PendingDeviceLink - - coord = Node( - label="Coordinator", - type="zigbee_coordinator", - status="unknown", - ieee_address="0xCOORD", - ) - db_session.add(coord) - - pending = PendingDevice( - ieee_address="0xR1", - friendly_name="router_1", - suggested_type="zigbee_router", - device_subtype="Router", - status="pending", - discovery_source="zigbee", - ) - db_session.add(pending) - - db_session.add( - PendingDeviceLink( - source_ieee="0xCOORD", - target_ieee="0xR1", - discovery_source="zigbee", - ) - ) - await db_session.commit() - return coord, pending - - -@pytest.mark.asyncio -async def test_approve_zigbee_creates_edge_when_other_endpoint_is_node( - client: AsyncClient, headers, db_session -): - from sqlalchemy import select - - from app.db.models import Edge - - coord, pending = await _seed_zigbee_pending_pair(db_session) - - res = await client.post( - f"/api/v1/scan/pending/{pending.id}/approve", - json={ - "label": "router_1", - "type": "zigbee_router", - "ip": None, - "status": "unknown", - "services": [], - }, - headers=headers, - ) - assert res.status_code == 200 - data = res.json() - assert data["approved"] is True - assert data["edges_created"] == 1 - - edges = (await db_session.execute(select(Edge))).scalars().all() - assert len(edges) == 1 - assert edges[0].source == coord.id - assert edges[0].target == data["node_id"] - assert edges[0].source_handle == "bottom" - # Bare side name (canonical stored form); renders at the top like before. - assert edges[0].target_handle == "top" - assert edges[0].type == "iot" - - -@pytest.mark.asyncio -async def test_approve_zigbee_skips_duplicate_edge( - client: AsyncClient, headers, db_session -): - """Re-running the resolution does not create a second edge for the same pair.""" - from sqlalchemy import select - - from app.db.models import Edge, PendingDevice, PendingDeviceLink - - coord, pending = await _seed_zigbee_pending_pair(db_session) - body = {"label": "router_1", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []} - await client.post(f"/api/v1/scan/pending/{pending.id}/approve", json=body, headers=headers) - - # Simulate a second pending row + link between same coord and a new device, - # but keep an existing edge in place to verify dedupe also handles - # the swapped-direction case. - new_pending = PendingDevice( - ieee_address="0xR1B", - friendly_name="r1b", - suggested_type="zigbee_router", - status="pending", - discovery_source="zigbee", - ) - db_session.add(new_pending) - db_session.add( - PendingDeviceLink(source_ieee="0xCOORD", target_ieee="0xR1B", discovery_source="zigbee") - ) - await db_session.commit() - res = await client.post( - f"/api/v1/scan/pending/{new_pending.id}/approve", json=body, headers=headers - ) - assert res.json()["edges_created"] == 1 # only the new pair - edges = (await db_session.execute(select(Edge))).scalars().all() - assert len(edges) == 2 # original + new, no duplicate - - -@pytest.mark.asyncio -async def test_approve_zigbee_skips_when_other_endpoint_still_pending( - client: AsyncClient, headers, db_session -): - """Both endpoints pending → no edge yet, link row preserved for later.""" - from sqlalchemy import select - - from app.db.models import Edge, PendingDevice, PendingDeviceLink - - a = PendingDevice( - ieee_address="0xA", - friendly_name="a", - suggested_type="zigbee_router", - status="pending", - discovery_source="zigbee", - ) - b = PendingDevice( - ieee_address="0xB", - friendly_name="b", - suggested_type="zigbee_enddevice", - status="pending", - discovery_source="zigbee", - ) - db_session.add_all([a, b]) - db_session.add( - PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee") - ) - await db_session.commit() - - res = await client.post( - f"/api/v1/scan/pending/{a.id}/approve", - json={ - "label": "a", - "type": "zigbee_router", - "ip": None, - "status": "unknown", - "services": [], - }, - headers=headers, - ) - assert res.status_code == 200 - assert res.json()["edges_created"] == 0 - - edges = (await db_session.execute(select(Edge))).scalars().all() - assert edges == [] - links = (await db_session.execute(select(PendingDeviceLink))).scalars().all() - assert len(links) == 1 # preserved for later resolution - - -@pytest.mark.asyncio -async def test_approve_zigbee_resolves_link_after_second_approval( - client: AsyncClient, headers, db_session -): - """First approval keeps link (other endpoint pending); second approval - creates the edge. The link row is retained afterwards so the same pair can - be re-approved onto another canvas — it's topology, wiped only on reimport.""" - from sqlalchemy import select - - from app.db.models import Edge, PendingDevice, PendingDeviceLink - - a = PendingDevice( - ieee_address="0xA", - friendly_name="a", - suggested_type="zigbee_router", - status="pending", - discovery_source="zigbee", - ) - b = PendingDevice( - ieee_address="0xB", - friendly_name="b", - suggested_type="zigbee_enddevice", - status="pending", - discovery_source="zigbee", - ) - db_session.add_all([a, b]) - db_session.add( - PendingDeviceLink(source_ieee="0xA", target_ieee="0xB", discovery_source="zigbee") - ) - await db_session.commit() - - body = {"label": "x", "type": "zigbee_router", "ip": None, "status": "unknown", "services": []} - await client.post(f"/api/v1/scan/pending/{a.id}/approve", json=body, headers=headers) - res = await client.post(f"/api/v1/scan/pending/{b.id}/approve", json=body, headers=headers) - assert res.json()["edges_created"] == 1 - - edges = (await db_session.execute(select(Edge))).scalars().all() - assert len(edges) == 1 - links = (await db_session.execute(select(PendingDeviceLink))).scalars().all() - assert len(links) == 1 # retained for re-approval onto other canvases - - -# --- Deep scan: trigger overrides + config persistence --- - -@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} - - -# --- Z-Wave approve: active design targeting + wireless props (regression) --- - -@pytest.mark.asyncio -async def test_bulk_approve_targets_requested_design(client, headers, db_session): - """bulk-approve must place nodes on the design_id sent by the UI, not the - first design — otherwise approved devices land on the wrong canvas.""" - first = await _add_design(db_session, "Default") # first design (fallback) - active = await _add_design(db_session, "zwave") # the design the user is on - dev = PendingDevice( - id=str(uuid.uuid4()), - ieee_address="zwave-H-2", - friendly_name="Living Room Plug", - suggested_type="zwave_router", - device_subtype="Router", - vendor="Aeotec", - model="ZW096", - status="pending", - discovery_source="zwave", - ) - db_session.add(dev) - await db_session.commit() - - res = await client.post( - "/api/v1/scan/pending/bulk-approve", - json={"device_ids": [dev.id], "design_id": active}, - headers=headers, - ) - assert res.status_code == 200 - assert res.json()["approved"] == 1 - - node = ( - await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-2")) - ).scalar_one() - assert node.design_id == active - assert node.design_id != first - # Z-Wave device → online + Z-Wave property rows, no ICMP check. - assert node.status == "online" - assert node.check_method == "none" - assert {p["key"] for p in node.properties} == {"Z-Wave ID", "Vendor", "Model"} - - -@pytest.mark.asyncio -async def test_single_approve_zwave_sets_wireless_fields(client, headers, db_session): - active = await _add_design(db_session, "zwave") - dev = PendingDevice( - id=str(uuid.uuid4()), - ieee_address="zwave-H-9", - friendly_name="Door Sensor", - suggested_type="zwave_enddevice", - vendor="Aeotec", - model="ZW120", - status="pending", - discovery_source="zwave", - ) - db_session.add(dev) - await db_session.commit() - - res = await client.post( - f"/api/v1/scan/pending/{dev.id}/approve", - json={"label": "Door Sensor", "type": "zwave_enddevice", "design_id": active}, - headers=headers, - ) - assert res.status_code == 200 - - node = ( - await db_session.execute(select(Node).where(Node.ieee_address == "zwave-H-9")) - ).scalar_one() - assert node.design_id == active - assert node.status == "online" - assert node.check_method == "none" - assert any(p["key"] == "Z-Wave ID" for p in node.properties)