Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion core/pkg/sync/http/http_sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,14 +286,19 @@ 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(
zap.String("component", "sync"),
zap.String("sync", "http"),
),
authHeader: config.AuthHeader,
headers: config.Headers,
headers: canonicalHeaders,
interval: interval,
poller: poller,
oauthCredential: oauthCredential,
Expand Down
167 changes: 57 additions & 110 deletions core/pkg/sync/http/http_sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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) {
Expand Down
36 changes: 34 additions & 2 deletions flagd/cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
}
Expand Down
63 changes: 63 additions & 0 deletions flagd/cmd/start_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading