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  }