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