github.com/grafana/synthetic-monitoring-agent@v0.64.2/internal/prober/http/http_test.go (about) 1 package http 2 3 import ( 4 "context" 5 "fmt" 6 "io" 7 "net/http" 8 "net/url" 9 "testing" 10 11 "github.com/go-kit/log" 12 "github.com/grafana/synthetic-monitoring-agent/internal/model" 13 "github.com/grafana/synthetic-monitoring-agent/internal/prober/http/testserver" 14 "github.com/grafana/synthetic-monitoring-agent/internal/testhelper" 15 "github.com/grafana/synthetic-monitoring-agent/internal/version" 16 sm "github.com/grafana/synthetic-monitoring-agent/pkg/pb/synthetic_monitoring" 17 "github.com/prometheus/blackbox_exporter/config" 18 "github.com/prometheus/client_golang/prometheus" 19 dto "github.com/prometheus/client_model/go" 20 httpConfig "github.com/prometheus/common/config" 21 "github.com/rs/zerolog" 22 "github.com/stretchr/testify/require" 23 ) 24 25 // expectedHeaders creates expected headers map with user-agent included 26 func expectedHeaders(extra map[string]string) map[string]string { 27 headers := map[string]string{ 28 "user-agent": version.UserAgent(), 29 } 30 for k, v := range extra { 31 headers[k] = v 32 } 33 34 return headers 35 } 36 37 func TestName(t *testing.T) { 38 name := Prober.Name(Prober{}) 39 require.Equal(t, name, "http") 40 } 41 42 func TestNewProber(t *testing.T) { 43 testcases := map[string]struct { 44 input model.Check 45 expectError bool 46 }{ 47 "default": { 48 input: model.Check{Check: sm.Check{ 49 Id: 3, 50 Target: "www.grafana.com", 51 Settings: sm.CheckSettings{ 52 Http: &sm.HttpSettings{ 53 Headers: []string{ 54 "X-SM-ID: 9880-98", 55 }, 56 }, 57 }, 58 }}, 59 expectError: false, 60 }, 61 "no-settings": { 62 input: model.Check{Check: sm.Check{ 63 Id: 1, 64 Target: "www.grafana.com", 65 Settings: sm.CheckSettings{ 66 Http: nil, 67 }, 68 }}, 69 expectError: true, 70 }, 71 "headers": { 72 input: model.Check{ 73 Check: sm.Check{ 74 Id: 5, 75 Target: "www.grafana.com", 76 Settings: sm.CheckSettings{ 77 Http: &sm.HttpSettings{ 78 Headers: []string{ 79 "uSeR-aGeNt: test-user-agent", 80 "some-header: some-value", 81 "x-SM-iD: 3232-32", 82 }, 83 }, 84 }, 85 }, 86 }, 87 expectError: false, 88 }, 89 } 90 91 for name, testcase := range testcases { 92 ctx := context.Background() 93 logger := testhelper.NewTestLogger() 94 95 t.Run(name, func(t *testing.T) { 96 // origin identifier for http requests is checkId-probeId; testing with checkId twice in the absence of probeId 97 checkId := testcase.input.Id 98 reservedHeaders := http.Header{} 99 reservedHeaders.Add("x-sm-id", fmt.Sprintf("%d-%d", checkId, checkId)) 100 101 actual, err := NewProber(ctx, testcase.input, logger, reservedHeaders, nil) 102 103 if testcase.expectError { 104 require.Error(t, err, "unsupported check") 105 } else { 106 require.NoError(t, err) 107 // Verify that the prober was created with the expected settings 108 require.NotNil(t, actual.settings) 109 require.Equal(t, testcase.input.Settings.Http, actual.settings) 110 require.Equal(t, testcase.input.GlobalTenantID(), actual.tenantID) 111 } 112 }) 113 } 114 } 115 116 func TestProbe(t *testing.T) { 117 srv := testserver.New(testserver.Config{}) 118 defer srv.Close() 119 120 testcases := map[string]struct { 121 srvSettings testserver.Settings 122 settings sm.HttpSettings 123 expectFailure bool 124 expectedRedirects int 125 }{ 126 "status 200 GET": { 127 srvSettings: testserver.Settings{ 128 Method: "GET", 129 Status: 200, 130 }, 131 settings: sm.HttpSettings{ 132 Method: sm.HttpMethod_GET, 133 }, 134 }, 135 "status 200 CONNECT": { 136 srvSettings: testserver.Settings{ 137 Method: "CONNECT", 138 Status: 200, 139 }, 140 settings: sm.HttpSettings{ 141 Method: sm.HttpMethod_CONNECT, 142 }, 143 }, 144 "status 200 DELETE": { 145 srvSettings: testserver.Settings{ 146 Method: "DELETE", 147 Status: 200, 148 }, 149 settings: sm.HttpSettings{ 150 Method: sm.HttpMethod_DELETE, 151 }, 152 }, 153 "status 200 HEAD": { 154 srvSettings: testserver.Settings{ 155 Method: "HEAD", 156 Status: 200, 157 }, 158 settings: sm.HttpSettings{ 159 Method: sm.HttpMethod_HEAD, 160 }, 161 }, 162 "status 200 OPTIONS": { 163 srvSettings: testserver.Settings{ 164 Method: "OPTIONS", 165 Status: 200, 166 }, 167 settings: sm.HttpSettings{ 168 Method: sm.HttpMethod_OPTIONS, 169 }, 170 }, 171 "status 200 POST": { 172 srvSettings: testserver.Settings{ 173 Method: "POST", 174 Status: 200, 175 }, 176 settings: sm.HttpSettings{ 177 Method: sm.HttpMethod_POST, 178 }, 179 }, 180 "status 200 PUT": { 181 srvSettings: testserver.Settings{ 182 Method: "PUT", 183 Status: 200, 184 }, 185 settings: sm.HttpSettings{ 186 Method: sm.HttpMethod_PUT, 187 }, 188 }, 189 "status 200 TRACE": { 190 srvSettings: testserver.Settings{ 191 Method: "TRACE", 192 Status: 200, 193 }, 194 settings: sm.HttpSettings{ 195 Method: sm.HttpMethod_TRACE, 196 }, 197 }, 198 "status 201": { 199 srvSettings: testserver.Settings{ 200 Status: 201, 201 }, 202 settings: sm.HttpSettings{}, 203 }, 204 "status 404": { 205 srvSettings: testserver.Settings{ 206 Status: 404, 207 }, 208 settings: sm.HttpSettings{}, 209 expectFailure: true, 210 }, 211 } 212 213 for name, tc := range testcases { 214 t.Run(name, func(t *testing.T) { 215 target := tc.srvSettings.URL(srv.Listener.Addr().String()) 216 check := model.Check{ 217 Check: sm.Check{ 218 Id: 1, 219 TenantId: 1, 220 Frequency: 10000, 221 Timeout: 1000, 222 Enabled: true, 223 Settings: sm.CheckSettings{ 224 Http: &tc.settings, 225 }, 226 Probes: []int64{1}, 227 Target: target, 228 Job: "test", 229 BasicMetricsOnly: true, 230 }, 231 } 232 233 t.Log(check.Target) 234 235 ctx := context.Background() 236 registry := prometheus.NewPedanticRegistry() 237 zl := zerolog.Logger{} 238 kl := log.NewLogfmtLogger(io.Discard) 239 240 prober, err := NewProber(ctx, check, zl, http.Header{}, nil) 241 require.NoError(t, err) 242 243 success, duration := prober.Probe(ctx, check.Target, registry, kl, "test-execution-id") 244 require.Equal(t, tc.expectFailure, !success) 245 require.Equal(t, float64(0), duration) 246 247 mfs, err := registry.Gather() 248 require.NoError(t, err) 249 require.NotEmpty(t, mfs) 250 251 foundMetrics := 0 252 253 for _, mf := range mfs { 254 require.NotNil(t, mf) 255 256 switch name := mf.GetName(); name { 257 case "probe_http_status_code": 258 require.Equal(t, float64(tc.srvSettings.Status), getGaugeValue(t, mf)) 259 260 foundMetrics++ 261 262 case "probe_http_redirects": 263 require.Equal(t, float64(tc.expectedRedirects), getGaugeValue(t, mf)) 264 265 foundMetrics++ 266 267 case "probe_http_version": 268 require.Equal(t, 1.1, getGaugeValue(t, mf)) 269 270 foundMetrics++ 271 } 272 } 273 274 require.Equal(t, 3, foundMetrics) 275 }) 276 } 277 } 278 279 func getGaugeValue(t *testing.T, mf *dto.MetricFamily) float64 { 280 metric := mf.GetMetric() 281 require.Len(t, metric, 1) 282 g := metric[0].GetGauge() 283 require.NotNil(t, g) 284 285 return g.GetValue() 286 } 287 288 func TestBuildHeaders(t *testing.T) { 289 testcases := map[string]struct { 290 input []string 291 expected map[string]string 292 }{ 293 "nil": { 294 input: nil, 295 expected: expectedHeaders(nil), 296 }, 297 298 "empty": { 299 input: []string{}, 300 expected: expectedHeaders(nil), 301 }, 302 303 "trivial": { 304 input: []string{ 305 "foo: bar", 306 }, 307 expected: expectedHeaders(map[string]string{"foo": "bar"}), 308 }, 309 310 "multiple headers": { 311 input: []string{ 312 "h1: v1", 313 "h2: v2", 314 }, 315 expected: expectedHeaders(map[string]string{"h1": "v1", "h2": "v2"}), 316 }, 317 318 "compact": { 319 input: []string{ 320 "h1:v1", 321 "h2:v2", 322 }, 323 expected: expectedHeaders(map[string]string{"h1": "v1", "h2": "v2"}), 324 }, 325 326 "trim leading whitespace": { 327 input: []string{ 328 "h1: v1", 329 "h2: v2", 330 }, 331 expected: expectedHeaders(map[string]string{"h1": "v1", "h2": "v2"}), 332 }, 333 334 "keep trailing whitespace": { 335 input: []string{ 336 "h1: v1 ", 337 "h2: v2 ", 338 }, 339 expected: expectedHeaders(map[string]string{"h1": "v1 ", "h2": "v2 "}), 340 }, 341 342 "empty values": { 343 input: []string{ 344 "h1: ", 345 "h2:", 346 }, 347 expected: expectedHeaders(map[string]string{"h1": "", "h2": ""}), 348 }, 349 350 "custom user agent": { 351 input: []string{ 352 "User-Agent: test agent", 353 }, 354 expected: map[string]string{ 355 "User-Agent": "test agent", 356 }, 357 }, 358 359 "custom user agent, weird header capitalization": { 360 input: []string{ 361 "uSEr-AGenT: test agent", 362 }, 363 expected: map[string]string{ 364 "uSEr-AGenT": "test agent", 365 }, 366 }, 367 } 368 369 for name, testcase := range testcases { 370 t.Run(name, func(t *testing.T) { 371 actual := buildHttpHeaders(testcase.input) 372 require.Equal(t, testcase.expected, actual) 373 }) 374 } 375 } 376 377 func TestSettingsToModule(t *testing.T) { 378 testcases := map[string]struct { 379 input sm.HttpSettings 380 expected config.Module 381 }{ 382 "default": { 383 input: sm.HttpSettings{}, 384 expected: getDefaultModule().getConfigModule(), 385 }, 386 "partial-settings": { 387 input: sm.HttpSettings{ 388 ValidStatusCodes: []int32{200, 201}, 389 Method: 5, 390 Body: "This is a body", 391 }, 392 expected: getDefaultModule(). 393 addHttpValidStatusCodes(200). 394 addHttpValidStatusCodes(201). 395 setHttpMethod("POST"). 396 setHttpBody("This is a body"). 397 getConfigModule(), 398 }, 399 } 400 401 for name, testcase := range testcases { 402 t.Run(name, func(t *testing.T) { 403 actual, err := buildStaticConfig(&testcase.input) 404 require.NoError(t, err) 405 406 // Note: buildStaticConfig doesn't include HTTP client config 407 // so we need to remove that from expected results for this test 408 expected := testcase.expected 409 expected.HTTP.HTTPClientConfig = httpConfig.HTTPClientConfig{} 410 411 require.Equal(t, &expected, &actual) 412 }) 413 } 414 } 415 416 func TestAddCacheBustParam(t *testing.T) { 417 target := "www.grafana.com" 418 paramName := "test" 419 salt := "12345" 420 421 newUrl := addCacheBustParam(target, paramName, salt) 422 require.NotEqual(t, target, newUrl) 423 424 // Parse query params and make sure "test" is present 425 newUrlQuery, err := url.Parse(newUrl) 426 require.Nil(t, err) 427 queryString, err := url.ParseQuery(newUrlQuery.RawQuery) 428 require.Nil(t, err) 429 430 hash := queryString.Get("test") 431 require.NotNil(t, hash) 432 433 // Make sure another call with same params generates a different hash 434 anotherUrl := addCacheBustParam(target, paramName, salt) 435 require.NotEqual(t, newUrl, anotherUrl) 436 } 437 438 type testModule config.Module 439 440 func getDefaultModule() *testModule { 441 testModule := testModule(config.Module{ 442 Prober: "http", 443 Timeout: 0, 444 HTTP: config.HTTPProbe{ 445 ValidStatusCodes: []int{}, 446 ValidHTTPVersions: []string{}, 447 IPProtocol: "ip6", 448 IPProtocolFallback: true, 449 Method: "GET", 450 Headers: map[string]string{ 451 "user-agent": version.UserAgent(), 452 }, 453 FailIfBodyMatchesRegexp: []config.Regexp{}, 454 FailIfBodyNotMatchesRegexp: []config.Regexp{}, 455 FailIfHeaderMatchesRegexp: []config.HeaderMatch{}, 456 FailIfHeaderNotMatchesRegexp: []config.HeaderMatch{}, 457 HTTPClientConfig: httpConfig.HTTPClientConfig{ 458 FollowRedirects: true, 459 EnableHTTP2: true, 460 }, 461 }, 462 }) 463 464 return &testModule 465 } 466 467 func (m *testModule) getConfigModule() config.Module { 468 return config.Module(*m) 469 } 470 471 func (m *testModule) addHttpValidStatusCodes(code int) *testModule { 472 m.HTTP.ValidStatusCodes = append(m.HTTP.ValidStatusCodes, code) 473 return m 474 } 475 476 func (m *testModule) setHttpMethod(method string) *testModule { 477 m.HTTP.Method = method 478 return m 479 } 480 481 func (m *testModule) setHttpBody(body string) *testModule { 482 m.HTTP.Body = body 483 return m 484 } 485 486 func TestNewProberWithSecretStore(t *testing.T) { 487 ctx := context.Background() 488 logger := testhelper.NewTestLogger() 489 490 check := model.Check{Check: sm.Check{ 491 Id: 1, 492 Target: "www.grafana.com", 493 Settings: sm.CheckSettings{ 494 Http: &sm.HttpSettings{}, 495 }, 496 }} 497 498 // Test with nil secret store (should work) 499 _, err := NewProber(ctx, check, logger, http.Header{}, nil) 500 require.NoError(t, err) 501 502 // This test verifies that the secretStore parameter is properly 503 // accepted and passed through the call chain without causing errors 504 } 505 506 func TestResolveSecretValue(t *testing.T) { 507 ctx := context.Background() 508 logger := testhelper.NewTestLogger() 509 tenantID := model.GlobalID(123) 510 511 testcases := map[string]struct { 512 input string 513 mockSecretFunc func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) 514 expectedOutput string 515 expectError bool 516 }{ 517 "empty value": { 518 input: "", 519 expectedOutput: "", 520 expectError: false, 521 }, 522 "secret interpolation with valid secret": { 523 input: "${secrets.my-secret-key}", 524 mockSecretFunc: func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { 525 if secretKey == "my-secret-key" { 526 return "secret-value-from-gsm", nil 527 } 528 529 return "", fmt.Errorf("secret not found") 530 }, 531 expectedOutput: "secret-value-from-gsm", 532 expectError: false, 533 }, 534 "secret interpolation with secret lookup error": { 535 input: "${secrets.non-existent-key}", 536 mockSecretFunc: func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { 537 return "", fmt.Errorf("secret not found") 538 }, 539 expectedOutput: "", 540 expectError: true, 541 }, 542 "secret interpolation with empty secret name": { 543 input: "${secrets.}", 544 expectedOutput: "", 545 expectError: true, 546 }, 547 "plaintext value (no interpolation)": { 548 input: "my-plain-password", 549 expectedOutput: "my-plain-password", 550 expectError: false, 551 }, 552 "mixed interpolation and plaintext": { 553 input: "Bearer ${secrets.my-token}", 554 mockSecretFunc: func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { 555 if secretKey == "my-token" { 556 return "actual-token-value", nil 557 } 558 559 return "", fmt.Errorf("secret not found") 560 }, 561 expectedOutput: "Bearer actual-token-value", 562 expectError: false, 563 }, 564 "multiple secrets in one string": { 565 input: "${secrets.username}:${secrets.password}", 566 mockSecretFunc: func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { 567 switch secretKey { 568 case "username": 569 return "admin", nil 570 case "password": 571 return "secret123", nil 572 default: 573 return "", fmt.Errorf("secret not found") 574 } 575 }, 576 expectedOutput: "admin:secret123", 577 expectError: false, 578 }, 579 } 580 581 for name, tc := range testcases { 582 t.Run(name, func(t *testing.T) { 583 // Create mock secret store 584 var mockSecretStore *testhelper.MockSecretProvider 585 if tc.mockSecretFunc != nil { 586 mockSecretStore = testhelper.NewMockSecretProviderWithFunc(tc.mockSecretFunc) 587 } 588 589 // Use mock secret store directly 590 secretStore := mockSecretStore 591 592 actual, err := resolveSecretValue(ctx, tc.input, secretStore, tenantID, logger, true) 593 594 if tc.expectError { 595 require.Error(t, err) 596 } else { 597 require.NoError(t, err) 598 require.Equal(t, tc.expectedOutput, actual) 599 } 600 }) 601 } 602 } 603 604 func TestBuildPrometheusHTTPClientConfig_WithSecrets(t *testing.T) { 605 ctx, logger, tenantID := testhelper.CommonTestSetup() 606 607 // Mock secret store that returns known values 608 mockSecretStore := testhelper.NewMockSecretProvider(map[string]string{ 609 "bearer-token-key": "bearer-secret-value", 610 "password-key": "password-secret-value", 611 }) 612 613 testcases := map[string]struct { 614 settings sm.HttpSettings 615 expectedBearer string 616 expectedPasswd string 617 }{ 618 "secret interpolation": { 619 settings: sm.HttpSettings{ 620 BearerToken: "${secrets.bearer-token-key}", 621 SecretManagerEnabled: true, 622 BasicAuth: &sm.BasicAuth{ 623 Username: "testuser", 624 Password: "${secrets.password-key}", 625 }, 626 }, 627 expectedBearer: "bearer-secret-value", 628 expectedPasswd: "password-secret-value", 629 }, 630 "plaintext values": { 631 settings: sm.HttpSettings{ 632 BearerToken: "plain-bearer-token", 633 SecretManagerEnabled: true, 634 BasicAuth: &sm.BasicAuth{ 635 Username: "testuser", 636 Password: "plain-password", 637 }, 638 }, 639 expectedBearer: "plain-bearer-token", 640 expectedPasswd: "plain-password", 641 }, 642 "mixed interpolation and plaintext": { 643 settings: sm.HttpSettings{ 644 BearerToken: "${secrets.bearer-token-key}", 645 SecretManagerEnabled: true, 646 BasicAuth: &sm.BasicAuth{ 647 Username: "testuser", 648 Password: "plain-password", 649 }, 650 }, 651 expectedBearer: "bearer-secret-value", 652 expectedPasswd: "plain-password", 653 }, 654 "complex interpolation": { 655 settings: sm.HttpSettings{ 656 BearerToken: "Bearer ${secrets.bearer-token-key}", 657 SecretManagerEnabled: true, 658 BasicAuth: &sm.BasicAuth{ 659 Username: "testuser", 660 Password: "${secrets.password-key}", 661 }, 662 }, 663 expectedBearer: "Bearer bearer-secret-value", 664 expectedPasswd: "password-secret-value", 665 }, 666 } 667 668 for name, tc := range testcases { 669 t.Run(name, func(t *testing.T) { 670 // Use mock secret store directly 671 secretStore := mockSecretStore 672 673 cfg, err := buildPrometheusHTTPClientConfig(ctx, &tc.settings, logger, secretStore, tenantID) 674 require.NoError(t, err) 675 676 require.Equal(t, tc.expectedBearer, string(cfg.BearerToken)) 677 678 if tc.settings.BasicAuth != nil { 679 require.NotNil(t, cfg.BasicAuth) 680 require.Equal(t, tc.expectedPasswd, string(cfg.BasicAuth.Password)) 681 require.Equal(t, tc.settings.BasicAuth.Username, cfg.BasicAuth.Username) 682 } 683 }) 684 } 685 } 686 687 func TestResolveSecretValueWithCapabilityFromSecretStore(t *testing.T) { 688 ctx, logger, tenantID := testhelper.CommonTestSetup() 689 690 // Mock secret store that should never be called when capability is disabled 691 mockSecretStore := testhelper.NewMockSecretProvider(map[string]string{ 692 "my-bearer-token": "resolved-bearer-token", 693 }) 694 695 t.Run("with EnableProtocolSecrets=true", func(t *testing.T) { 696 // Create secret store with capability enabled 697 secretStore := mockSecretStore 698 699 testcases := map[string]struct { 700 input string 701 expectedOutput string 702 expectError bool 703 }{ 704 "secret interpolation resolved when capability enabled": { 705 input: "${secrets.my-bearer-token}", 706 expectedOutput: "resolved-bearer-token", 707 expectError: false, 708 }, 709 "plaintext value unchanged when capability enabled": { 710 input: "my-plain-password", 711 expectedOutput: "my-plain-password", 712 expectError: false, 713 }, 714 "mixed interpolation and plaintext when capability enabled": { 715 input: "Bearer ${secrets.my-bearer-token}", 716 expectedOutput: "Bearer resolved-bearer-token", 717 expectError: false, 718 }, 719 } 720 721 for name, tc := range testcases { 722 t.Run(name, func(t *testing.T) { 723 actual, err := resolveSecretValue(ctx, tc.input, secretStore, tenantID, logger, true) 724 725 if tc.expectError { 726 require.Error(t, err) 727 } else { 728 require.NoError(t, err) 729 require.Equal(t, tc.expectedOutput, actual) 730 } 731 }) 732 } 733 }) 734 735 t.Run("with EnableProtocolSecrets=false", func(t *testing.T) { 736 // Mock that should never be called 737 failingMockStore := testhelper.NewMockSecretProviderWithFunc(func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { 738 t.Fatal("GetSecretValue should not be called when EnableProtocolSecrets is false") 739 return "", nil 740 }) 741 742 // Create secret store with capability disabled 743 secretStore := failingMockStore 744 745 testcases := map[string]struct { 746 input string 747 expectedOutput string 748 }{ 749 "secret interpolation preserved when capability disabled": { 750 input: "${secrets.my-bearer-token}", 751 expectedOutput: "${secrets.my-bearer-token}", 752 }, 753 "plaintext value unchanged when capability disabled": { 754 input: "my-plain-password", 755 expectedOutput: "my-plain-password", 756 }, 757 "mixed interpolation preserved when capability disabled": { 758 input: "Bearer ${secrets.my-bearer-token}", 759 expectedOutput: "Bearer ${secrets.my-bearer-token}", 760 }, 761 } 762 763 for name, tc := range testcases { 764 t.Run(name, func(t *testing.T) { 765 actual, err := resolveSecretValue(ctx, tc.input, secretStore, tenantID, logger, false) 766 767 require.NoError(t, err) 768 require.Equal(t, tc.expectedOutput, actual) 769 }) 770 } 771 }) 772 773 t.Run("with nil capabilities (defaults to false)", func(t *testing.T) { 774 // Mock that should never be called 775 failingMockStore := testhelper.NewMockSecretProviderWithFunc(func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { 776 t.Fatal("GetSecretValue should not be called when capabilities are nil") 777 return "", nil 778 }) 779 780 // Create secret store with nil capabilities 781 secretStore := failingMockStore 782 783 actual, err := resolveSecretValue(ctx, "gsm:my-bearer-token", secretStore, tenantID, logger, false) 784 require.NoError(t, err) 785 require.Equal(t, "gsm:my-bearer-token", actual) 786 }) 787 788 t.Run("with regular SecretProvider (no capability awareness)", func(t *testing.T) { 789 // When using a regular SecretProvider, should default to false (no resolution) 790 failingMockStore := testhelper.NewMockSecretProviderWithFunc(func(ctx context.Context, tenantID model.GlobalID, secretKey string) (string, error) { 791 t.Fatal("GetSecretValue should not be called when no capability interface is implemented") 792 return "", nil 793 }) 794 795 actual, err := resolveSecretValue(ctx, "gsm:my-bearer-token", failingMockStore, tenantID, logger, false) 796 require.NoError(t, err) 797 require.Equal(t, "gsm:my-bearer-token", actual) 798 }) 799 } 800 801 func TestUpdatableSecretProvider(t *testing.T) { 802 ctx, logger, tenantID := testhelper.CommonTestSetup() 803 804 // Mock secret store 805 mockSecretStore := testhelper.NewMockSecretProvider(map[string]string{ 806 "my-bearer-token": "resolved-bearer-token", 807 }) 808 809 // Create updatable secret store 810 updatableStore := mockSecretStore 811 812 t.Run("defaults to disabled", func(t *testing.T) { 813 require.False(t, updatableStore.IsProtocolSecretsEnabled()) 814 815 // Should not resolve secrets when disabled 816 actual, err := resolveSecretValue(ctx, "${secrets.my-bearer-token}", updatableStore, tenantID, logger, false) 817 require.NoError(t, err) 818 require.Equal(t, "${secrets.my-bearer-token}", actual) 819 }) 820 821 t.Run("can be updated to enabled", func(t *testing.T) { 822 // Update capabilities to enable protocol secrets 823 capabilities := &sm.Probe_Capabilities{ 824 EnableProtocolSecrets: true, 825 } 826 updatableStore.UpdateCapabilities(capabilities) 827 828 require.True(t, updatableStore.IsProtocolSecretsEnabled()) 829 830 // Should now resolve secrets 831 actual, err := resolveSecretValue(ctx, "${secrets.my-bearer-token}", updatableStore, tenantID, logger, true) 832 require.NoError(t, err) 833 require.Equal(t, "resolved-bearer-token", actual) 834 }) 835 836 t.Run("can be updated to disabled", func(t *testing.T) { 837 // Update capabilities to disable protocol secrets 838 capabilities := &sm.Probe_Capabilities{ 839 EnableProtocolSecrets: false, 840 } 841 updatableStore.UpdateCapabilities(capabilities) 842 843 require.False(t, updatableStore.IsProtocolSecretsEnabled()) 844 845 // Should not resolve secrets when disabled 846 actual, err := resolveSecretValue(ctx, "${secrets.my-bearer-token}", updatableStore, tenantID, logger, false) 847 require.NoError(t, err) 848 require.Equal(t, "${secrets.my-bearer-token}", actual) 849 }) 850 851 t.Run("handles nil capabilities", func(t *testing.T) { 852 // Update with nil capabilities (should default to disabled) 853 updatableStore.UpdateCapabilities(nil) 854 855 require.False(t, updatableStore.IsProtocolSecretsEnabled()) 856 857 // Should not resolve secrets when disabled 858 actual, err := resolveSecretValue(ctx, "${secrets.my-bearer-token}", updatableStore, tenantID, logger, false) 859 require.NoError(t, err) 860 require.Equal(t, "${secrets.my-bearer-token}", actual) 861 }) 862 } 863 864 func TestResolveSecretValueWithSecretManagerEnabled(t *testing.T) { 865 ctx, logger, tenantID := testhelper.CommonTestSetup() 866 867 // Mock secret store 868 mockSecretStore := testhelper.NewMockSecretProvider(map[string]string{ 869 "my-bearer-token": "resolved-bearer-token", 870 }) 871 872 testcases := map[string]struct { 873 input string 874 secretManagerEnabled bool 875 expectedOutput string 876 expectError bool 877 }{ 878 "secret manager enabled with secret interpolation": { 879 input: "${secrets.my-bearer-token}", 880 secretManagerEnabled: true, 881 expectedOutput: "resolved-bearer-token", 882 expectError: false, 883 }, 884 "secret manager enabled with plaintext value": { 885 input: "my-plain-password", 886 secretManagerEnabled: true, 887 expectedOutput: "my-plain-password", 888 expectError: false, 889 }, 890 "secret manager enabled with mixed interpolation": { 891 input: "Bearer ${secrets.my-bearer-token}", 892 secretManagerEnabled: true, 893 expectedOutput: "Bearer resolved-bearer-token", 894 expectError: false, 895 }, 896 "secret manager disabled with secret interpolation": { 897 input: "${secrets.my-bearer-token}", 898 secretManagerEnabled: false, 899 expectedOutput: "${secrets.my-bearer-token}", 900 expectError: false, 901 }, 902 "secret manager disabled with plaintext value": { 903 input: "my-plain-password", 904 secretManagerEnabled: false, 905 expectedOutput: "my-plain-password", 906 expectError: false, 907 }, 908 "secret manager disabled with mixed interpolation": { 909 input: "Bearer ${secrets.my-bearer-token}", 910 secretManagerEnabled: false, 911 expectedOutput: "Bearer ${secrets.my-bearer-token}", 912 expectError: false, 913 }, 914 } 915 916 for name, tc := range testcases { 917 t.Run(name, func(t *testing.T) { 918 actual, err := resolveSecretValue(ctx, tc.input, mockSecretStore, tenantID, logger, tc.secretManagerEnabled) 919 920 if tc.expectError { 921 require.Error(t, err) 922 } else { 923 require.NoError(t, err) 924 require.Equal(t, tc.expectedOutput, actual) 925 } 926 }) 927 } 928 } 929 930 func TestBuildTLSConfig_WithSecrets(t *testing.T) { 931 ctx, logger, tenantID := testhelper.CommonTestSetup() 932 933 // Mock secret store that returns known values 934 mockSecretStore := testhelper.NewMockSecretProvider(map[string]string{ 935 "ca-cert-key": "-----BEGIN CERTIFICATE-----\nCA_CERT_CONTENT\n-----END CERTIFICATE-----", 936 "client-cert-key": "-----BEGIN CERTIFICATE-----\nCLIENT_CERT_CONTENT\n-----END CERTIFICATE-----", 937 "client-key-key": "-----BEGIN PRIVATE KEY-----\nCLIENT_KEY_CONTENT\n-----END PRIVATE KEY-----", 938 }) 939 940 testcases := map[string]struct { 941 tlsConfig *sm.TLSConfig 942 secretManagerEnabled bool 943 expectError bool 944 }{ 945 "TLS with secret interpolation": { 946 tlsConfig: &sm.TLSConfig{ 947 InsecureSkipVerify: false, 948 ServerName: "example.com", 949 CACert: []byte("${secrets.ca-cert-key}"), 950 ClientCert: []byte("${secrets.client-cert-key}"), 951 ClientKey: []byte("${secrets.client-key-key}"), 952 }, 953 secretManagerEnabled: true, 954 expectError: false, 955 }, 956 "TLS with plain values": { 957 tlsConfig: &sm.TLSConfig{ 958 InsecureSkipVerify: true, 959 ServerName: "test.com", 960 CACert: []byte("-----BEGIN CERTIFICATE-----\nPLAIN_CA_CERT\n-----END CERTIFICATE-----"), 961 ClientCert: []byte("-----BEGIN CERTIFICATE-----\nPLAIN_CLIENT_CERT\n-----END CERTIFICATE-----"), 962 ClientKey: []byte("-----BEGIN PRIVATE KEY-----\nPLAIN_CLIENT_KEY\n-----END PRIVATE KEY-----"), 963 }, 964 secretManagerEnabled: true, 965 expectError: false, 966 }, 967 "TLS with secret manager disabled": { 968 tlsConfig: &sm.TLSConfig{ 969 InsecureSkipVerify: false, 970 ServerName: "example.com", 971 CACert: []byte("${secrets.ca-cert-key}"), 972 ClientCert: []byte("${secrets.client-cert-key}"), 973 ClientKey: []byte("${secrets.client-key-key}"), 974 }, 975 secretManagerEnabled: false, 976 expectError: false, 977 }, 978 "TLS with mixed secret and plain values": { 979 tlsConfig: &sm.TLSConfig{ 980 InsecureSkipVerify: false, 981 ServerName: "mixed.com", 982 CACert: []byte("${secrets.ca-cert-key}"), 983 ClientCert: []byte("-----BEGIN CERTIFICATE-----\nPLAIN_CLIENT_CERT\n-----END CERTIFICATE-----"), 984 ClientKey: []byte("${secrets.client-key-key}"), 985 }, 986 secretManagerEnabled: true, 987 expectError: false, 988 }, 989 "TLS with only some fields": { 990 tlsConfig: &sm.TLSConfig{ 991 InsecureSkipVerify: false, 992 ServerName: "partial.com", 993 CACert: []byte("${secrets.ca-cert-key}"), 994 // ClientCert and ClientKey are empty 995 }, 996 secretManagerEnabled: true, 997 expectError: false, 998 }, 999 } 1000 1001 for name, tc := range testcases { 1002 t.Run(name, func(t *testing.T) { 1003 cfg, err := buildTLSConfig(ctx, tc.tlsConfig, mockSecretStore, tenantID, logger, tc.secretManagerEnabled) 1004 1005 if tc.expectError { 1006 require.Error(t, err) 1007 return 1008 } 1009 1010 require.NoError(t, err) 1011 require.Equal(t, tc.tlsConfig.InsecureSkipVerify, cfg.InsecureSkipVerify) 1012 require.Equal(t, tc.tlsConfig.ServerName, cfg.ServerName) 1013 1014 // Verify that files were created for non-empty fields 1015 if len(tc.tlsConfig.CACert) > 0 { 1016 require.NotEmpty(t, cfg.CAFile) 1017 } 1018 1019 if len(tc.tlsConfig.ClientCert) > 0 { 1020 require.NotEmpty(t, cfg.CertFile) 1021 } 1022 1023 if len(tc.tlsConfig.ClientKey) > 0 { 1024 require.NotEmpty(t, cfg.KeyFile) 1025 } 1026 }) 1027 } 1028 }