master
go 1,579 lines 46.1 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package l2topology
4
5 import (
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/require"
10 )
11
12 func TestBuildL2ResultFromObservations_LLDPAndCDP(t *testing.T) {
13 observations := []L2Observation{
14 {
15 DeviceID: "switch-a",
16 Hostname: "switch-a.example.net",
17 ManagementIP: "10.0.0.1",
18 ChassisID: "aa:bb:cc:dd:ee:ff",
19 Interfaces: []ObservedInterface{
20 {IfIndex: 8, IfName: "Gi0/0", IfDescr: "Gi0/0"},
21 },
22 LLDPRemotes: []LLDPRemoteObservation{
23 {
24 LocalPortNum: "8",
25 RemoteIndex: "1",
26 LocalPortID: "Gi0/0",
27 ChassisID: "bb:cc:dd:ee:ff:00",
28 SysName: "switch-b.example.net",
29 PortID: "Gi0/1",
30 ManagementIP: "10.0.0.2",
31 },
32 },
33 CDPRemotes: []CDPRemoteObservation{
34 {
35 LocalIfIndex: 8,
36 LocalIfName: "Gi0/0",
37 DeviceIndex: "1",
38 DeviceID: "switch-b.example.net",
39 DevicePort: "Gi0/1",
40 },
41 },
42 },
43 {
44 DeviceID: "switch-b",
45 Hostname: "switch-b.example.net",
46 ManagementIP: "10.0.0.2",
47 ChassisID: "bb:cc:dd:ee:ff:00",
48 Interfaces: []ObservedInterface{
49 {IfIndex: 9, IfName: "Gi0/1", IfDescr: "Gi0/1"},
50 },
51 },
52 }
53
54 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true, EnableCDP: true})
55 require.NoError(t, err)
56 require.Len(t, result.Devices, 2)
57 require.Len(t, result.Interfaces, 2)
58 require.Len(t, result.Adjacencies, 2)
59 require.Equal(t, 2, result.Stats["links_total"])
60 require.Equal(t, 1, result.Stats["links_lldp"])
61 require.Equal(t, 1, result.Stats["links_cdp"])
62
63 require.Equal(t, "cdp", result.Adjacencies[0].Protocol)
64 require.Equal(t, "switch-a", result.Adjacencies[0].SourceID)
65 require.Equal(t, "switch-b", result.Adjacencies[0].TargetID)
66 require.Equal(t, "lldp", result.Adjacencies[1].Protocol)
67 require.Equal(t, "switch-a", result.Adjacencies[1].SourceID)
68 require.Equal(t, "switch-b", result.Adjacencies[1].TargetID)
69 }
70
71 func TestBuildL2ResultFromObservations_DefaultProtocols(t *testing.T) {
72 observations := []L2Observation{
73 {
74 DeviceID: "switch-a",
75 Hostname: "switch-a",
76 ManagementIP: "10.0.0.1",
77 CDPRemotes: []CDPRemoteObservation{
78 {
79 LocalIfIndex: 8,
80 DeviceIndex: "1",
81 Address: "0a000002",
82 },
83 },
84 },
85 {
86 DeviceID: "switch-b",
87 Hostname: "switch-b",
88 ManagementIP: "10.0.0.2",
89 },
90 }
91
92 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{})
93 require.NoError(t, err)
94 require.Len(t, result.Adjacencies, 1)
95 require.Equal(t, "switch-b", result.Adjacencies[0].TargetID)
96 require.Equal(t, 0, result.Stats["links_lldp"])
97 require.Equal(t, 1, result.Stats["links_cdp"])
98 }
99
100 func TestBuildL2ResultFromObservations_UsesProvidedCollectedAt(t *testing.T) {
101 collectedAt := time.Date(2026, time.April, 2, 0, 0, 0, 0, time.UTC)
102
103 result, err := BuildL2ResultFromObservations([]L2Observation{
104 {
105 DeviceID: "switch-a",
106 Hostname: "switch-a.example.net",
107 },
108 }, DiscoverOptions{CollectedAt: collectedAt})
109 require.NoError(t, err)
110 require.Equal(t, collectedAt, result.CollectedAt)
111 }
112
113 func TestBuildL2ResultFromObservations_InterfaceStatusLabels(t *testing.T) {
114 observations := []L2Observation{
115 {
116 DeviceID: "switch-a",
117 Hostname: "switch-a",
118 Interfaces: []ObservedInterface{
119 {
120 IfIndex: 8,
121 IfName: "Gi0/0",
122 IfDescr: "Gi0/0",
123 IfAlias: "uplink-a",
124 MAC: "AA:BB:CC:DD:EE:FF",
125 SpeedBps: 1_000_000_000,
126 LastChange: 12345,
127 Duplex: "full",
128 InterfaceType: "ethernetcsmacd",
129 AdminStatus: "up",
130 OperStatus: "lowerLayerDown",
131 },
132 },
133 },
134 }
135
136 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{})
137 require.NoError(t, err)
138 require.Len(t, result.Interfaces, 1)
139 require.Equal(t, "ethernetcsmacd", result.Interfaces[0].Labels["if_type"])
140 require.Equal(t, "up", result.Interfaces[0].Labels["admin_status"])
141 require.Equal(t, "lowerLayerDown", result.Interfaces[0].Labels["oper_status"])
142 require.Equal(t, "uplink-a", result.Interfaces[0].Labels["if_alias"])
143 require.Equal(t, "aa:bb:cc:dd:ee:ff", result.Interfaces[0].Labels["mac"])
144 require.Equal(t, "1000000000", result.Interfaces[0].Labels["speed_bps"])
145 require.Equal(t, "12345", result.Interfaces[0].Labels["last_change"])
146 require.Equal(t, "full", result.Interfaces[0].Labels["duplex"])
147 }
148
149 func TestBuildL2ResultFromObservations_DeviceProtocolsObservedLabel(t *testing.T) {
150 observations := []L2Observation{
151 {
152 DeviceID: "switch-a",
153 Hostname: "switch-a",
154 ManagementIP: "10.0.0.1",
155 BridgePorts: []BridgePortObservation{
156 {BasePort: "3", IfIndex: 3},
157 },
158 FDBEntries: []FDBObservation{
159 {MAC: "70:49:a2:65:72:cd", BridgePort: "3", Status: "learned"},
160 },
161 ARPNDEntries: []ARPNDObservation{
162 {IfIndex: 3, IP: "10.0.0.20", MAC: "70:49:a2:65:72:cd", Protocol: "arp"},
163 },
164 // Collected STP row that does not form a usable topology edge.
165 STPPorts: []STPPortObservation{
166 {Port: "3", DesignatedBridge: "00:11:22:33:44:55"},
167 },
168 },
169 }
170
171 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{
172 EnableBridge: true,
173 EnableARP: true,
174 EnableSTP: true,
175 })
176 require.NoError(t, err)
177 require.Len(t, result.Devices, 1)
178 require.Equal(t, "arp,bridge,fdb,stp", result.Devices[0].Labels["protocols_observed"])
179 }
180
181 func TestBuildL2ResultFromObservations_MergesDuplicateDeviceObservations(t *testing.T) {
182 observations := []L2Observation{
183 {
184 DeviceID: "switch-a",
185 Hostname: "switch-a.example.net",
186 ManagementIP: "10.0.0.1",
187 SysObjectID: "1.3.6.1.4.1.9.1.1",
188 ChassisID: "aa:bb:cc:dd:ee:ff",
189 BridgePorts: []BridgePortObservation{
190 {BasePort: "3", IfIndex: 3},
191 },
192 },
193 {
194 DeviceID: "switch-a",
195 ManagementIP: "10.0.0.2",
196 ARPNDEntries: []ARPNDObservation{
197 {IfIndex: 3, IP: "10.0.0.20", MAC: "70:49:a2:65:72:cd", Protocol: "arp"},
198 },
199 },
200 }
201
202 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{
203 EnableBridge: true,
204 EnableARP: true,
205 })
206 require.NoError(t, err)
207
208 device := findDeviceByID(result.Devices, "switch-a")
209 require.NotNil(t, device)
210 require.Equal(t, "switch-a.example.net", device.Hostname)
211 require.Equal(t, "1.3.6.1.4.1.9.1.1", device.SysObject)
212 require.Equal(t, "aa:bb:cc:dd:ee:ff", device.ChassisID)
213 require.Equal(t, []string{"10.0.0.1", "10.0.0.2"}, deviceAddressStrings(*device))
214 require.Equal(t, "arp,bridge", device.Labels["protocols_observed"])
215 }
216
217 func TestBuildL2ResultFromObservations_SkipsSelfAdjacencies(t *testing.T) {
218 observations := []L2Observation{
219 {
220 DeviceID: "dw",
221 Hostname: "dw",
222 ManagementIP: "10.104.133.114",
223 ChassisID: "cf",
224 LLDPRemotes: []LLDPRemoteObservation{
225 {
226 LocalPortNum: "1",
227 RemoteIndex: "1",
228 LocalPortID: "CF",
229 ChassisID: "cf",
230 SysName: "dw",
231 PortID: "CF",
232 },
233 },
234 CDPRemotes: []CDPRemoteObservation{
235 {
236 LocalIfIndex: 1,
237 LocalIfName: "CF",
238 DeviceIndex: "1",
239 DeviceID: "dw",
240 SysName: "dw",
241 DevicePort: "CF",
242 Address: "10.104.133.114",
243 },
244 },
245 },
246 }
247
248 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true, EnableCDP: true})
249 require.NoError(t, err)
250 require.Len(t, result.Devices, 1)
251 require.Empty(t, result.Adjacencies)
252 require.Equal(t, 0, result.Stats["links_total"])
253 require.Equal(t, 0, result.Stats["links_lldp"])
254 require.Equal(t, 0, result.Stats["links_cdp"])
255 }
256
257 func TestBuildL2ResultFromObservations_CDPSysNameAndDeviceID(t *testing.T) {
258 observations := []L2Observation{
259 {
260 DeviceID: "switch-a",
261 Hostname: "switch-a",
262 ManagementIP: "10.0.0.1",
263 CDPRemotes: []CDPRemoteObservation{
264 {
265 LocalIfIndex: 1,
266 DeviceIndex: "1",
267 DeviceID: "SEP001122334455",
268 SysName: "distribution-sw",
269 Address: "0a000002",
270 DevicePort: "Gi0/48",
271 },
272 },
273 },
274 }
275
276 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableCDP: true})
277 require.NoError(t, err)
278 require.Len(t, result.Devices, 2)
279 require.Len(t, result.Adjacencies, 1)
280 require.Equal(t, "distribution-sw", result.Adjacencies[0].TargetID)
281
282 var remote Device
283 var local Device
284 for _, dev := range result.Devices {
285 if dev.ID == "distribution-sw" {
286 remote = dev
287 }
288 if dev.ID == "switch-a" {
289 local = dev
290 }
291 }
292 require.Equal(t, "distribution-sw", remote.ID)
293 require.Equal(t, "distribution-sw", remote.Hostname)
294 require.Equal(t, "SEP001122334455", remote.ChassisID)
295 require.Equal(t, "10.0.0.2", remote.Addresses[0].String())
296 require.Equal(t, "true", remote.Labels["inferred"])
297 require.Equal(t, "false", local.Labels["inferred"])
298 }
299
300 func TestBuildL2ResultFromObservations_FDBAttachments(t *testing.T) {
301 observations := []L2Observation{
302 {
303 DeviceID: "switch-a",
304 Hostname: "switch-a",
305 Interfaces: []ObservedInterface{
306 {IfIndex: 3, IfName: "Port3", IfDescr: "Port3"},
307 },
308 BridgePorts: []BridgePortObservation{
309 {BasePort: "7", IfIndex: 3},
310 },
311 FDBEntries: []FDBObservation{
312 {MAC: "7049a26572cd", BridgePort: "7", Status: "learned"},
313 {MAC: "70:49:a2:65:72:ce", BridgePort: "7", Status: "learned"},
314 {MAC: "70:49:a2:65:72:cd", BridgePort: "7", Status: "learned"},
315 },
316 },
317 }
318
319 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
320 require.NoError(t, err)
321 require.Empty(t, result.Adjacencies)
322 require.Len(t, result.Attachments, 2)
323
324 first := result.Attachments[0]
325 require.Equal(t, "switch-a", first.DeviceID)
326 require.Equal(t, 3, first.IfIndex)
327 require.Equal(t, "mac:70:49:a2:65:72:cd", first.EndpointID)
328 require.Equal(t, "fdb", first.Method)
329 require.Equal(t, "bridge-domain:switch-a:if:3", first.Labels["bridge_domain"])
330 require.Equal(t, "7", first.Labels["bridge_port"])
331 require.Equal(t, "Port3", first.Labels["if_name"])
332
333 require.Equal(t, 2, result.Stats["attachments_total"])
334 require.Equal(t, 2, result.Stats["attachments_fdb"])
335 require.Equal(t, 1, result.Stats["bridge_domains_total"])
336 require.Equal(t, 2, result.Stats["endpoints_total"])
337 }
338
339 func TestBuildL2ResultFromObservations_FDBDropsDuplicateMACAcrossPorts(t *testing.T) {
340 observations := []L2Observation{
341 {
342 DeviceID: "switch-a",
343 Hostname: "switch-a",
344 BridgePorts: []BridgePortObservation{
345 {BasePort: "1", IfIndex: 1},
346 {BasePort: "2", IfIndex: 2},
347 },
348 FDBEntries: []FDBObservation{
349 {MAC: "70:49:a2:65:72:cd", BridgePort: "1", Status: "learned"},
350 {MAC: "70:49:a2:65:72:cd", BridgePort: "2", Status: "learned"},
351 {MAC: "70:49:a2:65:72:ce", BridgePort: "2", Status: "learned"},
352 },
353 },
354 }
355
356 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
357 require.NoError(t, err)
358 require.Len(t, result.Attachments, 1)
359 require.Equal(t, "mac:70:49:a2:65:72:ce", result.Attachments[0].EndpointID)
360 require.Equal(t, 1, result.Stats["attachments_fdb"])
361 }
362
363 func TestBuildL2ResultFromObservations_FDBKeepsSameMACAcrossPortsWhenVLANDiffers(t *testing.T) {
364 observations := []L2Observation{
365 {
366 DeviceID: "switch-a",
367 Hostname: "switch-a",
368 BridgePorts: []BridgePortObservation{
369 {BasePort: "1", IfIndex: 1},
370 {BasePort: "2", IfIndex: 2},
371 },
372 FDBEntries: []FDBObservation{
373 {MAC: "70:49:a2:65:72:cd", BridgePort: "1", Status: "learned", VLANID: "10"},
374 {MAC: "70:49:a2:65:72:cd", BridgePort: "2", Status: "learned", VLANID: "20"},
375 },
376 },
377 }
378
379 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
380 require.NoError(t, err)
381 require.Len(t, result.Attachments, 2)
382
383 first := result.Attachments[0]
384 second := result.Attachments[1]
385 require.Equal(t, "mac:70:49:a2:65:72:cd", first.EndpointID)
386 require.Equal(t, "mac:70:49:a2:65:72:cd", second.EndpointID)
387 require.NotEqual(t, first.Labels["vlan_id"], second.Labels["vlan_id"])
388 require.Equal(t, 2, result.Stats["attachments_fdb"])
389 }
390
391 func TestBuildL2ResultFromObservations_FDBSkipsSelfAndNonLearned(t *testing.T) {
392 observations := []L2Observation{
393 {
394 DeviceID: "switch-a",
395 Hostname: "switch-a",
396 BridgePorts: []BridgePortObservation{
397 {BasePort: "1", IfIndex: 1},
398 {BasePort: "2", IfIndex: 2},
399 {BasePort: "3", IfIndex: 3},
400 },
401 FDBEntries: []FDBObservation{
402 {MAC: "00:11:22:33:44:55", BridgePort: "1", Status: "self"},
403 {MAC: "00:11:22:33:44:55", BridgePort: "2", Status: "learned"},
404 {MAC: "00:aa:bb:cc:dd:ee", BridgePort: "2", Status: "mgmt"},
405 {MAC: "00:ff:ee:dd:cc:bb", BridgePort: "3", Status: "learned"},
406 },
407 },
408 }
409
410 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
411 require.NoError(t, err)
412 require.Len(t, result.Attachments, 1)
413 require.Equal(t, "mac:00:ff:ee:dd:cc:bb", result.Attachments[0].EndpointID)
414 require.Equal(t, "3", result.Attachments[0].Labels["bridge_port"])
415 }
416
417 func TestBuildL2ResultFromObservations_STPAdjacency(t *testing.T) {
418 observations := []L2Observation{
419 {
420 DeviceID: "switch-a",
421 Hostname: "switch-a",
422 BaseBridgeAddress: "00:11:22:33:44:55",
423 Interfaces: []ObservedInterface{
424 {IfIndex: 3, IfName: "Port3", IfDescr: "Port3"},
425 },
426 BridgePorts: []BridgePortObservation{
427 {BasePort: "3", IfIndex: 3},
428 },
429 STPPorts: []STPPortObservation{
430 {
431 Port: "3",
432 VLANID: "200",
433 VLANName: "servers",
434 DesignatedBridge: "66:77:88:99:aa:bb",
435 DesignatedPort: "8001",
436 State: "forwarding",
437 },
438 },
439 },
440 {
441 DeviceID: "switch-b",
442 Hostname: "switch-b",
443 BaseBridgeAddress: "66:77:88:99:aa:bb",
444 },
445 }
446
447 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableSTP: true})
448 require.NoError(t, err)
449 require.Len(t, result.Adjacencies, 1)
450 require.Equal(t, "stp", result.Adjacencies[0].Protocol)
451 require.Equal(t, "switch-a", result.Adjacencies[0].SourceID)
452 require.Equal(t, "switch-b", result.Adjacencies[0].TargetID)
453 require.Equal(t, "Port3", result.Adjacencies[0].SourcePort)
454 require.Equal(t, "200", result.Adjacencies[0].Labels["vlan_id"])
455 require.Equal(t, "servers", result.Adjacencies[0].Labels["vlan_name"])
456 require.Equal(t, 1, result.Stats["links_stp"])
457 }
458
459 func TestBuildL2ResultFromObservations_STPDoesNotCreateSyntheticActors(t *testing.T) {
460 observations := []L2Observation{
461 {
462 DeviceID: "switch-a",
463 Hostname: "switch-a",
464 BaseBridgeAddress: "00:11:22:33:44:55",
465 Interfaces: []ObservedInterface{
466 {IfIndex: 3, IfName: "Port3", IfDescr: "Port3"},
467 },
468 BridgePorts: []BridgePortObservation{
469 {BasePort: "3", IfIndex: 3},
470 },
471 STPPorts: []STPPortObservation{
472 {
473 Port: "3",
474 DesignatedBridge: "66:77:88:99:aa:bb",
475 DesignatedPort: "8001",
476 State: "forwarding",
477 },
478 },
479 },
480 }
481
482 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableSTP: true})
483 require.NoError(t, err)
484 require.Len(t, result.Devices, 1)
485 require.Empty(t, result.Adjacencies)
486 require.Equal(t, 0, result.Stats["links_stp"])
487 }
488
489 func TestBuildL2ResultFromObservations_FDBBridgeDomainFallbackToBridgePort(t *testing.T) {
490 observations := []L2Observation{
491 {
492 DeviceID: "switch-a",
493 Hostname: "switch-a",
494 FDBEntries: []FDBObservation{
495 {MAC: "70:49:a2:65:72:cd", BridgePort: "77", Status: "learned"},
496 },
497 },
498 }
499
500 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
501 require.NoError(t, err)
502 require.Len(t, result.Attachments, 1)
503 require.Equal(t, 0, result.Attachments[0].IfIndex)
504 require.Equal(t, "bridge-domain:switch-a:bp:77", result.Attachments[0].Labels["bridge_domain"])
505 }
506
507 func TestBuildL2ResultFromObservations_FDBVLANNameLabel(t *testing.T) {
508 observations := []L2Observation{
509 {
510 DeviceID: "switch-a",
511 Hostname: "switch-a",
512 BridgePorts: []BridgePortObservation{
513 {BasePort: "7", IfIndex: 3},
514 },
515 FDBEntries: []FDBObservation{
516 {MAC: "70:49:a2:65:72:cd", BridgePort: "7", Status: "learned", VLANID: "200", VLANName: "servers"},
517 },
518 },
519 }
520
521 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
522 require.NoError(t, err)
523 require.Len(t, result.Attachments, 1)
524 require.Equal(t, "servers", result.Attachments[0].Labels["vlan_name"])
525 }
526
527 func TestBuildL2ResultFromObservations_BridgeOnlyDoesNotAutoEnableDiscoveryProtocols(t *testing.T) {
528 observations := []L2Observation{
529 {
530 DeviceID: "switch-a",
531 Hostname: "switch-a",
532 LLDPRemotes: []LLDPRemoteObservation{
533 {
534 LocalPortNum: "1",
535 LocalPortID: "Gi0/1",
536 SysName: "switch-b",
537 PortID: "Gi0/2",
538 },
539 },
540 BridgePorts: []BridgePortObservation{
541 {BasePort: "1", IfIndex: 1},
542 },
543 FDBEntries: []FDBObservation{
544 {MAC: "70:49:a2:65:72:cd", BridgePort: "1"},
545 },
546 },
547 {
548 DeviceID: "switch-b",
549 Hostname: "switch-b",
550 },
551 }
552
553 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true})
554 require.NoError(t, err)
555 require.Empty(t, result.Adjacencies)
556 require.Len(t, result.Attachments, 1)
557 }
558
559 func TestBuildL2ResultFromObservations_ARPNDEnrichment(t *testing.T) {
560 observations := []L2Observation{
561 {
562 DeviceID: "switch-a",
563 Hostname: "switch-a",
564 ARPNDEntries: []ARPNDObservation{
565 {
566 Protocol: "arp",
567 IfIndex: 3,
568 IfName: "Port3",
569 IP: "10.20.4.84",
570 MAC: "7049a26572cd",
571 State: "reachable",
572 AddrType: "ipv4",
573 },
574 {
575 Protocol: "arp",
576 IfIndex: 5,
577 IfName: "Port5",
578 IP: "10.20.4.85",
579 MAC: "70:49:a2:65:72:cd",
580 State: "reachable",
581 AddrType: "ipv4",
582 },
583 },
584 },
585 }
586
587 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableARP: true})
588 require.NoError(t, err)
589 require.Empty(t, result.Adjacencies)
590 require.Empty(t, result.Attachments)
591 require.Len(t, result.Enrichments, 1)
592
593 enrichment := result.Enrichments[0]
594 require.Equal(t, "mac:70:49:a2:65:72:cd", enrichment.EndpointID)
595 require.Equal(t, "70:49:a2:65:72:cd", enrichment.MAC)
596 require.Len(t, enrichment.IPs, 2)
597 require.Equal(t, "10.20.4.84", enrichment.IPs[0].String())
598 require.Equal(t, "10.20.4.85", enrichment.IPs[1].String())
599 require.Equal(t, "arp", enrichment.Labels["sources"])
600 require.Equal(t, "switch-a", enrichment.Labels["device_ids"])
601 require.Equal(t, "3,5", enrichment.Labels["if_indexes"])
602 require.Equal(t, "Port3,Port5", enrichment.Labels["if_names"])
603 require.Equal(t, "reachable", enrichment.Labels["states"])
604 require.Equal(t, "ipv4", enrichment.Labels["addr_types"])
605 require.Equal(t, 1, result.Stats["enrichments_total"])
606 require.Equal(t, 1, result.Stats["enrichments_arp_nd"])
607 }
608
609 func TestBuildL2ResultFromObservations_SkipsMACLessARPNDEntries(t *testing.T) {
610 observations := []L2Observation{
611 {
612 DeviceID: "switch-a",
613 Hostname: "switch-a",
614 ARPNDEntries: []ARPNDObservation{
615 {
616 Protocol: "arp",
617 IfIndex: 7,
618 IfName: "Port7",
619 IP: "10.20.4.99",
620 State: "reachable",
621 AddrType: "ipv4",
622 },
623 {
624 Protocol: "arp",
625 IfIndex: 7,
626 IfName: "Port7",
627 IP: "10.20.4.100",
628 MAC: "7049a26572cf",
629 State: "reachable",
630 AddrType: "ipv4",
631 },
632 },
633 },
634 }
635
636 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableARP: true})
637 require.NoError(t, err)
638 require.Len(t, result.Enrichments, 1)
639 require.Equal(t, "mac:70:49:a2:65:72:cf", result.Enrichments[0].EndpointID)
640 require.Equal(t, []string{"10.20.4.100"}, addressStrings(result.Enrichments[0].IPs))
641 require.Equal(t, 1, result.Stats["endpoints_total"])
642 }
643
644 func TestBuildL2ResultFromObservations_ReconcilesARPAliasIntoLLDPDeviceIdentity(t *testing.T) {
645 observations := []L2Observation{
646 {
647 DeviceID: "mikrotik-router",
648 Hostname: "MikroTik-router",
649 ManagementIP: "10.20.4.1",
650 ChassisID: "18:fd:74:7e:c5:80",
651 LLDPRemotes: []LLDPRemoteObservation{
652 {
653 LocalPortNum: "5",
654 LocalPortID: "ether5",
655 LocalPortIDSubtype: "interfaceName",
656 ChassisID: "d8:5e:d3:0e:c5:e6",
657 SysName: "costa-desktop",
658 PortID: "enp6s0",
659 PortIDSubtype: "interfaceName",
660 ManagementIP: "fc00:f853:ccd:e793::1",
661 },
662 },
663 ARPNDEntries: []ARPNDObservation{
664 {
665 Protocol: "arp",
666 IfIndex: 5,
667 IfName: "ether5",
668 IP: "10.20.4.205",
669 MAC: "d8:5e:d3:0e:c5:e6",
670 State: "reachable",
671 AddrType: "ipv4",
672 },
673 },
674 },
675 }
676
677 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true, EnableARP: true})
678 require.NoError(t, err)
679
680 costaDesktop := findDeviceByHostname(result.Devices, "costa-desktop")
681 require.NotNil(t, costaDesktop)
682 require.ElementsMatch(
683 t,
684 []string{"10.20.4.205", "fc00:f853:ccd:e793::1"},
685 deviceAddressStrings(*costaDesktop),
686 )
687 require.Equal(t, 1, result.Stats["identity_alias_endpoints_mapped"])
688 require.Equal(t, 1, result.Stats["identity_alias_ips_merged"])
689 }
690
691 func TestBuildL2ResultFromObservations_SkipsConflictingARPAliases(t *testing.T) {
692 observations := []L2Observation{
693 {
694 DeviceID: "mikrotik-router",
695 Hostname: "MikroTik-router",
696 ManagementIP: "10.20.4.1",
697 ChassisID: "18:fd:74:7e:c5:80",
698 LLDPRemotes: []LLDPRemoteObservation{
699 {
700 LocalPortNum: "5",
701 LocalPortID: "ether5",
702 LocalPortIDSubtype: "interfaceName",
703 ChassisID: "d8:5e:d3:0e:c5:e6",
704 SysName: "costa-desktop",
705 PortID: "enp6s0",
706 PortIDSubtype: "interfaceName",
707 ManagementIP: "fc00:f853:ccd:e793::1",
708 },
709 },
710 ARPNDEntries: []ARPNDObservation{
711 {
712 Protocol: "arp",
713 IfIndex: 5,
714 IfName: "ether5",
715 IP: "10.20.4.205",
716 MAC: "d8:5e:d3:0e:c5:e6",
717 State: "reachable",
718 AddrType: "ipv4",
719 },
720 {
721 Protocol: "arp",
722 IfIndex: 7,
723 IfName: "ether7",
724 IP: "10.20.4.205",
725 MAC: "70:49:a2:65:72:cd",
726 State: "reachable",
727 AddrType: "ipv4",
728 },
729 },
730 },
731 }
732
733 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true, EnableARP: true})
734 require.NoError(t, err)
735
736 costaDesktop := findDeviceByHostname(result.Devices, "costa-desktop")
737 require.NotNil(t, costaDesktop)
738 require.Equal(t, []string{"fc00:f853:ccd:e793::1"}, deviceAddressStrings(*costaDesktop))
739 require.Equal(t, 1, result.Stats["identity_alias_endpoints_mapped"])
740 require.Equal(t, 0, result.Stats["identity_alias_ips_merged"])
741 require.Equal(t, 1, result.Stats["identity_alias_ips_conflict_skipped"])
742 }
743
744 func TestBuildL2ResultFromObservations_SkipsAmbiguousMACAliasOwnership(t *testing.T) {
745 observations := []L2Observation{
746 {
747 DeviceID: "switch-a",
748 Hostname: "switch-a",
749 ManagementIP: "10.0.0.1",
750 ChassisID: "00:11:22:33:44:55",
751 Interfaces: []ObservedInterface{
752 {IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
753 },
754 },
755 {
756 DeviceID: "switch-b",
757 Hostname: "switch-b",
758 ManagementIP: "10.0.0.2",
759 ChassisID: "00:11:22:33:44:66",
760 Interfaces: []ObservedInterface{
761 {IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1", MAC: "aa:aa:aa:aa:aa:aa"},
762 },
763 },
764 {
765 DeviceID: "observer",
766 Hostname: "observer",
767 ARPNDEntries: []ARPNDObservation{
768 {
769 Protocol: "arp",
770 IfIndex: 9,
771 IfName: "Gi0/9",
772 IP: "10.0.0.50",
773 MAC: "aa:aa:aa:aa:aa:aa",
774 State: "reachable",
775 AddrType: "ipv4",
776 },
777 },
778 },
779 }
780
781 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableARP: true})
782 require.NoError(t, err)
783
784 switchA := findDeviceByID(result.Devices, "switch-a")
785 switchB := findDeviceByID(result.Devices, "switch-b")
786 require.NotNil(t, switchA)
787 require.NotNil(t, switchB)
788 require.NotContains(t, deviceAddressStrings(*switchA), "10.0.0.50")
789 require.NotContains(t, deviceAddressStrings(*switchB), "10.0.0.50")
790 require.Equal(t, 0, result.Stats["identity_alias_ips_merged"])
791 require.Equal(t, 1, result.Stats["identity_alias_endpoints_ambiguous_mac"])
792 }
793
794 func TestNormalizeMAC_PadsSingleNibbleTokens(t *testing.T) {
795 require.Equal(t, "00:15:99:9f:07:ef", normalizeMAC("0:15:99:9f:7:ef"))
796 require.Equal(t, "60:33:4b:08:17:a8", normalizeMAC("60:33:4b:8:17:a8"))
797 require.Equal(t, "00:90:1a:42:22:f8", normalizeMAC("0:90:1a:42:22:f8"))
798 require.Equal(t, "00:11:22:33:44:55", normalizeMAC("0011.2233.4455"))
799 }
800
801 func TestBuildL2ResultFromObservations_DeterministicOrderingAndDedup(t *testing.T) {
802 observations := []L2Observation{
803 {
804 DeviceID: "switch-a",
805 Hostname: "switch-a",
806 Interfaces: []ObservedInterface{
807 {IfIndex: 7, IfName: "Port7", IfDescr: "Port7"},
808 },
809 BridgePorts: []BridgePortObservation{
810 {BasePort: "2", IfIndex: 7},
811 },
812 FDBEntries: []FDBObservation{
813 {MAC: "70:49:a2:65:72:ce", BridgePort: "2"},
814 {MAC: "70:49:a2:65:72:cd", BridgePort: "2"},
815 {MAC: "7049a26572ce", BridgePort: "2"}, // duplicate MAC, different format
816 },
817 ARPNDEntries: []ARPNDObservation{
818 {Protocol: "arp", IfIndex: 7, IfName: "Port7", IP: "10.20.4.86", MAC: "70:49:a2:65:72:ce"},
819 {Protocol: "arp", IfIndex: 7, IfName: "Port7", IP: "10.20.4.85", MAC: "70:49:a2:65:72:ce"},
820 {Protocol: "arp", IfIndex: 7, IfName: "Port7", IP: "10.20.4.85", MAC: "7049a26572ce"}, // duplicate
821 },
822 },
823 }
824
825 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableBridge: true, EnableARP: true})
826 require.NoError(t, err)
827
828 require.Len(t, result.Attachments, 2)
829 require.Equal(t, "mac:70:49:a2:65:72:cd", result.Attachments[0].EndpointID)
830 require.Equal(t, "mac:70:49:a2:65:72:ce", result.Attachments[1].EndpointID)
831
832 require.Len(t, result.Enrichments, 1)
833 require.Equal(t, "mac:70:49:a2:65:72:ce", result.Enrichments[0].EndpointID)
834 require.Len(t, result.Enrichments[0].IPs, 2)
835 require.Equal(t, "10.20.4.85", result.Enrichments[0].IPs[0].String())
836 require.Equal(t, "10.20.4.86", result.Enrichments[0].IPs[1].String())
837 }
838
839 func TestMatchLLDPLinksEnlinkdPassOrder_Precedence(t *testing.T) {
840 links := []lldpMatchLink{
841 {
842 index: 0,
843 sourceDeviceID: "node-a",
844 localChassisID: "chassis-a",
845 remoteChassisID: "chassis-b",
846 localSysName: "node-a",
847 remoteSysName: "node-b",
848 localPortID: "Gi0/1",
849 localPortIDSubtype: "5",
850 remotePortID: "Gi0/2",
851 remotePortIDSubtype: "5",
852 localPortDescr: "GigabitEthernet0/1",
853 remotePortDescr: "GigabitEthernet0/2",
854 },
855 {
856 index: 1,
857 sourceDeviceID: "node-b",
858 localChassisID: "chassis-b",
859 remoteChassisID: "chassis-a",
860 localSysName: "node-b",
861 remoteSysName: "node-a",
862 localPortID: "Gi0/2",
863 localPortIDSubtype: "5",
864 remotePortID: "Gi0/1",
865 remotePortIDSubtype: "5",
866 localPortDescr: "GigabitEthernet0/2",
867 remotePortDescr: "GigabitEthernet0/1",
868 },
869 }
870
871 pairs := matchLLDPLinksEnlinkdPassOrder(links)
872 require.Len(t, pairs, 1)
873 require.Equal(t, 0, pairs[0].sourceIndex)
874 require.Equal(t, 1, pairs[0].targetIndex)
875 require.NotEmpty(t, pairs[0].pass)
876 }
877
878 func TestMatchLLDPLinksEnlinkdPassOrder_FallbackPasses(t *testing.T) {
879 tests := []struct {
880 name string
881 pass string
882 left, right lldpMatchLink
883 }{
884 {
885 name: "port-description",
886 pass: lldpMatchPassPortDesc,
887 left: lldpMatchLink{
888 index: 0,
889 sourceDeviceID: "a",
890 localChassisID: "A",
891 remoteChassisID: "B",
892 localSysName: "a",
893 remoteSysName: "b",
894 localPortID: "Gi0/1",
895 localPortIDSubtype: "5",
896 remotePortID: "wrong-b",
897 remotePortIDSubtype: "5",
898 localPortDescr: "PORT-A",
899 remotePortDescr: "PORT-B",
900 },
901 right: lldpMatchLink{
902 index: 1,
903 sourceDeviceID: "b",
904 localChassisID: "B",
905 remoteChassisID: "A",
906 localSysName: "b",
907 remoteSysName: "a",
908 localPortID: "Gi0/2",
909 localPortIDSubtype: "5",
910 remotePortID: "wrong-a",
911 remotePortIDSubtype: "5",
912 localPortDescr: "PORT-B",
913 remotePortDescr: "PORT-A",
914 },
915 },
916 {
917 name: "sysname",
918 pass: lldpMatchPassSysName,
919 left: lldpMatchLink{
920 index: 0,
921 sourceDeviceID: "a",
922 localChassisID: "A-LOCAL",
923 remoteChassisID: "B-REMOTE",
924 localSysName: "sys-a",
925 remoteSysName: "sys-b",
926 localPortID: "xe-0/0/1",
927 localPortIDSubtype: "5",
928 remotePortID: "xe-0/0/2",
929 remotePortIDSubtype: "5",
930 localPortDescr: "left-a",
931 remotePortDescr: "left-b",
932 },
933 right: lldpMatchLink{
934 index: 1,
935 sourceDeviceID: "b",
936 localChassisID: "B-LOCAL",
937 remoteChassisID: "A-REMOTE",
938 localSysName: "sys-b",
939 remoteSysName: "sys-a",
940 localPortID: "xe-0/0/2",
941 localPortIDSubtype: "5",
942 remotePortID: "xe-0/0/1",
943 remotePortIDSubtype: "5",
944 localPortDescr: "right-b",
945 remotePortDescr: "right-a",
946 },
947 },
948 {
949 name: "chassis-port-subtype",
950 pass: lldpMatchPassChassisPort,
951 left: lldpMatchLink{
952 index: 0,
953 sourceDeviceID: "a",
954 localChassisID: "A",
955 remoteChassisID: "B",
956 localSysName: "a-local",
957 remoteSysName: "b-remote",
958 localPortID: "A1",
959 localPortIDSubtype: "5",
960 remotePortID: "wrong-b",
961 remotePortIDSubtype: "5",
962 localPortDescr: "DA",
963 remotePortDescr: "RA",
964 },
965 right: lldpMatchLink{
966 index: 1,
967 sourceDeviceID: "b",
968 localChassisID: "B",
969 remoteChassisID: "A",
970 localSysName: "b-local",
971 remoteSysName: "a-remote",
972 localPortID: "B1",
973 localPortIDSubtype: "5",
974 remotePortID: "A1",
975 remotePortIDSubtype: "5",
976 localPortDescr: "DB",
977 remotePortDescr: "RB",
978 },
979 },
980 {
981 name: "chassis-port-description",
982 pass: lldpMatchPassChassisDescr,
983 left: lldpMatchLink{
984 index: 0,
985 sourceDeviceID: "a",
986 localChassisID: "A",
987 remoteChassisID: "B",
988 localSysName: "a-local",
989 remoteSysName: "b-remote",
990 localPortID: "A1",
991 localPortIDSubtype: "5",
992 remotePortID: "wrong-b",
993 remotePortIDSubtype: "5",
994 localPortDescr: "PORT-A",
995 remotePortDescr: "REMOTE-A",
996 },
997 right: lldpMatchLink{
998 index: 1,
999 sourceDeviceID: "b",
1000 localChassisID: "B",
1001 remoteChassisID: "A",
1002 localSysName: "b-local",
1003 remoteSysName: "a-remote",
1004 localPortID: "B1",
1005 localPortIDSubtype: "5",
1006 remotePortID: "wrong-a",
1007 remotePortIDSubtype: "5",
1008 localPortDescr: "PORT-B",
1009 remotePortDescr: "PORT-A",
1010 },
1011 },
1012 {
1013 name: "chassis-only",
1014 pass: lldpMatchPassChassis,
1015 left: lldpMatchLink{
1016 index: 0,
1017 sourceDeviceID: "a",
1018 localChassisID: "A",
1019 remoteChassisID: "B",
1020 localSysName: "a-local",
1021 remoteSysName: "b-remote",
1022 localPortID: "A1",
1023 localPortIDSubtype: "5",
1024 remotePortID: "wrong-b",
1025 remotePortIDSubtype: "5",
1026 localPortDescr: "PORT-A",
1027 remotePortDescr: "REMOTE-A",
1028 },
1029 right: lldpMatchLink{
1030 index: 1,
1031 sourceDeviceID: "b",
1032 localChassisID: "B",
1033 remoteChassisID: "A",
1034 localSysName: "b-local",
1035 remoteSysName: "a-remote",
1036 localPortID: "B1",
1037 localPortIDSubtype: "5",
1038 remotePortID: "wrong-a",
1039 remotePortIDSubtype: "5",
1040 localPortDescr: "PORT-B",
1041 remotePortDescr: "REMOTE-B",
1042 },
1043 },
1044 }
1045
1046 for _, tc := range tests {
1047 t.Run(tc.name, func(t *testing.T) {
1048 pairs := matchLLDPLinksEnlinkdPassOrder([]lldpMatchLink{tc.left, tc.right})
1049 require.Len(t, pairs, 1)
1050 require.Equal(t, 0, pairs[0].sourceIndex)
1051 require.Equal(t, 1, pairs[0].targetIndex)
1052 require.Equal(t, tc.pass, pairs[0].pass)
1053 })
1054 }
1055 }
1056
1057 func TestBuildL2ResultFromObservations_LLDPPairsAcrossChassisRepresentations(t *testing.T) {
1058 observations := []L2Observation{
1059 {
1060 DeviceID: "router-a",
1061 Hostname: "MikroTik-router",
1062 ManagementIP: "10.20.4.1",
1063 ChassisID: "18:FD:74:7E:C5:80",
1064 LLDPRemotes: []LLDPRemoteObservation{
1065 {
1066 LocalPortNum: "3",
1067 LocalPortID: "ether3",
1068 LocalPortIDSubtype: "interfaceName",
1069 ChassisID: "7049A26572CD",
1070 PortID: "",
1071 PortIDSubtype: "interfaceName",
1072 SysName: "XS1930",
1073 ManagementIP: "10.20.4.84",
1074 },
1075 },
1076 },
1077 {
1078 DeviceID: "switch-b",
1079 Hostname: "XS1930",
1080 ManagementIP: "10.20.4.84",
1081 ChassisID: "70:49:a2:65:72:cd",
1082 LLDPRemotes: []LLDPRemoteObservation{
1083 {
1084 LocalPortNum: "8",
1085 LocalPortID: "8",
1086 LocalPortIDSubtype: "local",
1087 ChassisID: "18fd747ec580",
1088 PortID: "ether3",
1089 PortIDSubtype: "interfaceName",
1090 SysName: "MikroTik-router",
1091 ManagementIP: "10.20.4.1",
1092 },
1093 },
1094 },
1095 }
1096
1097 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true})
1098 require.NoError(t, err)
1099 require.Len(t, result.Adjacencies, 2)
1100
1101 var pairID string
1102 for _, adj := range result.Adjacencies {
1103 require.Equal(t, "lldp", adj.Protocol)
1104 require.NotEmpty(t, adj.Labels[adjacencyLabelPairID])
1105 require.NotEmpty(t, adj.Labels[adjacencyLabelPairPass])
1106 if pairID == "" {
1107 pairID = adj.Labels[adjacencyLabelPairID]
1108 }
1109 require.Equal(t, pairID, adj.Labels[adjacencyLabelPairID])
1110 }
1111 }
1112
1113 func TestBuildL2ResultFromObservations_LLDPPairsAcrossKnownDeviceIdentityDespiteChassisMismatch(t *testing.T) {
1114 observations := []L2Observation{
1115 {
1116 DeviceID: "mikrotik-router",
1117 Hostname: "MikroTik-router",
1118 ManagementIP: "10.20.4.1",
1119 ChassisID: "18:FD:74:7E:C5:80",
1120 LLDPRemotes: []LLDPRemoteObservation{
1121 {
1122 LocalPortNum: "3",
1123 LocalPortID: "ether3",
1124 LocalPortIDSubtype: "interfaceName",
1125 ChassisID: "70:49:A2:65:72:D5",
1126 PortID: "",
1127 PortIDSubtype: "interfaceName",
1128 SysName: "XS1930",
1129 ManagementIP: "10.20.4.84",
1130 },
1131 },
1132 },
1133 {
1134 DeviceID: "xs1930",
1135 Hostname: "XS1930",
1136 ManagementIP: "10.20.4.84",
1137 ChassisID: "70:49:A2:65:72:CD",
1138 LLDPRemotes: []LLDPRemoteObservation{
1139 {
1140 LocalPortNum: "8",
1141 LocalPortID: "8",
1142 LocalPortIDSubtype: "local",
1143 ChassisID: "18:FD:74:7E:C5:80",
1144 PortID: "ether3",
1145 PortIDSubtype: "interfaceName",
1146 SysName: "MikroTik-router",
1147 ManagementIP: "10.20.4.1",
1148 },
1149 },
1150 },
1151 }
1152
1153 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true})
1154 require.NoError(t, err)
1155 require.Len(t, result.Adjacencies, 2)
1156
1157 for _, adj := range result.Adjacencies {
1158 require.Equal(t, "lldp", adj.Protocol)
1159 require.NotEmpty(t, adj.Labels[adjacencyLabelPairID])
1160 require.NotEmpty(t, adj.Labels[adjacencyLabelPairPass])
1161 }
1162 }
1163
1164 func TestBuildL2ResultFromObservations_KeepsDistinctRemotesWhenMACDiffersDespiteSameSecondaryIdentity(t *testing.T) {
1165 observations := []L2Observation{
1166 {
1167 DeviceID: "local-a",
1168 Hostname: "local-a",
1169 ManagementIP: "10.0.0.1",
1170 ChassisID: "00:00:00:00:10:01",
1171 LLDPRemotes: []LLDPRemoteObservation{
1172 {
1173 LocalPortNum: "1",
1174 LocalPortID: "eth1",
1175 LocalPortIDSubtype: "interfaceName",
1176 ChassisID: "00:11:22:33:44:55",
1177 SysName: "shared-secondary-id",
1178 ManagementIP: "10.20.30.40",
1179 },
1180 },
1181 },
1182 {
1183 DeviceID: "local-b",
1184 Hostname: "local-b",
1185 ManagementIP: "10.0.0.2",
1186 ChassisID: "00:00:00:00:10:02",
1187 LLDPRemotes: []LLDPRemoteObservation{
1188 {
1189 LocalPortNum: "1",
1190 LocalPortID: "eth1",
1191 LocalPortIDSubtype: "interfaceName",
1192 ChassisID: "00:11:22:33:44:66",
1193 SysName: "shared-secondary-id",
1194 ManagementIP: "10.20.30.40",
1195 },
1196 },
1197 },
1198 }
1199
1200 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true})
1201 require.NoError(t, err)
1202
1203 var remoteCount int
1204 remoteIDs := make(map[string]struct{})
1205 for _, device := range result.Devices {
1206 if device.Hostname != "shared-secondary-id" {
1207 continue
1208 }
1209 remoteCount++
1210 remoteIDs[device.ID] = struct{}{}
1211 }
1212 require.Equal(t, 2, remoteCount)
1213 require.Len(t, remoteIDs, 2)
1214 }
1215
1216 func TestMatchLLDPLinksEnlinkdPassOrder_DoesNotDropLinksWhenSysNamesAreEmpty(t *testing.T) {
1217 links := []lldpMatchLink{
1218 {
1219 index: 0,
1220 localChassisID: "00:11:22:33:44:55",
1221 localMatchID: "device-a",
1222 localPortID: "Gi0/1",
1223 localPortIDSubtype: "interfaceName",
1224 remoteChassisID: "00:11:22:33:44:66",
1225 remotePortID: "Gi0/2",
1226 remotePortIDSubtype: "interfaceName",
1227 },
1228 {
1229 index: 1,
1230 localChassisID: "00:11:22:33:44:66",
1231 localMatchID: "device-b",
1232 localPortID: "Gi0/2",
1233 localPortIDSubtype: "interfaceName",
1234 remoteChassisID: "00:11:22:33:44:55",
1235 remotePortID: "Gi0/1",
1236 remotePortIDSubtype: "interfaceName",
1237 },
1238 }
1239
1240 pairs := matchLLDPLinksEnlinkdPassOrder(links)
1241
1242 require.Len(t, pairs, 1)
1243 require.Equal(t, 0, pairs[0].sourceIndex)
1244 require.Equal(t, 1, pairs[0].targetIndex)
1245 require.NotEmpty(t, pairs[0].pass)
1246 }
1247
1248 func TestResolveKnownRemote_RejectsHostnameMatchWhenMACMismatchesWithoutMgmtIP(t *testing.T) {
1249 state := newL2BuildState(1)
1250 state.devices["known-device"] = Device{
1251 ID: "known-device",
1252 Hostname: "shared-host",
1253 ChassisID: "00:11:22:33:44:55",
1254 }
1255 state.hostToID["shared-host"] = "known-device"
1256
1257 require.Empty(t, state.resolveKnownRemote("shared-host", "", "", "00:11:22:33:44:66"))
1258 }
1259
1260 func TestResolveRemote_UsesMACDerivedIDWhenManagedHostnameCollidesWithoutMgmtIP(t *testing.T) {
1261 state := newL2BuildState(1)
1262 state.devices["known-device"] = Device{
1263 ID: "known-device",
1264 Hostname: "shared-host",
1265 ChassisID: "00:11:22:33:44:55",
1266 }
1267 state.managedObservationByDeviceID["known-device"] = true
1268 state.hostToID["shared-host"] = "known-device"
1269
1270 id := state.resolveRemote("shared-host", "00:11:22:33:44:66", "", "")
1271
1272 require.Equal(t, "chassis-001122334466", id)
1273 require.Equal(t, "known-device", state.hostToID["shared-host"])
1274 require.Equal(t, id, state.macToID["00:11:22:33:44:66"])
1275 }
1276
1277 func TestResolveRemote_ReusesHostnameIDForUnmanagedRemoteCollisions(t *testing.T) {
1278 state := newL2BuildState(1)
1279
1280 firstID := state.resolveRemote("shared-host", "00:11:22:33:44:55", "", "")
1281 secondID := state.resolveRemote("shared-host", "00:11:22:33:44:66", "", "")
1282
1283 require.Equal(t, "shared-host", firstID)
1284 require.Equal(t, "shared-host", secondID)
1285 require.Equal(t, "shared-host", state.hostToID["shared-host"])
1286 }
1287
1288 func TestResolveRemoteEnforcingHostnameMACGuard_SplitsUnmanagedHostnameCollisions(t *testing.T) {
1289 state := newL2BuildState(1)
1290
1291 firstID := state.resolveRemoteEnforcingHostnameMACGuard("shared-host", "00:11:22:33:44:55", "", "")
1292 secondID := state.resolveRemoteEnforcingHostnameMACGuard("shared-host", "00:11:22:33:44:66", "", "")
1293
1294 require.Equal(t, "shared-host", firstID)
1295 require.Equal(t, "chassis-001122334466", secondID)
1296 require.Equal(t, "shared-host", state.hostToID["shared-host"])
1297 require.Equal(t, secondID, state.macToID["00:11:22:33:44:66"])
1298 }
1299
1300 func TestRegisterObservation_ReinitializesLabelsAfterEmptyMerge(t *testing.T) {
1301 state := newL2BuildState(1)
1302 state.devices["switch-a"] = Device{ID: "switch-a", Hostname: "switch-a"}
1303
1304 err := state.registerObservation(L2Observation{
1305 DeviceID: "switch-a",
1306 LLDPRemotes: []LLDPRemoteObservation{
1307 {LocalPortNum: "1", ChassisID: "00:11:22:33:44:55"},
1308 },
1309 })
1310
1311 require.NoError(t, err)
1312 require.Equal(t, "lldp", state.devices["switch-a"].Labels["protocols_observed"])
1313 }
1314
1315 func TestMatchLLDPLinksEnlinkdPassOrder_SkipsEmptyPortDescriptions(t *testing.T) {
1316 links := []lldpMatchLink{
1317 {
1318 index: 0,
1319 sourceDeviceID: "a",
1320 localChassisID: "A",
1321 remoteChassisID: "B",
1322 localPortID: "A-1",
1323 localPortIDSubtype: "5",
1324 remotePortID: "wrong-b-1",
1325 remotePortIDSubtype: "5",
1326 },
1327 {
1328 index: 1,
1329 sourceDeviceID: "a",
1330 localChassisID: "A",
1331 remoteChassisID: "B",
1332 localPortID: "A-2",
1333 localPortIDSubtype: "5",
1334 remotePortID: "wrong-b-2",
1335 remotePortIDSubtype: "5",
1336 },
1337 {
1338 index: 2,
1339 sourceDeviceID: "b",
1340 localChassisID: "B",
1341 remoteChassisID: "A",
1342 localPortID: "B-2",
1343 localPortIDSubtype: "5",
1344 remotePortID: "A-2",
1345 remotePortIDSubtype: "5",
1346 },
1347 {
1348 index: 3,
1349 sourceDeviceID: "b",
1350 localChassisID: "B",
1351 remoteChassisID: "A",
1352 localPortID: "B-1",
1353 localPortIDSubtype: "5",
1354 remotePortID: "A-1",
1355 remotePortIDSubtype: "5",
1356 },
1357 }
1358
1359 pairs := matchLLDPLinksEnlinkdPassOrder(links)
1360
1361 require.Len(t, pairs, 2)
1362 require.Equal(t, lldpMatchPassChassisPort, pairs[0].pass)
1363 require.Equal(t, 0, pairs[0].sourceIndex)
1364 require.Equal(t, 3, pairs[0].targetIndex)
1365 require.Equal(t, lldpMatchPassChassisPort, pairs[1].pass)
1366 require.Equal(t, 1, pairs[1].sourceIndex)
1367 require.Equal(t, 2, pairs[1].targetIndex)
1368 }
1369
1370 func TestMatchCDPLinksEnlinkdPassOrder_DefaultAndParsedTarget(t *testing.T) {
1371 links := []cdpMatchLink{
1372 {
1373 index: 0,
1374 sourceDeviceID: "node-a",
1375 sourceGlobalID: "A-GID",
1376 localInterfaceName: "Gi0/0",
1377 remoteDeviceID: "B-GID",
1378 remoteDevicePort: "Gi0/1",
1379 },
1380 {
1381 index: 1,
1382 sourceDeviceID: "node-b",
1383 sourceGlobalID: "B-GID",
1384 localInterfaceName: "Gi0/1",
1385 remoteDeviceID: "A-GID",
1386 remoteDevicePort: "Gi0/0",
1387 },
1388 {
1389 index: 2,
1390 sourceDeviceID: "node-c",
1391 sourceGlobalID: "A-GID",
1392 localInterfaceName: "Gi0/0",
1393 remoteDeviceID: "B-GID",
1394 remoteDevicePort: "Gi0/1",
1395 },
1396 }
1397
1398 pairs := matchCDPLinksEnlinkdPassOrder(links)
1399 require.Len(t, pairs, 1)
1400 require.Equal(t, 0, pairs[0].sourceIndex)
1401 require.Equal(t, 1, pairs[0].targetIndex)
1402 require.Equal(t, cdpMatchPassDefault, pairs[0].pass)
1403 }
1404
1405 func TestBuildCDPLookupMap_PreservesFirstDuplicateKey(t *testing.T) {
1406 links := []cdpMatchLink{
1407 {
1408 index: 0,
1409 sourceGlobalID: "A-GID",
1410 localInterfaceName: "Gi0/0",
1411 remoteDeviceID: "B-GID",
1412 remoteDevicePort: "Gi0/1",
1413 },
1414 {
1415 index: 1,
1416 sourceGlobalID: "A-GID",
1417 localInterfaceName: "Gi0/0",
1418 remoteDeviceID: "B-GID",
1419 remoteDevicePort: "Gi0/1",
1420 },
1421 }
1422
1423 lookup := buildCDPLookupMap(links)
1424 key := topologyMatchCompositeKey("Gi0/1", "Gi0/0", "A-GID", "B-GID")
1425 value, ok := lookup[key]
1426 require.True(t, ok)
1427 require.Equal(t, 0, value)
1428 }
1429
1430 func TestMatchCDPLinksEnlinkdPassOrder_SkipsSelfTarget(t *testing.T) {
1431 links := []cdpMatchLink{
1432 {
1433 index: 0,
1434 sourceDeviceID: "node-a",
1435 sourceGlobalID: "A-GID",
1436 localInterfaceName: "Gi0/0",
1437 remoteDeviceID: "A-GID",
1438 remoteDevicePort: "Gi0/0",
1439 },
1440 }
1441
1442 pairs := matchCDPLinksEnlinkdPassOrder(links)
1443 require.Empty(t, pairs)
1444 }
1445
1446 func TestBuildL2ResultFromObservations_AnnotatesPairMetadata(t *testing.T) {
1447 observations := []L2Observation{
1448 {
1449 DeviceID: "switch-a",
1450 Hostname: "A-GID",
1451 ManagementIP: "10.0.0.1",
1452 ChassisID: "aa:aa:aa:aa:aa:aa",
1453 Interfaces: []ObservedInterface{
1454 {IfIndex: 1, IfName: "Gi0/1", IfDescr: "Gi0/1"},
1455 },
1456 LLDPRemotes: []LLDPRemoteObservation{
1457 {
1458 LocalPortNum: "1",
1459 RemoteIndex: "1",
1460 LocalPortID: "Gi0/1",
1461 LocalPortIDSubtype: "5",
1462 ChassisID: "bb:bb:bb:bb:bb:bb",
1463 SysName: "B-GID",
1464 PortID: "Gi0/2",
1465 PortIDSubtype: "5",
1466 },
1467 },
1468 CDPRemotes: []CDPRemoteObservation{
1469 {
1470 LocalIfIndex: 1,
1471 LocalIfName: "Gi0/1",
1472 DeviceIndex: "1",
1473 DeviceID: "B-GID",
1474 SysName: "B-GID",
1475 DevicePort: "Gi0/2",
1476 },
1477 },
1478 },
1479 {
1480 DeviceID: "switch-b",
1481 Hostname: "B-GID",
1482 ManagementIP: "10.0.0.2",
1483 ChassisID: "bb:bb:bb:bb:bb:bb",
1484 Interfaces: []ObservedInterface{
1485 {IfIndex: 2, IfName: "Gi0/2", IfDescr: "Gi0/2"},
1486 },
1487 LLDPRemotes: []LLDPRemoteObservation{
1488 {
1489 LocalPortNum: "2",
1490 RemoteIndex: "1",
1491 LocalPortID: "Gi0/2",
1492 LocalPortIDSubtype: "5",
1493 ChassisID: "aa:aa:aa:aa:aa:aa",
1494 SysName: "A-GID",
1495 PortID: "Gi0/1",
1496 PortIDSubtype: "5",
1497 },
1498 },
1499 CDPRemotes: []CDPRemoteObservation{
1500 {
1501 LocalIfIndex: 2,
1502 LocalIfName: "Gi0/2",
1503 DeviceIndex: "1",
1504 DeviceID: "A-GID",
1505 SysName: "A-GID",
1506 DevicePort: "Gi0/1",
1507 },
1508 },
1509 },
1510 }
1511
1512 result, err := BuildL2ResultFromObservations(observations, DiscoverOptions{EnableLLDP: true, EnableCDP: true})
1513 require.NoError(t, err)
1514 require.Len(t, result.Adjacencies, 4)
1515
1516 pairIDs := make(map[string]map[string]struct{})
1517 pairPasses := make(map[string]string)
1518 for _, adj := range result.Adjacencies {
1519 if adj.Protocol != "lldp" && adj.Protocol != "cdp" {
1520 continue
1521 }
1522 pairID := adj.Labels[adjacencyLabelPairID]
1523 pairPass := adj.Labels[adjacencyLabelPairPass]
1524
1525 require.NotEmpty(t, pairID)
1526 require.NotEmpty(t, pairPass)
1527
1528 if pairIDs[adj.Protocol] == nil {
1529 pairIDs[adj.Protocol] = make(map[string]struct{})
1530 }
1531 pairIDs[adj.Protocol][pairID] = struct{}{}
1532
1533 if existingPass, ok := pairPasses[adj.Protocol]; ok {
1534 require.Equal(t, existingPass, pairPass)
1535 } else {
1536 pairPasses[adj.Protocol] = pairPass
1537 }
1538 }
1539
1540 require.Len(t, pairIDs["lldp"], 1)
1541 require.Len(t, pairIDs["cdp"], 1)
1542 require.Equal(t, lldpMatchPassDefault, pairPasses["lldp"])
1543 require.Equal(t, cdpMatchPassDefault, pairPasses["cdp"])
1544 }
1545
1546 func TestBuildL2ResultFromObservations_ErrorsOnEmptyInput(t *testing.T) {
1547 _, err := BuildL2ResultFromObservations(nil, DiscoverOptions{EnableLLDP: true})
1548 require.Error(t, err)
1549 require.ErrorIs(t, err, ErrInvalidRequest)
1550 }
1551
1552 func findDeviceByHostname(devices []Device, hostname string) *Device {
1553 for i := range devices {
1554 if devices[i].Hostname == hostname {
1555 return &devices[i]
1556 }
1557 }
1558 return nil
1559 }
1560
1561 func findDeviceByID(devices []Device, id string) *Device {
1562 for i := range devices {
1563 if devices[i].ID == id {
1564 return &devices[i]
1565 }
1566 }
1567 return nil
1568 }
1569
1570 func deviceAddressStrings(device Device) []string {
1571 out := make([]string, 0, len(device.Addresses))
1572 for _, addr := range device.Addresses {
1573 if !addr.IsValid() {
1574 continue
1575 }
1576 out = append(out, addr.String())
1577 }
1578 return out
1579 }