master
go 2,467 lines 91.2 KB
Raw
1 // SPDX-License-Identifier: GPL-3.0-or-later
2
3 package panos
4
5 import (
6 "bytes"
7 "context"
8 "errors"
9 "maps"
10 "os"
11 "sort"
12 "strconv"
13 "strings"
14 "testing"
15 "time"
16
17 "github.com/netdata/netdata/go/plugins/logger"
18 "github.com/netdata/netdata/go/plugins/pkg/metrix"
19 "github.com/netdata/netdata/go/plugins/plugin/framework/chartengine"
20 "github.com/netdata/netdata/go/plugins/plugin/framework/charttpl"
21 "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/collecttest"
22
23 "github.com/stretchr/testify/assert"
24 "github.com/stretchr/testify/require"
25 )
26
27 var (
28 dataConfigJSON, _ = os.ReadFile("testdata/config.json")
29 dataConfigYAML, _ = os.ReadFile("testdata/config.yaml")
30 dataLegacyBGPPeers, _ = os.ReadFile("testdata/legacy_bgp_peers.xml")
31 dataAdvancedBGPPeers, _ = os.ReadFile("testdata/advanced_bgp_peers.xml")
32 dataSystemInfo, _ = os.ReadFile("testdata/system_info.xml")
33 dataHAState, _ = os.ReadFile("testdata/ha_state.xml")
34 dataEnvironment, _ = os.ReadFile("testdata/environment.xml")
35 dataLicenses, _ = os.ReadFile("testdata/licenses.xml")
36 dataIPSecSA, _ = os.ReadFile("testdata/ipsec_sa.xml")
37 )
38
39 func Test_testDataIsValid(t *testing.T) {
40 for name, data := range map[string][]byte{
41 "dataConfigJSON": dataConfigJSON,
42 "dataConfigYAML": dataConfigYAML,
43 "dataLegacyBGPPeers": dataLegacyBGPPeers,
44 "dataAdvancedBGPPeers": dataAdvancedBGPPeers,
45 "dataSystemInfo": dataSystemInfo,
46 "dataHAState": dataHAState,
47 "dataEnvironment": dataEnvironment,
48 "dataLicenses": dataLicenses,
49 "dataIPSecSA": dataIPSecSA,
50 } {
51 require.NotNil(t, data, name)
52 }
53 }
54
55 func TestCollector_ConfigurationSerialize(t *testing.T) {
56 collecttest.TestConfigurationSerialize(t, &Collector{}, dataConfigJSON, dataConfigYAML)
57 }
58
59 func TestCollector_Init(t *testing.T) {
60 tests := map[string]struct {
61 setup func(*Collector)
62 keepFactory bool
63 wantErr string
64 check func(*testing.T, *Collector)
65 }{
66 "success with API key": {
67 setup: func(c *Collector) {
68 c.APIKey = "key"
69 },
70 check: func(t *testing.T, c *Collector) {
71 assert.NotNil(t, c.apiClient)
72 },
73 },
74 "success with username and password": {
75 setup: func(c *Collector) {
76 c.Username = "user"
77 c.Password = "pass"
78 },
79 check: func(t *testing.T, c *Collector) {
80 assert.NotNil(t, c.apiClient)
81 },
82 },
83 "api client factory error": {
84 setup: func(c *Collector) {
85 c.APIKey = "key"
86 c.newAPIClient = func(Config) (panosAPIClient, error) {
87 return nil, errors.New("factory failed")
88 }
89 },
90 keepFactory: true,
91 wantErr: "init PAN-OS API client: factory failed",
92 },
93 "URL not set": {
94 setup: func(c *Collector) {
95 c.URL = ""
96 c.APIKey = "key"
97 },
98 wantErr: "url not configured",
99 },
100 "auth not set": {
101 wantErr: "api_key or username/password",
102 },
103 "force_http2 is not supported": {
104 setup: func(c *Collector) {
105 c.APIKey = "key"
106 c.ForceHTTP2 = true
107 },
108 wantErr: "force_http2",
109 },
110 "request body is not supported": {
111 setup: func(c *Collector) {
112 c.APIKey = "key"
113 c.Body = "body"
114 },
115 wantErr: "body",
116 },
117 "bearer token file is not supported": {
118 setup: func(c *Collector) {
119 c.APIKey = "key"
120 c.BearerTokenFile = "/tmp/token"
121 },
122 wantErr: "bearer_token_file",
123 },
124 "request method is not supported": {
125 setup: func(c *Collector) {
126 c.APIKey = "key"
127 c.Method = "POST"
128 },
129 wantErr: "method",
130 },
131 "not following redirects is not supported": {
132 setup: func(c *Collector) {
133 c.APIKey = "key"
134 c.NotFollowRedirect = true
135 },
136 wantErr: "not_follow_redirects",
137 },
138 "proxy username is not supported": {
139 setup: func(c *Collector) {
140 c.APIKey = "key"
141 c.ProxyUsername = "proxy-user"
142 },
143 wantErr: "proxy_username/proxy_password",
144 },
145 "proxy password is not supported": {
146 setup: func(c *Collector) {
147 c.APIKey = "key"
148 c.ProxyPassword = "proxy-pass"
149 },
150 wantErr: "proxy_username/proxy_password",
151 },
152 "tls cert without key is rejected": {
153 setup: func(c *Collector) {
154 c.APIKey = "key"
155 c.TLSCert = "/tmp/client.pem"
156 },
157 wantErr: "tls_cert and tls_key",
158 },
159 "tls key without cert is rejected": {
160 setup: func(c *Collector) {
161 c.APIKey = "key"
162 c.TLSKey = "/tmp/client-key.pem"
163 },
164 wantErr: "tls_cert and tls_key",
165 },
166 }
167
168 for name, tc := range tests {
169 t.Run(name, func(t *testing.T) {
170 collr := New()
171 if tc.setup != nil {
172 tc.setup(collr)
173 }
174 if !tc.keepFactory {
175 collr.newAPIClient = func(Config) (panosAPIClient, error) {
176 return &mockAPIClient{}, nil
177 }
178 }
179
180 err := collr.Init(context.Background())
181 if tc.wantErr != "" {
182 require.ErrorContains(t, err, tc.wantErr)
183 return
184 }
185 require.NoError(t, err)
186 if tc.check != nil {
187 tc.check(t, collr)
188 }
189 })
190 }
191 }
192
193 func TestCollector_Check(t *testing.T) {
194 tests := map[string]struct {
195 client panosAPIClient
196 wantErr string
197 wantCommands []string
198 }{
199 "success probes system info only": {
200 client: &mockAPIClient{
201 responses: map[string][]byte{
202 systemInfoCommand: dataSystemInfo,
203 haStateCommand: []byte(`<response status="success"><result></result></response>`),
204 environmentCommand: []byte(`<response status="success"><result></result></response>`),
205 licenseInfoCommand: []byte(`<response status="success"><result></result></response>`),
206 ipsecSACommand: []byte(`<response status="success"><result></result></response>`),
207 legacyBGPPeerCommand: dataLegacyBGPPeers,
208 },
209 },
210 wantCommands: []string{systemInfoCommand},
211 },
212 "malformed optional metricsets do not fail check": {
213 client: &mockAPIClient{
214 responses: map[string][]byte{
215 systemInfoCommand: dataSystemInfo,
216 haStateCommand: []byte(`<response status="success"><result></result></response>`),
217 environmentCommand: []byte(`<response status="success"><result></result></response>`),
218 licenseInfoCommand: []byte(`<response status="success"><result></result></response>`),
219 ipsecSACommand: []byte(`<response status="success"><result></result></response>`),
220 },
221 },
222 wantCommands: []string{systemInfoCommand},
223 },
224 "fails when system info API call fails": {
225 client: &mockAPIClient{
226 errors: map[string]error{systemInfoCommand: errors.New("api error")},
227 },
228 wantErr: "api error",
229 wantCommands: []string{systemInfoCommand},
230 },
231 "fails when system info payload is missing": {
232 client: &mockAPIClient{
233 responses: map[string][]byte{systemInfoCommand: []byte(`<response status="success"><result></result></response>`)},
234 },
235 wantErr: "expected <system>",
236 wantCommands: []string{systemInfoCommand},
237 },
238 "fails when API client is not initialized": {
239 wantErr: "API client not initialized",
240 },
241 }
242
243 for name, tc := range tests {
244 t.Run(name, func(t *testing.T) {
245 collr := New()
246 collr.apiClient = tc.client
247 api, _ := tc.client.(*mockAPIClient)
248
249 err := collr.Check(context.Background())
250 if tc.wantErr != "" {
251 require.ErrorContains(t, err, tc.wantErr)
252 if tc.wantCommands != nil {
253 require.NotNil(t, api)
254 assert.Equal(t, tc.wantCommands, api.commands)
255 }
256 return
257 }
258 require.NoError(t, err)
259 require.NotNil(t, api)
260 assert.Equal(t, tc.wantCommands, api.commands)
261 })
262 }
263 }
264
265 func TestCollector_CheckStopsOnCanceledContext(t *testing.T) {
266 ctx, cancel := context.WithCancel(context.Background())
267 cancel()
268
269 api := &mockAPIClient{}
270 collr := New()
271 collr.apiClient = api
272
273 err := collr.Check(ctx)
274 require.ErrorIs(t, err, context.Canceled)
275 assert.Empty(t, api.commands)
276 }
277
278 func TestCollector_Cleanup(t *testing.T) {
279 tests := map[string]struct {
280 client *mockAPIClient
281 want int
282 }{
283 "client not initialized": {},
284 "client initialized": {
285 client: &mockAPIClient{},
286 want: 1,
287 },
288 }
289
290 for name, tc := range tests {
291 t.Run(name, func(t *testing.T) {
292 collr := New()
293 if tc.client != nil {
294 collr.apiClient = tc.client
295 }
296
297 assert.NotPanics(t, func() { collr.Cleanup(context.Background()) })
298 if tc.client != nil {
299 assert.Equal(t, tc.want, tc.client.closeCalls)
300 }
301 })
302 }
303 }
304
305 func TestCollector_CollectStopsOnCanceledContext(t *testing.T) {
306 tests := map[string]struct {
307 cancelBeforeCollect bool
308 cancelAfterCommand string
309 wantCommands []string
310 }{
311 "canceled before first API call": {
312 cancelBeforeCollect: true,
313 },
314 "canceled after system metricset": {
315 cancelAfterCommand: systemInfoCommand,
316 wantCommands: []string{systemInfoCommand},
317 },
318 }
319
320 for name, tc := range tests {
321 t.Run(name, func(t *testing.T) {
322 ctx, cancel := context.WithCancel(context.Background())
323 defer cancel()
324 if tc.cancelBeforeCollect {
325 cancel()
326 }
327
328 api := &mockAPIClient{}
329 api.onOp = func(_ context.Context, cmd string) {
330 if cmd == tc.cancelAfterCommand {
331 cancel()
332 }
333 }
334 collr := New()
335 collr.apiClient = api
336
337 err := collectOnceWithContext(t, collr, ctx)
338 require.ErrorIs(t, err, context.Canceled)
339 assert.Equal(t, tc.wantCommands, api.commands)
340 })
341 }
342 }
343
344 func TestCollector_MetricStore(t *testing.T) {
345 assert.NotNil(t, New().MetricStore())
346 }
347
348 func TestCollector_ChartTemplateYAML(t *testing.T) {
349 collr := New()
350
351 collecttest.AssertChartTemplateSchema(t, collr.ChartTemplateYAML())
352 spec, err := charttpl.DecodeYAML([]byte(collr.ChartTemplateYAML()))
353 require.NoError(t, err)
354 _, err = chartengine.Compile(spec, 1)
355 require.NoError(t, err)
356 }
357
358 func TestCollector_Collect(t *testing.T) {
359 type collectStep struct {
360 name string
361 setup func(*Collector, *mockAPIClient)
362 wantErr string
363 wantMetrics map[string]metrix.SampleValue
364 wantMissing []string
365 wantLog []string
366 notWantLog []string
367 check func(*testing.T, *Collector, *mockAPIClient, map[string]metrix.SampleValue)
368 }
369 tests := map[string]struct {
370 prepare func(*Collector, *mockAPIClient)
371 steps []collectStep
372 }{
373 "read-only telemetry and legacy BGP": {
374 prepare: func(c *Collector, api *mockAPIClient) {
375 api.responses = map[string][]byte{
376 systemInfoCommand: dataSystemInfo,
377 haStateCommand: dataHAState,
378 environmentCommand: dataEnvironment,
379 licenseInfoCommand: dataLicenses,
380 ipsecSACommand: dataIPSecSA,
381 legacyBGPPeerCommand: dataLegacyBGPPeers,
382 }
383 c.now = func() time.Time { return time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC) }
384 },
385 steps: []collectStep{
386 {
387 name: "collects all read-only metricsets",
388 wantMetrics: map[string]metrix.SampleValue{
389 metricKey("system_uptime", systemLabels()): 183845,
390 stateMetricKey("system_device_certificate_status", "valid", systemLabels()): 1,
391 stateMetricKey("system_operational_mode", "normal", systemLabels()): 1,
392 stateMetricKey("ha_status", "enabled", nil): 1,
393 stateMetricKey("ha_status", "disabled", nil): 0,
394 stateMetricKey("ha_local_state", "active", nil): 1,
395 stateMetricKey("ha_peer_state", "passive", nil): 1,
396 stateMetricKey("ha_peer_connection_status", "up", nil): 1,
397 stateMetricKey("ha_peer_connection_status", "down", nil): 0,
398 stateMetricKey("ha_peer_connection_status", "unknown", nil): 0,
399 stateMetricKey("ha_state_sync_status", "synchronized", nil): 1,
400 stateMetricKey("ha_state_sync_status", "not_synchronized", nil): 0,
401 stateMetricKey("ha_state_sync_status", "unknown", nil): 0,
402 stateMetricKey("ha_link_status", "up", haLinkLabels("ha1")): 1,
403 stateMetricKey("ha_link_status", "down", haLinkLabels("ha1")): 0,
404 stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha1")): 0,
405 stateMetricKey("ha_link_status", "up", haLinkLabels("ha1_backup")): 0,
406 stateMetricKey("ha_link_status", "down", haLinkLabels("ha1_backup")): 1,
407 stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha1_backup")): 0,
408 metricKey("environment_temperature", envLabels("temperature", "1", "Temperature Inlet")): 40900,
409 metricKey("environment_fan_speed", envLabels("fan", "1", "Fan 1 RPM")): 9157,
410 metricKey("environment_voltage", envLabels("voltage", "1", "3.3V Power Rail")): 3332,
411 stateMetricKey("environment_sensor_alarm_status", "alarm", envLabels("voltage", "1", "3.3V Power Rail")): 1,
412 stateMetricKey("environment_sensor_alarm_status", "clear", envLabels("voltage", "1", "3.3V Power Rail")): 0,
413 stateMetricKey("environment_power_supply_presence_status", "present", envLabels("power_supply", "1", "Power Supply 1")): 1,
414 stateMetricKey("environment_power_supply_presence_status", "absent", envLabels("power_supply", "1", "Power Supply 1")): 0,
415 stateMetricKey("environment_power_supply_alarm_status", "clear", envLabels("power_supply", "1", "Power Supply 1")): 1,
416 stateMetricKey("environment_power_supply_alarm_status", "alarm", envLabels("power_supply", "1", "Power Supply 1")): 0,
417 metricKey("license_count_total", nil): 3,
418 metricKey("license_count_expired", nil): 1,
419 metricKey("license_time_until_expiration", licenseLabels("Threat Prevention", "Threat prevention updates")): 30,
420 stateMetricKey("license_status", "expired", licenseLabels("Premium Support", "Support entitlement")): 1,
421 metricKey("license_time_until_expiration", licenseLabels("GlobalProtect Portal", "Portal entitlement")): metrix.SampleValue(licenseNeverExpires),
422 metricKey("ipsec_tunnels_active", nil): 2,
423 metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("branch-a", "gw-branch-a", "198.51.100.10", "66", "ESP", "G256")): 1727,
424 metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("branch-b", "gw-branch-b", "203.0.113.20", "67", "ESP", "AES128")): 99,
425 stateMetricKey("bgp_peer_state", "established", legacyPeerLabels()): 1,
426 },
427 wantMissing: []string{
428 metricKey("license_time_until_expiration", licenseLabels("Premium Support", "Support entitlement")),
429 "env_sensors_collection_discovered",
430 "license_collection_discovered",
431 "ipsec_tunnels_collection_discovered",
432 },
433 check: func(t *testing.T, c *Collector, _ *mockAPIClient, _ map[string]metrix.SampleValue) {
434 assert.Equal(t, routingEngineLegacy, c.routingEngine)
435 collecttest.AssertChartCoverage(t, c, collecttest.ChartCoverageExpectation{})
436 },
437 },
438 },
439 },
440 "advanced BGP fallback": {
441 prepare: func(_ *Collector, api *mockAPIClient) {
442 api.responses = map[string][]byte{
443 legacyBGPPeerCommand: []byte(`<response status="success"><result></result></response>`),
444 advancedBGPPeerCommands[0]: dataAdvancedBGPPeers,
445 advancedBGPPeerCommands[1]: []byte(`<response status="success"><result></result></response>`),
446 advancedBGPPeerCommands[2]: []byte(`<response status="success"><result></result></response>`),
447 }
448 },
449 steps: []collectStep{
450 {
451 name: "collects ARE peers after legacy empty success",
452 wantMetrics: map[string]metrix.SampleValue{
453 stateMetricKey("bgp_peer_state", "openconfirm", advancedPeerLabels()): 1,
454 metricKey("bgp_peer_uptime", advancedPeerLabels()): 93784,
455 metricKey("bgp_peer_prefixes_received_total", advancedPrefixLabels("ipv4", "unicast")): 100,
456 metricKey("bgp_vr_peers_total_configured", metrix.Labels{"vr": "lr-a"}): 1,
457 },
458 check: func(t *testing.T, c *Collector, _ *mockAPIClient, _ map[string]metrix.SampleValue) {
459 assert.Equal(t, routingEngineAdvanced, c.routingEngine)
460 assert.Equal(t, advancedBGPPeerCommands[0], c.bgpCommand)
461 },
462 },
463 },
464 },
465 "no BGP state is cached": {
466 prepare: func(c *Collector, _ *mockAPIClient) {
467 now := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC)
468 c.now = func() time.Time { return now }
469 },
470 steps: []collectStep{
471 {
472 name: "initial full BGP probe",
473 wantMetrics: map[string]metrix.SampleValue{
474 metricKey("system_uptime", systemLabels()): 183845,
475 },
476 check: func(t *testing.T, c *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
477 assert.Equal(t, routingEngineNone, c.routingEngine)
478 assert.Len(t, api.commands, 9)
479 },
480 },
481 {
482 name: "cached no-BGP skips BGP commands",
483 setup: func(_ *Collector, api *mockAPIClient) {
484 api.commands = nil
485 },
486 wantMetrics: map[string]metrix.SampleValue{
487 metricKey("system_uptime", systemLabels()): 183845,
488 },
489 check: func(t *testing.T, _ *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
490 assert.Len(t, api.commands, 5)
491 },
492 },
493 {
494 name: "reprobes after no-BGP interval",
495 setup: func(c *Collector, api *mockAPIClient) {
496 api.commands = nil
497 c.now = func() time.Time {
498 return time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC).Add(noBGPReprobeInterval)
499 }
500 },
501 wantMetrics: map[string]metrix.SampleValue{
502 metricKey("system_uptime", systemLabels()): 183845,
503 },
504 check: func(t *testing.T, _ *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
505 assert.Len(t, api.commands, 9)
506 },
507 },
508 },
509 },
510 "BGP probe errors with empty success do not cache no-BGP": {
511 prepare: func(_ *Collector, api *mockAPIClient) {
512 api.responses = map[string][]byte{
513 legacyBGPPeerCommand: []byte(`<response status="success"><result></result></response>`),
514 advancedBGPPeerCommands[1]: []byte(`<response status="success"><result></result></response>`),
515 advancedBGPPeerCommands[2]: []byte(`<response status="success"><result></result></response>`),
516 }
517 api.errors = map[string]error{
518 advancedBGPPeerCommands[0]: errors.New("advanced routing query failed"),
519 }
520 },
521 steps: []collectStep{
522 {
523 name: "first partial BGP probe failure",
524 wantMetrics: map[string]metrix.SampleValue{
525 metricKey("system_uptime", systemLabels()): 183845,
526 },
527 wantLog: []string{"advanced routing query failed"},
528 check: assertBGPProbeErrorNotCached,
529 },
530 {
531 name: "second cycle probes again",
532 setup: func(_ *Collector, api *mockAPIClient) {
533 api.commands = nil
534 },
535 wantMetrics: map[string]metrix.SampleValue{
536 metricKey("system_uptime", systemLabels()): 183845,
537 },
538 notWantLog: []string{"advanced routing query failed"},
539 check: assertBGPProbeErrorNotCached,
540 },
541 },
542 },
543 "stale cached BGP command reprobes": {
544 prepare: func(c *Collector, api *mockAPIClient) {
545 c.routingEngine = routingEngineLegacy
546 c.bgpCommand = legacyBGPPeerCommand
547 api.responses = map[string][]byte{
548 legacyBGPPeerCommand: []byte(`<response status="success"><result></result></response>`),
549 advancedBGPPeerCommands[0]: dataAdvancedBGPPeers,
550 }
551 },
552 steps: []collectStep{
553 {
554 name: "empty cached legacy command tries ARE commands",
555 wantMetrics: map[string]metrix.SampleValue{
556 stateMetricKey("bgp_peer_state", "openconfirm", advancedPeerLabels()): 1,
557 },
558 check: func(t *testing.T, c *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
559 assert.Equal(t, routingEngineAdvanced, c.routingEngine)
560 assert.Equal(t, advancedBGPPeerCommands[0], c.bgpCommand)
561 assert.Equal(t, []string{
562 systemInfoCommand,
563 haStateCommand,
564 environmentCommand,
565 licenseInfoCommand,
566 ipsecSACommand,
567 legacyBGPPeerCommand,
568 advancedBGPPeerCommands[0],
569 }, api.commands)
570 },
571 },
572 },
573 },
574 "stale BGP labels are dropped between cycles": {
575 prepare: func(_ *Collector, api *mockAPIClient) {
576 api.responses = map[string][]byte{legacyBGPPeerCommand: dataLegacyBGPPeers}
577 },
578 steps: []collectStep{
579 {
580 name: "old remote AS",
581 wantMetrics: map[string]metrix.SampleValue{
582 stateMetricKey("bgp_peer_state", "established", legacyPeerLabels()): 1,
583 },
584 },
585 {
586 name: "new remote AS replaces old label set",
587 setup: func(_ *Collector, api *mockAPIClient) {
588 api.responses[legacyBGPPeerCommand] = []byte(strings.Replace(string(dataLegacyBGPPeers), "<remote-as>65001</remote-as>", "<remote-as>65111</remote-as>", 1))
589 },
590 wantMetrics: map[string]metrix.SampleValue{
591 stateMetricKey("bgp_peer_state", "established", legacyPeerLabelsWithRemoteAS("65111")): 1,
592 },
593 wantMissing: []string{
594 stateMetricKey("bgp_peer_state", "established", legacyPeerLabels()),
595 },
596 },
597 },
598 },
599 "malformed BGP peer preserves valid peers": {
600 prepare: func(_ *Collector, api *mockAPIClient) {
601 api.responses = map[string][]byte{
602 legacyBGPPeerCommand: []byte(`<response status="success"><result>
603 <entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>abc</msg-total-in><msg-total-out>1</msg-total-out><msg-update-in>1</msg-update-in><msg-update-out>1</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
604 <entry><peer-address>192.0.2.2</peer-address><status>Established</status><status-duration>120</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
605 </result></response>`),
606 }
607 },
608 steps: []collectStep{
609 {
610 name: "valid peer still emitted",
611 wantMetrics: map[string]metrix.SampleValue{
612 stateMetricKey("bgp_peer_state", "established", fallbackPeerLabels("192.0.2.2")): 1,
613 },
614 wantMissing: []string{
615 stateMetricKey("bgp_peer_state", "established", fallbackPeerLabels("192.0.2.1")),
616 },
617 wantLog: []string{`BGP peer entry 192.0.2.1: BGP peer 192.0.2.1 msg-total-in: invalid integer`},
618 },
619 },
620 },
621 "malformed BGP prefix preserves peer": {
622 prepare: func(_ *Collector, api *mockAPIClient) {
623 api.responses = map[string][]byte{
624 legacyBGPPeerCommand: []byte(`<response status="success"><result>
625 <entry>
626 <peer-address>192.0.2.1</peer-address>
627 <status>Established</status>
628 <status-duration>60</status-duration>
629 <msg-total-in>10</msg-total-in>
630 <msg-total-out>20</msg-total-out>
631 <msg-update-in>3</msg-update-in>
632 <msg-update-out>4</msg-update-out>
633 <status-flap-counts>0</status-flap-counts>
634 <established-counts>1</established-counts>
635 <prefix-counter>
636 <entry name="ipv4-unicast"><incoming-total>abc</incoming-total><incoming-accepted>1</incoming-accepted><incoming-rejected>0</incoming-rejected><outgoing-advertised>2</outgoing-advertised></entry>
637 </prefix-counter>
638 </entry>
639 </result></response>`),
640 }
641 },
642 steps: []collectStep{
643 {
644 name: "peer metrics survive malformed prefix counter",
645 wantMetrics: map[string]metrix.SampleValue{
646 stateMetricKey("bgp_peer_state", "established", fallbackPeerLabels("192.0.2.1")): 1,
647 metricKey("bgp_vr_peers_total_configured", metrix.Labels{"vr": "default"}): 1,
648 },
649 wantMissing: []string{
650 metricKey("bgp_peer_prefixes_received_total", fallbackPrefixLabels("192.0.2.1", "ipv4", "unicast")),
651 },
652 wantLog: []string{`BGP peer entry 192.0.2.1: BGP peer 192.0.2.1 ipv4-unicast incoming-total: invalid integer`},
653 },
654 },
655 },
656 "advanced BGP second command fallback": {
657 prepare: func(_ *Collector, api *mockAPIClient) {
658 api.responses = map[string][]byte{
659 legacyBGPPeerCommand: []byte(`<response status="success"><result></result></response>`),
660 advancedBGPPeerCommands[0]: []byte(`<response status="success"><result></result></response>`),
661 advancedBGPPeerCommands[1]: dataAdvancedBGPPeers,
662 }
663 },
664 steps: []collectStep{
665 {
666 name: "collects ARE peers from second supported command",
667 wantMetrics: map[string]metrix.SampleValue{
668 stateMetricKey("bgp_peer_state", "openconfirm", advancedPeerLabels()): 1,
669 },
670 check: func(t *testing.T, c *Collector, _ *mockAPIClient, _ map[string]metrix.SampleValue) {
671 assert.Equal(t, routingEngineAdvanced, c.routingEngine)
672 assert.Equal(t, advancedBGPPeerCommands[1], c.bgpCommand)
673 },
674 },
675 },
676 },
677 "advanced BGP third command fallback": {
678 prepare: func(_ *Collector, api *mockAPIClient) {
679 api.responses = map[string][]byte{
680 legacyBGPPeerCommand: []byte(`<response status="success"><result></result></response>`),
681 advancedBGPPeerCommands[0]: []byte(`<response status="success"><result></result></response>`),
682 advancedBGPPeerCommands[1]: []byte(`<response status="success"><result></result></response>`),
683 advancedBGPPeerCommands[2]: dataAdvancedBGPPeers,
684 }
685 },
686 steps: []collectStep{
687 {
688 name: "collects ARE peers from third supported command",
689 wantMetrics: map[string]metrix.SampleValue{
690 stateMetricKey("bgp_peer_state", "openconfirm", advancedPeerLabels()): 1,
691 },
692 check: func(t *testing.T, c *Collector, _ *mockAPIClient, _ map[string]metrix.SampleValue) {
693 assert.Equal(t, routingEngineAdvanced, c.routingEngine)
694 assert.Equal(t, advancedBGPPeerCommands[2], c.bgpCommand)
695 },
696 },
697 },
698 },
699 "cached BGP command failure reprobes alternate commands": {
700 prepare: func(c *Collector, api *mockAPIClient) {
701 c.routingEngine = routingEngineLegacy
702 c.bgpCommand = legacyBGPPeerCommand
703 api.errors = map[string]error{legacyBGPPeerCommand: errors.New("legacy BGP query failed")}
704 api.responses = map[string][]byte{
705 advancedBGPPeerCommands[0]: []byte(`<response status="success"><result></result></response>`),
706 advancedBGPPeerCommands[1]: dataAdvancedBGPPeers,
707 }
708 },
709 steps: []collectStep{
710 {
711 name: "cached command error does not prevent ARE fallback",
712 wantMetrics: map[string]metrix.SampleValue{
713 stateMetricKey("bgp_peer_state", "openconfirm", advancedPeerLabels()): 1,
714 },
715 wantLog: []string{"legacy BGP query failed"},
716 check: func(t *testing.T, c *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
717 assert.Equal(t, routingEngineAdvanced, c.routingEngine)
718 assert.Equal(t, advancedBGPPeerCommands[1], c.bgpCommand)
719 assert.Equal(t, []string{
720 systemInfoCommand,
721 haStateCommand,
722 environmentCommand,
723 licenseInfoCommand,
724 ipsecSACommand,
725 legacyBGPPeerCommand,
726 legacyBGPPeerCommand,
727 advancedBGPPeerCommands[0],
728 advancedBGPPeerCommands[1],
729 }, api.commands)
730 },
731 },
732 },
733 },
734 "unknown BGP state": {
735 prepare: func(_ *Collector, api *mockAPIClient) {
736 api.responses = map[string][]byte{
737 legacyBGPPeerCommand: []byte(`<response status="success"><result>
738 <entry><peer-address>192.0.2.1</peer-address><status>Clearing</status><status-duration>60</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
739 </result></response>`),
740 }
741 },
742 steps: []collectStep{
743 {
744 name: "unrecognized non-empty state maps to unknown",
745 wantMetrics: map[string]metrix.SampleValue{
746 stateMetricKey("bgp_peer_state", "unknown", fallbackPeerLabels("192.0.2.1")): 1,
747 stateMetricKey("bgp_peer_state", "established", fallbackPeerLabels("192.0.2.1")): 0,
748 metricKey("bgp_vr_peers_by_state_unknown", metrix.Labels{"vr": "default"}): 1,
749 metricKey("bgp_vr_peers_total_configured", metrix.Labels{"vr": "default"}): 1,
750 metricKey("bgp_vr_peers_total_established", metrix.Labels{"vr": "default"}): 0,
751 },
752 },
753 },
754 },
755 "missing optional label values use fallbacks": {
756 prepare: func(_ *Collector, api *mockAPIClient) {
757 systemInfo := strings.Replace(string(dataSystemInfo), " <sw-version>11.1.2</sw-version>\n", "", 1)
758 bgpPeers := strings.Replace(string(dataLegacyBGPPeers), " <peer-group>edge</peer-group>\n", "", 1)
759 bgpPeers = strings.Replace(bgpPeers, " <remote-as>65001</remote-as>\n", "", 1)
760 api.responses = map[string][]byte{
761 systemInfoCommand: []byte(systemInfo),
762 licenseInfoCommand: []byte(`<response status="success"><result><licenses><entry><feature>Threat Prevention</feature><expires>June 01, 2026</expires><expired>no</expired></entry></licenses></result></response>`),
763 legacyBGPPeerCommand: []byte(bgpPeers),
764 }
765 },
766 steps: []collectStep{
767 {
768 name: "fallback label values are explicit",
769 wantMetrics: map[string]metrix.SampleValue{
770 metricKey("system_uptime", metrix.Labels{"hostname": "edge-fw-a", "model": "PA-850", "serial": "0123456789", "sw_version": "unknown"}): 183845,
771 stateMetricKey("license_status", "valid", licenseLabels("Threat Prevention", "unknown")): 1,
772 stateMetricKey("bgp_peer_state", "established", metrix.Labels{"vr": "default", "peer_address": "192.0.2.1", "local_address": "192.0.2.254", "remote_as": "unknown_as", "peer_group": "unknown_group"}): 1,
773 },
774 },
775 },
776 },
777 "system abnormal status states": {
778 prepare: func(_ *Collector, api *mockAPIClient) {
779 systemInfo := strings.Replace(string(dataSystemInfo), "<device-certificate-status>Valid</device-certificate-status>", "<device-certificate-status>invalid</device-certificate-status>", 1)
780 systemInfo = strings.Replace(systemInfo, "<operational-mode>normal</operational-mode>", "<operational-mode>maintenance</operational-mode>", 1)
781 api.responses = map[string][]byte{systemInfoCommand: []byte(systemInfo)}
782 },
783 steps: []collectStep{
784 {
785 name: "invalid certificate and non-normal mode are explicit states",
786 wantMetrics: map[string]metrix.SampleValue{
787 stateMetricKey("system_device_certificate_status", "valid", systemLabels()): 0,
788 stateMetricKey("system_device_certificate_status", "invalid", systemLabels()): 1,
789 stateMetricKey("system_operational_mode", "normal", systemLabels()): 0,
790 stateMetricKey("system_operational_mode", "other", systemLabels()): 1,
791 },
792 },
793 },
794 },
795 "malformed system uptime is partial failure": {
796 prepare: func(_ *Collector, api *mockAPIClient) {
797 api.responses = map[string][]byte{
798 systemInfoCommand: []byte(strings.Replace(string(dataSystemInfo), "<uptime>2 days, 03:04:05</uptime>", "<uptime>soon</uptime>", 1)),
799 }
800 },
801 steps: []collectStep{
802 {
803 name: "other metricsets commit and system metrics are omitted",
804 wantMetrics: map[string]metrix.SampleValue{
805 stateMetricKey("ha_status", "enabled", nil): 1,
806 },
807 wantMissing: []string{metricKey("system_uptime", systemLabels())},
808 wantLog: []string{`system uptime: invalid duration`},
809 },
810 },
811 },
812 "malformed environment value preserves other metrics": {
813 prepare: func(_ *Collector, api *mockAPIClient) {
814 api.responses = map[string][]byte{
815 environmentCommand: []byte(`<response status="success"><result><thermal><entry><slot>1</slot><description>Temperature Inlet</description><DegreesC>not-a-number</DegreesC><alarm>True</alarm></entry></thermal></result></response>`),
816 }
817 },
818 steps: []collectStep{
819 {
820 name: "sensor alarm survives bad temperature",
821 wantMetrics: map[string]metrix.SampleValue{
822 metricKey("system_uptime", systemLabels()): 183845,
823 stateMetricKey("environment_sensor_alarm_status", "alarm", envLabels("temperature", "1", "Temperature Inlet")): 1,
824 stateMetricKey("environment_sensor_alarm_status", "clear", envLabels("temperature", "1", "Temperature Inlet")): 0,
825 },
826 wantMissing: []string{
827 metricKey("environment_temperature", envLabels("temperature", "1", "Temperature Inlet")),
828 },
829 wantLog: []string{`environment temperature Temperature Inlet: invalid decimal`},
830 },
831 },
832 },
833 "environment fan and fans sections are both collected": {
834 prepare: func(_ *Collector, api *mockAPIClient) {
835 api.responses = map[string][]byte{
836 environmentCommand: []byte(`<response status="success"><result>
837 <fan>
838 <entry><slot>1</slot><description>Fan 1 RPM</description><RPMs>9000</RPMs><alarm>False</alarm></entry>
839 </fan>
840 <fans>
841 <entry><slot>1</slot><description>Fan 1 RPM</description><RPMs>9100</RPMs><alarm>False</alarm></entry>
842 <entry><slot>2</slot><description>Fan 2 RPM</description><RPMs>9200</RPMs><alarm>True</alarm></entry>
843 </fans>
844 </result></response>`),
845 }
846 },
847 steps: []collectStep{
848 {
849 name: "first duplicate fan wins and second fan is collected",
850 wantMetrics: map[string]metrix.SampleValue{
851 metricKey("environment_fan_speed", envLabels("fan", "1", "Fan 1 RPM")): 9000,
852 stateMetricKey("environment_sensor_alarm_status", "clear", envLabels("fan", "1", "Fan 1 RPM")): 1,
853 stateMetricKey("environment_sensor_alarm_status", "alarm", envLabels("fan", "1", "Fan 1 RPM")): 0,
854 metricKey("environment_fan_speed", envLabels("fan", "2", "Fan 2 RPM")): 9200,
855 stateMetricKey("environment_sensor_alarm_status", "clear", envLabels("fan", "2", "Fan 2 RPM")): 0,
856 stateMetricKey("environment_sensor_alarm_status", "alarm", envLabels("fan", "2", "Fan 2 RPM")): 1,
857 },
858 },
859 },
860 },
861 "malformed power supply alarm preserves presence": {
862 prepare: func(_ *Collector, api *mockAPIClient) {
863 api.responses = map[string][]byte{
864 environmentCommand: []byte(`<response status="success"><result>
865 <power-supply>
866 <entry><slot>1</slot><description>Power Supply 1</description><Inserted>False</Inserted><alarm>maybe</alarm></entry>
867 </power-supply>
868 </result></response>`),
869 }
870 },
871 steps: []collectStep{
872 {
873 name: "presence commits and alarm is omitted",
874 wantMetrics: map[string]metrix.SampleValue{
875 stateMetricKey("environment_power_supply_presence_status", "present", envLabels("power_supply", "1", "Power Supply 1")): 0,
876 stateMetricKey("environment_power_supply_presence_status", "absent", envLabels("power_supply", "1", "Power Supply 1")): 1,
877 },
878 wantMissing: []string{
879 stateMetricKey("environment_power_supply_alarm_status", "clear", envLabels("power_supply", "1", "Power Supply 1")),
880 stateMetricKey("environment_power_supply_alarm_status", "alarm", envLabels("power_supply", "1", "Power Supply 1")),
881 },
882 wantLog: []string{`environment power supply Power Supply 1 alarm: invalid status`},
883 },
884 },
885 },
886 "empty environment payload is partial success": {
887 prepare: func(_ *Collector, api *mockAPIClient) {
888 api.responses = map[string][]byte{
889 environmentCommand: []byte(`<response status="success"><result></result></response>`),
890 }
891 },
892 steps: []collectStep{
893 {
894 name: "system metrics commit and environment metrics are absent",
895 wantMetrics: map[string]metrix.SampleValue{
896 metricKey("system_uptime", systemLabels()): 183845,
897 },
898 wantMissing: []string{
899 metricKey("environment_fan_speed", envLabels("fan", "1", "Fan 1 RPM")),
900 },
901 wantLog: []string{
902 "environment metricset",
903 "expected <thermal>, <fan>, <fans>, <power>, or <power-supply>",
904 },
905 },
906 },
907 },
908 "HA priority fields are ignored": {
909 prepare: func(_ *Collector, api *mockAPIClient) {
910 api.responses = map[string][]byte{
911 haStateCommand: []byte(strings.Replace(string(dataHAState), "<priority>100</priority>", "<priority>high</priority>", 1)),
912 }
913 },
914 steps: []collectStep{
915 {
916 name: "malformed priority does not affect HA state collection",
917 wantMetrics: map[string]metrix.SampleValue{
918 stateMetricKey("ha_status", "enabled", nil): 1,
919 stateMetricKey("ha_status", "disabled", nil): 0,
920 stateMetricKey("ha_local_state", "active", nil): 1,
921 stateMetricKey("ha_peer_state", "passive", nil): 1,
922 stateMetricKey("ha_state_sync_status", "synchronized", nil): 1,
923 stateMetricKey("ha_state_sync_status", "unknown", nil): 0,
924 },
925 notWantLog: []string{"PAN-OS partial collection error"},
926 },
927 },
928 },
929 "HA disabled emits disabled status": {
930 prepare: func(_ *Collector, api *mockAPIClient) {
931 api.responses = map[string][]byte{
932 haStateCommand: []byte(`<response status="success"><result><enabled>no</enabled></result></response>`),
933 }
934 },
935 steps: []collectStep{
936 {
937 name: "disabled status commits without HA detail samples",
938 wantMetrics: map[string]metrix.SampleValue{
939 stateMetricKey("ha_status", "enabled", nil): 0,
940 stateMetricKey("ha_status", "disabled", nil): 1,
941 },
942 wantMissing: []string{
943 stateMetricKey("ha_local_state", "unknown", nil),
944 stateMetricKey("ha_peer_state", "unknown", nil),
945 },
946 },
947 },
948 },
949 "missing HA binary status fields are omitted": {
950 prepare: func(_ *Collector, api *mockAPIClient) {
951 api.responses = map[string][]byte{
952 haStateCommand: []byte(`<response status="success"><result>
953 <enabled>yes</enabled>
954 <group>
955 <mode>Active-Passive</mode>
956 <local-info>
957 <state>active</state>
958 <priority>100</priority>
959 </local-info>
960 <peer-info>
961 <state>passive</state>
962 <priority>110</priority>
963 <conn-ha1-backup>
964 <conn-status>down</conn-status>
965 </conn-ha1-backup>
966 <conn-ha2>
967 <conn-status>probing</conn-status>
968 </conn-ha2>
969 </peer-info>
970 </group>
971 </result></response>`),
972 }
973 },
974 steps: []collectStep{
975 {
976 name: "missing peer/sync/link fields produce gaps, explicit down and unknown remain state sets",
977 wantMetrics: map[string]metrix.SampleValue{
978 stateMetricKey("ha_status", "enabled", nil): 1,
979 stateMetricKey("ha_status", "disabled", nil): 0,
980 stateMetricKey("ha_local_state", "active", nil): 1,
981 stateMetricKey("ha_peer_state", "passive", nil): 1,
982 stateMetricKey("ha_link_status", "up", haLinkLabels("ha1_backup")): 0,
983 stateMetricKey("ha_link_status", "down", haLinkLabels("ha1_backup")): 1,
984 stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha1_backup")): 0,
985 stateMetricKey("ha_link_status", "up", haLinkLabels("ha2")): 0,
986 stateMetricKey("ha_link_status", "down", haLinkLabels("ha2")): 0,
987 stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha2")): 1,
988 },
989 wantMissing: []string{
990 stateMetricKey("ha_peer_connection_status", "up", nil),
991 stateMetricKey("ha_peer_connection_status", "down", nil),
992 stateMetricKey("ha_peer_connection_status", "unknown", nil),
993 stateMetricKey("ha_state_sync_status", "synchronized", nil),
994 stateMetricKey("ha_state_sync_status", "not_synchronized", nil),
995 stateMetricKey("ha_state_sync_status", "unknown", nil),
996 stateMetricKey("ha_link_status", "up", haLinkLabels("ha1")),
997 stateMetricKey("ha_link_status", "down", haLinkLabels("ha1")),
998 stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha1")),
999 stateMetricKey("ha_link_status", "up", haLinkLabels("ha2_backup")),
1000 stateMetricKey("ha_link_status", "down", haLinkLabels("ha2_backup")),
1001 stateMetricKey("ha_link_status", "unknown", haLinkLabels("ha2_backup")),
1002 },
1003 },
1004 },
1005 },
1006 "HA non-happy states are normalized": {
1007 prepare: func(_ *Collector, api *mockAPIClient) {
1008 api.responses = map[string][]byte{
1009 haStateCommand: []byte(`<response status="success"><result>
1010 <enabled>yes</enabled>
1011 <group>
1012 <running-sync>incomplete</running-sync>
1013 <local-info><state>suspended</state></local-info>
1014 <peer-info>
1015 <state>non-functional</state>
1016 <conn-status>probing</conn-status>
1017 </peer-info>
1018 </group>
1019 </result></response>`),
1020 }
1021 },
1022 steps: []collectStep{
1023 {
1024 name: "suspended non-functional and unknown connection states are explicit",
1025 wantMetrics: map[string]metrix.SampleValue{
1026 stateMetricKey("ha_local_state", "suspended", nil): 1,
1027 stateMetricKey("ha_peer_state", "non_functional", nil): 1,
1028 stateMetricKey("ha_peer_connection_status", "unknown", nil): 1,
1029 stateMetricKey("ha_state_sync_status", "not_synchronized", nil): 1,
1030 stateMetricKey("ha_state_sync_status", "synchronized", nil): 0,
1031 stateMetricKey("ha_state_sync_status", "unknown", nil): 0,
1032 stateMetricKey("ha_peer_connection_status", "up", nil): 0,
1033 stateMetricKey("ha_peer_connection_status", "down", nil): 0,
1034 stateMetricKey("ha_peer_state", "active", nil): 0,
1035 stateMetricKey("ha_peer_state", "passive", nil): 0,
1036 stateMetricKey("ha_peer_state", "suspended", nil): 0,
1037 stateMetricKey("ha_peer_state", "unknown", nil): 0,
1038 stateMetricKey("ha_local_state", "active", nil): 0,
1039 stateMetricKey("ha_local_state", "passive", nil): 0,
1040 stateMetricKey("ha_local_state", "non_functional", nil): 0,
1041 stateMetricKey("ha_local_state", "unknown", nil): 0,
1042 },
1043 },
1044 },
1045 },
1046 "malformed license expiration does not emit fake never value": {
1047 prepare: func(_ *Collector, api *mockAPIClient) {
1048 api.responses = map[string][]byte{
1049 licenseInfoCommand: []byte(`<response status="success"><result><licenses><entry><feature>Threat Prevention</feature><description>Threat prevention updates</description><expires>tomorrow-ish</expires><expired>no</expired></entry></licenses></result></response>`),
1050 }
1051 },
1052 steps: []collectStep{
1053 {
1054 name: "status commits and expiration is omitted",
1055 wantMetrics: map[string]metrix.SampleValue{
1056 metricKey("license_count_total", nil): 1,
1057 stateMetricKey("license_status", "valid", licenseLabels("Threat Prevention", "Threat prevention updates")): 1,
1058 },
1059 wantMissing: []string{metricKey("license_time_until_expiration", licenseLabels("Threat Prevention", "Threat prevention updates"))},
1060 wantLog: []string{`license Threat Prevention expiration: invalid expiration date`},
1061 },
1062 },
1063 },
1064 "license expiration edge cases": {
1065 prepare: func(c *Collector, api *mockAPIClient) {
1066 c.now = func() time.Time { return time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC) }
1067 api.responses = map[string][]byte{
1068 licenseInfoCommand: []byte(`<response status="success"><result><licenses>
1069 <entry><feature>Expires Today</feature><description>today</description><expires>May 02, 2026</expires><expired>no</expired></entry>
1070 <entry><feature>Future</feature><description>future</description><expires>June 01, 2026</expires><expired>no</expired></entry>
1071 <entry><feature>Never</feature><description>never</description><expires>Never</expires><expired>no</expired></entry>
1072 <entry><feature>Explicitly Expired</feature><description>explicit expired</description><expires>April 01, 2026</expires><expired>yes</expired></entry>
1073 <entry><feature>Date Expired</feature><description>date expired</description><expires>April 01, 2026</expires></entry>
1074 </licenses></result></response>`),
1075 }
1076 },
1077 steps: []collectStep{
1078 {
1079 name: "expired licenses trigger status only",
1080 wantMetrics: map[string]metrix.SampleValue{
1081 metricKey("license_count_total", nil): 5,
1082 metricKey("license_count_expired", nil): 2,
1083 stateMetricKey("license_status", "valid", licenseLabels("Expires Today", "today")): 1,
1084 metricKey("license_time_until_expiration", licenseLabels("Expires Today", "today")): 0,
1085 metricKey("license_time_until_expiration", licenseLabels("Future", "future")): 30,
1086 metricKey("license_time_until_expiration", licenseLabels("Never", "never")): metrix.SampleValue(licenseNeverExpires),
1087 stateMetricKey("license_status", "expired", licenseLabels("Explicitly Expired", "explicit expired")): 1,
1088 stateMetricKey("license_status", "expired", licenseLabels("Date Expired", "date expired")): 1,
1089 },
1090 wantMissing: []string{
1091 metricKey("license_time_until_expiration", licenseLabels("Explicitly Expired", "explicit expired")),
1092 metricKey("license_time_until_expiration", licenseLabels("Date Expired", "date expired")),
1093 },
1094 },
1095 },
1096 },
1097 "missing licenses payload is partial success": {
1098 prepare: func(_ *Collector, api *mockAPIClient) {
1099 api.responses = map[string][]byte{
1100 licenseInfoCommand: []byte(`<response status="success"><result></result></response>`),
1101 }
1102 },
1103 steps: []collectStep{
1104 {
1105 name: "system commits and license metrics are absent",
1106 wantMetrics: map[string]metrix.SampleValue{
1107 metricKey("system_uptime", systemLabels()): 183845,
1108 },
1109 wantMissing: []string{metricKey("license_count_total", nil)},
1110 wantLog: []string{
1111 "licenses metricset",
1112 "expected <licenses>",
1113 },
1114 },
1115 },
1116 },
1117 "malformed license status omits status dimensions": {
1118 prepare: func(c *Collector, api *mockAPIClient) {
1119 c.now = func() time.Time { return time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC) }
1120 api.responses = map[string][]byte{
1121 licenseInfoCommand: []byte(`<response status="success"><result><licenses><entry><feature>Threat Prevention</feature><description>Threat prevention updates</description><expires>June 01, 2026</expires><expired>maybe</expired></entry></licenses></result></response>`),
1122 }
1123 },
1124 steps: []collectStep{
1125 {
1126 name: "expiration commits and status is omitted",
1127 wantMetrics: map[string]metrix.SampleValue{
1128 metricKey("license_count_total", nil): 1,
1129 metricKey("license_time_until_expiration", licenseLabels("Threat Prevention", "Threat prevention updates")): 30,
1130 },
1131 wantMissing: []string{
1132 stateMetricKey("license_status", "valid", licenseLabels("Threat Prevention", "Threat prevention updates")),
1133 stateMetricKey("license_status", "expired", licenseLabels("Threat Prevention", "Threat prevention updates")),
1134 },
1135 wantLog: []string{`license Threat Prevention expired status: invalid status`},
1136 },
1137 },
1138 },
1139 "malformed IPsec lifetime preserves active tunnel count": {
1140 prepare: func(_ *Collector, api *mockAPIClient) {
1141 api.responses = map[string][]byte{
1142 ipsecSACommand: []byte(`<response status="success"><result><ntun>1</ntun><entries><entry><name>branch-a</name><gateway>gw-branch-a</gateway><remote>198.51.100.10</remote><remain>soon</remain><tid>66</tid></entry></entries></result></response>`),
1143 }
1144 },
1145 steps: []collectStep{
1146 {
1147 name: "bad tunnel lifetime is omitted",
1148 wantMetrics: map[string]metrix.SampleValue{metricKey("ipsec_tunnels_active", nil): 1},
1149 wantMissing: []string{metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("branch-a", "gw-branch-a", "198.51.100.10", "66", "unknown", "unknown"))},
1150 wantLog: []string{`IPsec tunnel branch-a remain: invalid integer`},
1151 },
1152 },
1153 },
1154 "IPsec summary-only response uses ntun": {
1155 prepare: func(_ *Collector, api *mockAPIClient) {
1156 api.responses = map[string][]byte{
1157 ipsecSACommand: []byte(`<response status="success"><result><ntun>2</ntun></result></response>`),
1158 }
1159 },
1160 steps: []collectStep{
1161 {
1162 name: "active count commits without tunnel instances",
1163 wantMetrics: map[string]metrix.SampleValue{metricKey("ipsec_tunnels_active", nil): 2},
1164 wantMissing: []string{metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("unknown", "unknown", "unknown", "unknown", "unknown", "unknown"))},
1165 },
1166 },
1167 },
1168 "IPsec count mismatch is partial success": {
1169 prepare: func(_ *Collector, api *mockAPIClient) {
1170 api.responses = map[string][]byte{
1171 ipsecSACommand: []byte(`<response status="success"><result><ntun>2</ntun><entries><entry><name>branch-a</name><gateway>gw-branch-a</gateway><remote>198.51.100.10</remote><remain>60</remain><tid>66</tid></entry></entries></result></response>`),
1172 }
1173 },
1174 steps: []collectStep{
1175 {
1176 name: "count and tunnel metrics commit",
1177 wantMetrics: map[string]metrix.SampleValue{
1178 metricKey("ipsec_tunnels_active", nil): 2,
1179 metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("branch-a", "gw-branch-a", "198.51.100.10", "66", "unknown", "unknown")): 60,
1180 },
1181 wantLog: []string{"IPsec active tunnel count mismatch: ntun=2 entries=1"},
1182 },
1183 },
1184 },
1185 "IPsec entries-only response infers active count": {
1186 prepare: func(_ *Collector, api *mockAPIClient) {
1187 api.responses = map[string][]byte{
1188 ipsecSACommand: []byte(`<response status="success"><result><entries><entry><name>branch-a</name><gateway>gw-branch-a</gateway><remote>198.51.100.10</remote><remain>60</remain><tid>66</tid></entry></entries></result></response>`),
1189 }
1190 },
1191 steps: []collectStep{
1192 {
1193 name: "entries length becomes active tunnel count",
1194 wantMetrics: map[string]metrix.SampleValue{
1195 metricKey("ipsec_tunnels_active", nil): 1,
1196 metricKey("ipsec_tunnel_sa_lifetime", ipsecLabels("branch-a", "gw-branch-a", "198.51.100.10", "66", "unknown", "unknown")): 60,
1197 },
1198 },
1199 },
1200 },
1201 "malformed IPsec active count is partial failure": {
1202 prepare: func(_ *Collector, api *mockAPIClient) {
1203 api.responses = map[string][]byte{
1204 ipsecSACommand: []byte(`<response status="success"><result><ntun>two</ntun></result></response>`),
1205 }
1206 },
1207 steps: []collectStep{
1208 {
1209 name: "system commits and IPsec active count is omitted",
1210 wantMetrics: map[string]metrix.SampleValue{
1211 metricKey("system_uptime", systemLabels()): 183845,
1212 },
1213 wantMissing: []string{metricKey("ipsec_tunnels_active", nil)},
1214 wantLog: []string{`IPsec active tunnel count: invalid integer`},
1215 },
1216 },
1217 },
1218 "missing IPsec payload is partial success": {
1219 prepare: func(_ *Collector, api *mockAPIClient) {
1220 api.responses = map[string][]byte{
1221 ipsecSACommand: []byte(`<response status="success"><result></result></response>`),
1222 }
1223 },
1224 steps: []collectStep{
1225 {
1226 name: "system commits and IPsec metrics are absent",
1227 wantMetrics: map[string]metrix.SampleValue{
1228 metricKey("system_uptime", systemLabels()): 183845,
1229 },
1230 wantMissing: []string{metricKey("ipsec_tunnels_active", nil)},
1231 wantLog: []string{
1232 "ipsec metricset",
1233 "expected <ntun> or <entries>",
1234 },
1235 },
1236 },
1237 },
1238 "all metricsets fail": {
1239 prepare: func(_ *Collector, api *mockAPIClient) {
1240 api.errors = allCommandErrors(errors.New("api error"))
1241 },
1242 steps: []collectStep{
1243 {
1244 name: "public Collect returns an error",
1245 wantErr: "api error",
1246 notWantLog: []string{
1247 "api error",
1248 "PAN-OS partial collection error",
1249 },
1250 },
1251 },
1252 },
1253 }
1254
1255 for name, tc := range tests {
1256 t.Run(name, func(t *testing.T) {
1257 collr := New()
1258 var logBuf bytes.Buffer
1259 collr.Logger = logger.NewWithWriter(&logBuf)
1260 api := &mockAPIClient{}
1261 collr.apiClient = api
1262 if tc.prepare != nil {
1263 tc.prepare(collr, api)
1264 }
1265
1266 for _, step := range tc.steps {
1267 t.Run(step.name, func(t *testing.T) {
1268 if step.setup != nil {
1269 step.setup(collr, api)
1270 }
1271
1272 logBuf.Reset()
1273 mx, err := collecttest.CollectScalarSeries(collr, metrix.ReadFlatten())
1274 logOutput := logBuf.String()
1275 if step.wantErr != "" {
1276 require.ErrorContains(t, err, step.wantErr)
1277 assertExpectedLogs(t, logOutput, step.wantLog, step.notWantLog)
1278 return
1279 }
1280 require.NoError(t, err)
1281 assertExpectedMetrics(t, mx, step.wantMetrics)
1282 assertMissingMetrics(t, mx, step.wantMissing)
1283 assertExpectedLogs(t, logOutput, step.wantLog, step.notWantLog)
1284 if step.check != nil {
1285 step.check(t, collr, api, mx)
1286 }
1287 })
1288 }
1289 })
1290 }
1291 }
1292
1293 func TestCollector_Collect_ReturnsMetricsetAPIErrors(t *testing.T) {
1294 tests := map[string]struct {
1295 command string
1296 wantMetric string
1297 wantMissing string
1298 wantLog string
1299 }{
1300 "system": {
1301 command: systemInfoCommand,
1302 wantMetric: stateMetricKey("ha_status", "enabled", nil),
1303 wantMissing: metricKey("system_uptime", systemLabels()),
1304 wantLog: "system metricset: system info query API call: transport failed",
1305 },
1306 "ha": {
1307 command: haStateCommand,
1308 wantMetric: metricKey("system_uptime", systemLabels()),
1309 wantMissing: stateMetricKey("ha_status", "enabled", nil),
1310 wantLog: "ha metricset: HA state query API call: transport failed",
1311 },
1312 "environment": {
1313 command: environmentCommand,
1314 wantMetric: metricKey("system_uptime", systemLabels()),
1315 wantMissing: metricKey("environment_temperature", envLabels("temperature", "1", "Temperature Inlet")),
1316 wantLog: "environment metricset: environmentals query API call: transport failed",
1317 },
1318 "licenses": {
1319 command: licenseInfoCommand,
1320 wantMetric: metricKey("system_uptime", systemLabels()),
1321 wantMissing: metricKey("license_count_total", nil),
1322 wantLog: "licenses metricset: license info query API call: transport failed",
1323 },
1324 "ipsec": {
1325 command: ipsecSACommand,
1326 wantMetric: metricKey("system_uptime", systemLabels()),
1327 wantMissing: metricKey("ipsec_tunnels_active", nil),
1328 wantLog: "ipsec metricset: IPsec SA query API call: transport failed",
1329 },
1330 }
1331
1332 for name, tc := range tests {
1333 t.Run(name, func(t *testing.T) {
1334 var logBuf bytes.Buffer
1335 collr := New()
1336 collr.Logger = logger.NewWithWriter(&logBuf)
1337 collr.apiClient = &mockAPIClient{
1338 errors: map[string]error{tc.command: errors.New("transport failed")},
1339 }
1340
1341 mx, err := collecttest.CollectScalarSeries(collr, metrix.ReadFlatten())
1342 require.NoError(t, err)
1343 assertMetricPresent(t, mx, tc.wantMetric)
1344 assertMissingMetrics(t, mx, []string{tc.wantMissing})
1345 assert.Contains(t, logBuf.String(), tc.wantLog)
1346 })
1347 }
1348 }
1349
1350 func TestCollector_Collect_ReportsMalformedXMLResponse(t *testing.T) {
1351 tests := map[string]struct {
1352 command string
1353 wantMetric string
1354 wantMissing string
1355 wantLog string
1356 }{
1357 "system": {
1358 command: systemInfoCommand,
1359 wantMetric: stateMetricKey("ha_status", "enabled", nil),
1360 wantMissing: metricKey("system_uptime", systemLabels()),
1361 wantLog: "parse PAN-OS system info response",
1362 },
1363 "ha": {
1364 command: haStateCommand,
1365 wantMetric: metricKey("system_uptime", systemLabels()),
1366 wantMissing: stateMetricKey("ha_status", "enabled", nil),
1367 wantLog: "parse PAN-OS HA response",
1368 },
1369 "environment": {
1370 command: environmentCommand,
1371 wantMetric: metricKey("system_uptime", systemLabels()),
1372 wantMissing: metricKey("environment_temperature", envLabels("temperature", "1", "Temperature Inlet")),
1373 wantLog: "parse PAN-OS environment response",
1374 },
1375 "licenses": {
1376 command: licenseInfoCommand,
1377 wantMetric: metricKey("system_uptime", systemLabels()),
1378 wantMissing: metricKey("license_count_total", nil),
1379 wantLog: "parse PAN-OS licenses response",
1380 },
1381 "ipsec": {
1382 command: ipsecSACommand,
1383 wantMetric: metricKey("system_uptime", systemLabels()),
1384 wantMissing: metricKey("ipsec_tunnels_active", nil),
1385 wantLog: "parse PAN-OS IPsec response",
1386 },
1387 "bgp": {
1388 command: legacyBGPPeerCommand,
1389 wantMetric: metricKey("system_uptime", systemLabels()),
1390 wantMissing: stateMetricKey("bgp_peer_state", "established", legacyPeerLabels()),
1391 wantLog: "parse PAN-OS BGP response",
1392 },
1393 }
1394
1395 for name, tc := range tests {
1396 t.Run(name, func(t *testing.T) {
1397 var logBuf bytes.Buffer
1398 collr := New()
1399 collr.Logger = logger.NewWithWriter(&logBuf)
1400 collr.apiClient = &mockAPIClient{
1401 responses: map[string][]byte{tc.command: []byte(`<response status="success"><result><broken></result></response>`)},
1402 }
1403
1404 mx, err := collecttest.CollectScalarSeries(collr, metrix.ReadFlatten())
1405 require.NoError(t, err)
1406 assertMetricPresent(t, mx, tc.wantMetric)
1407 assertMissingMetrics(t, mx, []string{tc.wantMissing})
1408 assert.Contains(t, logBuf.String(), tc.wantLog)
1409 })
1410 }
1411 }
1412
1413 func TestPangoAPIClient_Op(t *testing.T) {
1414 tests := map[string]struct {
1415 client *pangoAPIClient
1416 check func(*testing.T, *pangoAPIClient, *mockPangoOperator, []byte, error)
1417 }{
1418 "refreshes API key once on unauthorized operation": {
1419 client: &pangoAPIClient{
1420 client: &mockPangoOperator{
1421 responses: []mockPangoResponse{
1422 {err: errors.New("code 16: Unauthorized")},
1423 {body: []byte("<response status=\"success\"/>")},
1424 },
1425 },
1426 canRefresh: true,
1427 },
1428 check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1429 require.NoError(t, err)
1430 assert.Equal(t, []byte("<response status=\"success\"/>"), body)
1431 assert.Equal(t, 2, operator.opCalls)
1432 assert.Equal(t, 1, operator.refreshCalls)
1433 },
1434 },
1435 "passes vsys to pango operation": {
1436 client: &pangoAPIClient{
1437 client: &mockPangoOperator{
1438 responses: []mockPangoResponse{
1439 {body: []byte("<response status=\"success\"/>")},
1440 },
1441 },
1442 vsys: "vsys2",
1443 initialized: true,
1444 },
1445 check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1446 require.NoError(t, err)
1447 assert.Equal(t, []byte("<response status=\"success\"/>"), body)
1448 assert.Equal(t, []string{"vsys2"}, operator.vsys)
1449 },
1450 },
1451 "initialize non unauthorized error is not refreshed": {
1452 client: &pangoAPIClient{
1453 client: &mockPangoOperator{
1454 initializeErr: errors.New("dial tcp failed"),
1455 },
1456 canRefresh: true,
1457 },
1458 check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1459 require.ErrorContains(t, err, "dial tcp failed")
1460 assert.Nil(t, body)
1461 assert.Equal(t, 1, operator.initializeCalls)
1462 assert.Equal(t, 0, operator.refreshCalls)
1463 assert.Equal(t, 0, operator.opCalls)
1464 },
1465 },
1466 "refreshes API key when initialize finds expired key": {
1467 client: &pangoAPIClient{
1468 client: &mockPangoOperator{
1469 initializeErrs: []error{errors.New("code 16: Unauthorized"), nil},
1470 responses: []mockPangoResponse{
1471 {body: []byte("<response status=\"success\"/>")},
1472 },
1473 },
1474 canRefresh: true,
1475 },
1476 check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1477 require.NoError(t, err)
1478 assert.Equal(t, []byte("<response status=\"success\"/>"), body)
1479 assert.Equal(t, 2, operator.initializeCalls)
1480 assert.Equal(t, 1, operator.opCalls)
1481 assert.Equal(t, 1, operator.refreshCalls)
1482 },
1483 },
1484 "refresh failure after unauthorized initialize is returned": {
1485 client: &pangoAPIClient{
1486 client: &mockPangoOperator{
1487 initializeErrs: []error{errors.New("code 16: Unauthorized")},
1488 refreshErr: errors.New("refresh failed"),
1489 },
1490 canRefresh: true,
1491 },
1492 check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1493 require.ErrorContains(t, err, "refresh PAN-OS API key after unauthorized initialization")
1494 require.ErrorContains(t, err, "refresh failed")
1495 assert.Nil(t, body)
1496 assert.Equal(t, 1, operator.initializeCalls)
1497 assert.Equal(t, 1, operator.refreshCalls)
1498 assert.Equal(t, 0, operator.opCalls)
1499 },
1500 },
1501 "reinitialize failure after refresh is returned": {
1502 client: &pangoAPIClient{
1503 client: &mockPangoOperator{
1504 initializeErrs: []error{errors.New("code 16: Unauthorized"), errors.New("still unauthorized")},
1505 },
1506 canRefresh: true,
1507 },
1508 check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1509 require.ErrorContains(t, err, "re-initialize PAN-OS API client after key refresh")
1510 require.ErrorContains(t, err, "still unauthorized")
1511 assert.Nil(t, body)
1512 assert.Equal(t, 2, operator.initializeCalls)
1513 assert.Equal(t, 1, operator.refreshCalls)
1514 assert.Equal(t, 0, operator.opCalls)
1515 },
1516 },
1517 "does not refresh API key on unrelated response code": {
1518 client: &pangoAPIClient{
1519 client: &mockPangoOperator{
1520 responses: []mockPangoResponse{
1521 {err: errors.New("code 160: operation failed")},
1522 {body: []byte("<response status=\"success\"/>")},
1523 },
1524 },
1525 canRefresh: true,
1526 },
1527 check: func(t *testing.T, _ *pangoAPIClient, operator *mockPangoOperator, body []byte, err error) {
1528 require.ErrorContains(t, err, "code 160")
1529 assert.Nil(t, body)
1530 assert.Equal(t, 1, operator.opCalls)
1531 assert.Equal(t, 0, operator.refreshCalls)
1532 },
1533 },
1534 "resets initialization when refresh fails": {
1535 client: &pangoAPIClient{
1536 client: &mockPangoOperator{
1537 responses: []mockPangoResponse{
1538 {err: errors.New("code 16: Unauthorized")},
1539 },
1540 refreshErr: errors.New("refresh failed with key=secret"),
1541 },
1542 canRefresh: true,
1543 initialized: true,
1544 },
1545 check: func(t *testing.T, client *pangoAPIClient, _ *mockPangoOperator, body []byte, err error) {
1546 require.Error(t, err)
1547 assert.Nil(t, body)
1548 assert.False(t, client.initialized)
1549 assert.NotContains(t, err.Error(), "secret")
1550 assert.Contains(t, err.Error(), "key=<redacted>")
1551 },
1552 },
1553 }
1554
1555 for name, tc := range tests {
1556 t.Run(name, func(t *testing.T) {
1557 operator := tc.client.client.(*mockPangoOperator)
1558 body, err := tc.client.op(context.Background(), "cmd")
1559 tc.check(t, tc.client, operator, body, err)
1560 })
1561 }
1562 }
1563
1564 func TestIsUnauthorizedError(t *testing.T) {
1565 tests := map[string]struct {
1566 err error
1567 want bool
1568 }{
1569 "nil": {
1570 err: nil,
1571 want: false,
1572 },
1573 "unauthorized": {
1574 err: errors.New("Unauthorized"),
1575 want: true,
1576 },
1577 "code 16": {
1578 err: errors.New("code 16: Unauthorized"),
1579 want: true,
1580 },
1581 "code colon 16": {
1582 err: errors.New("code: 16"),
1583 want: true,
1584 },
1585 "code 22": {
1586 err: errors.New("code 22: session timed out"),
1587 want: true,
1588 },
1589 "code 403": {
1590 err: errors.New("code 403: forbidden"),
1591 want: true,
1592 },
1593 "forbidden": {
1594 err: errors.New("forbidden"),
1595 want: true,
1596 },
1597 "session timed out": {
1598 err: errors.New("session timed out"),
1599 want: true,
1600 },
1601 "code 160": {
1602 err: errors.New("code 160: operation failed"),
1603 want: false,
1604 },
1605 "code 162": {
1606 err: errors.New("code 162: operation failed"),
1607 want: false,
1608 },
1609 "connection refused": {
1610 err: errors.New("dial tcp 192.0.2.1:443: connect: connection refused"),
1611 want: false,
1612 },
1613 "tls error": {
1614 err: errors.New("tls: failed to verify certificate"),
1615 want: false,
1616 },
1617 }
1618
1619 for name, tc := range tests {
1620 t.Run(name, func(t *testing.T) {
1621 assert.Equal(t, tc.want, isUnauthorizedError(tc.err))
1622 })
1623 }
1624 }
1625
1626 func TestSanitizePANOSAPIError(t *testing.T) {
1627 tests := map[string]struct {
1628 err error
1629 notWant []string
1630 want []string
1631 }{
1632 "password query parameter": {
1633 err: errors.New("https://fw.example.invalid/api/?type=keygen&user=netdata&password=secret"),
1634 notWant: []string{"netdata", "secret"},
1635 want: []string{"type=keygen", "user=<redacted>", "password=<redacted>"},
1636 },
1637 "username query parameter": {
1638 err: errors.New("https://fw.example.invalid/api/?username=netdata&api_key=secret"),
1639 notWant: []string{"netdata", "secret"},
1640 want: []string{"username=<redacted>", "api_key=<redacted>"},
1641 },
1642 }
1643
1644 for name, tc := range tests {
1645 t.Run(name, func(t *testing.T) {
1646 err := sanitizePANOSAPIError(tc.err)
1647 require.Error(t, err)
1648 for _, s := range tc.notWant {
1649 assert.NotContains(t, err.Error(), s)
1650 }
1651 for _, s := range tc.want {
1652 assert.Contains(t, err.Error(), s)
1653 }
1654 })
1655 }
1656 }
1657
1658 func TestParseBGPPeers(t *testing.T) {
1659 tests := map[string]struct {
1660 data []byte
1661 wantLen int
1662 wantErr string
1663 validate func(*testing.T, []bgpPeer)
1664 }{
1665 "legacy": {
1666 data: dataLegacyBGPPeers,
1667 wantLen: 2,
1668 validate: func(t *testing.T, peers []bgpPeer) {
1669 assert.Equal(t, "default", peers[0].VR)
1670 assert.Equal(t, "192.0.2.1", peers[0].PeerAddress)
1671 assert.Equal(t, "192.0.2.254", peers[0].LocalAddress)
1672 assert.Equal(t, "edge", peers[0].PeerGroup)
1673 assert.Equal(t, "65001", peers[0].RemoteAS)
1674 assert.Equal(t, "established", peers[0].State)
1675 assert.Equal(t, "ipv4", peers[0].PrefixCounters[0].AFI)
1676 assert.Equal(t, "unicast", peers[0].PrefixCounters[0].SAFI)
1677 assert.Equal(t, "198.51.100.1", peers[1].PeerAddress)
1678 assert.Equal(t, "active", peers[1].State)
1679 },
1680 },
1681 "advanced": {
1682 data: dataAdvancedBGPPeers,
1683 wantLen: 1,
1684 validate: func(t *testing.T, peers []bgpPeer) {
1685 assert.Equal(t, "lr-a", peers[0].VR)
1686 assert.Equal(t, "203.0.113.1", peers[0].PeerAddress)
1687 assert.Equal(t, "openconfirm", peers[0].State)
1688 assert.Equal(t, int64(93784), peers[0].Uptime)
1689 },
1690 },
1691 "error response with nested lines": {
1692 data: []byte(`<response status="error" code="16"><msg><line>Unauthorized</line><line>Invalid API key</line></msg></response>`),
1693 wantErr: "Unauthorized; Invalid API key",
1694 },
1695 "error response with result message": {
1696 data: []byte(`<response status="error" code="400"><result><msg>Parameter &quot;format&quot; is required while exporting certificate</msg></result></response>`),
1697 wantErr: `Parameter "format" is required while exporting certificate`,
1698 },
1699 "malformed numeric field fails peer": {
1700 data: []byte(`<response status="success"><result><entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>abc</msg-total-in><msg-total-out>1</msg-total-out><msg-update-in>1</msg-update-in><msg-update-out>1</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry></result></response>`),
1701 wantErr: `BGP peer 192.0.2.1 msg-total-in: invalid integer "abc"`,
1702 },
1703 "missing numeric field fails peer": {
1704 data: []byte(`<response status="success"><result><entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>1</msg-total-in><msg-total-out>1</msg-total-out><msg-update-in>1</msg-update-in><msg-update-out>1</msg-update-out><status-flap-counts>0</status-flap-counts></entry></result></response>`),
1705 wantErr: "BGP peer 192.0.2.1 established-counts: missing integer",
1706 },
1707 "malformed peer is skipped when another peer is valid": {
1708 data: []byte(`<response status="success"><result>
1709 <entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>abc</msg-total-in><msg-total-out>1</msg-total-out><msg-update-in>1</msg-update-in><msg-update-out>1</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1710 <entry><peer-address>192.0.2.2</peer-address><status>Established</status><status-duration>120</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1711 </result></response>`),
1712 wantLen: 1,
1713 wantErr: `BGP peer entry 192.0.2.1: BGP peer 192.0.2.1 msg-total-in: invalid integer "abc"`,
1714 validate: func(t *testing.T, peers []bgpPeer) {
1715 assert.Equal(t, "192.0.2.2", peers[0].PeerAddress)
1716 assert.Equal(t, int64(120), peers[0].Uptime)
1717 },
1718 },
1719 "malformed prefix counter preserves peer": {
1720 data: []byte(`<response status="success"><result>
1721 <entry>
1722 <peer-address>192.0.2.1</peer-address>
1723 <status>Established</status>
1724 <status-duration>60</status-duration>
1725 <msg-total-in>10</msg-total-in>
1726 <msg-total-out>20</msg-total-out>
1727 <msg-update-in>3</msg-update-in>
1728 <msg-update-out>4</msg-update-out>
1729 <status-flap-counts>0</status-flap-counts>
1730 <established-counts>1</established-counts>
1731 <prefix-counter>
1732 <entry name="ipv4-unicast"><incoming-total>bad</incoming-total><incoming-accepted>1</incoming-accepted><incoming-rejected>0</incoming-rejected><outgoing-advertised>2</outgoing-advertised></entry>
1733 <entry name="ipv6-unicast"><incoming-total>7</incoming-total><incoming-accepted>6</incoming-accepted><incoming-rejected>1</incoming-rejected><outgoing-advertised>3</outgoing-advertised></entry>
1734 </prefix-counter>
1735 </entry>
1736 </result></response>`),
1737 wantLen: 1,
1738 wantErr: `BGP peer entry 192.0.2.1: BGP peer 192.0.2.1 ipv4-unicast incoming-total: invalid integer "bad"`,
1739 validate: func(t *testing.T, peers []bgpPeer) {
1740 assert.Equal(t, "192.0.2.1", peers[0].PeerAddress)
1741 require.Len(t, peers[0].PrefixCounters, 1)
1742 assert.Equal(t, "ipv6", peers[0].PrefixCounters[0].AFI)
1743 assert.Equal(t, "unicast", peers[0].PrefixCounters[0].SAFI)
1744 assert.Equal(t, int64(7), peers[0].PrefixCounters[0].IncomingTotal)
1745 },
1746 },
1747 "deduplicates same vr and peer": {
1748 data: []byte(`<response status="success"><result>
1749 <entry><vr>default</vr><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1750 <entry><vr>default</vr><peer-address>192.0.2.1</peer-address><status>Active</status><status-duration>120</status-duration><msg-total-in>11</msg-total-in><msg-total-out>21</msg-total-out><msg-update-in>4</msg-update-in><msg-update-out>5</msg-update-out><status-flap-counts>1</status-flap-counts><established-counts>2</established-counts></entry>
1751 </result></response>`),
1752 wantLen: 1,
1753 validate: func(t *testing.T, peers []bgpPeer) {
1754 assert.Equal(t, "192.0.2.1", peers[0].PeerAddress)
1755 assert.Equal(t, "established", peers[0].State)
1756 assert.Equal(t, int64(10), peers[0].MessagesIn)
1757 },
1758 },
1759 "uses peer name when peer address is missing": {
1760 data: []byte(`<response status="success"><result>
1761 <entry name="peer-a"><state>Established</state><uptime>60</uptime><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><flap-count>0</flap-count><established-counts>1</established-counts></entry>
1762 </result></response>`),
1763 wantLen: 1,
1764 validate: func(t *testing.T, peers []bgpPeer) {
1765 assert.Equal(t, "peer-a", peers[0].PeerAddress)
1766 assert.Equal(t, "established", peers[0].State)
1767 },
1768 },
1769 "attribute-only peer fields": {
1770 data: []byte(`<response status="success"><result>
1771 <entry peer-address="192.0.2.4" vr="vr-a" peer-group="edge" remote-as="65010"><status>Established</status><status-duration>60</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1772 </result></response>`),
1773 wantLen: 1,
1774 validate: func(t *testing.T, peers []bgpPeer) {
1775 assert.Equal(t, "vr-a", peers[0].VR)
1776 assert.Equal(t, "192.0.2.4", peers[0].PeerAddress)
1777 assert.Equal(t, "edge", peers[0].PeerGroup)
1778 assert.Equal(t, "65010", peers[0].RemoteAS)
1779 },
1780 },
1781 "prefix counters without afi safi use unknown family": {
1782 data: []byte(`<response status="success"><result>
1783 <entry>
1784 <peer-address>192.0.2.1</peer-address>
1785 <status>Established</status>
1786 <status-duration>60</status-duration>
1787 <msg-total-in>10</msg-total-in>
1788 <msg-total-out>20</msg-total-out>
1789 <msg-update-in>3</msg-update-in>
1790 <msg-update-out>4</msg-update-out>
1791 <status-flap-counts>0</status-flap-counts>
1792 <established-counts>1</established-counts>
1793 <prefix-counter>
1794 <entry><incoming-total>7</incoming-total><incoming-accepted>6</incoming-accepted><incoming-rejected>1</incoming-rejected><outgoing-advertised>3</outgoing-advertised></entry>
1795 </prefix-counter>
1796 </entry>
1797 </result></response>`),
1798 wantLen: 1,
1799 validate: func(t *testing.T, peers []bgpPeer) {
1800 require.Len(t, peers[0].PrefixCounters, 1)
1801 assert.Equal(t, "unknown", peers[0].PrefixCounters[0].AFI)
1802 assert.Equal(t, "unknown", peers[0].PrefixCounters[0].SAFI)
1803 assert.Equal(t, int64(7), peers[0].PrefixCounters[0].IncomingTotal)
1804 },
1805 },
1806 "container entries without peer data are skipped": {
1807 data: []byte(`<response status="success"><result>
1808 <entry name="default">
1809 <entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1810 </entry>
1811 </result></response>`),
1812 wantLen: 1,
1813 validate: func(t *testing.T, peers []bgpPeer) {
1814 assert.Equal(t, "default", peers[0].VR)
1815 assert.Equal(t, "192.0.2.1", peers[0].PeerAddress)
1816 },
1817 },
1818 "deep nesting beyond limit is truncated": {
1819 data: deepNestedBGPPeerXML(maxBGPPeerEntryDepth + 1),
1820 validate: func(t *testing.T, peers []bgpPeer) {
1821 assert.Empty(t, peers)
1822 },
1823 },
1824 "placeholder uptime fails peer": {
1825 data: []byte(`<response status="success"><result>
1826 <entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>n/a</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1827 </result></response>`),
1828 wantErr: `BGP peer 192.0.2.1 uptime: invalid duration "n/a"`,
1829 },
1830 "zero uptime is accepted": {
1831 data: []byte(`<response status="success"><result>
1832 <entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>0</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>
1833 </result></response>`),
1834 wantLen: 1,
1835 validate: func(t *testing.T, peers []bgpPeer) {
1836 assert.Equal(t, int64(0), peers[0].Uptime)
1837 },
1838 },
1839 }
1840
1841 for name, tc := range tests {
1842 t.Run(name, func(t *testing.T) {
1843 peers, err := parseBGPPeers(tc.data)
1844 if tc.wantErr != "" {
1845 require.ErrorContains(t, err, tc.wantErr)
1846 } else {
1847 require.NoError(t, err)
1848 }
1849 if tc.wantLen > 0 {
1850 require.Len(t, peers, tc.wantLen)
1851 }
1852 if tc.validate != nil {
1853 tc.validate(t, peers)
1854 }
1855 })
1856 }
1857 }
1858
1859 func TestParseReadOnlyTelemetry(t *testing.T) {
1860 tests := map[string]func(*testing.T){
1861 "system": func(t *testing.T) {
1862 system, err := parseSystemInfo(dataSystemInfo)
1863 require.NoError(t, err)
1864 assert.Equal(t, "edge-fw-a", system.Hostname)
1865 assert.Equal(t, "11.1.2", system.SWVersion)
1866 },
1867 "ha": func(t *testing.T) {
1868 ha, err := parseHAState(dataHAState)
1869 require.NoError(t, err)
1870 assert.Equal(t, "yes", ha.Enabled)
1871 assert.Equal(t, "active", normalizeHAState(ha.Group.LocalInfo.State))
1872 assert.Equal(t, "passive", normalizeHAState(ha.Group.PeerInfo.State))
1873 },
1874 "environment": func(t *testing.T) {
1875 env, err := parseEnvironment(dataEnvironment)
1876 require.NoError(t, err)
1877 require.Len(t, env.ThermalEntries, 1)
1878 require.Len(t, env.FanEntries, 1)
1879 require.Len(t, env.VoltageEntries, 1)
1880 require.Len(t, env.PowerSupplyEntries, 1)
1881 assert.Equal(t, "Temperature Inlet", env.ThermalEntries[0].Description)
1882 assert.Equal(t, "3.332", env.VoltageEntries[0].Volts)
1883 },
1884 "environment fan and fans": func(t *testing.T) {
1885 env, err := parseEnvironment([]byte(`<response status="success"><result>
1886 <fan>
1887 <entry><slot>1</slot><description>Fan 1 RPM</description><RPMs>9000</RPMs><alarm>False</alarm></entry>
1888 </fan>
1889 <fans>
1890 <entry><slot>1</slot><description>Fan 1 RPM</description><RPMs>9100</RPMs><alarm>False</alarm></entry>
1891 <entry><slot>2</slot><description>Fan 2 RPM</description><RPMs>9200</RPMs><alarm>True</alarm></entry>
1892 </fans>
1893 </result></response>`))
1894 require.NoError(t, err)
1895 require.Len(t, env.FanEntries, 2)
1896 assert.Equal(t, "Fan 1 RPM", env.FanEntries[0].Description)
1897 assert.Equal(t, "9000", env.FanEntries[0].RPMs)
1898 assert.Equal(t, "Fan 2 RPM", env.FanEntries[1].Description)
1899 assert.Equal(t, "9200", env.FanEntries[1].RPMs)
1900 },
1901 "licenses": func(t *testing.T) {
1902 licenses, found, err := parseLicenses(dataLicenses)
1903 require.NoError(t, err)
1904 assert.True(t, found)
1905 require.Len(t, licenses, 3)
1906 assert.Equal(t, "Threat Prevention", licenses[0].Feature)
1907 },
1908 "ipsec": func(t *testing.T) {
1909 ipsecPayload, err := parseIPSecTunnels(dataIPSecSA)
1910 require.NoError(t, err)
1911 assert.True(t, ipsecPayload.found)
1912 assert.True(t, ipsecPayload.entriesFound)
1913 assert.Equal(t, int64(2), ipsecPayload.activeCount)
1914 require.Len(t, ipsecPayload.tunnels, 2)
1915 assert.Equal(t, "branch-a", ipsecPayload.tunnels[0].Name)
1916 },
1917 }
1918
1919 for name, run := range tests {
1920 t.Run(name, run)
1921 }
1922 }
1923
1924 func TestParserHelpers(t *testing.T) {
1925 t.Run("normalize BGP state", func(t *testing.T) {
1926 tests := map[string]string{
1927 "Established": "established",
1928 "OpenConfirm": "openconfirm",
1929 "Open-Sent": "opensent",
1930 "Active": "active",
1931 "Connect": "connect",
1932 "Idle": "idle",
1933 "unknown-state": "unknown",
1934 "": "",
1935 }
1936 for in, want := range tests {
1937 assert.Equal(t, want, normalizeBGPState(in), in)
1938 }
1939 })
1940
1941 t.Run("parse PAN-OS duration", func(t *testing.T) {
1942 tests := map[string]int64{
1943 "3600": 3600,
1944 "0": 0,
1945 "01:00:00": 3600,
1946 "1 days 02:03:04": 93784,
1947 "30m": 1800,
1948 "2 mins": 120,
1949 "5 secs": 5,
1950 "2 hours 5 seconds": 7205,
1951 "": 0,
1952 }
1953 for in, want := range tests {
1954 got, err := parsePANOSDurationField("duration", in)
1955 require.NoError(t, err, in)
1956 assert.Equal(t, want, got, in)
1957 }
1958 })
1959
1960 t.Run("strict parsers report malformed values", func(t *testing.T) {
1961 tests := map[string]struct {
1962 parse func() error
1963 wantErr string
1964 }{
1965 "invalid integer": {
1966 parse: func() error {
1967 _, err := parsePANOSIntField("test integer", "not-an-int")
1968 return err
1969 },
1970 wantErr: `test integer: invalid integer "not-an-int"`,
1971 },
1972 "missing integer": {
1973 parse: func() error {
1974 _, err := parseRequiredPANOSIntField("test integer", "")
1975 return err
1976 },
1977 wantErr: "test integer: missing integer",
1978 },
1979 "invalid decimal": {
1980 parse: func() error {
1981 _, err := parsePANOSDecimalField("test decimal", "not-a-decimal", 1000)
1982 return err
1983 },
1984 wantErr: `test decimal: invalid decimal "not-a-decimal"`,
1985 },
1986 "missing decimal": {
1987 parse: func() error {
1988 _, err := parseRequiredPANOSDecimalField("test decimal", "", 1000)
1989 return err
1990 },
1991 wantErr: "test decimal: missing decimal",
1992 },
1993 "invalid duration": {
1994 parse: func() error {
1995 _, err := parsePANOSDurationField("test duration", "since reboot")
1996 return err
1997 },
1998 wantErr: `test duration: invalid duration "since reboot"`,
1999 },
2000 "missing duration": {
2001 parse: func() error {
2002 _, err := parseRequiredPANOSDurationField("test duration", "")
2003 return err
2004 },
2005 wantErr: "test duration: missing duration",
2006 },
2007 "invalid clock duration": {
2008 parse: func() error {
2009 _, err := parsePANOSDurationField("test duration", "01:99:00")
2010 return err
2011 },
2012 wantErr: `test duration: invalid duration "01:99:00"`,
2013 },
2014 "placeholder duration never": {
2015 parse: func() error {
2016 _, err := parsePANOSDurationField("test duration", "never")
2017 return err
2018 },
2019 wantErr: `test duration: invalid duration "never"`,
2020 },
2021 "placeholder duration dash": {
2022 parse: func() error {
2023 _, err := parsePANOSDurationField("test duration", "-")
2024 return err
2025 },
2026 wantErr: `test duration: invalid duration "-"`,
2027 },
2028 "placeholder duration n/a": {
2029 parse: func() error {
2030 _, err := parsePANOSDurationField("test duration", "n/a")
2031 return err
2032 },
2033 wantErr: `test duration: invalid duration "n/a"`,
2034 },
2035 }
2036 for name, tc := range tests {
2037 t.Run(name, func(t *testing.T) {
2038 assert.EqualError(t, tc.parse(), tc.wantErr)
2039 })
2040 }
2041 })
2042
2043 t.Run("normalize address", func(t *testing.T) {
2044 tests := map[string]string{
2045 "192.0.2.1:179": "192.0.2.1",
2046 "192.0.2.1": "192.0.2.1",
2047 "[2001:db8::1]:179": "2001:db8::1",
2048 "2001:db8::1": "2001:db8::1",
2049 "[2001:db8::1]": "2001:db8::1",
2050 "fw.example.invalid": "fw.example.invalid",
2051 "example.invalid:179": "example.invalid",
2052 "example.invalid:bgp": "example.invalid",
2053 }
2054 for in, want := range tests {
2055 assert.Equal(t, want, normalizeAddress(in), in)
2056 }
2057 })
2058
2059 t.Run("normalize AFI SAFI", func(t *testing.T) {
2060 tests := map[string]struct {
2061 wantAFI string
2062 wantSAFI string
2063 }{
2064 "bgpAfiIpv4-unicast": {wantAFI: "ipv4", wantSAFI: "unicast"},
2065 "ipv6-unicast": {wantAFI: "ipv6", wantSAFI: "unicast"},
2066 }
2067 for in, want := range tests {
2068 afi, safi := normalizeAFISAFI(in)
2069 assert.Equal(t, want.wantAFI, afi, in)
2070 assert.Equal(t, want.wantSAFI, safi, in)
2071 }
2072 })
2073
2074 t.Run("PAN-OS response code names", func(t *testing.T) {
2075 tests := map[string]string{
2076 "1": "Unknown command",
2077 "6": "Bad XPath",
2078 "16": "Unauthorized",
2079 "22": "Session timed out",
2080 "400": "Bad request",
2081 " 403 ": "Forbidden",
2082 "unknown": "",
2083 }
2084 for code, want := range tests {
2085 assert.Equal(t, want, panosResponseCodeName(code), code)
2086 }
2087 })
2088 }
2089
2090 func TestParseAPIURL(t *testing.T) {
2091 tests := map[string]struct {
2092 raw string
2093 want panosAPIURL
2094 wantFail bool
2095 }{
2096 "https host": {
2097 raw: "https://192.0.2.1",
2098 want: panosAPIURL{
2099 protocol: "https",
2100 hostname: "192.0.2.1",
2101 },
2102 },
2103 "http port api path": {
2104 raw: "http://fw.example.invalid:8443/api",
2105 want: panosAPIURL{
2106 protocol: "http",
2107 hostname: "fw.example.invalid",
2108 port: 8443,
2109 },
2110 },
2111 "ipv4 port api path": {
2112 raw: "https://192.0.2.1:8443/api",
2113 want: panosAPIURL{
2114 protocol: "https",
2115 hostname: "192.0.2.1",
2116 port: 8443,
2117 },
2118 },
2119 "ipv6": {
2120 raw: "https://[2001:db8::1]/",
2121 want: panosAPIURL{
2122 protocol: "https",
2123 hostname: "[2001:db8::1]",
2124 },
2125 },
2126 "ipv6 port": {
2127 raw: "https://[2001:db8::1]:8443/api",
2128 want: panosAPIURL{
2129 protocol: "https",
2130 hostname: "[2001:db8::1]",
2131 port: 8443,
2132 },
2133 },
2134 "bad scheme": {
2135 raw: "ftp://192.0.2.1",
2136 wantFail: true,
2137 },
2138 "embedded credentials": {
2139 raw: "https://user:pass@192.0.2.1",
2140 wantFail: true,
2141 },
2142 "bad path": {
2143 raw: "https://192.0.2.1/other",
2144 wantFail: true,
2145 },
2146 "query": {
2147 raw: "https://192.0.2.1/api?type=keygen",
2148 wantFail: true,
2149 },
2150 "fragment": {
2151 raw: "https://192.0.2.1/api#fragment",
2152 wantFail: true,
2153 },
2154 "port zero": {
2155 raw: "https://192.0.2.1:0/api",
2156 wantFail: true,
2157 },
2158 "port greater than max": {
2159 raw: "https://192.0.2.1:65536/api",
2160 wantFail: true,
2161 },
2162 "non numeric port": {
2163 raw: "https://192.0.2.1:not-a-port/api",
2164 wantFail: true,
2165 },
2166 }
2167
2168 for name, test := range tests {
2169 t.Run(name, func(t *testing.T) {
2170 got, err := parseAPIURL(test.raw)
2171 if test.wantFail {
2172 assert.Error(t, err)
2173 } else {
2174 require.NoError(t, err)
2175 assert.Equal(t, test.want, got)
2176 }
2177 })
2178 }
2179 }
2180
2181 func assertBGPProbeErrorNotCached(t *testing.T, c *Collector, api *mockAPIClient, _ map[string]metrix.SampleValue) {
2182 t.Helper()
2183 assert.Equal(t, routingEngineUnknown, c.routingEngine)
2184 assert.True(t, c.noBGPProbedAt.IsZero())
2185 assert.Len(t, api.commands, 9)
2186 }
2187
2188 func collectOnceWithContext(t *testing.T, c *Collector, ctx context.Context) error {
2189 t.Helper()
2190
2191 managed, ok := metrix.AsCycleManagedStore(c.MetricStore())
2192 require.True(t, ok)
2193
2194 cycle := managed.CycleController()
2195 committed := false
2196 cycle.BeginCycle()
2197 defer func() {
2198 if !committed {
2199 cycle.AbortCycle()
2200 }
2201 }()
2202
2203 if err := c.Collect(ctx); err != nil {
2204 return err
2205 }
2206 require.NoError(t, cycle.CommitCycleSuccess())
2207 committed = true
2208 return nil
2209 }
2210
2211 func allCommandErrors(err error) map[string]error {
2212 return map[string]error{
2213 systemInfoCommand: err,
2214 haStateCommand: err,
2215 environmentCommand: err,
2216 licenseInfoCommand: err,
2217 ipsecSACommand: err,
2218 legacyBGPPeerCommand: err,
2219 advancedBGPPeerCommands[0]: err,
2220 advancedBGPPeerCommands[1]: err,
2221 advancedBGPPeerCommands[2]: err,
2222 }
2223 }
2224
2225 func assertExpectedMetrics(t *testing.T, got map[string]metrix.SampleValue, want map[string]metrix.SampleValue) {
2226 t.Helper()
2227 for key, wantValue := range want {
2228 gotValue, ok := got[key]
2229 require.True(t, ok, "metric %s", key)
2230 assert.Equal(t, wantValue, gotValue, key)
2231 }
2232 }
2233
2234 func assertMetricPresent(t *testing.T, got map[string]metrix.SampleValue, key string) {
2235 t.Helper()
2236 _, ok := got[key]
2237 require.True(t, ok, "metric %s", key)
2238 }
2239
2240 func assertMissingMetrics(t *testing.T, got map[string]metrix.SampleValue, missing []string) {
2241 t.Helper()
2242 for _, key := range missing {
2243 _, ok := got[key]
2244 assert.False(t, ok, "metric %s", key)
2245 }
2246 }
2247
2248 func assertExpectedLogs(t *testing.T, got string, want, notWant []string) {
2249 t.Helper()
2250 for _, text := range want {
2251 assert.Contains(t, got, text)
2252 }
2253 for _, text := range notWant {
2254 assert.NotContains(t, got, text)
2255 }
2256 }
2257
2258 func metricKey(name string, labels metrix.Labels) string {
2259 if len(labels) == 0 {
2260 return name
2261 }
2262
2263 keys := make([]string, 0, len(labels))
2264 for key := range labels {
2265 keys = append(keys, key)
2266 }
2267 sort.Strings(keys)
2268
2269 var b strings.Builder
2270 b.WriteString(name)
2271 b.WriteByte('{')
2272 for i, key := range keys {
2273 if i > 0 {
2274 b.WriteByte(',')
2275 }
2276 b.WriteString(key)
2277 b.WriteByte('=')
2278 b.WriteString(strconv.Quote(labels[key]))
2279 }
2280 b.WriteByte('}')
2281 return b.String()
2282 }
2283
2284 func stateMetricKey(name, state string, labels metrix.Labels) string {
2285 return metricKey(name, stateLabels(name, state, labels))
2286 }
2287
2288 func stateLabels(name, state string, labels metrix.Labels) metrix.Labels {
2289 out := make(metrix.Labels, len(labels)+1)
2290 maps.Copy(out, labels)
2291 out[name] = state
2292 return out
2293 }
2294
2295 func systemLabels() metrix.Labels {
2296 return metrix.Labels{"hostname": "edge-fw-a", "model": "PA-850", "serial": "0123456789", "sw_version": "11.1.2"}
2297 }
2298
2299 func envLabels(sensorType, slot, sensor string) metrix.Labels {
2300 return metrix.Labels{"sensor_type": sensorType, "slot": slot, "sensor": sensor}
2301 }
2302
2303 func haLinkLabels(link string) metrix.Labels {
2304 return metrix.Labels{"link": link}
2305 }
2306
2307 func licenseLabels(feature, description string) metrix.Labels {
2308 return metrix.Labels{"feature": feature, "description": description}
2309 }
2310
2311 func ipsecLabels(tunnel, gateway, remote, tunnelID, protocol, encryption string) metrix.Labels {
2312 return metrix.Labels{
2313 "tunnel": tunnel,
2314 "gateway": gateway,
2315 "remote": remote,
2316 "tunnel_id": tunnelID,
2317 "protocol": protocol,
2318 "encryption": encryption,
2319 }
2320 }
2321
2322 func legacyPeerLabels() metrix.Labels {
2323 return legacyPeerLabelsWithRemoteAS("65001")
2324 }
2325
2326 func legacyPeerLabelsWithRemoteAS(remoteAS string) metrix.Labels {
2327 return metrix.Labels{
2328 "vr": "default",
2329 "peer_address": "192.0.2.1",
2330 "local_address": "192.0.2.254",
2331 "remote_as": remoteAS,
2332 "peer_group": "edge",
2333 }
2334 }
2335
2336 func advancedPeerLabels() metrix.Labels {
2337 return metrix.Labels{
2338 "vr": "lr-a",
2339 "peer_address": "203.0.113.1",
2340 "local_address": "203.0.113.254",
2341 "remote_as": "65100",
2342 "peer_group": "core",
2343 }
2344 }
2345
2346 func advancedPrefixLabels(afi, safi string) metrix.Labels {
2347 labels := advancedPeerLabels()
2348 labels["afi"] = afi
2349 labels["safi"] = safi
2350 return labels
2351 }
2352
2353 func fallbackPeerLabels(peerAddress string) metrix.Labels {
2354 return metrix.Labels{
2355 "vr": "default",
2356 "peer_address": peerAddress,
2357 "local_address": "unknown",
2358 "remote_as": "unknown_as",
2359 "peer_group": "unknown_group",
2360 }
2361 }
2362
2363 func fallbackPrefixLabels(peerAddress, afi, safi string) metrix.Labels {
2364 labels := fallbackPeerLabels(peerAddress)
2365 labels["afi"] = afi
2366 labels["safi"] = safi
2367 return labels
2368 }
2369
2370 func deepNestedBGPPeerXML(depth int) []byte {
2371 var b strings.Builder
2372 b.WriteString(`<response status="success"><result>`)
2373 for i := range depth {
2374 b.WriteString(`<entry name="container`)
2375 b.WriteString(strconv.Itoa(i))
2376 b.WriteString(`">`)
2377 }
2378 b.WriteString(`<entry><peer-address>192.0.2.1</peer-address><status>Established</status><status-duration>60</status-duration><msg-total-in>10</msg-total-in><msg-total-out>20</msg-total-out><msg-update-in>3</msg-update-in><msg-update-out>4</msg-update-out><status-flap-counts>0</status-flap-counts><established-counts>1</established-counts></entry>`)
2379 for range depth {
2380 b.WriteString(`</entry>`)
2381 }
2382 b.WriteString(`</result></response>`)
2383 return []byte(b.String())
2384 }
2385
2386 type mockAPIClient struct {
2387 responses map[string][]byte
2388 errors map[string]error
2389 commands []string
2390 info map[string]string
2391 closeCalls int
2392 onOp func(context.Context, string)
2393 }
2394
2395 func (m *mockAPIClient) op(ctx context.Context, cmd string) ([]byte, error) {
2396 m.commands = append(m.commands, cmd)
2397 if m.onOp != nil {
2398 m.onOp(ctx, cmd)
2399 }
2400 if err := m.errors[cmd]; err != nil {
2401 return nil, err
2402 }
2403 if resp := m.responses[cmd]; resp != nil {
2404 return resp, nil
2405 }
2406 switch cmd {
2407 case systemInfoCommand:
2408 return dataSystemInfo, nil
2409 case haStateCommand:
2410 return dataHAState, nil
2411 case environmentCommand:
2412 return dataEnvironment, nil
2413 case licenseInfoCommand:
2414 return dataLicenses, nil
2415 case ipsecSACommand:
2416 return dataIPSecSA, nil
2417 case legacyBGPPeerCommand, advancedBGPPeerCommands[0], advancedBGPPeerCommands[1], advancedBGPPeerCommands[2]:
2418 return []byte(`<response status="success"><result></result></response>`), nil
2419 default:
2420 return []byte(`<response status="success"><result></result></response>`), nil
2421 }
2422 }
2423
2424 func (m *mockAPIClient) closeIdleConnections() { m.closeCalls++ }
2425
2426 func (m *mockAPIClient) systemInfo() map[string]string { return m.info }
2427
2428 type mockPangoResponse struct {
2429 body []byte
2430 err error
2431 }
2432
2433 type mockPangoOperator struct {
2434 initializeErr error
2435 initializeErrs []error
2436 refreshErr error
2437 initializeCalls int
2438 responses []mockPangoResponse
2439 info map[string]string
2440 vsys []string
2441 opCalls int
2442 refreshCalls int
2443 }
2444
2445 func (m *mockPangoOperator) Initialize() error {
2446 if len(m.initializeErrs) > 0 {
2447 err := m.initializeErrs[min(m.initializeCalls, len(m.initializeErrs)-1)]
2448 m.initializeCalls++
2449 return err
2450 }
2451 m.initializeCalls++
2452 return m.initializeErr
2453 }
2454
2455 func (m *mockPangoOperator) Op(_ any, vsys string, _, _ any) ([]byte, error) {
2456 m.vsys = append(m.vsys, vsys)
2457 resp := m.responses[m.opCalls]
2458 m.opCalls++
2459 return resp.body, resp.err
2460 }
2461
2462 func (m *mockPangoOperator) RetrieveApiKey() error {
2463 m.refreshCalls++
2464 return m.refreshErr
2465 }
2466
2467 func (m *mockPangoOperator) SystemInfo() map[string]string { return m.info }