diff --git a/core/pkg/sync/http/http_sync.go b/core/pkg/sync/http/http_sync.go index 5f4e734bd..e68f1f6d8 100644 --- a/core/pkg/sync/http/http_sync.go +++ b/core/pkg/sync/http/http_sync.go @@ -286,6 +286,11 @@ func NewHTTP(config sync.SourceConfig, logger *logger.Logger, poller polling.Pol } } + canonicalHeaders := make(map[string]string, len(config.Headers)) + for k, v := range config.Headers { + canonicalHeaders[http.CanonicalHeaderKey(k)] = v + } + return &Sync{ uri: config.URI, logger: logger.WithFields( @@ -293,7 +298,7 @@ func NewHTTP(config sync.SourceConfig, logger *logger.Logger, poller polling.Pol zap.String("sync", "http"), ), authHeader: config.AuthHeader, - headers: config.Headers, + headers: canonicalHeaders, interval: interval, poller: poller, oauthCredential: oauthCredential, diff --git a/core/pkg/sync/http/http_sync_test.go b/core/pkg/sync/http/http_sync_test.go index ced1b9c0b..0728f9d8f 100644 --- a/core/pkg/sync/http/http_sync_test.go +++ b/core/pkg/sync/http/http_sync_test.go @@ -320,41 +320,6 @@ func TestHTTPSync_Fetch(t *testing.T) { } } -func TestHTTPSync_CustomHeaders(t *testing.T) { - ctrl := gomock.NewController(t) - mockClient := syncmock.NewMockClient(ctrl) - - customHeaders := map[string]string{ - "X-Interop-Gateway-Host": "myhost", - "X-Tenant-ID": "tenant1", - } - - mockClient.EXPECT().Do(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) { - for key, expectedVal := range customHeaders { - actual := req.Header.Get(key) - if actual != expectedVal { - t.Errorf("expected header %s to be '%s', got '%s'", key, expectedVal, actual) - } - } - return &http.Response{ - Header: buildHeaders(map[string][]string{"Content-Type": {"application/json"}}), - Body: io.NopCloser(strings.NewReader("test response")), - StatusCode: http.StatusOK, - }, nil - }) - - httpSync := Sync{ - uri: "http://localhost", - client: mockClient, - headers: customHeaders, - logger: logger.NewLogger(nil, false), - } - - fetched, err := httpSync.Fetch(context.Background()) - require.NoError(t, err) - require.Equal(t, "test response", fetched) -} - func TestNewHTTP_PassesHeaders(t *testing.T) { headers := map[string]string{"X-Custom": "value"} config := sync.SourceConfig{ @@ -366,88 +331,70 @@ func TestNewHTTP_PassesHeaders(t *testing.T) { require.Equal(t, headers, httpSync.headers) } -func TestHTTPSync_HostHeader(t *testing.T) { - ctrl := gomock.NewController(t) - mockClient := syncmock.NewMockClient(ctrl) - - mockClient.EXPECT().Do(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) { - require.Equal(t, "custom-host.example.com", req.Host) - require.Empty(t, req.Header.Get("Host")) - return &http.Response{ - Header: buildHeaders(map[string][]string{"Content-Type": {"application/json"}}), - Body: io.NopCloser(strings.NewReader("{}")), - StatusCode: http.StatusOK, - }, nil - }) - - httpSync := Sync{ - uri: "http://localhost", - client: mockClient, - headers: map[string]string{"Host": "custom-host.example.com"}, - logger: logger.NewLogger(nil, false), - } - - _, err := httpSync.Fetch(context.Background()) - require.NoError(t, err) -} - -func TestHTTPSync_HeadersOverwriteAuth(t *testing.T) { - ctrl := gomock.NewController(t) - mockClient := syncmock.NewMockClient(ctrl) - - mockClient.EXPECT().Do(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) { - // Custom headers are applied after authHeader, so they take precedence - require.Equal(t, "Bearer custom-token", req.Header.Get("Authorization")) - require.Equal(t, "custom-value", req.Header.Get("X-Custom")) - return &http.Response{ - Header: buildHeaders(map[string][]string{"Content-Type": {"application/json"}}), - Body: io.NopCloser(strings.NewReader("{}")), - StatusCode: http.StatusOK, - }, nil - }) - - httpSync := Sync{ - uri: "http://localhost", - client: mockClient, - authHeader: "Bearer original-token", - headers: map[string]string{ - "Authorization": "Bearer custom-token", - "X-Custom": "custom-value", +func TestHTTPSync_CustomHeaders(t *testing.T) { + tests := map[string]struct { + authHeader string + headers map[string]string + assertRequest func(t *testing.T, req *http.Request) + }{ + "injects custom headers": { + headers: map[string]string{"X-Interop-Gateway-Host": "myhost", "X-Tenant-ID": "tenant1"}, + assertRequest: func(t *testing.T, req *http.Request) { + require.Equal(t, "myhost", req.Header.Get("X-Interop-Gateway-Host")) + require.Equal(t, "tenant1", req.Header.Get("X-Tenant-ID")) + }, + }, + "sets Host header via req.Host": { + headers: map[string]string{"Host": "custom-host.example.com"}, + assertRequest: func(t *testing.T, req *http.Request) { + require.Equal(t, "custom-host.example.com", req.Host) + require.Empty(t, req.Header.Get("Host")) + }, + }, + "custom headers override authHeader": { + authHeader: "Bearer original-token", + headers: map[string]string{"Authorization": "Bearer custom-token", "X-Custom": "custom-value"}, + assertRequest: func(t *testing.T, req *http.Request) { + require.Equal(t, "Bearer custom-token", req.Header.Get("Authorization")) + require.Equal(t, "custom-value", req.Header.Get("X-Custom")) + }, + }, + "authHeader preserved when not overridden": { + authHeader: "Bearer token123", + headers: map[string]string{"X-Custom": "custom-value"}, + assertRequest: func(t *testing.T, req *http.Request) { + require.Equal(t, "Bearer token123", req.Header.Get("Authorization")) + require.Equal(t, "custom-value", req.Header.Get("X-Custom")) + }, }, - logger: logger.NewLogger(nil, false), } - _, err := httpSync.Fetch(context.Background()) - require.NoError(t, err) -} + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + mockClient := syncmock.NewMockClient(ctrl) -func TestHTTPSync_AuthHeaderWithoutOverride(t *testing.T) { - ctrl := gomock.NewController(t) - mockClient := syncmock.NewMockClient(ctrl) + mockClient.EXPECT().Do(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) { + tt.assertRequest(t, req) + return &http.Response{ + Header: buildHeaders(map[string][]string{"Content-Type": {"application/json"}}), + Body: io.NopCloser(strings.NewReader("{}")), + StatusCode: http.StatusOK, + }, nil + }) - mockClient.EXPECT().Do(gomock.Any()).DoAndReturn(func(req *http.Request) (*http.Response, error) { - // When custom headers don't include Authorization, authHeader is preserved - require.Equal(t, "Bearer token123", req.Header.Get("Authorization")) - require.Equal(t, "custom-value", req.Header.Get("X-Custom")) - return &http.Response{ - Header: buildHeaders(map[string][]string{"Content-Type": {"application/json"}}), - Body: io.NopCloser(strings.NewReader("{}")), - StatusCode: http.StatusOK, - }, nil - }) + httpSync := Sync{ + uri: "http://localhost", + client: mockClient, + authHeader: tt.authHeader, + headers: tt.headers, + logger: logger.NewLogger(nil, false), + } - httpSync := Sync{ - uri: "http://localhost", - client: mockClient, - authHeader: "Bearer token123", - headers: map[string]string{ - "X-Custom": "custom-value", - }, - logger: logger.NewLogger(nil, false), + _, err := httpSync.Fetch(context.Background()) + require.NoError(t, err) + }) } - - _, err := httpSync.Fetch(context.Background()) - require.NoError(t, err) } func TestHTTPSync_Resync(t *testing.T) { diff --git a/flagd/cmd/start.go b/flagd/cmd/start.go index 1f61b5ff6..879fe19ef 100644 --- a/flagd/cmd/start.go +++ b/flagd/cmd/start.go @@ -131,6 +131,31 @@ func bindFlags(flags *pflag.FlagSet) { _ = viper.BindPFlag(syncHeadersFlagName, flags.Lookup(syncHeadersFlagName)) } +// parseSyncHeaders returns the global sync headers map. Viper cannot parse +// StringToString flags from environment variables, so we fall back to manual +// parsing of the raw env value when the typed accessor returns empty. +func parseSyncHeaders() map[string]string { + if m := viper.GetStringMapString(syncHeadersFlagName); len(m) > 0 { + return m + } + return parseHeaderString(viper.GetString(syncHeadersFlagName)) +} + +// parseHeaderString parses a comma-separated "key=value" string into a map. +func parseHeaderString(raw string) map[string]string { + if raw == "" { + return map[string]string{} + } + result := make(map[string]string) + for _, pair := range strings.Split(raw, ",") { + k, v, ok := strings.Cut(strings.TrimSpace(pair), "=") + if ok && k != "" { + result[k] = v + } + } + return result +} + // startCmd represents the start command var startCmd = &cobra.Command{ Use: "start", @@ -173,13 +198,20 @@ var startCmd = &cobra.Command{ } syncProviders = append(syncProviders, syncProvidersFromConfig...) - globalHeaders := viper.GetStringMapString(syncHeadersFlagName) + globalHeaders := parseSyncHeaders() for i := range syncProviders { if syncProviders[i].Headers == nil { syncProviders[i].Headers = make(map[string]string) } for k, v := range globalHeaders { - if _, exists := syncProviders[i].Headers[k]; !exists { + headerExists := false + for existingKey := range syncProviders[i].Headers { + if strings.EqualFold(existingKey, k) { + headerExists = true + break + } + } + if !headerExists { syncProviders[i].Headers[k] = v } } diff --git a/flagd/cmd/start_test.go b/flagd/cmd/start_test.go new file mode 100644 index 000000000..5869bcea5 --- /dev/null +++ b/flagd/cmd/start_test.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_parseHeaderString(t *testing.T) { + tests := map[string]struct { + input string + expected map[string]string + }{ + "empty string": { + input: "", + expected: map[string]string{}, + }, + "single header": { + input: "X-Proxy-Gateway-Host=b-flags-api.service", + expected: map[string]string{"X-Proxy-Gateway-Host": "b-flags-api.service"}, + }, + "multiple headers": { + input: "X-Proxy-Gateway-Host=myhost.service,X-Tenant-ID=tenant1", + expected: map[string]string{ + "X-Proxy-Gateway-Host": "myhost.service", + "X-Tenant-ID": "tenant1", + }, + }, + "value with equals sign": { + input: "Authorization=Bearer=token123", + expected: map[string]string{"Authorization": "Bearer=token123"}, + }, + "whitespace around pairs": { + input: "X-Custom=value , X-Other=val2", + expected: map[string]string{ + "X-Custom": "value", + "X-Other": "val2", + }, + }, + "empty value": { + input: "X-Empty=", + expected: map[string]string{"X-Empty": ""}, + }, + "missing equals is skipped": { + input: "invalidentry", + expected: map[string]string{}, + }, + "mix of valid and invalid": { + input: "X-Valid=value,invalid,X-Also-Valid=ok", + expected: map[string]string{ + "X-Valid": "value", + "X-Also-Valid": "ok", + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + result := parseHeaderString(tt.input) + require.Equal(t, tt.expected, result) + }) + } +}