fix: match scanned devices to canvas nodes by ip-token and mac (#258)

Node.ip stores several comma-separated addresses once a user adds an
IPv6 (e.g. "fe80::1, 192.168.1.5"). All placement/inventory matching did
exact string equality on that field, so a device scanned as the plain
IPv4 looked absent from the canvas: canvas_count stayed 0, the "In N
canvas" badge and hide-on-canvas button vanished, and bulk-approve
re-placed a duplicate node.

Match per-address instead, and add MAC (a stable identifier immune to IP
edits) as a cumulative match key alongside ieee/ip:
- _canvas_correlation indexes ip per token + by_mac
- bulk_approve skip detection tokenizes ip + tracks mac
- find_duplicate_node narrows with Node.ip.contains then confirms per
  token in Python (guards the 10.0.0.4 / 10.0.0.40 substring false match)

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-07-09 14:41:53 +02:00
parent 2bebe8f42d
commit 2d3b646e45
3 changed files with 192 additions and 36 deletions
+92 -2
View File
@@ -181,14 +181,38 @@ async def _add_design(db_session, name: str) -> str:
return design.id
def _node(design_id: str, *, ip=None, ieee=None) -> Node:
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, ieee_address=ieee, services=[], pos_x=0.0, pos_y=0.0,
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.
@@ -443,6 +467,50 @@ async def test_approve_device_conflicts_on_existing_mac(
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
@@ -1085,6 +1153,28 @@ async def test_bulk_approve_skips_device_already_on_target_design(
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