master
go 3,010 lines 88.9 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package l2topology
4
5 import (
6 "github.com/stretchr/testify/require"
7 "net/netip"
8 "slices"
9 "strings"
10 "testing"
11 "time"
12 )
13
14 func TestToGraph_ProjectsResult(t *testing.T) {
15 collectedAt := time.Date(2026, time.February, 20, 4, 5, 6, 0, time.UTC)
16
17 result := Result{
18 CollectedAt: collectedAt,
19 Devices: []Device{
20 {
21 ID: "local-device",
22 Hostname: "sw1",
23 ChassisID: "00:11:22:33:44:55",
24 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
25 Labels: map[string]string{"protocols_observed": "bridge,fdb,stp"},
26 },
27 {
28 ID: "remote-device",
29 Hostname: "sw2",
30 ChassisID: "aa:bb:cc:dd:ee:ff",
31 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
32 Labels: map[string]string{"inferred": "true"},
33 },
34 },
35 Interfaces: []Interface{
36 {DeviceID: "local-device", IfIndex: 3, IfName: "Gi0/3", IfDescr: "Gi0/3", Labels: map[string]string{"admin_status": "up", "oper_status": "up"}},
37 {DeviceID: "local-device", IfIndex: 4, IfName: "Gi0/4", IfDescr: "Gi0/4", Labels: map[string]string{"admin_status": "up", "oper_status": "lowerLayerDown"}},
38 },
39 Adjacencies: []Adjacency{
40 {
41 Protocol: "lldp",
42 SourceID: "local-device",
43 SourcePort: "Gi0/3",
44 TargetID: "remote-device",
45 TargetPort: "Gi0/1",
46 },
47 },
48 Attachments: []Attachment{
49 {DeviceID: "local-device", IfIndex: 4, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
50 },
51 Enrichments: []Enrichment{
52 {
53 EndpointID: "mac:70:49:a2:65:72:cd",
54 MAC: "70:49:a2:65:72:cd",
55 IPs: []netip.Addr{netip.MustParseAddr("10.20.4.84")},
56 Labels: map[string]string{
57 "sources": "arp",
58 "if_indexes": "4",
59 "if_names": "Gi0/4",
60 },
61 },
62 },
63 }
64
65 data := ToGraph(result, GraphOptions{
66 SchemaVersion: "2.0",
67 Source: "snmp",
68 Layer: "2",
69 View: "summary",
70 AgentID: "agent-1",
71 LocalDeviceID: "local-device",
72 })
73
74 require.Equal(t, "2.0", data.SchemaVersion)
75 require.Equal(t, "snmp", data.Source)
76 require.Equal(t, "2", data.Layer)
77 require.Equal(t, "agent-1", data.AgentID)
78 require.Equal(t, collectedAt, data.CollectedAt)
79
80 require.Len(t, data.Actors, 3)
81 require.Len(t, data.Links, 2)
82 lldpLink := findLinkByProtocol(data.Links, "lldp")
83 require.NotNil(t, lldpLink)
84 require.Equal(t, "Gi0/3", lldpLink.Src.Attributes["if_name"])
85 require.Equal(t, "Gi0/3", lldpLink.Src.Attributes["port_id"])
86 require.Equal(t, "up", lldpLink.Src.Attributes["if_admin_status"])
87 require.Equal(t, "up", lldpLink.Src.Attributes["if_oper_status"])
88 require.Equal(t, "sw2", lldpLink.Dst.Attributes["sys_name"])
89
90 localActor := findActorBySysName(data.Actors, "sw1")
91 require.NotNil(t, localActor)
92 require.Equal(t, false, localActor.Attributes["discovered"])
93 require.Equal(t, false, localActor.Attributes["inferred"])
94 require.Equal(t, []string{"bridge", "fdb", "stp"}, localActor.Attributes["protocols"])
95 require.Equal(t, []string{"bridge", "fdb", "stp"}, localActor.Attributes["protocols_collected"])
96 require.Equal(t, 2, localActor.Attributes["ports_total"])
97 require.NotNil(t, localActor.Attributes["if_admin_status_counts"])
98 require.NotNil(t, localActor.Attributes["if_oper_status_counts"])
99 require.NotNil(t, localActor.Attributes["if_link_mode_counts"])
100 require.NotNil(t, localActor.Attributes["if_topology_role_counts"])
101 require.NotNil(t, localActor.Attributes["if_statuses"])
102 remoteActor := findActorBySysName(data.Actors, "sw2")
103 require.NotNil(t, remoteActor)
104 require.Equal(t, true, remoteActor.Attributes["inferred"])
105
106 endpointActor := findActorByMAC(data.Actors, "70:49:a2:65:72:cd")
107 require.NotNil(t, endpointActor)
108 require.Equal(t, "endpoint", endpointActor.ActorType)
109 require.Equal(t, []string{"10.20.4.84"}, endpointActor.Match.IPAddresses)
110 require.Equal(t, []string{"arp", "fdb"}, endpointActor.Attributes["learned_sources"])
111 require.Equal(t, "single_port_mac", endpointActor.Attributes["attachment_source"])
112 require.Equal(t, "sw1", endpointActor.Attributes["attached_device"])
113 require.Equal(t, "Gi0/4", endpointActor.Attributes["attached_port"])
114
115 require.Equal(t, 2, data.Stats["devices_total"])
116 require.Equal(t, 1, data.Stats["devices_discovered"])
117 require.Equal(t, 2, data.Stats["links_total"])
118 require.Equal(t, 1, data.Stats["links_lldp"])
119 require.Equal(t, 0, data.Stats["links_cdp"])
120 require.Equal(t, 1, data.Stats["links_fdb"])
121 require.Equal(t, 0, data.Stats["links_arp"])
122 require.Equal(t, 1, data.Stats["links_bidirectional"])
123 require.Equal(t, 1, data.Stats["links_unidirectional"])
124 require.Equal(t, 3, data.Stats["actors_total"])
125 require.Equal(t, 1, data.Stats["endpoints_total"])
126 }
127
128 func TestToGraph_ClassifiesPortLinkModesFromFDBAndSTPEvidence(t *testing.T) {
129 result := Result{
130 Devices: []Device{
131 {
132 ID: "sw1",
133 Hostname: "sw1",
134 ChassisID: "00:11:22:33:44:55",
135 },
136 {
137 ID: "sw2",
138 Hostname: "sw2",
139 ChassisID: "00:11:22:33:44:66",
140 },
141 },
142 Interfaces: []Interface{
143 {DeviceID: "sw1", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
144 {DeviceID: "sw1", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
145 {DeviceID: "sw1", IfIndex: 3, IfName: "Gi0/3", IfDescr: "Gi0/3"},
146 {DeviceID: "sw1", IfIndex: 4, IfName: "Gi0/4", IfDescr: "Gi0/4"},
147 {DeviceID: "sw2", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
148 },
149 Adjacencies: []Adjacency{
150 {
151 Protocol: "lldp",
152 SourceID: "sw1",
153 SourcePort: "Gi0/3",
154 TargetID: "sw2",
155 TargetPort: "Gi0/1",
156 },
157 {
158 Protocol: "stp",
159 SourceID: "sw1",
160 SourcePort: "Gi0/1",
161 TargetID: "sw2",
162 TargetPort: "Gi0/1",
163 Labels: map[string]string{
164 "vlan_id": "20",
165 },
166 },
167 },
168 Attachments: []Attachment{
169 {
170 DeviceID: "sw1",
171 IfIndex: 1,
172 EndpointID: "mac:00:00:00:00:10:01",
173 Method: "fdb",
174 Labels: map[string]string{
175 "fdb_status": "learned",
176 "vlan_id": "10",
177 },
178 },
179 {
180 DeviceID: "sw1",
181 IfIndex: 1,
182 EndpointID: "mac:00:00:00:00:20:01",
183 Method: "fdb",
184 Labels: map[string]string{
185 "fdb_status": "learned",
186 "vlan_id": "20",
187 },
188 },
189 {
190 DeviceID: "sw1",
191 IfIndex: 2,
192 EndpointID: "mac:00:00:00:00:30:01",
193 Method: "fdb",
194 Labels: map[string]string{
195 "fdb_status": "learned",
196 "vlan_id": "30",
197 },
198 },
199 {
200 DeviceID: "sw1",
201 IfIndex: 3,
202 EndpointID: "mac:00:00:00:00:40:01",
203 Method: "fdb",
204 Labels: map[string]string{
205 "fdb_status": "learned",
206 "vlan_id": "40",
207 },
208 },
209 {
210 DeviceID: "sw1",
211 IfIndex: 4,
212 EndpointID: "mac:00:00:00:00:50:01",
213 Method: "fdb",
214 Labels: map[string]string{
215 "fdb_status": "learned",
216 },
217 },
218 {
219 DeviceID: "sw1",
220 IfIndex: 4,
221 EndpointID: "mac:00:00:00:00:50:02",
222 Method: "fdb",
223 Labels: map[string]string{
224 "fdb_status": "learned",
225 },
226 },
227 },
228 }
229
230 data := ToGraph(result, GraphOptions{
231 Source: "snmp",
232 Layer: "2",
233 View: "summary",
234 })
235
236 actor := findActorBySysName(data.Actors, "sw1")
237 require.NotNil(t, actor)
238
239 modeCounts, ok := actor.Attributes["if_link_mode_counts"].(map[string]any)
240 require.True(t, ok)
241 require.Equal(t, 1, modeCounts["trunk"])
242 require.Equal(t, 1, modeCounts["access"])
243 require.Equal(t, 2, modeCounts["unknown"])
244
245 roleCounts, ok := actor.Attributes["if_topology_role_counts"].(map[string]any)
246 require.True(t, ok)
247 require.Equal(t, 1, roleCounts["switch_facing"])
248 require.Equal(t, 1, roleCounts["host_facing"])
249 require.Equal(t, 1, roleCounts["host_candidate"])
250 require.Equal(t, 1, roleCounts["unknown"])
251
252 statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
253 require.True(t, ok)
254
255 port1 := findInterfaceStatusByIndex(statuses, 1)
256 require.Equal(t, "trunk", port1["link_mode"])
257 require.Equal(t, "high", port1["link_mode_confidence"])
258 require.Equal(t, []string{"fdb", "stp"}, port1["link_mode_sources"])
259 require.Equal(t, []string{"10", "20"}, port1["vlan_ids"])
260 require.Equal(t, "unknown", port1["topology_role"])
261 require.Equal(t, "low", port1["topology_role_confidence"])
262 require.Equal(t, []string{"stp", "fdb"}, port1["topology_role_sources"])
263
264 port2 := findInterfaceStatusByIndex(statuses, 2)
265 require.Equal(t, "access", port2["link_mode"])
266 require.Equal(t, "medium", port2["link_mode_confidence"])
267 require.Equal(t, []string{"fdb"}, port2["link_mode_sources"])
268 require.Equal(t, []string{"30"}, port2["vlan_ids"])
269 require.Equal(t, "host_facing", port2["topology_role"])
270 require.Equal(t, "medium", port2["topology_role_confidence"])
271 require.Equal(t, []string{"fdb"}, port2["topology_role_sources"])
272
273 port3 := findInterfaceStatusByIndex(statuses, 3)
274 require.Equal(t, "unknown", port3["link_mode"])
275 require.Equal(t, "low", port3["link_mode_confidence"])
276 require.Equal(t, []string{"fdb", "peer_link"}, port3["link_mode_sources"])
277 require.Equal(t, []string{"40"}, port3["vlan_ids"])
278 require.Equal(t, "switch_facing", port3["topology_role"])
279 require.Equal(t, "high", port3["topology_role_confidence"])
280 require.Equal(t, []string{"peer_link", "bridge_link", "fdb"}, port3["topology_role_sources"])
281
282 port4 := findInterfaceStatusByIndex(statuses, 4)
283 require.Equal(t, "unknown", port4["link_mode"])
284 require.Equal(t, "low", port4["link_mode_confidence"])
285 require.Equal(t, []string{"fdb"}, port4["link_mode_sources"])
286 _, hasVLANs := port4["vlan_ids"]
287 require.False(t, hasVLANs)
288 require.Equal(t, "host_candidate", port4["topology_role"])
289 require.Equal(t, "low", port4["topology_role_confidence"])
290 require.Equal(t, []string{"fdb"}, port4["topology_role_sources"])
291 }
292
293 func TestToGraph_IgnoresIgnoredFDBStatusForLinkModeClassification(t *testing.T) {
294 result := Result{
295 Devices: []Device{
296 {
297 ID: "sw1",
298 Hostname: "sw1",
299 },
300 },
301 Interfaces: []Interface{
302 {DeviceID: "sw1", IfIndex: 10, IfName: "Gi0/10", IfDescr: "Gi0/10"},
303 },
304 Attachments: []Attachment{
305 {
306 DeviceID: "sw1",
307 IfIndex: 10,
308 EndpointID: "mac:00:00:00:00:50:01",
309 Method: "fdb",
310 Labels: map[string]string{
311 "fdb_status": "ignored",
312 "vlan_id": "50",
313 },
314 },
315 },
316 }
317
318 data := ToGraph(result, GraphOptions{
319 Source: "snmp",
320 Layer: "2",
321 View: "summary",
322 })
323
324 actor := findActorBySysName(data.Actors, "sw1")
325 require.NotNil(t, actor)
326 statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
327 require.True(t, ok)
328 port := findInterfaceStatusByIndex(statuses, 10)
329 require.Equal(t, "unknown", port["link_mode"])
330 require.Equal(t, "low", port["link_mode_confidence"])
331 _, hasSources := port["link_mode_sources"]
332 require.False(t, hasSources)
333 _, hasVLANs := port["vlan_ids"]
334 require.False(t, hasVLANs)
335 require.Equal(t, "unknown", port["topology_role"])
336 require.Equal(t, "low", port["topology_role_confidence"])
337 _, hasRoleSources := port["topology_role_sources"]
338 require.False(t, hasRoleSources)
339 }
340
341 func TestToGraph_ClassifiesSTPCorroboratedManagedAliasAsSwitchFacing(t *testing.T) {
342 result := Result{
343 Devices: []Device{
344 {
345 ID: "sw1",
346 Hostname: "sw1",
347 ChassisID: "00:11:22:33:44:55",
348 },
349 {
350 ID: "sw2",
351 Hostname: "sw2",
352 ChassisID: "00:11:22:33:44:66",
353 },
354 },
355 Interfaces: []Interface{
356 {DeviceID: "sw1", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
357 },
358 Adjacencies: []Adjacency{
359 {
360 Protocol: "stp",
361 SourceID: "sw1",
362 SourcePort: "Gi0/1",
363 TargetID: "stp-root",
364 Labels: map[string]string{
365 "vlan_id": "10",
366 },
367 },
368 },
369 Attachments: []Attachment{
370 {
371 DeviceID: "sw1",
372 IfIndex: 1,
373 EndpointID: "mac:00:11:22:33:44:66",
374 Method: "fdb",
375 Labels: map[string]string{
376 "fdb_status": "learned",
377 "vlan_id": "10",
378 },
379 },
380 },
381 }
382
383 data := ToGraph(result, GraphOptions{
384 Source: "snmp",
385 Layer: "2",
386 View: "summary",
387 })
388
389 actor := findActorBySysName(data.Actors, "sw1")
390 require.NotNil(t, actor)
391
392 statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
393 require.True(t, ok)
394 port1 := findInterfaceStatusByIndex(statuses, 1)
395 require.Equal(t, "switch_facing", port1["topology_role"])
396 require.Equal(t, "medium", port1["topology_role_confidence"])
397 require.Equal(t, []string{"stp", "fdb", "fdb_managed_alias"}, port1["topology_role_sources"])
398 }
399
400 func TestToGraph_EnrichesPortStatusesWithNeighborsFDBAndSTP(t *testing.T) {
401 result := Result{
402 Devices: []Device{
403 {
404 ID: "sw1",
405 Hostname: "sw1",
406 ChassisID: "00:11:22:33:44:55",
407 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
408 },
409 {
410 ID: "sw2",
411 Hostname: "sw2",
412 ChassisID: "aa:bb:cc:dd:ee:ff",
413 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
414 Labels: map[string]string{
415 "capabilities_enabled": "bridge,router",
416 },
417 },
418 },
419 Interfaces: []Interface{
420 {
421 DeviceID: "sw1",
422 IfIndex: 1,
423 IfName: "Gi0/1",
424 IfDescr: "Gi0/1",
425 MAC: "00:11:22:33:44:55",
426 Labels: map[string]string{
427 "admin_status": "up",
428 "oper_status": "up",
429 "if_alias": "uplink-core",
430 "speed_bps": "1000000000",
431 "last_change": "12345",
432 "duplex": "full",
433 },
434 },
435 {
436 DeviceID: "sw1",
437 IfIndex: 2,
438 IfName: "Gi0/2",
439 IfDescr: "Gi0/2",
440 MAC: "00:11:22:33:44:66",
441 Labels: map[string]string{
442 "admin_status": "down",
443 "oper_status": "down",
444 "if_alias": "server-a",
445 "speed_bps": "100000000",
446 "last_change": "54321",
447 "duplex": "half",
448 },
449 },
450 },
451 Adjacencies: []Adjacency{
452 {
453 Protocol: "lldp",
454 SourceID: "sw1",
455 SourcePort: "Gi0/1",
456 TargetID: "sw2",
457 TargetPort: "Gi0/24",
458 },
459 {
460 Protocol: "cdp",
461 SourceID: "sw1",
462 SourcePort: "Gi0/1",
463 TargetID: "sw2",
464 TargetPort: "Gi0/24",
465 },
466 {
467 Protocol: "stp",
468 SourceID: "sw1",
469 SourcePort: "Gi0/1",
470 TargetID: "sw2",
471 TargetPort: "Gi0/24",
472 Labels: map[string]string{
473 "stp_state": "forwarding",
474 "vlan_id": "10",
475 },
476 },
477 {
478 Protocol: "stp",
479 SourceID: "sw1",
480 SourcePort: "Gi0/1",
481 TargetID: "sw2",
482 TargetPort: "Gi0/24",
483 Labels: map[string]string{
484 "stp_state": "blocking",
485 "vlan_id": "20",
486 },
487 },
488 },
489 Attachments: []Attachment{
490 {
491 DeviceID: "sw1",
492 IfIndex: 1,
493 EndpointID: "mac:00:00:00:00:10:01",
494 Method: "fdb",
495 Labels: map[string]string{
496 "fdb_status": "learned",
497 "vlan_id": "10",
498 },
499 },
500 {
501 DeviceID: "sw1",
502 IfIndex: 1,
503 EndpointID: "mac:00:00:00:00:20:01",
504 Method: "fdb",
505 Labels: map[string]string{
506 "fdb_status": "learned",
507 "vlan_id": "20",
508 },
509 },
510 {
511 DeviceID: "sw1",
512 IfIndex: 2,
513 EndpointID: "mac:00:00:00:00:30:01",
514 Method: "fdb",
515 Labels: map[string]string{
516 "fdb_status": "learned",
517 },
518 },
519 },
520 }
521
522 data := ToGraph(result, GraphOptions{
523 Source: "snmp",
524 Layer: "2",
525 View: "summary",
526 })
527
528 actor := findActorBySysName(data.Actors, "sw1")
529 require.NotNil(t, actor)
530 require.Equal(t, 1, actor.Attributes["ports_up"])
531 require.Equal(t, 1, actor.Attributes["ports_down"])
532 require.Equal(t, 1, actor.Attributes["ports_admin_down"])
533 require.EqualValues(t, 1_000_000_000, actor.Attributes["total_bandwidth_bps"])
534 require.Equal(t, 3, actor.Attributes["fdb_total_macs"])
535 require.Equal(t, 2, actor.Attributes["vlan_count"])
536 require.Equal(t, 1, actor.Attributes["lldp_neighbor_count"])
537 require.Equal(t, 1, actor.Attributes["cdp_neighbor_count"])
538
539 statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
540 require.True(t, ok)
541
542 port1 := findInterfaceStatusByIndex(statuses, 1)
543 require.Equal(t, "Gi0/1", port1["if_descr"])
544 require.Equal(t, "uplink-core", port1["if_alias"])
545 require.Equal(t, "00:11:22:33:44:55", port1["mac"])
546 require.EqualValues(t, 1_000_000_000, port1["speed"])
547 require.EqualValues(t, 12345, port1["last_change"])
548 require.Equal(t, "full", port1["duplex"])
549 require.Equal(t, 2, port1["fdb_mac_count"])
550 require.Equal(t, "blocking", port1["stp_state"])
551 vlans, ok := port1["vlans"].([]map[string]any)
552 require.True(t, ok)
553 require.Len(t, vlans, 2)
554 require.Equal(t, "10", vlans[0]["vlan_id"])
555 require.Equal(t, true, vlans[0]["tagged"])
556 require.Equal(t, "20", vlans[1]["vlan_id"])
557 require.Equal(t, true, vlans[1]["tagged"])
558 neighbors, ok := port1["neighbors"].([]map[string]any)
559 require.True(t, ok)
560 require.Len(t, neighbors, 2)
561
562 cdpNeighbor := findNeighborByProtocol(neighbors, "cdp")
563 require.NotNil(t, cdpNeighbor)
564 require.Equal(t, "sw2", cdpNeighbor["remote_device"])
565 require.Equal(t, "Gi0/24", cdpNeighbor["remote_port"])
566 require.Equal(t, "10.0.0.2", cdpNeighbor["remote_ip"])
567 require.Equal(t, "aa:bb:cc:dd:ee:ff", cdpNeighbor["remote_chassis_id"])
568 require.Equal(t, []string{"bridge", "router"}, cdpNeighbor["remote_capabilities"])
569
570 lldpNeighbor := findNeighborByProtocol(neighbors, "lldp")
571 require.NotNil(t, lldpNeighbor)
572 require.Equal(t, "sw2", lldpNeighbor["remote_device"])
573 require.Equal(t, "Gi0/24", lldpNeighbor["remote_port"])
574 require.Equal(t, "10.0.0.2", lldpNeighbor["remote_ip"])
575 require.Equal(t, "aa:bb:cc:dd:ee:ff", lldpNeighbor["remote_chassis_id"])
576 require.Equal(t, []string{"bridge", "router"}, lldpNeighbor["remote_capabilities"])
577
578 port2 := findInterfaceStatusByIndex(statuses, 2)
579 require.Equal(t, "server-a", port2["if_alias"])
580 require.Equal(t, "00:11:22:33:44:66", port2["mac"])
581 require.EqualValues(t, 100_000_000, port2["speed"])
582 require.EqualValues(t, 54321, port2["last_change"])
583 require.Equal(t, "half", port2["duplex"])
584 require.Equal(t, 1, port2["fdb_mac_count"])
585 _, hasNeighbors := port2["neighbors"]
586 require.False(t, hasNeighbors)
587 }
588
589 func TestToGraph_InfersVendorFromMACOUI(t *testing.T) {
590 result := Result{
591 Devices: []Device{
592 {
593 ID: "sw1",
594 Hostname: "sw1",
595 ChassisID: "00:11:22:33:44:55",
596 },
597 {
598 ID: "remote-device",
599 Hostname: "edge-remote",
600 ChassisID: "28:6f:b9:00:00:22",
601 Labels: map[string]string{"inferred": "true"},
602 },
603 },
604 Interfaces: []Interface{
605 {DeviceID: "sw1", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
606 },
607 Attachments: []Attachment{
608 {DeviceID: "sw1", IfIndex: 1, EndpointID: "mac:08:ea:44:11:22:33", Method: "fdb"},
609 },
610 Enrichments: []Enrichment{
611 {EndpointID: "mac:08:ea:44:11:22:33", MAC: "08:ea:44:11:22:33"},
612 },
613 }
614
615 data := ToGraph(result, GraphOptions{
616 Source: "snmp",
617 Layer: "2",
618 View: "summary",
619 })
620
621 remote := findActorBySysName(data.Actors, "edge-remote")
622 require.NotNil(t, remote)
623 require.Equal(t, "Nokia Shanghai Bell Co., Ltd.", remote.Attributes["vendor"])
624 require.Equal(t, "mac_oui", remote.Attributes["vendor_source"])
625 require.Equal(t, "low", remote.Attributes["vendor_confidence"])
626 require.Equal(t, "Nokia Shanghai Bell Co., Ltd.", remote.Attributes["vendor_derived"])
627 require.Equal(t, "mac_oui", remote.Attributes["vendor_derived_source"])
628 require.Equal(t, "low", remote.Attributes["vendor_derived_confidence"])
629 require.NotEmpty(t, remote.Attributes["vendor_derived_match_prefix"])
630
631 endpoint := findActorByMAC(data.Actors, "08:ea:44:11:22:33")
632 require.NotNil(t, endpoint)
633 require.Equal(t, "endpoint", endpoint.ActorType)
634 require.Equal(t, "Extreme Networks Headquarters", endpoint.Attributes["vendor"])
635 require.Equal(t, "mac_oui", endpoint.Attributes["vendor_source"])
636 require.Equal(t, "low", endpoint.Attributes["vendor_confidence"])
637 require.Equal(t, "Extreme Networks Headquarters", endpoint.Attributes["vendor_derived"])
638 require.Equal(t, "mac_oui", endpoint.Attributes["vendor_derived_source"])
639 require.Equal(t, "low", endpoint.Attributes["vendor_derived_confidence"])
640 require.NotEmpty(t, endpoint.Attributes["vendor_derived_match_prefix"])
641 }
642
643 func TestDeviceToTopologyActor_DoesNotOverrideExplicitVendor(t *testing.T) {
644 actor := deviceToTopologyActor(
645 Device{
646 ID: "switch-a",
647 Hostname: "switch-a",
648 ChassisID: "08:ea:44:99:88:77",
649 Labels: map[string]string{
650 "vendor": "Explicit Vendor",
651 },
652 },
653 "snmp",
654 "2",
655 "",
656 topologyDeviceInterfaceSummary{},
657 nil,
658 )
659
660 require.Equal(t, "Explicit Vendor", actor.Attributes["vendor"])
661 require.Equal(t, "labels", actor.Attributes["vendor_source"])
662 require.Equal(t, "high", actor.Attributes["vendor_confidence"])
663 require.Equal(t, "Extreme Networks Headquarters", actor.Attributes["vendor_derived"])
664 require.Equal(t, "mac_oui", actor.Attributes["vendor_derived_source"])
665 require.Equal(t, "low", actor.Attributes["vendor_derived_confidence"])
666 require.NotEmpty(t, actor.Attributes["vendor_derived_match_prefix"])
667 }
668
669 func TestToGraph_DefaultDiscoveredCountWithoutLocalID(t *testing.T) {
670 result := Result{
671 Devices: []Device{
672 {ID: "a", Hostname: "a"},
673 {ID: "b", Hostname: "b"},
674 {ID: "c", Hostname: "c"},
675 },
676 }
677
678 data := ToGraph(result, GraphOptions{})
679 require.Equal(t, 2, data.Stats["devices_discovered"])
680 }
681
682 func TestToGraph_AssignsDeterministicActorIDsAndLinkActorIDs(t *testing.T) {
683 result := Result{
684 Devices: []Device{
685 {
686 ID: "sw1",
687 Hostname: "sw1",
688 ChassisID: "00:11:22:33:44:55",
689 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
690 },
691 {
692 ID: "sw2",
693 Hostname: "sw2",
694 ChassisID: "00:11:22:33:44:66",
695 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
696 },
697 },
698 Adjacencies: []Adjacency{
699 {
700 Protocol: "lldp",
701 SourceID: "sw1",
702 SourcePort: "Gi0/1",
703 TargetID: "sw2",
704 TargetPort: "Gi0/2",
705 },
706 },
707 }
708
709 data := ToGraph(result, GraphOptions{
710 Source: "snmp",
711 Layer: "2",
712 View: "summary",
713 })
714
715 require.Len(t, data.Actors, 2)
716 actorIDs := make(map[string]struct{}, len(data.Actors))
717 for _, actor := range data.Actors {
718 require.NotEmpty(t, actor.ActorID)
719 _, exists := actorIDs[actor.ActorID]
720 require.False(t, exists, "duplicate actor_id %q", actor.ActorID)
721 actorIDs[actor.ActorID] = struct{}{}
722 }
723
724 require.Len(t, data.Links, 1)
725 require.NotEmpty(t, data.Links[0].SrcActorID)
726 require.NotEmpty(t, data.Links[0].DstActorID)
727 _, srcExists := actorIDs[data.Links[0].SrcActorID]
728 _, dstExists := actorIDs[data.Links[0].DstActorID]
729 require.True(t, srcExists)
730 require.True(t, dstExists)
731 }
732
733 func TestToGraph_DeterministicAcrossRepeatedCalls(t *testing.T) {
734 collectedAt := time.Date(2026, time.February, 20, 4, 5, 6, 0, time.UTC)
735
736 result := Result{
737 CollectedAt: collectedAt,
738 Devices: []Device{
739 {
740 ID: "local-device",
741 Hostname: "sw1",
742 ChassisID: "00:11:22:33:44:55",
743 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
744 Labels: map[string]string{"protocols_observed": "bridge,fdb,stp"},
745 },
746 {
747 ID: "remote-device",
748 Hostname: "sw2",
749 ChassisID: "aa:bb:cc:dd:ee:ff",
750 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
751 Labels: map[string]string{"inferred": "true"},
752 },
753 },
754 Interfaces: []Interface{
755 {DeviceID: "local-device", IfIndex: 3, IfName: "Gi0/3", IfDescr: "Gi0/3", Labels: map[string]string{"admin_status": "up", "oper_status": "up"}},
756 {DeviceID: "local-device", IfIndex: 4, IfName: "Gi0/4", IfDescr: "Gi0/4", Labels: map[string]string{"admin_status": "up", "oper_status": "lowerLayerDown"}},
757 },
758 Adjacencies: []Adjacency{
759 {
760 Protocol: "lldp",
761 SourceID: "local-device",
762 SourcePort: "Gi0/3",
763 TargetID: "remote-device",
764 TargetPort: "Gi0/1",
765 },
766 },
767 Attachments: []Attachment{
768 {DeviceID: "local-device", IfIndex: 4, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
769 },
770 Enrichments: []Enrichment{
771 {
772 EndpointID: "mac:70:49:a2:65:72:cd",
773 MAC: "70:49:a2:65:72:cd",
774 IPs: []netip.Addr{netip.MustParseAddr("10.20.4.84")},
775 Labels: map[string]string{
776 "sources": "arp",
777 "if_indexes": "4",
778 "if_names": "Gi0/4",
779 },
780 },
781 },
782 }
783
784 opts := GraphOptions{
785 SchemaVersion: "2.0",
786 Source: "snmp",
787 Layer: "2",
788 View: "summary",
789 AgentID: "agent-1",
790 LocalDeviceID: "local-device",
791 }
792
793 baseline := ToGraph(result, opts)
794 for range 10 {
795 next := ToGraph(result, opts)
796 require.Equal(t, baseline, next)
797 }
798 }
799
800 func TestToGraph_DeduplicatesEndpointActorOverlappingManagedDevice(t *testing.T) {
801 result := Result{
802 Devices: []Device{
803 {
804 ID: "sw1",
805 Hostname: "sw1",
806 ChassisID: "7049a26572cd",
807 Addresses: []netip.Addr{netip.MustParseAddr("10.20.4.84")},
808 },
809 },
810 Attachments: []Attachment{
811 {
812 DeviceID: "sw1",
813 IfIndex: 1,
814 EndpointID: "mac:70:49:a2:65:72:cd",
815 Method: "fdb",
816 },
817 },
818 Enrichments: []Enrichment{
819 {
820 EndpointID: "mac:70:49:a2:65:72:cd",
821 MAC: "70:49:a2:65:72:cd",
822 IPs: []netip.Addr{netip.MustParseAddr("10.20.4.84")},
823 },
824 },
825 }
826
827 data := ToGraph(result, GraphOptions{
828 Source: "snmp",
829 Layer: "2",
830 View: "summary",
831 })
832
833 require.Len(t, data.Actors, 1)
834 require.Equal(t, "device", data.Actors[0].ActorType)
835 require.Equal(t, 0, data.Stats["endpoints_total"])
836 require.Equal(t, 1, data.Stats["actors_total"])
837 require.Equal(t, 0, data.Stats["links_total"])
838 require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
839 require.Equal(t, 1, data.Stats["segments_suppressed"])
840 }
841
842 func TestCanonicalTopologyMatchKey_NormalizesEquivalentMACRepresentations(t *testing.T) {
843 raw := Match{
844 ChassisIDs: []string{"7049a26572cd"},
845 }
846 colon := Match{
847 ChassisIDs: []string{"70:49:A2:65:72:CD"},
848 }
849
850 require.Equal(t, canonicalTopologyMatchKey(raw), canonicalTopologyMatchKey(colon))
851 require.Equal(t, "mac:70:49:a2:65:72:cd", canonicalTopologyMatchKey(raw))
852 }
853
854 func TestToGraph_UsesDeterministicPrimaryManagementIP(t *testing.T) {
855 result := Result{
856 Devices: []Device{
857 {
858 ID: "device-a",
859 Hostname: "device-a",
860 ChassisID: "aa:bb:cc:dd:ee:ff",
861 Addresses: []netip.Addr{
862 netip.MustParseAddr("10.0.0.9"),
863 netip.MustParseAddr("10.0.0.2"),
864 netip.MustParseAddr("10.0.0.9"),
865 },
866 },
867 },
868 }
869
870 data := ToGraph(result, GraphOptions{
871 Source: "snmp",
872 Layer: "2",
873 View: "summary",
874 })
875
876 actor := findActorBySysName(data.Actors, "device-a")
877 require.NotNil(t, actor)
878 require.Equal(t, "10.0.0.2", actor.Attributes["management_ip"])
879 require.Equal(t, []string{"10.0.0.2", "10.0.0.9"}, actor.Attributes["management_addresses"])
880 }
881
882 func TestToGraph_KeepsDistinctActorsWhenMACDiffersDespiteSameSecondaryIdentity(t *testing.T) {
883 result := Result{
884 Devices: []Device{
885 {
886 ID: "device-a",
887 Hostname: "shared-name",
888 ChassisID: "00:11:22:33:44:55",
889 Addresses: []netip.Addr{netip.MustParseAddr("10.20.30.40")},
890 },
891 {
892 ID: "device-b",
893 Hostname: "shared-name",
894 ChassisID: "00:11:22:33:44:66",
895 Addresses: []netip.Addr{netip.MustParseAddr("10.20.30.40")},
896 },
897 },
898 }
899
900 data := ToGraph(result, GraphOptions{
901 Source: "snmp",
902 Layer: "2",
903 View: "summary",
904 })
905
906 require.Len(t, data.Actors, 2)
907 macs := make(map[string]struct{}, 2)
908 for _, actor := range data.Actors {
909 require.Equal(t, "device", actor.ActorType)
910 require.NotEmpty(t, actor.Match.MacAddresses)
911 macs[actor.Match.MacAddresses[0]] = struct{}{}
912 }
913 require.Contains(t, macs, "00:11:22:33:44:55")
914 require.Contains(t, macs, "00:11:22:33:44:66")
915 }
916
917 func TestToGraph_MergesPairedAdjacenciesIntoBidirectionalLink(t *testing.T) {
918 result := Result{
919 Devices: []Device{
920 {
921 ID: "switch-a",
922 Hostname: "switch-a",
923 ChassisID: "aa:aa:aa:aa:aa:aa",
924 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
925 },
926 {
927 ID: "switch-b",
928 Hostname: "switch-b",
929 ChassisID: "bb:bb:bb:bb:bb:bb",
930 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
931 },
932 },
933 Interfaces: []Interface{
934 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
935 {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
936 },
937 Adjacencies: []Adjacency{
938 {
939 Protocol: "lldp",
940 SourceID: "switch-a",
941 SourcePort: "Gi0/1",
942 TargetID: "switch-b",
943 TargetPort: "Gi0/2",
944 Labels: map[string]string{
945 adjacencyLabelPairID: "lldp:pair-a-b",
946 adjacencyLabelPairPass: lldpMatchPassDefault,
947 },
948 },
949 {
950 Protocol: "lldp",
951 SourceID: "switch-b",
952 SourcePort: "Gi0/2",
953 TargetID: "switch-a",
954 TargetPort: "Gi0/1",
955 Labels: map[string]string{
956 adjacencyLabelPairID: "lldp:pair-a-b",
957 adjacencyLabelPairPass: lldpMatchPassDefault,
958 },
959 },
960 },
961 }
962
963 data := ToGraph(result, GraphOptions{
964 Source: "snmp",
965 Layer: "2",
966 View: "summary",
967 })
968
969 require.Len(t, data.Links, 1)
970 link := data.Links[0]
971 require.Equal(t, "lldp", link.Protocol)
972 require.Equal(t, "bidirectional", link.Direction)
973 require.Equal(t, "Gi0/1", link.Src.Attributes["if_name"])
974 require.Equal(t, "Gi0/1", link.Src.Attributes["port_id"])
975 require.Equal(t, "Gi0/2", link.Dst.Attributes["if_name"])
976 require.Equal(t, "Gi0/2", link.Dst.Attributes["port_id"])
977
978 require.NotNil(t, link.Metrics)
979 require.Equal(t, "lldp:pair-a-b", link.Metrics[adjacencyLabelPairID])
980 require.Equal(t, lldpMatchPassDefault, link.Metrics[adjacencyLabelPairPass])
981 require.Equal(t, true, link.Metrics["pair_consistent"])
982
983 require.Equal(t, 1, data.Stats["links_total"])
984 require.Equal(t, 1, data.Stats["links_lldp"])
985 require.Equal(t, 1, data.Stats["links_bidirectional"])
986 require.Equal(t, 0, data.Stats["links_unidirectional"])
987 }
988
989 func TestToGraph_MergesPairedAdjacenciesPreservesRawAddressHints(t *testing.T) {
990 result := Result{
991 Devices: []Device{
992 {
993 ID: "switch-a",
994 Hostname: "switch-a",
995 ChassisID: "aa:aa:aa:aa:aa:aa",
996 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
997 },
998 {
999 ID: "switch-b",
1000 Hostname: "switch-b",
1001 ChassisID: "bb:bb:bb:bb:bb:bb",
1002 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1003 },
1004 },
1005 Adjacencies: []Adjacency{
1006 {
1007 Protocol: "cdp",
1008 SourceID: "switch-a",
1009 SourcePort: "Gi0/1",
1010 TargetID: "switch-b",
1011 TargetPort: "Gi0/2",
1012 Labels: map[string]string{
1013 adjacencyLabelPairID: "cdp:pair-a-b",
1014 adjacencyLabelPairPass: cdpMatchPassDefault,
1015 "remote_address_raw": "edge-sw3.mgmt.local",
1016 },
1017 },
1018 {
1019 Protocol: "cdp",
1020 SourceID: "switch-b",
1021 SourcePort: "Gi0/2",
1022 TargetID: "switch-a",
1023 TargetPort: "Gi0/1",
1024 Labels: map[string]string{
1025 adjacencyLabelPairID: "cdp:pair-a-b",
1026 adjacencyLabelPairPass: cdpMatchPassDefault,
1027 "remote_address_raw": "10.0.0.1",
1028 },
1029 },
1030 },
1031 }
1032
1033 data := ToGraph(result, GraphOptions{
1034 Source: "snmp",
1035 Layer: "2",
1036 View: "summary",
1037 })
1038
1039 require.Len(t, data.Links, 1)
1040 link := data.Links[0]
1041 require.Equal(t, "cdp", link.Protocol)
1042 require.Equal(t, "bidirectional", link.Direction)
1043 require.Contains(t, link.Dst.Match.IPAddresses, "edge-sw3.mgmt.local")
1044 require.Contains(t, link.Metrics, "src_remote_address_raw")
1045 require.Contains(t, link.Metrics, "dst_remote_address_raw")
1046 }
1047
1048 func TestToGraph_MergesReversePairsWithoutDirectionalPairLabels(t *testing.T) {
1049 result := Result{
1050 Devices: []Device{
1051 {
1052 ID: "router-a",
1053 Hostname: "MikroTik-router",
1054 ChassisID: "aa:aa:aa:aa:aa:aa",
1055 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1056 },
1057 {
1058 ID: "switch-b",
1059 Hostname: "XS1930",
1060 ChassisID: "bb:bb:bb:bb:bb:bb",
1061 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1062 },
1063 },
1064 Interfaces: []Interface{
1065 {DeviceID: "router-a", IfIndex: 3, IfName: "ether3", IfDescr: "ether3"},
1066 {DeviceID: "switch-b", IfIndex: 8, IfName: "swp07", IfDescr: "swp07"},
1067 },
1068 Adjacencies: []Adjacency{
1069 {
1070 Protocol: "lldp",
1071 SourceID: "router-a",
1072 SourcePort: "ether3",
1073 TargetID: "switch-b",
1074 TargetPort: "",
1075 Labels: map[string]string{
1076 adjacencyLabelPairID: "lldp:pair-router-xs",
1077 adjacencyLabelPairPass: lldpMatchPassPortDesc,
1078 },
1079 },
1080 {
1081 Protocol: "lldp",
1082 SourceID: "switch-b",
1083 SourcePort: "8",
1084 TargetID: "router-a",
1085 TargetPort: "ether3",
1086 Labels: map[string]string{
1087 adjacencyLabelPairID: "lldp:pair-router-xs",
1088 adjacencyLabelPairPass: lldpMatchPassPortDesc,
1089 },
1090 },
1091 },
1092 }
1093
1094 data := ToGraph(result, GraphOptions{
1095 Source: "snmp",
1096 Layer: "2",
1097 View: "summary",
1098 })
1099
1100 require.Equal(t, 1, data.Stats["links_total"])
1101 require.Equal(t, 1, data.Stats["links_lldp"])
1102 require.Equal(t, 1, data.Stats["links_bidirectional"])
1103 require.Equal(t, 0, data.Stats["links_unidirectional"])
1104 require.Len(t, data.Links, 1)
1105
1106 link := data.Links[0]
1107 require.Equal(t, "lldp", link.Protocol)
1108 require.Equal(t, "bidirectional", link.Direction)
1109 require.Equal(t, "MikroTik-router", topologyAttrString(link.Src.Attributes, "sys_name"))
1110 require.Equal(t, "XS1930", topologyAttrString(link.Dst.Attributes, "sys_name"))
1111 require.Equal(t, "ether3", topologyAttrString(link.Src.Attributes, "if_name"))
1112 require.Equal(t, "swp07", topologyAttrString(link.Dst.Attributes, "if_name"))
1113 require.Equal(t, "8", topologyAttrString(link.Dst.Attributes, "port_id"))
1114 require.Equal(t, "swp07", topologyAttrString(link.Dst.Attributes, "port_name"))
1115 }
1116
1117 func TestToGraph_UnknownAdjacencyPortsRemainUnsetWithoutZeroFallback(t *testing.T) {
1118 result := Result{
1119 Devices: []Device{
1120 {
1121 ID: "router-a",
1122 Hostname: "MikroTik-router",
1123 ChassisID: "aa:aa:aa:aa:aa:aa",
1124 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1125 },
1126 {
1127 ID: "switch-b",
1128 Hostname: "XS1930",
1129 ChassisID: "bb:bb:bb:bb:bb:bb",
1130 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1131 },
1132 },
1133 Interfaces: []Interface{
1134 {DeviceID: "router-a", IfIndex: 3, IfName: "ether3", IfDescr: "ether3"},
1135 },
1136 Adjacencies: []Adjacency{
1137 {
1138 Protocol: "lldp",
1139 SourceID: "router-a",
1140 SourcePort: "ether3",
1141 TargetID: "switch-b",
1142 TargetPort: "",
1143 },
1144 },
1145 }
1146
1147 data := ToGraph(result, GraphOptions{
1148 Source: "snmp",
1149 Layer: "2",
1150 View: "summary",
1151 })
1152
1153 require.Len(t, data.Links, 1)
1154 link := data.Links[0]
1155 require.Equal(t, "lldp", link.Protocol)
1156 require.Equal(t, "unidirectional", link.Direction)
1157 _, hasPortName := link.Dst.Attributes["port_name"]
1158 require.False(t, hasPortName)
1159 require.Equal(t, "", strings.TrimSpace(topologyMetricString(link.Metrics, "dst_port_name")))
1160 require.Contains(t, topologyMetricString(link.Metrics, "display_name"), ":[unset]")
1161 }
1162
1163 func TestToGraph_DropsAmbiguousEndpointSegmentLinks(t *testing.T) {
1164 result := Result{
1165 Devices: []Device{
1166 {
1167 ID: "switch-a",
1168 Hostname: "switch-a",
1169 ChassisID: "aa:aa:aa:aa:aa:aa",
1170 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1171 },
1172 {
1173 ID: "switch-b",
1174 Hostname: "switch-b",
1175 ChassisID: "bb:bb:bb:bb:bb:bb",
1176 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1177 },
1178 },
1179 Interfaces: []Interface{
1180 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1181 {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1182 },
1183 Attachments: []Attachment{
1184 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
1185 {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
1186 },
1187 }
1188
1189 data := ToGraph(result, GraphOptions{
1190 Source: "snmp",
1191 Layer: "2",
1192 View: "summary",
1193 })
1194
1195 bridgeLinks := 0
1196 fdbLinks := 0
1197 for _, link := range data.Links {
1198 switch link.Protocol {
1199 case "bridge":
1200 bridgeLinks++
1201 case "fdb":
1202 fdbLinks++
1203 }
1204 }
1205
1206 require.Equal(t, 0, bridgeLinks)
1207 require.Equal(t, 0, fdbLinks)
1208 require.Equal(t, 0, data.Stats["links_total"])
1209 require.Equal(t, 0, data.Stats["links_fdb"])
1210 require.Equal(t, 2, data.Stats["links_fdb_endpoint_candidates"])
1211 require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
1212 require.Equal(t, 2, data.Stats["links_fdb_endpoint_suppressed"])
1213 require.Equal(t, 1, data.Stats["endpoints_ambiguous_segments"])
1214 require.Equal(t, 2, data.Stats["segments_suppressed"])
1215 }
1216
1217 func TestToGraph_ProbableConnectivityConnectsAmbiguousEndpoint(t *testing.T) {
1218 result := Result{
1219 Devices: []Device{
1220 {
1221 ID: "switch-a",
1222 Hostname: "switch-a",
1223 ChassisID: "aa:aa:aa:aa:aa:aa",
1224 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1225 },
1226 {
1227 ID: "switch-b",
1228 Hostname: "switch-b",
1229 ChassisID: "bb:bb:bb:bb:bb:bb",
1230 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1231 },
1232 },
1233 Interfaces: []Interface{
1234 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1235 {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1236 },
1237 Attachments: []Attachment{
1238 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
1239 {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
1240 },
1241 }
1242
1243 strictData := ToGraph(result, GraphOptions{
1244 Source: "snmp",
1245 Layer: "2",
1246 View: "summary",
1247 })
1248 require.Len(t, findFDBLinksByEndpointMAC(strictData.Links, "70:49:a2:65:72:cd"), 0)
1249
1250 data := ToGraph(result, GraphOptions{
1251 Source: "snmp",
1252 Layer: "2",
1253 View: "summary",
1254 ProbabilisticConnectivity: true,
1255 })
1256
1257 fdbLinks := findFDBLinksByEndpointMAC(data.Links, "70:49:a2:65:72:cd")
1258 require.Len(t, fdbLinks, 1)
1259 require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1260 require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(topologyMetricString(fdbLinks[0].Metrics, "inference"))))
1261 require.Equal(t, "probable_segment", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1262 require.Equal(t, "low", topologyMetricString(fdbLinks[0].Metrics, "confidence"))
1263
1264 require.Equal(t, 1, data.Stats["links_probable"])
1265 require.Equal(t, 1, data.Stats["links_fdb_endpoint_emitted"])
1266 require.Equal(t, 1, data.Stats["links_fdb_endpoint_suppressed"])
1267 }
1268
1269 func TestToGraph_ProbableConnectivityDoesNotReclassifyStrictSinglePortEndpoint(t *testing.T) {
1270 result := Result{
1271 Devices: []Device{
1272 {
1273 ID: "switch-a",
1274 Hostname: "switch-a",
1275 ChassisID: "aa:aa:aa:aa:aa:aa",
1276 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1277 },
1278 },
1279 Interfaces: []Interface{
1280 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1281 },
1282 Attachments: []Attachment{
1283 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
1284 },
1285 }
1286
1287 strictData := ToGraph(result, GraphOptions{
1288 Source: "snmp",
1289 Layer: "2",
1290 View: "summary",
1291 })
1292 strictFDBLinks := findFDBLinksByEndpointMAC(strictData.Links, "dd:dd:dd:dd:dd:dd")
1293 require.Len(t, strictFDBLinks, 1)
1294 require.Equal(t, "", strings.TrimSpace(strictFDBLinks[0].State))
1295 require.Equal(t, "", strings.TrimSpace(topologyMetricString(strictFDBLinks[0].Metrics, "inference")))
1296
1297 data := ToGraph(result, GraphOptions{
1298 Source: "snmp",
1299 Layer: "2",
1300 View: "summary",
1301 ProbabilisticConnectivity: true,
1302 })
1303
1304 fdbLinks := findFDBLinksByEndpointMAC(data.Links, "dd:dd:dd:dd:dd:dd")
1305 require.Len(t, fdbLinks, 1)
1306 require.Equal(t, "", strings.TrimSpace(fdbLinks[0].State))
1307 require.Equal(t, "", strings.TrimSpace(topologyMetricString(fdbLinks[0].Metrics, "inference")))
1308 require.Equal(t, "direct", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1309 require.Equal(t, 0, data.Stats["links_probable"])
1310 }
1311
1312 func TestToGraph_ProbableConnectivityConnectsUnlinkedLLDPEndpoint(t *testing.T) {
1313 result := Result{
1314 Devices: []Device{
1315 {
1316 ID: "switch-a",
1317 Hostname: "switch-a",
1318 ChassisID: "aa:aa:aa:aa:aa:aa",
1319 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1320 },
1321 {
1322 ID: "switch-b",
1323 Hostname: "switch-b",
1324 ChassisID: "bb:bb:bb:bb:bb:bb",
1325 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1326 },
1327 },
1328 Interfaces: []Interface{
1329 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1330 {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1331 },
1332 Attachments: []Attachment{
1333 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:cf", Method: "lldp"},
1334 {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:cf", Method: "lldp"},
1335 },
1336 }
1337
1338 strictData := ToGraph(result, GraphOptions{
1339 Source: "snmp",
1340 Layer: "2",
1341 View: "summary",
1342 })
1343 require.Len(t, findFDBLinksByEndpointMAC(strictData.Links, "70:49:a2:65:72:cf"), 0)
1344
1345 data := ToGraph(result, GraphOptions{
1346 Source: "snmp",
1347 Layer: "2",
1348 View: "summary",
1349 ProbabilisticConnectivity: true,
1350 })
1351
1352 fdbLinks := findFDBLinksByEndpointMAC(data.Links, "70:49:a2:65:72:cf")
1353 require.Len(t, fdbLinks, 1)
1354 require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1355 require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(topologyMetricString(fdbLinks[0].Metrics, "inference"))))
1356 require.Equal(t, "probable_segment", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1357 }
1358
1359 func TestToGraph_ProbableConnectivityAvoidsExtraBridgePathForLLDPPeers(t *testing.T) {
1360 result := Result{
1361 Devices: []Device{
1362 {
1363 ID: "switch-a",
1364 Hostname: "switch-a",
1365 ChassisID: "aa:aa:aa:aa:aa:aa",
1366 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1367 },
1368 {
1369 ID: "switch-b",
1370 Hostname: "switch-b",
1371 ChassisID: "bb:bb:bb:bb:bb:bb",
1372 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1373 },
1374 },
1375 Interfaces: []Interface{
1376 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1377 {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1378 {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1379 {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1380 },
1381 Adjacencies: []Adjacency{
1382 {
1383 Protocol: "lldp",
1384 SourceID: "switch-a",
1385 SourcePort: "Gi0/1",
1386 TargetID: "switch-b",
1387 TargetPort: "Gi0/1",
1388 },
1389 {
1390 Protocol: "lldp",
1391 SourceID: "switch-a",
1392 SourcePort: "Gi0/2",
1393 TargetID: "switch-b",
1394 TargetPort: "Gi0/2",
1395 },
1396 },
1397 Attachments: []Attachment{
1398 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:aa", Method: "lldp"},
1399 {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:aa", Method: "lldp"},
1400 {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:aa", Method: "lldp"},
1401 {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:aa", Method: "lldp"},
1402 },
1403 }
1404
1405 strictData := ToGraph(result, GraphOptions{
1406 Source: "snmp",
1407 Layer: "2",
1408 View: "summary",
1409 })
1410 require.Len(t, findFDBLinksByEndpointMAC(strictData.Links, "70:49:a2:65:72:aa"), 0)
1411
1412 data := ToGraph(result, GraphOptions{
1413 Source: "snmp",
1414 Layer: "2",
1415 View: "summary",
1416 ProbabilisticConnectivity: true,
1417 })
1418
1419 fdbLinks := findFDBLinksByEndpointMAC(data.Links, "70:49:a2:65:72:aa")
1420 require.Len(t, fdbLinks, 1)
1421 require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1422
1423 bridgeLinksBySegment := make(map[string]map[string]struct{})
1424 for _, link := range data.Links {
1425 if !strings.EqualFold(strings.TrimSpace(link.Protocol), "bridge") {
1426 continue
1427 }
1428 segmentActorID := strings.TrimSpace(link.DstActorID)
1429 if segmentActorID == "" {
1430 continue
1431 }
1432 devices := bridgeLinksBySegment[segmentActorID]
1433 if devices == nil {
1434 devices = make(map[string]struct{})
1435 bridgeLinksBySegment[segmentActorID] = devices
1436 }
1437 devices[strings.TrimSpace(link.SrcActorID)] = struct{}{}
1438 }
1439 for _, devices := range bridgeLinksBySegment {
1440 require.LessOrEqual(t, len(devices), 1)
1441 }
1442 }
1443
1444 func TestToGraph_ProbableConnectivityConnectsZeroCandidateEndpointUsingReporterHints(t *testing.T) {
1445 result := Result{
1446 Devices: []Device{
1447 {
1448 ID: "switch-a",
1449 Hostname: "switch-a",
1450 ChassisID: "aa:aa:aa:aa:aa:aa",
1451 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1452 },
1453 },
1454 Interfaces: []Interface{
1455 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1456 },
1457 Attachments: []Attachment{
1458 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
1459 },
1460 Enrichments: []Enrichment{
1461 {
1462 EndpointID: "ip:10.0.0.99",
1463 IPs: []netip.Addr{netip.MustParseAddr("10.0.0.99")},
1464 Labels: map[string]string{
1465 "sources": "arp",
1466 "device_ids": "switch-a",
1467 "if_indexes": "1",
1468 "if_names": "Gi0/1",
1469 },
1470 },
1471 },
1472 }
1473
1474 strictData := ToGraph(result, GraphOptions{
1475 Source: "snmp",
1476 Layer: "2",
1477 View: "summary",
1478 })
1479 require.Len(t, findFDBLinksByEndpointIP(strictData.Links, "10.0.0.99"), 0)
1480
1481 data := ToGraph(result, GraphOptions{
1482 Source: "snmp",
1483 Layer: "2",
1484 View: "summary",
1485 ProbabilisticConnectivity: true,
1486 })
1487
1488 fdbLinks := findFDBLinksByEndpointIP(data.Links, "10.0.0.99")
1489 require.Len(t, fdbLinks, 1)
1490 require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1491 require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(topologyMetricString(fdbLinks[0].Metrics, "inference"))))
1492 require.Equal(t, "probable_segment", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1493 require.Equal(t, "low", topologyMetricString(fdbLinks[0].Metrics, "confidence"))
1494
1495 strictSignatures := topologyLinkSignatures(strictData.Links)
1496 probableSignatures := topologyLinkSignatures(data.Links)
1497 for signature := range strictSignatures {
1498 _, ok := probableSignatures[signature]
1499 require.Truef(t, ok, "strict link signature missing in probable output: %s", signature)
1500 }
1501 }
1502
1503 func TestToGraph_ProbableConnectivityCreatesPortlessAttachmentForZeroCandidateEndpoint(t *testing.T) {
1504 result := Result{
1505 Devices: []Device{
1506 {
1507 ID: "switch-a",
1508 Hostname: "switch-a",
1509 ChassisID: "aa:aa:aa:aa:aa:aa",
1510 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1511 },
1512 },
1513 Interfaces: []Interface{
1514 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1515 },
1516 Attachments: []Attachment{
1517 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
1518 },
1519 Enrichments: []Enrichment{
1520 {
1521 EndpointID: "ip:10.0.0.199",
1522 IPs: []netip.Addr{netip.MustParseAddr("10.0.0.199")},
1523 Labels: map[string]string{
1524 "sources": "arp",
1525 "device_ids": "switch-a",
1526 "if_indexes": "999",
1527 "if_names": "Gi0/999",
1528 },
1529 },
1530 },
1531 }
1532
1533 data := ToGraph(result, GraphOptions{
1534 Source: "snmp",
1535 Layer: "2",
1536 View: "summary",
1537 ProbabilisticConnectivity: true,
1538 })
1539
1540 fdbLinks := findFDBLinksByEndpointIP(data.Links, "10.0.0.199")
1541 require.Len(t, fdbLinks, 1)
1542 require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1543 require.Equal(t, "probable_portless", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1544
1545 segmentActor := findActorByMatch(data.Actors, fdbLinks[0].Src.Match)
1546 require.NotNil(t, segmentActor)
1547 require.Contains(t, topologyAttrString(segmentActor.Attributes, "segment_id"), "bridge-domain:probable:")
1548 require.Equal(t, []string{"switch-a"}, segmentActor.Attributes["parent_devices"])
1549
1550 bridgeCount := 0
1551 for _, link := range data.Links {
1552 if !strings.EqualFold(strings.TrimSpace(link.Protocol), "bridge") {
1553 continue
1554 }
1555 if canonicalTopologyMatchKey(link.Dst.Match) != canonicalTopologyMatchKey(fdbLinks[0].Src.Match) {
1556 continue
1557 }
1558 bridgeCount++
1559 }
1560 require.Equal(t, 1, bridgeCount)
1561 }
1562
1563 func TestToGraph_InferenceStrategy_STPParentDoesNotSuppressFDBEndpointOwnership(t *testing.T) {
1564 result := Result{
1565 Devices: []Device{
1566 {
1567 ID: "switch-a",
1568 Hostname: "switch-a",
1569 ChassisID: "aa:aa:aa:aa:aa:aa",
1570 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1571 },
1572 {
1573 ID: "switch-b",
1574 Hostname: "switch-b",
1575 ChassisID: "bb:bb:bb:bb:bb:bb",
1576 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1577 },
1578 },
1579 Interfaces: []Interface{
1580 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1581 {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1582 },
1583 Adjacencies: []Adjacency{
1584 {
1585 Protocol: "stp",
1586 SourceID: "switch-a",
1587 SourcePort: "Gi0/1",
1588 TargetID: "switch-b",
1589 TargetPort: "Gi0/2",
1590 },
1591 },
1592 Attachments: []Attachment{
1593 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:00:00:00:00:00:11", Method: "fdb"},
1594 {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:00:00:00:00:00:22", Method: "fdb"},
1595 },
1596 }
1597
1598 baseline := ToGraph(result, GraphOptions{
1599 Source: "snmp",
1600 Layer: "2",
1601 View: "summary",
1602 })
1603 require.Equal(t, topologyInferenceStrategyFDBMinimumKnowledge, baseline.Stats["inference_strategy"])
1604 require.Greater(t, baseline.Stats["links_fdb_endpoint_emitted"].(int), 0)
1605
1606 stpData := ToGraph(result, GraphOptions{
1607 Source: "snmp",
1608 Layer: "2",
1609 View: "summary",
1610 InferenceStrategy: topologyInferenceStrategySTPParentTree,
1611 })
1612 require.Equal(t, topologyInferenceStrategySTPParentTree, stpData.Stats["inference_strategy"])
1613 require.Greater(t, stpData.Stats["links_fdb_endpoint_emitted"].(int), 0)
1614 }
1615
1616 func TestToGraph_InferenceStrategy_CDPHybridPrefersCDPBridgeLinks(t *testing.T) {
1617 result := Result{
1618 Devices: []Device{
1619 {ID: "sw-a", Hostname: "sw-a", ChassisID: "00:00:00:00:00:aa"},
1620 {ID: "sw-b", Hostname: "sw-b", ChassisID: "00:00:00:00:00:bb"},
1621 {ID: "sw-c", Hostname: "sw-c", ChassisID: "00:00:00:00:00:cc"},
1622 },
1623 Interfaces: []Interface{
1624 {DeviceID: "sw-a", IfIndex: 1, IfName: "Gi0/1"},
1625 {DeviceID: "sw-a", IfIndex: 2, IfName: "Gi0/2"},
1626 {DeviceID: "sw-b", IfIndex: 1, IfName: "Gi0/1"},
1627 {DeviceID: "sw-c", IfIndex: 1, IfName: "Gi0/1"},
1628 },
1629 Adjacencies: []Adjacency{
1630 {
1631 Protocol: "lldp",
1632 SourceID: "sw-a",
1633 SourcePort: "Gi0/1",
1634 TargetID: "sw-b",
1635 TargetPort: "Gi0/1",
1636 },
1637 {
1638 Protocol: "cdp",
1639 SourceID: "sw-a",
1640 SourcePort: "Gi0/2",
1641 TargetID: "sw-c",
1642 TargetPort: "Gi0/1",
1643 },
1644 },
1645 Attachments: []Attachment{
1646 {DeviceID: "sw-a", IfIndex: 1, EndpointID: "mac:00:00:00:00:10:01", Method: "fdb"},
1647 {DeviceID: "sw-a", IfIndex: 2, EndpointID: "mac:00:00:00:00:10:02", Method: "fdb"},
1648 },
1649 }
1650
1651 baseline := ToGraph(result, GraphOptions{
1652 Source: "snmp",
1653 Layer: "2",
1654 View: "summary",
1655 })
1656 require.Equal(t, topologyInferenceStrategyFDBMinimumKnowledge, baseline.Stats["inference_strategy"])
1657 require.Equal(t, 0, baseline.Stats["links_fdb_endpoint_emitted"])
1658
1659 data := ToGraph(result, GraphOptions{
1660 Source: "snmp",
1661 Layer: "2",
1662 View: "summary",
1663 InferenceStrategy: topologyInferenceStrategyCDPFDBHybrid,
1664 })
1665
1666 require.Equal(t, topologyInferenceStrategyCDPFDBHybrid, data.Stats["inference_strategy"])
1667 require.Equal(t, 1, data.Stats["links_cdp"])
1668 require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
1669 }
1670
1671 func TestPickProbableSegmentAnchorPortID_PrefersManagedPortWhenOwnerPointsToUnmanaged(t *testing.T) {
1672 unmanagedPort := bridgePortRef{
1673 deviceID: "ghost-switch",
1674 ifIndex: 900,
1675 ifName: "ghost0",
1676 }
1677 managedPort := bridgePortRef{
1678 deviceID: "managed-switch",
1679 ifIndex: 7,
1680 ifName: "swp06",
1681 }
1682
1683 segment := newBridgeDomainSegment(unmanagedPort)
1684 segment.addPort(managedPort)
1685
1686 endpointID := "mac:50:2c:c6:a6:fc:35"
1687 owner := fdbEndpointOwner{
1688 portKey: bridgePortObservationKey(unmanagedPort),
1689 portVLANKey: bridgePortObservationVLANKey(unmanagedPort),
1690 port: unmanagedPort,
1691 source: "single_port_mac",
1692 }
1693
1694 picked := pickProbableSegmentAnchorPortID(
1695 segment,
1696 map[string]struct{}{endpointID: {}},
1697 map[string]fdbEndpointOwner{endpointID: owner},
1698 map[string]struct{}{"managed-switch": {}},
1699 )
1700
1701 require.NotEmpty(t, picked)
1702 require.Equal(t, bridgePortRefSortKey(managedPort), bridgePortRefSortKey(segment.ports[picked]))
1703 }
1704
1705 func TestSelectProbableEndpointReporterHint_PrefersManagedHintsOverUnmanagedOwner(t *testing.T) {
1706 endpointLabels := map[string]string{
1707 "learned_device_ids": "ghost-switch,managed-switch",
1708 "learned_if_indexes": "7",
1709 "learned_if_names": "swp06",
1710 }
1711 reporterHints := map[string][]bridgePortRef{
1712 "ghost-switch": {
1713 {deviceID: "ghost-switch", ifIndex: 900, ifName: "ghost0"},
1714 },
1715 "managed-switch": {
1716 {deviceID: "managed-switch", ifIndex: 7, ifName: "swp06"},
1717 },
1718 }
1719 owner := fdbEndpointOwner{
1720 port: bridgePortRef{
1721 deviceID: "ghost-switch",
1722 ifIndex: 900,
1723 ifName: "ghost0",
1724 },
1725 source: "single_port_mac",
1726 }
1727
1728 hint := selectProbableEndpointReporterHint(
1729 endpointLabels,
1730 reporterHints,
1731 owner,
1732 nil,
1733 map[string]struct{}{"managed-switch": {}},
1734 )
1735
1736 require.Equal(t, "managed-switch", hint.deviceID)
1737 require.Equal(t, 7, hint.ifIndex)
1738 require.Equal(t, "swp06", hint.ifName)
1739 }
1740
1741 func TestProbableCandidateSegmentsFromReporterHints_PrefersManagedReporterSegments(t *testing.T) {
1742 index := segmentReporterIndex{
1743 byDevice: map[string]map[string]struct{}{
1744 "ghost-switch": {"segment:ghost": {}},
1745 "managed-switch": {"segment:managed": {}},
1746 },
1747 byDeviceIfIndex: map[string]map[string]struct{}{
1748 "ghost-switch\x007": {"segment:ghost": {}},
1749 "managed-switch\x007": {"segment:managed": {}},
1750 },
1751 byDeviceIfName: map[string]map[string]struct{}{
1752 "ghost-switch\x00swp06": {"segment:ghost": {}},
1753 "managed-switch\x00swp06": {"segment:managed": {}},
1754 },
1755 }
1756
1757 segments := probableCandidateSegmentsFromReporterHints(
1758 map[string]string{
1759 "learned_device_ids": "ghost-switch,managed-switch",
1760 "learned_if_indexes": "7",
1761 "learned_if_names": "swp06",
1762 },
1763 nil,
1764 index,
1765 nil,
1766 map[string]struct{}{"managed-switch": {}},
1767 )
1768
1769 require.Equal(t, []string{"segment:managed"}, segments)
1770 }
1771
1772 func TestEnsureManagedProbableReporterHint_UpgradesUnmanagedHint(t *testing.T) {
1773 hint := probableEndpointReporterHint{
1774 deviceID: "ghost-switch",
1775 }
1776 endpointLabels := map[string]string{
1777 "learned_device_ids": "macAddress:18:fd:74:7e:c5:80",
1778 "learned_if_indexes": "7",
1779 "learned_if_names": "swp06",
1780 }
1781 aliasOwnerIDs := map[string]map[string]struct{}{
1782 "mac:18:fd:74:7e:c5:80": {
1783 "managed-switch": {},
1784 },
1785 }
1786
1787 updated := ensureManagedProbableReporterHint(
1788 hint,
1789 endpointLabels,
1790 nil,
1791 aliasOwnerIDs,
1792 map[string]struct{}{"managed-switch": {}},
1793 []string{"managed-switch"},
1794 )
1795
1796 require.Equal(t, "managed-switch", updated.deviceID)
1797 require.Equal(t, 7, updated.ifIndex)
1798 require.Equal(t, "swp06", updated.ifName)
1799 }
1800
1801 func TestEnsureManagedProbableReporterHint_FallsBackToFirstManagedDevice(t *testing.T) {
1802 updated := ensureManagedProbableReporterHint(
1803 probableEndpointReporterHint{deviceID: "ghost-switch"},
1804 nil,
1805 nil,
1806 nil,
1807 map[string]struct{}{"managed-a": {}, "managed-b": {}},
1808 []string{"managed-a", "managed-b"},
1809 )
1810
1811 require.Equal(t, "managed-a", updated.deviceID)
1812 require.Equal(t, 0, updated.ifIndex)
1813 require.Equal(t, "0", updated.ifName)
1814 }
1815
1816 func TestToGraph_ProbableConnectivityRecoversUnmanagedOverlapSuppression(t *testing.T) {
1817 result := Result{
1818 Devices: []Device{
1819 {
1820 ID: "switch-a",
1821 Hostname: "switch-a",
1822 ChassisID: "aa:aa:aa:aa:aa:aa",
1823 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1824 },
1825 },
1826 Interfaces: []Interface{
1827 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1828 {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1829 },
1830 Adjacencies: []Adjacency{
1831 {
1832 Protocol: "lldp",
1833 SourceID: "switch-a",
1834 SourcePort: "Gi0/1",
1835 TargetID: "remote-peer",
1836 TargetPort: "cc:cc:cc:cc:cc:cc",
1837 },
1838 },
1839 Attachments: []Attachment{
1840 {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:cc:cc:cc:cc:cc:cc", Method: "fdb"},
1841 },
1842 }
1843
1844 strictData := ToGraph(result, GraphOptions{
1845 Source: "snmp",
1846 Layer: "2",
1847 View: "summary",
1848 })
1849 require.Len(t, findFDBLinksByEndpointMAC(strictData.Links, "cc:cc:cc:cc:cc:cc"), 0)
1850
1851 data := ToGraph(result, GraphOptions{
1852 Source: "snmp",
1853 Layer: "2",
1854 View: "summary",
1855 ProbabilisticConnectivity: true,
1856 })
1857
1858 fdbLinks := findFDBLinksByEndpointMAC(data.Links, "cc:cc:cc:cc:cc:cc")
1859 require.Len(t, fdbLinks, 1)
1860 require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(fdbLinks[0].State)))
1861 require.Equal(t, "probable", strings.ToLower(strings.TrimSpace(topologyMetricString(fdbLinks[0].Metrics, "inference"))))
1862 require.Equal(t, "probable_direct", topologyMetricString(fdbLinks[0].Metrics, "attachment_mode"))
1863 require.Equal(t, "low", topologyMetricString(fdbLinks[0].Metrics, "confidence"))
1864 }
1865
1866 func TestToGraph_CollapseByIPPrunesSuppressedManagedOverlapEndpoint(t *testing.T) {
1867 result := Result{
1868 Devices: []Device{
1869 {
1870 ID: "switch-a",
1871 Hostname: "switch-a",
1872 ChassisID: "aa:aa:aa:aa:aa:aa",
1873 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1874 },
1875 {
1876 ID: "nova",
1877 Hostname: "nova",
1878 ChassisID: "9c:6b:00:7b:98:c6",
1879 Addresses: []netip.Addr{netip.MustParseAddr("172.22.0.1")},
1880 Labels: map[string]string{"inferred": "true"},
1881 },
1882 },
1883 Interfaces: []Interface{
1884 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1885 {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1886 },
1887 Adjacencies: []Adjacency{
1888 {
1889 Protocol: "lldp",
1890 SourceID: "switch-a",
1891 SourcePort: "Gi0/1",
1892 TargetID: "nova",
1893 TargetPort: "9c:6b:00:7b:98:c7",
1894 },
1895 },
1896 Attachments: []Attachment{
1897 {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:9c:6b:00:7b:98:c7", Method: "fdb"},
1898 },
1899 Enrichments: []Enrichment{
1900 {
1901 EndpointID: "mac:9c:6b:00:7b:98:c7",
1902 MAC: "9c:6b:00:7b:98:c7",
1903 IPs: []netip.Addr{netip.MustParseAddr("10.20.4.22")},
1904 Labels: map[string]string{
1905 "sources": "arp",
1906 },
1907 },
1908 },
1909 }
1910
1911 withoutCollapse := ToGraph(result, GraphOptions{
1912 Source: "snmp",
1913 Layer: "2",
1914 View: "summary",
1915 })
1916 require.NotNil(t, findActorByMAC(withoutCollapse.Actors, "9c:6b:00:7b:98:c7"))
1917
1918 withCollapse := ToGraph(result, GraphOptions{
1919 Source: "snmp",
1920 Layer: "2",
1921 View: "summary",
1922 CollapseActorsByIP: true,
1923 })
1924 require.NotNil(t, findActorByMAC(withCollapse.Actors, "9c:6b:00:7b:98:c6"))
1925 require.Nil(t, findActorByMAC(withCollapse.Actors, "9c:6b:00:7b:98:c7"))
1926 require.Equal(t, 1, withCollapse.Stats["actors_unlinked_suppressed"])
1927 }
1928
1929 func TestToGraph_ReplacesKnownDeviceEndpointWithManagedDeviceEdge(t *testing.T) {
1930 result := Result{
1931 Devices: []Device{
1932 {
1933 ID: "router-a",
1934 Hostname: "router-a",
1935 ChassisID: "aa:aa:aa:aa:aa:aa",
1936 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1937 },
1938 {
1939 ID: "switch-b",
1940 Hostname: "switch-b",
1941 ChassisID: "bb:bb:bb:bb:bb:bb",
1942 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1943 },
1944 },
1945 Interfaces: []Interface{
1946 {DeviceID: "router-a", IfIndex: 1, IfName: "ether1"},
1947 },
1948 Attachments: []Attachment{
1949 {DeviceID: "router-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
1950 },
1951 }
1952
1953 data := ToGraph(result, GraphOptions{
1954 Source: "snmp",
1955 Layer: "2",
1956 View: "summary",
1957 })
1958
1959 fdbLinks := findFDBLinksByDstSysName(data.Links, "switch-b")
1960 require.Len(t, fdbLinks, 1)
1961 require.Equal(t, "managed_device_overlap", fdbLinks[0].Metrics["attachment_mode"])
1962 require.Equal(t, 1, data.Stats["links_fdb_endpoint_emitted"])
1963 require.Equal(t, 0, data.Stats["links_fdb_endpoint_suppressed"])
1964
1965 for _, actor := range data.Actors {
1966 if actor.ActorType != "endpoint" {
1967 continue
1968 }
1969 for _, mac := range actor.Match.MacAddresses {
1970 require.NotEqual(t, "bb:bb:bb:bb:bb:bb", mac)
1971 }
1972 }
1973 }
1974
1975 func TestToGraph_KnownDeviceOverlapUsesInterfaceMACAlias(t *testing.T) {
1976 result := Result{
1977 Devices: []Device{
1978 {
1979 ID: "router-a",
1980 Hostname: "router-a",
1981 ChassisID: "aa:aa:aa:aa:aa:80",
1982 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
1983 },
1984 {
1985 ID: "switch-b",
1986 Hostname: "switch-b",
1987 ChassisID: "bb:bb:bb:bb:bb:bb",
1988 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
1989 },
1990 },
1991 Interfaces: []Interface{
1992 {DeviceID: "router-a", IfIndex: 1, IfName: "ether1", MAC: "aa:aa:aa:aa:aa:8c"},
1993 {DeviceID: "switch-b", IfIndex: 1, IfName: "ether1"},
1994 },
1995 Attachments: []Attachment{
1996 {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:8c", Method: "fdb"},
1997 },
1998 }
1999
2000 data := ToGraph(result, GraphOptions{
2001 Source: "snmp",
2002 Layer: "2",
2003 View: "summary",
2004 })
2005
2006 fdbLinks := findFDBLinksByDstSysName(data.Links, "router-a")
2007 require.Len(t, fdbLinks, 1)
2008 require.Equal(t, "managed_device_overlap", fdbLinks[0].Metrics["attachment_mode"])
2009 require.Equal(t, 1, data.Stats["links_fdb_endpoint_emitted"])
2010 require.Equal(t, 0, data.Stats["links_fdb_endpoint_suppressed"])
2011 }
2012
2013 func TestToGraph_DeviceActorIncludesInterfaceMACAliases(t *testing.T) {
2014 result := Result{
2015 Devices: []Device{
2016 {
2017 ID: "switch-a",
2018 Hostname: "switch-a",
2019 ChassisID: "aa:aa:aa:aa:aa:01",
2020 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2021 },
2022 },
2023 Interfaces: []Interface{
2024 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", MAC: "aa:aa:aa:aa:aa:11"},
2025 {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2", MAC: "aa:aa:aa:aa:aa:12"},
2026 {DeviceID: "switch-a", IfIndex: 3, IfName: "Gi0/3", MAC: "AA-AA-AA-AA-AA-12"},
2027 },
2028 }
2029
2030 data := ToGraph(result, GraphOptions{
2031 Source: "snmp",
2032 Layer: "2",
2033 View: "summary",
2034 })
2035
2036 actor := findActorBySysName(data.Actors, "switch-a")
2037 require.NotNil(t, actor)
2038 require.ElementsMatch(
2039 t,
2040 []string{"aa:aa:aa:aa:aa:01", "aa:aa:aa:aa:aa:11", "aa:aa:aa:aa:aa:12"},
2041 actor.Match.MacAddresses,
2042 )
2043 }
2044
2045 func TestToGraph_KeepsUnlinkedEndpointsAndDevices(t *testing.T) {
2046 result := Result{
2047 Devices: []Device{
2048 {
2049 ID: "device-a",
2050 Hostname: "device-a",
2051 ChassisID: "aa:aa:aa:aa:aa:aa",
2052 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2053 },
2054 },
2055 Enrichments: []Enrichment{
2056 {
2057 EndpointID: "ip:10.0.0.42",
2058 IPs: []netip.Addr{netip.MustParseAddr("10.0.0.42")},
2059 },
2060 },
2061 }
2062
2063 data := ToGraph(result, GraphOptions{
2064 Source: "snmp",
2065 Layer: "2",
2066 View: "summary",
2067 })
2068
2069 require.Len(t, data.Actors, 2)
2070 deviceCount := 0
2071 endpointCount := 0
2072 for _, actor := range data.Actors {
2073 switch actor.ActorType {
2074 case "device":
2075 deviceCount++
2076 case "endpoint":
2077 endpointCount++
2078 }
2079 }
2080 require.Equal(t, 1, deviceCount)
2081 require.Equal(t, 1, endpointCount)
2082 require.Equal(t, 0, data.Stats["links_total"])
2083 require.Equal(t, 0, data.Stats["actors_unlinked_suppressed"])
2084 }
2085
2086 func TestToGraph_KeepsUnlinkedEndpointWhenIdentityOverlapsLinkedDevice(t *testing.T) {
2087 result := Result{
2088 Devices: []Device{
2089 {
2090 ID: "switch-a",
2091 Hostname: "switch-a",
2092 ChassisID: "aa:aa:aa:aa:aa:aa",
2093 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2094 },
2095 {
2096 ID: "mega",
2097 Hostname: "mega",
2098 ChassisID: "bb:bb:bb:bb:bb:bb",
2099 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
2100 },
2101 },
2102 Interfaces: []Interface{
2103 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1"},
2104 {DeviceID: "mega", IfIndex: 1, IfName: "eth0"},
2105 },
2106 Adjacencies: []Adjacency{
2107 {
2108 Protocol: "lldp",
2109 SourceID: "switch-a",
2110 SourcePort: "Gi0/1",
2111 TargetID: "mega",
2112 TargetPort: "eth0",
2113 },
2114 },
2115 Enrichments: []Enrichment{
2116 {
2117 EndpointID: "mac:cc:cc:cc:cc:cc:cc",
2118 IPs: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
2119 },
2120 },
2121 }
2122
2123 data := ToGraph(result, GraphOptions{
2124 Source: "snmp",
2125 Layer: "2",
2126 View: "summary",
2127 })
2128
2129 require.NotNil(t, findActorByMAC(data.Actors, "cc:cc:cc:cc:cc:cc"))
2130 require.Equal(t, 3, data.Stats["actors_total"])
2131 require.Equal(t, 1, data.Stats["links_total"])
2132 require.Equal(t, 0, data.Stats["actors_unlinked_suppressed"])
2133 }
2134
2135 func TestToGraph_DisplayNamesPreferDNSThenIPThenMAC(t *testing.T) {
2136 result := Result{
2137 Devices: []Device{
2138 {
2139 ID: "switch-a",
2140 Hostname: "switch-a",
2141 ChassisID: "aa:aa:aa:aa:aa:aa",
2142 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2143 },
2144 },
2145 Interfaces: []Interface{
2146 {DeviceID: "switch-a", IfIndex: 3, IfName: "Gi0/3", IfDescr: "Gi0/3"},
2147 },
2148 Attachments: []Attachment{
2149 {DeviceID: "switch-a", IfIndex: 3, EndpointID: "ip:10.0.0.42", Method: "arp"},
2150 {DeviceID: "switch-a", IfIndex: 3, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
2151 },
2152 }
2153
2154 data := ToGraph(result, GraphOptions{
2155 Source: "snmp",
2156 Layer: "2",
2157 View: "summary",
2158 ResolveDNSName: func(ip string) string {
2159 switch ip {
2160 case "10.0.0.1":
2161 return "switch-a.example.net."
2162 default:
2163 return ""
2164 }
2165 },
2166 })
2167
2168 device := findActorBySysName(data.Actors, "switch-a")
2169 require.NotNil(t, device)
2170 require.Equal(t, "switch-a.example.net", device.Labels["display_name"])
2171 require.Equal(t, "dns", device.Labels["display_source"])
2172 require.Equal(t, "switch-a.example.net", device.Attributes["display_name"])
2173
2174 ipEndpoint := findActorByIP(data.Actors, "10.0.0.42")
2175 require.NotNil(t, ipEndpoint)
2176 require.Equal(t, "10.0.0.42", ipEndpoint.Labels["display_name"])
2177 require.Equal(t, "ip", ipEndpoint.Labels["display_source"])
2178
2179 macEndpoint := findActorByMAC(data.Actors, "70:49:a2:65:72:cd")
2180 require.NotNil(t, macEndpoint)
2181 require.Equal(t, "70:49:a2:65:72:cd", macEndpoint.Labels["display_name"])
2182 require.Equal(t, "mac", macEndpoint.Labels["display_source"])
2183
2184 require.NotEmpty(t, data.Links)
2185 for _, link := range data.Links {
2186 require.NotNil(t, link.Src.Attributes)
2187 require.NotNil(t, link.Dst.Attributes)
2188 require.NotEmpty(t, link.Src.Attributes["display_name"])
2189 require.NotEmpty(t, link.Dst.Attributes["display_name"])
2190 }
2191 }
2192
2193 func TestTopologyDisplayNameFromMatch_PrefersSysNameBeforeIP(t *testing.T) {
2194 display := topologyDisplayNameFromMatch(Match{
2195 SysName: "MikroTik-router",
2196 IPAddresses: []string{"10.20.4.1"},
2197 }, &topologyDisplayNameResolver{
2198 lookup: func(string) string { return "" },
2199 cache: map[string]string{},
2200 })
2201
2202 require.Equal(t, "MikroTik-router", display.name)
2203 require.Equal(t, "sys_name", display.source)
2204 }
2205
2206 func TestTopologyDisplayNameFromMatch_PrefersHostnameBeforeIPWhenSysNameMissing(t *testing.T) {
2207 display := topologyDisplayNameFromMatch(Match{
2208 Hostnames: []string{"nova"},
2209 IPAddresses: []string{"10.20.4.22"},
2210 }, &topologyDisplayNameResolver{
2211 lookup: func(string) string { return "" },
2212 cache: map[string]string{},
2213 })
2214
2215 require.Equal(t, "nova", display.name)
2216 require.Equal(t, "hostname", display.source)
2217 }
2218
2219 func TestToGraph_SegmentDisplayNameUsesParentPortPattern(t *testing.T) {
2220 result := Result{
2221 Devices: []Device{
2222 {
2223 ID: "switch-a",
2224 Hostname: "switch-a",
2225 ChassisID: "aa:aa:aa:aa:aa:aa",
2226 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2227 },
2228 },
2229 Interfaces: []Interface{
2230 {DeviceID: "switch-a", IfIndex: 3, IfName: "Gi0/3", IfDescr: "Gi0/3"},
2231 },
2232 Attachments: []Attachment{
2233 {DeviceID: "switch-a", IfIndex: 3, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
2234 {DeviceID: "switch-a", IfIndex: 3, EndpointID: "mac:70:49:a2:65:72:ce", Method: "fdb"},
2235 },
2236 }
2237
2238 data := ToGraph(result, GraphOptions{
2239 Source: "snmp",
2240 Layer: "2",
2241 View: "summary",
2242 ResolveDNSName: func(ip string) string {
2243 if ip == "10.0.0.1" {
2244 return "switch-a.example.net."
2245 }
2246 return ""
2247 },
2248 })
2249
2250 segment := findActorByType(data.Actors, "segment")
2251 require.NotNil(t, segment)
2252 require.Equal(t, "switch-a.example.net.gi0/3.segment", segment.Labels["display_name"])
2253 require.Equal(t, "segment", segment.Labels["display_source"])
2254 require.Equal(t, "switch-a.example.net.gi0/3.segment", segment.Attributes["display_name"])
2255 }
2256
2257 func TestToGraph_FDBOwnerInferencePrefersNonLLDPSide(t *testing.T) {
2258 result := Result{
2259 Devices: []Device{
2260 {
2261 ID: "switch-a",
2262 Hostname: "switch-a",
2263 ChassisID: "aa:aa:aa:aa:aa:aa",
2264 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2265 },
2266 {
2267 ID: "switch-b",
2268 Hostname: "switch-b",
2269 ChassisID: "bb:bb:bb:bb:bb:bb",
2270 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
2271 },
2272 },
2273 Interfaces: []Interface{
2274 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
2275 {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1", MAC: "bb:bb:bb:bb:bb:bb"},
2276 {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", MAC: "bb:bb:bb:bb:bb:bc"},
2277 },
2278 Adjacencies: []Adjacency{
2279 {
2280 Protocol: "lldp",
2281 SourceID: "switch-a",
2282 SourcePort: "Gi0/1",
2283 TargetID: "switch-b",
2284 TargetPort: "Gi0/1",
2285 },
2286 },
2287 Attachments: []Attachment{
2288 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2289 {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2290 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
2291 {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:70:49:a2:65:72:cd", Method: "fdb"},
2292 {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:ee:ee:ee:ee:ee:ee", Method: "fdb"},
2293 },
2294 }
2295
2296 data := ToGraph(result, GraphOptions{
2297 Source: "snmp",
2298 Layer: "2",
2299 View: "summary",
2300 })
2301
2302 targetLinks := findFDBLinksByEndpointMAC(data.Links, "70:49:a2:65:72:cd")
2303 require.Len(t, targetLinks, 1)
2304
2305 segmentActor := findActorByMatch(data.Actors, targetLinks[0].Src.Match)
2306 require.NotNil(t, segmentActor)
2307 require.Equal(t, []string{"switch-b"}, segmentActor.Attributes["parent_devices"])
2308 require.Equal(t, []string{"Gi0/2"}, segmentActor.Attributes["if_names"])
2309 }
2310
2311 func TestToGraph_FDBOwnerInferenceUsesSingleMACPortRule(t *testing.T) {
2312 result := Result{
2313 Devices: []Device{
2314 {
2315 ID: "switch-a",
2316 Hostname: "switch-a",
2317 },
2318 {
2319 ID: "switch-b",
2320 Hostname: "switch-b",
2321 },
2322 },
2323 Interfaces: []Interface{
2324 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1"},
2325 {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2"},
2326 {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1"},
2327 },
2328 Attachments: []Attachment{
2329 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2330 {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2331 {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2332 {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2333 },
2334 }
2335
2336 data := ToGraph(result, GraphOptions{
2337 Source: "snmp",
2338 Layer: "2",
2339 View: "summary",
2340 })
2341
2342 targetLinks := findFDBLinksByEndpointMAC(data.Links, "dd:dd:dd:dd:dd:dd")
2343 require.Len(t, targetLinks, 1)
2344
2345 srcActor := findActorByMatch(data.Actors, targetLinks[0].Src.Match)
2346 require.NotNil(t, srcActor)
2347 require.Equal(t, "device", srcActor.ActorType)
2348 require.Equal(t, "switch-a", srcActor.Match.SysName)
2349
2350 endpointActor := findActorByMAC(data.Actors, "dd:dd:dd:dd:dd:dd")
2351 require.NotNil(t, endpointActor)
2352 require.Equal(t, "endpoint", endpointActor.ActorType)
2353 require.Equal(t, "single_port_mac", endpointActor.Attributes["attachment_source"])
2354 require.Equal(t, "switch-a", endpointActor.Attributes["attached_device"])
2355 require.Equal(t, "Gi0/1", endpointActor.Attributes["attached_port"])
2356 require.Equal(t, "single_port_mac", endpointActor.Labels["attached_by"])
2357 }
2358
2359 func TestToGraph_FDBOwnerInferenceSuppressesManagedAliasSwitchFacingPorts(t *testing.T) {
2360 result := Result{
2361 Devices: []Device{
2362 {
2363 ID: "switch-a",
2364 Hostname: "switch-a",
2365 ChassisID: "aa:aa:aa:aa:aa:aa",
2366 },
2367 {
2368 ID: "switch-b",
2369 Hostname: "switch-b",
2370 ChassisID: "bb:bb:bb:bb:bb:bb",
2371 },
2372 },
2373 Interfaces: []Interface{
2374 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1"},
2375 {DeviceID: "switch-a", IfIndex: 2, IfName: "Gi0/2"},
2376 {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1"},
2377 },
2378 Attachments: []Attachment{
2379 // Managed-device aliases learned on the same port mark it as switch-facing.
2380 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2381 {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2382
2383 // Candidate endpoint appears behind the managed-link port and must be suppressed.
2384 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2385 {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2386
2387 // Control endpoint on non-switch-facing port remains directly attachable.
2388 {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:ee:ee:ee:ee:ee:ee", Method: "fdb"},
2389 },
2390 }
2391
2392 data := ToGraph(result, GraphOptions{
2393 Source: "snmp",
2394 Layer: "2",
2395 View: "summary",
2396 })
2397
2398 ddLinks := findFDBLinksByEndpointMAC(data.Links, "dd:dd:dd:dd:dd:dd")
2399 require.Len(t, ddLinks, 0)
2400 ddActor := findActorByMAC(data.Actors, "dd:dd:dd:dd:dd:dd")
2401 require.NotNil(t, ddActor)
2402 _, hasAttachmentSource := ddActor.Attributes["attachment_source"]
2403 require.False(t, hasAttachmentSource)
2404 _, hasAttachedDevice := ddActor.Attributes["attached_device"]
2405 require.False(t, hasAttachedDevice)
2406
2407 eeLinks := findFDBLinksByEndpointMAC(data.Links, "ee:ee:ee:ee:ee:ee")
2408 require.Len(t, eeLinks, 1)
2409 eeSrc := findActorByMatch(data.Actors, eeLinks[0].Src.Match)
2410 require.NotNil(t, eeSrc)
2411 require.Equal(t, "device", eeSrc.ActorType)
2412 require.Equal(t, "switch-a", eeSrc.Match.SysName)
2413 }
2414
2415 func TestToGraph_SuppressesFDBEndpointsOnLLDPPorts(t *testing.T) {
2416 result := Result{
2417 Devices: []Device{
2418 {
2419 ID: "switch-a",
2420 Hostname: "switch-a",
2421 ChassisID: "aa:aa:aa:aa:aa:aa",
2422 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2423 },
2424 {
2425 ID: "host-b",
2426 Hostname: "host-b",
2427 ChassisID: "bb:bb:bb:bb:bb:bb",
2428 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")},
2429 },
2430 },
2431 Interfaces: []Interface{
2432 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
2433 {DeviceID: "host-b", IfIndex: 1, IfName: "eth0", MAC: "bb:bb:bb:bb:bb:bb"},
2434 },
2435 Adjacencies: []Adjacency{
2436 {
2437 Protocol: "lldp",
2438 SourceID: "switch-a",
2439 SourcePort: "Gi0/1",
2440 TargetID: "host-b",
2441 TargetPort: "eth0",
2442 },
2443 },
2444 Attachments: []Attachment{
2445 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2446 {DeviceID: "host-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2447 },
2448 }
2449
2450 data := ToGraph(result, GraphOptions{
2451 Source: "snmp",
2452 Layer: "2",
2453 View: "summary",
2454 })
2455
2456 bridgeLinks := 0
2457 fdbLinks := 0
2458 segmentActors := 0
2459 for _, actor := range data.Actors {
2460 if actor.ActorType == "segment" {
2461 segmentActors++
2462 }
2463 }
2464 for _, link := range data.Links {
2465 switch link.Protocol {
2466 case "bridge":
2467 bridgeLinks++
2468 case "fdb":
2469 fdbLinks++
2470 }
2471 }
2472
2473 require.Equal(t, 0, segmentActors)
2474 require.Equal(t, 0, bridgeLinks)
2475 require.Equal(t, 0, fdbLinks)
2476 require.Equal(t, 1, data.Stats["links_total"])
2477 require.Equal(t, 0, data.Stats["links_fdb"])
2478 require.Equal(t, 0, data.Stats["links_fdb_endpoint_candidates"])
2479 require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
2480 require.Equal(t, 0, data.Stats["links_fdb_endpoint_suppressed"])
2481 }
2482
2483 func TestToGraph_KeepsChassisPlaceholderDevicesAsDevices(t *testing.T) {
2484 result := Result{
2485 Devices: []Device{
2486 {
2487 ID: "switch-a",
2488 Hostname: "switch-a",
2489 ChassisID: "aa:aa:aa:aa:aa:aa",
2490 Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")},
2491 },
2492 {
2493 ID: "chassis-788cb595dfcc",
2494 Hostname: "chassis-788cb595dfcc",
2495 ChassisID: "78:8c:b5:95:df:cc",
2496 },
2497 },
2498 Adjacencies: []Adjacency{
2499 {
2500 Protocol: "lldp",
2501 SourceID: "switch-a",
2502 SourcePort: "Gi0/1",
2503 TargetID: "chassis-788cb595dfcc",
2504 TargetPort: "eth0",
2505 },
2506 },
2507 }
2508
2509 data := ToGraph(result, GraphOptions{
2510 Source: "snmp",
2511 Layer: "2",
2512 View: "summary",
2513 })
2514
2515 placeholder := findActorBySysName(data.Actors, "chassis-788cb595dfcc")
2516 require.NotNil(t, placeholder)
2517 require.Equal(t, "device", placeholder.ActorType)
2518 }
2519
2520 func TestPruneSegmentArtifacts_SuppressesLLDPDuplicateSegmentPath(t *testing.T) {
2521 actors := []Actor{
2522 {
2523 ActorType: "device",
2524 Match: Match{IPAddresses: []string{"10.0.0.1"}, SysName: "switch-a"},
2525 },
2526 {
2527 ActorType: "device",
2528 Match: Match{IPAddresses: []string{"10.0.0.2"}, SysName: "switch-b"},
2529 },
2530 {
2531 ActorType: "segment",
2532 Match: Match{Hostnames: []string{"segment:dup"}},
2533 },
2534 }
2535
2536 links := []Link{
2537 {
2538 Protocol: "lldp",
2539 Src: LinkEndpoint{Match: Match{IPAddresses: []string{"10.0.0.1"}}},
2540 Dst: LinkEndpoint{Match: Match{IPAddresses: []string{"10.0.0.2"}}},
2541 },
2542 {
2543 Protocol: "bridge",
2544 Src: LinkEndpoint{Match: Match{IPAddresses: []string{"10.0.0.1"}}},
2545 Dst: LinkEndpoint{Match: Match{Hostnames: []string{"segment:dup"}}},
2546 },
2547 {
2548 Protocol: "bridge",
2549 Src: LinkEndpoint{Match: Match{Hostnames: []string{"segment:dup"}}},
2550 Dst: LinkEndpoint{Match: Match{IPAddresses: []string{"10.0.0.2"}}},
2551 },
2552 }
2553
2554 filteredActors, filteredLinks, suppressed := pruneSegmentArtifacts(actors, links)
2555 require.Equal(t, 1, suppressed)
2556 require.Len(t, filteredActors, 2)
2557 require.Len(t, filteredLinks, 1)
2558 require.Equal(t, "lldp", filteredLinks[0].Protocol)
2559 }
2560
2561 func TestPruneSegmentArtifacts_SuppressesCDPDuplicateSegmentPath(t *testing.T) {
2562 actors := []Actor{
2563 {
2564 ActorType: "device",
2565 Match: Match{IPAddresses: []string{"10.0.1.1"}, SysName: "switch-a"},
2566 },
2567 {
2568 ActorType: "device",
2569 Match: Match{IPAddresses: []string{"10.0.1.2"}, SysName: "switch-b"},
2570 },
2571 {
2572 ActorType: "segment",
2573 Match: Match{Hostnames: []string{"segment:dup-cdp"}},
2574 },
2575 }
2576
2577 links := []Link{
2578 {
2579 Protocol: "cdp",
2580 Src: LinkEndpoint{Match: Match{IPAddresses: []string{"10.0.1.1"}}},
2581 Dst: LinkEndpoint{Match: Match{IPAddresses: []string{"10.0.1.2"}}},
2582 },
2583 {
2584 Protocol: "bridge",
2585 Src: LinkEndpoint{Match: Match{IPAddresses: []string{"10.0.1.1"}}},
2586 Dst: LinkEndpoint{Match: Match{Hostnames: []string{"segment:dup-cdp"}}},
2587 },
2588 {
2589 Protocol: "bridge",
2590 Src: LinkEndpoint{Match: Match{Hostnames: []string{"segment:dup-cdp"}}},
2591 Dst: LinkEndpoint{Match: Match{IPAddresses: []string{"10.0.1.2"}}},
2592 },
2593 }
2594
2595 filteredActors, filteredLinks, suppressed := pruneSegmentArtifacts(actors, links)
2596 require.Equal(t, 1, suppressed)
2597 require.Len(t, filteredActors, 2)
2598 require.Len(t, filteredLinks, 1)
2599 require.Equal(t, "cdp", filteredLinks[0].Protocol)
2600 }
2601
2602 func TestPruneSegmentArtifacts_SuppressesSegmentsWithSingleNeighbor(t *testing.T) {
2603 actors := []Actor{
2604 {
2605 ActorType: "device",
2606 Match: Match{IPAddresses: []string{"10.0.0.1"}, SysName: "router-a"},
2607 },
2608 {
2609 ActorType: "segment",
2610 Match: Match{Hostnames: []string{"segment:orphan"}},
2611 },
2612 }
2613
2614 links := []Link{
2615 {
2616 Protocol: "bridge",
2617 Src: LinkEndpoint{Match: Match{IPAddresses: []string{"10.0.0.1"}}},
2618 Dst: LinkEndpoint{Match: Match{Hostnames: []string{"segment:orphan"}}},
2619 },
2620 }
2621
2622 filteredActors, filteredLinks, suppressed := pruneSegmentArtifacts(actors, links)
2623 require.Equal(t, 1, suppressed)
2624 require.Len(t, filteredActors, 1)
2625 require.Len(t, filteredLinks, 0)
2626 }
2627
2628 func TestToGraph_DeterministicTransitRuleSuppressesFDBOnLLDPPortInExperimental(t *testing.T) {
2629 result := Result{
2630 Devices: []Device{
2631 {
2632 ID: "switch-a",
2633 Hostname: "switch-a",
2634 ChassisID: "aa:aa:aa:aa:aa:aa",
2635 },
2636 {
2637 ID: "switch-b",
2638 Hostname: "switch-b",
2639 ChassisID: "bb:bb:bb:bb:bb:bb",
2640 },
2641 },
2642 Interfaces: []Interface{
2643 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
2644 {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
2645 },
2646 Adjacencies: []Adjacency{
2647 {
2648 Protocol: "lldp",
2649 SourceID: "switch-a",
2650 SourcePort: "Gi0/1",
2651 TargetID: "switch-b",
2652 TargetPort: "Gi0/2",
2653 },
2654 },
2655 Attachments: []Attachment{
2656 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:00:00:00:00:00:11", Method: "fdb"},
2657 },
2658 }
2659
2660 data := ToGraph(result, GraphOptions{
2661 Source: "snmp",
2662 Layer: "2",
2663 View: "summary",
2664 InferenceStrategy: topologyInferenceStrategyFDBMinimumKnowledge,
2665 })
2666
2667 require.Equal(t, topologyInferenceStrategyFDBMinimumKnowledge, data.Stats["inference_strategy"])
2668 require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
2669 require.Nil(t, findActorByType(data.Actors, "segment"))
2670 }
2671
2672 func TestToGraph_DeterministicTransitRuleMatchesNumericLLDPPortToIfIndex(t *testing.T) {
2673 result := Result{
2674 Devices: []Device{
2675 {
2676 ID: "switch-a",
2677 Hostname: "switch-a",
2678 ChassisID: "aa:aa:aa:aa:aa:aa",
2679 },
2680 {
2681 ID: "switch-b",
2682 Hostname: "switch-b",
2683 ChassisID: "bb:bb:bb:bb:bb:bb",
2684 },
2685 },
2686 Interfaces: []Interface{
2687 {DeviceID: "switch-a", IfIndex: 2, IfName: "GigabitEthernet2", IfDescr: "GigabitEthernet2"},
2688 {DeviceID: "switch-b", IfIndex: 4, IfName: "ether4", IfDescr: "ether4"},
2689 },
2690 Adjacencies: []Adjacency{
2691 {
2692 Protocol: "lldp",
2693 SourceID: "switch-a",
2694 SourcePort: "2",
2695 TargetID: "switch-b",
2696 TargetPort: "ether4",
2697 },
2698 },
2699 Attachments: []Attachment{
2700 {DeviceID: "switch-a", IfIndex: 2, EndpointID: "mac:00:00:00:00:00:11", Method: "fdb"},
2701 },
2702 }
2703
2704 data := ToGraph(result, GraphOptions{
2705 Source: "snmp",
2706 Layer: "2",
2707 View: "summary",
2708 InferenceStrategy: topologyInferenceStrategyFDBMinimumKnowledge,
2709 })
2710
2711 require.Equal(t, 0, data.Stats["links_fdb_endpoint_emitted"])
2712 require.Nil(t, findActorByType(data.Actors, "segment"))
2713 lldpLink := findLinkByProtocol(data.Links, "lldp")
2714 require.NotNil(t, lldpLink)
2715 require.Equal(t, 2, lldpLink.Src.Attributes["if_index"])
2716 require.Equal(t, "GigabitEthernet2", lldpLink.Src.Attributes["if_name"])
2717 require.Equal(t, "2", lldpLink.Src.Attributes["port_id"])
2718 require.Equal(t, 4, lldpLink.Dst.Attributes["if_index"])
2719 require.Equal(t, "ether4", lldpLink.Dst.Attributes["if_name"])
2720 require.Equal(t, "ether4", lldpLink.Dst.Attributes["port_id"])
2721 }
2722
2723 func TestToGraph_SwitchFacingPortDoesNotSuppressEndpointOwnership(t *testing.T) {
2724 result := Result{
2725 Devices: []Device{
2726 {
2727 ID: "switch-a",
2728 Hostname: "switch-a",
2729 ChassisID: "aa:aa:aa:aa:aa:aa",
2730 },
2731 {
2732 ID: "switch-b",
2733 Hostname: "switch-b",
2734 ChassisID: "bb:bb:bb:bb:bb:bb",
2735 },
2736 },
2737 Interfaces: []Interface{
2738 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
2739 {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1", MAC: "bb:bb:bb:bb:bb:bb"},
2740 },
2741 Attachments: []Attachment{
2742 // Reciprocal managed-alias observations make this a switch-facing bridge pair.
2743 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2744 {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2745 // Host endpoint learned on the same switch-facing port.
2746 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:00:00:00:00:00:11", Method: "fdb"},
2747 },
2748 }
2749
2750 data := ToGraph(result, GraphOptions{
2751 Source: "snmp",
2752 Layer: "2",
2753 View: "summary",
2754 InferenceStrategy: topologyInferenceStrategyFDBPairwise,
2755 })
2756
2757 actor := findActorBySysName(data.Actors, "switch-a")
2758 require.NotNil(t, actor)
2759
2760 statuses, ok := actor.Attributes["if_statuses"].([]map[string]any)
2761 require.True(t, ok)
2762 port1 := findInterfaceStatusByIndex(statuses, 1)
2763 require.Equal(t, "switch_facing", port1["topology_role"])
2764
2765 targetLinks := findFDBLinksByEndpointMAC(data.Links, "00:00:00:00:00:11")
2766 require.Len(t, targetLinks, 1)
2767 segmentActor := findActorByMatch(data.Actors, targetLinks[0].Src.Match)
2768 require.NotNil(t, segmentActor)
2769 parentDevices, ok := segmentActor.Attributes["parent_devices"].([]string)
2770 require.True(t, ok)
2771 require.Contains(t, parentDevices, "switch-a")
2772 ifNames, ok := segmentActor.Attributes["if_names"].([]string)
2773 require.True(t, ok)
2774 require.Contains(t, ifNames, "Gi0/1")
2775 }
2776
2777 func TestSuppressInferredBridgeLinksOnDeterministicDiscovery(t *testing.T) {
2778 deterministic := make(map[string]struct{})
2779 addBridgePortObservationKeys(deterministic, bridgePortRef{
2780 deviceID: "switch-a",
2781 ifIndex: 1,
2782 ifName: "Gi0/1",
2783 })
2784 discoveryPairs := map[string]struct{}{
2785 topologyUndirectedPairKey("switch-a", "switch-b"): {},
2786 }
2787
2788 links := []bridgeBridgeLinkRecord{
2789 {
2790 designatedPort: bridgePortRef{deviceID: "switch-a", ifIndex: 1, ifName: "Gi0/1"},
2791 port: bridgePortRef{deviceID: "switch-b", ifIndex: 1, ifName: "Gi0/1"},
2792 method: "lldp",
2793 },
2794 {
2795 designatedPort: bridgePortRef{deviceID: "switch-a", ifIndex: 2, ifName: "Gi0/2"},
2796 port: bridgePortRef{deviceID: "switch-b", ifIndex: 2, ifName: "Gi0/2"},
2797 method: "fdb_pairwise",
2798 },
2799 {
2800 designatedPort: bridgePortRef{deviceID: "switch-a", ifIndex: 1, ifName: "Gi0/1"},
2801 port: bridgePortRef{deviceID: "switch-c", ifIndex: 7, ifName: "Gi0/7"},
2802 method: "stp",
2803 },
2804 {
2805 designatedPort: bridgePortRef{deviceID: "switch-a", ifIndex: 3, ifName: "Gi0/3"},
2806 port: bridgePortRef{deviceID: "switch-c", ifIndex: 3, ifName: "Gi0/3"},
2807 method: "fdb_pairwise",
2808 },
2809 }
2810
2811 filtered := suppressInferredBridgeLinksOnDeterministicDiscovery(links, deterministic, discoveryPairs)
2812 require.Len(t, filtered, 2)
2813 require.Equal(t, "lldp", filtered[0].method)
2814 require.Equal(t, "fdb_pairwise", filtered[1].method)
2815 require.Equal(t, "switch-c", filtered[1].port.deviceID)
2816 }
2817
2818 func TestToGraph_FDBOwnerInferenceUsesReporterMatrixRule(t *testing.T) {
2819 result := Result{
2820 Devices: []Device{
2821 {
2822 ID: "switch-a",
2823 Hostname: "switch-a",
2824 ChassisID: "aa:aa:aa:aa:aa:aa",
2825 },
2826 {
2827 ID: "switch-b",
2828 Hostname: "switch-b",
2829 ChassisID: "bb:bb:bb:bb:bb:bb",
2830 },
2831 {
2832 ID: "switch-c",
2833 Hostname: "switch-c",
2834 ChassisID: "cc:cc:cc:cc:cc:cc",
2835 },
2836 },
2837 Interfaces: []Interface{
2838 {DeviceID: "switch-a", IfIndex: 1, IfName: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
2839 {DeviceID: "switch-b", IfIndex: 1, IfName: "Gi0/1", MAC: "bb:bb:bb:bb:bb:bb"},
2840 {DeviceID: "switch-b", IfIndex: 2, IfName: "Gi0/2", MAC: "bb:bb:bb:bb:bb:bc"},
2841 {DeviceID: "switch-c", IfIndex: 1, IfName: "Gi0/1", MAC: "cc:cc:cc:cc:cc:cc"},
2842 {DeviceID: "switch-c", IfIndex: 2, IfName: "Gi0/2", MAC: "cc:cc:cc:cc:cc:cd"},
2843 },
2844 Attachments: []Attachment{
2845 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2846 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:cc:cc:cc:cc:cc:cc", Method: "fdb"},
2847 {DeviceID: "switch-a", IfIndex: 1, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2848 {DeviceID: "switch-b", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2849 {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:cc:cc:cc:cc:cc:cc", Method: "fdb"},
2850 {DeviceID: "switch-b", IfIndex: 2, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2851 {DeviceID: "switch-c", IfIndex: 1, EndpointID: "mac:aa:aa:aa:aa:aa:aa", Method: "fdb"},
2852 {DeviceID: "switch-c", IfIndex: 1, EndpointID: "mac:bb:bb:bb:bb:bb:bb", Method: "fdb"},
2853 {DeviceID: "switch-c", IfIndex: 2, EndpointID: "mac:dd:dd:dd:dd:dd:dd", Method: "fdb"},
2854 {DeviceID: "switch-c", IfIndex: 2, EndpointID: "mac:ee:ee:ee:ee:ee:ee", Method: "fdb"},
2855 },
2856 }
2857
2858 data := ToGraph(result, GraphOptions{
2859 Source: "snmp",
2860 Layer: "2",
2861 View: "summary",
2862 })
2863
2864 targetLinks := findFDBLinksByEndpointMAC(data.Links, "dd:dd:dd:dd:dd:dd")
2865 require.Len(t, targetLinks, 1)
2866
2867 segmentActor := findActorByMatch(data.Actors, targetLinks[0].Src.Match)
2868 require.NotNil(t, segmentActor)
2869 require.Equal(t, []string{"switch-c"}, segmentActor.Attributes["parent_devices"])
2870 require.Equal(t, []string{"Gi0/2"}, segmentActor.Attributes["if_names"])
2871 }
2872
2873 func findInterfaceStatusByIndex(statuses []map[string]any, ifIndex int) map[string]any {
2874 for _, status := range statuses {
2875 value, ok := status["if_index"].(int)
2876 if ok && value == ifIndex {
2877 return status
2878 }
2879 }
2880 return nil
2881 }
2882
2883 func findNeighborByProtocol(neighbors []map[string]any, protocol string) map[string]any {
2884 for _, neighbor := range neighbors {
2885 value, ok := neighbor["protocol"].(string)
2886 if ok && strings.EqualFold(value, protocol) {
2887 return neighbor
2888 }
2889 }
2890 return nil
2891 }
2892
2893 func findFDBLinksByEndpointMAC(links []Link, mac string) []Link {
2894 out := make([]Link, 0)
2895 for _, link := range links {
2896 if link.Protocol != "fdb" {
2897 continue
2898 }
2899 if slices.Contains(link.Dst.Match.MacAddresses, mac) {
2900 out = append(out, link)
2901 }
2902 }
2903 return out
2904 }
2905
2906 func findFDBLinksByEndpointIP(links []Link, ip string) []Link {
2907 out := make([]Link, 0)
2908 for _, link := range links {
2909 if link.Protocol != "fdb" {
2910 continue
2911 }
2912 if slices.Contains(link.Dst.Match.IPAddresses, ip) {
2913 out = append(out, link)
2914 }
2915 }
2916 return out
2917 }
2918
2919 func topologyLinkSignatures(links []Link) map[string]struct{} {
2920 out := make(map[string]struct{}, len(links))
2921 for _, link := range links {
2922 srcKey := canonicalTopologyMatchKey(link.Src.Match)
2923 dstKey := canonicalTopologyMatchKey(link.Dst.Match)
2924 if srcKey == "" || dstKey == "" {
2925 continue
2926 }
2927 key := strings.Join([]string{
2928 strings.ToLower(strings.TrimSpace(link.Protocol)),
2929 strings.ToLower(strings.TrimSpace(link.Direction)),
2930 srcKey,
2931 dstKey,
2932 strings.ToLower(strings.TrimSpace(link.State)),
2933 strings.ToLower(topologyMetricString(link.Metrics, "attachment_mode")),
2934 }, keySep)
2935 out[key] = struct{}{}
2936 }
2937 return out
2938 }
2939
2940 func findFDBLinksByDstSysName(links []Link, sysName string) []Link {
2941 out := make([]Link, 0)
2942 for _, link := range links {
2943 if link.Protocol != "fdb" {
2944 continue
2945 }
2946 if link.Dst.Match.SysName != sysName {
2947 continue
2948 }
2949 out = append(out, link)
2950 }
2951 return out
2952 }
2953
2954 func findActorByMatch(actors []Actor, match Match) *Actor {
2955 target := canonicalTopologyMatchKey(match)
2956 if target == "" {
2957 return nil
2958 }
2959 for i := range actors {
2960 if canonicalTopologyMatchKey(actors[i].Match) == target {
2961 return &actors[i]
2962 }
2963 }
2964 return nil
2965 }
2966
2967 func findActorBySysName(actors []Actor, sysName string) *Actor {
2968 for i := range actors {
2969 if actors[i].Match.SysName == sysName {
2970 return &actors[i]
2971 }
2972 }
2973 return nil
2974 }
2975
2976 func findActorByMAC(actors []Actor, mac string) *Actor {
2977 for i := range actors {
2978 if slices.Contains(actors[i].Match.MacAddresses, mac) {
2979 return &actors[i]
2980 }
2981 }
2982 return nil
2983 }
2984
2985 func findActorByIP(actors []Actor, ip string) *Actor {
2986 for i := range actors {
2987 if slices.Contains(actors[i].Match.IPAddresses, ip) {
2988 return &actors[i]
2989 }
2990 }
2991 return nil
2992 }
2993
2994 func findActorByType(actors []Actor, actorType string) *Actor {
2995 for i := range actors {
2996 if actors[i].ActorType == actorType {
2997 return &actors[i]
2998 }
2999 }
3000 return nil
3001 }
3002
3003 func findLinkByProtocol(links []Link, protocol string) *Link {
3004 for i := range links {
3005 if links[i].Protocol == protocol {
3006 return &links[i]
3007 }
3008 }
3009 return nil
3010 }