diff --git a/router-tests/header_propagation_test.go b/router-tests/header_propagation_test.go index 7fd773d77f..444e358415 100644 --- a/router-tests/header_propagation_test.go +++ b/router-tests/header_propagation_test.go @@ -583,6 +583,221 @@ func TestHeaderPropagation(t *testing.T) { require.Equal(t, `{"data":{"employee":{"id":1,"hobbies":[{},{"name":"Counter Strike"},{},{},{}]}}}`, res.Body) }) }) + + // Tests that verify the append algorithm produces a SINGLE header with + // comma-separated values, not multiple separate headers (issue #2531). + t.Run("global append produces single comma-separated header", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + RouterOptions: global(config.ResponseHeaderRuleAlgorithmAppend, customHeader, ""), + Subgraphs: subgraphsPropagateCustomHeader, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: queryEmployeeWithHobby, + }) + values := res.Response.Header.Values(customHeader) + require.Len(t, values, 1, + "append algorithm should produce a single header with comma-separated values, got %d entries: %v", len(values), values) + require.Equal(t, "employee-value,hobby-value", values[0]) + }) + }) + + t.Run("local append produces single comma-separated header", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + RouterOptions: local(config.ResponseHeaderRuleAlgorithmAppend, customHeader, "", ""), + Subgraphs: subgraphsPropagateCustomHeader, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: queryEmployeeWithHobby, + }) + values := res.Response.Header.Values(customHeader) + require.Len(t, values, 1, + "append algorithm should produce a single header with comma-separated values, got %d entries: %v", len(values), values) + require.Equal(t, "employee-value,hobby-value", values[0]) + }) + }) + + t.Run("repeated header names append produces single comma-separated header", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + RouterOptions: global(config.ResponseHeaderRuleAlgorithmAppend, customHeader, ""), + Subgraphs: subgraphsPropagateRepeatedCustomHeader, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: queryEmployeeWithHobby, + }) + values := res.Response.Header.Values(customHeader) + require.Len(t, values, 1, + "append algorithm should produce a single header with comma-separated values, got %d entries: %v", len(values), values) + require.Equal(t, "employee-value,employee-value-2,hobby-value,hobby-value-2", values[0]) + }) + }) + + t.Run("append with default value produces single header", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + RouterOptions: global(config.ResponseHeaderRuleAlgorithmAppend, customHeader, "default-val"), + Subgraphs: testenv.SubgraphsConfig{ + Employees: testenv.SubgraphConfig{ + Middleware: func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header()[customHeader] = []string{employeeVal} + handler.ServeHTTP(w, r) + }) + }, + }, + // Hobbies does NOT set the header — the default should be used + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: queryEmployeeWithHobby, + }) + values := res.Response.Header.Values(customHeader) + require.Len(t, values, 1, + "append algorithm should produce a single header with comma-separated values, got %d entries: %v", len(values), values) + require.Equal(t, "employee-value,default-val", values[0]) + }) + }) + + t.Run("append with Set-Cookie produces multiple headers", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithHeaderRules(config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Response: []*config.ResponseHeaderRule{ + { + Operation: config.HeaderRuleOperationPropagate, + Named: "Set-Cookie", + Algorithm: config.ResponseHeaderRuleAlgorithmAppend, + }, + }, + }, + }), + }, + Subgraphs: testenv.SubgraphsConfig{ + Employees: testenv.SubgraphConfig{ + Middleware: func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Set-Cookie", "session=abc; Path=/") + handler.ServeHTTP(w, r) + }) + }, + }, + Hobbies: testenv.SubgraphConfig{ + Middleware: func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Set-Cookie", "lang=en; Path=/") + handler.ServeHTTP(w, r) + }) + }, + }, + }, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: queryEmployeeWithHobby, + }) + values := res.Response.Header.Values("Set-Cookie") + // Set-Cookie must NOT be comma-joined (RFC 6265) — each cookie stays as a separate header + require.ElementsMatch(t, []string{"session=abc; Path=/", "lang=en; Path=/"}, values) + }) + }) + + t.Run("append with regex matching produces single comma-separated header", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + RouterOptions: []core.Option{ + core.WithHeaderRules(config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Response: []*config.ResponseHeaderRule{ + { + Operation: config.HeaderRuleOperationPropagate, + Matching: "^X-Custom-Header$", + Algorithm: config.ResponseHeaderRuleAlgorithmAppend, + }, + }, + }, + }), + }, + Subgraphs: subgraphsPropagateCustomHeader, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: queryEmployeeWithHobby, + }) + values := res.Response.Header.Values(customHeader) + require.Len(t, values, 1, + "append algorithm should produce a single header with comma-separated values, got %d entries: %v", len(values), values) + require.Equal(t, "employee-value,hobby-value", values[0]) + }) + }) + }) + + // Tests for default value fallback when a subgraph does not return the header + t.Run("DefaultValue", func(t *testing.T) { + t.Parallel() + + subgraphsOnlyEmployeeSetsHeader := testenv.SubgraphsConfig{ + Employees: testenv.SubgraphConfig{ + Middleware: func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header()[customHeader] = []string{employeeVal} + handler.ServeHTTP(w, r) + }) + }, + }, + // Hobbies does NOT set the header + } + + // When a subgraph does not return the header and a default is configured, + // the default is treated as if the subgraph returned that value. This means + // it counts as a "write" for last_write/first_write semantics. + t.Run("last write with default uses default from non-responding subgraph", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + RouterOptions: global(config.ResponseHeaderRuleAlgorithmLastWrite, customHeader, "default-val"), + Subgraphs: subgraphsOnlyEmployeeSetsHeader, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: queryEmployeeWithHobby, + }) + ch := res.Response.Header.Get(customHeader) + // Hobbies responds last and uses the default value + require.Equal(t, "default-val", ch) + }) + }) + + t.Run("first write with default keeps first value", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + RouterOptions: global(config.ResponseHeaderRuleAlgorithmFirstWrite, customHeader, "default-val"), + Subgraphs: subgraphsOnlyEmployeeSetsHeader, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: queryEmployeeWithHobby, + }) + ch := res.Response.Header.Get(customHeader) + // Employees responds first with its actual value + require.Equal(t, employeeVal, ch) + }) + }) + + t.Run("append with default from both subgraphs produces duplicated default", func(t *testing.T) { + t.Parallel() + testenv.Run(t, &testenv.Config{ + RouterOptions: global(config.ResponseHeaderRuleAlgorithmAppend, customHeader, "default-val"), + // Neither subgraph sets the header — both trigger the default + Subgraphs: testenv.SubgraphsConfig{}, + }, func(t *testing.T, xEnv *testenv.Environment) { + res := xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{ + Query: queryEmployeeWithHobby, + }) + values := res.Response.Header.Values(customHeader) + require.Len(t, values, 1) + // Each subgraph applies the default, so it appears twice + require.Equal(t, "default-val,default-val", values[0]) + }) + }) }) t.Run("Cache Control Propagation", func(t *testing.T) { diff --git a/router/core/header_rule_engine.go b/router/core/header_rule_engine.go index 2ecca783e0..3d825fdb21 100644 --- a/router/core/header_rule_engine.go +++ b/router/core/header_rule_engine.go @@ -494,7 +494,15 @@ func (h *HeaderPropagation) applyResponseRuleKeyValue(res *http.Response, propag propagation.m.Unlock() case config.ResponseHeaderRuleAlgorithmAppend: propagation.m.Lock() - propagation.header[key] = append(propagation.header[key], values...) + // Set-Cookie cannot be comma-combined per RFC 6265 — commas appear + // inside cookie values (e.g. Expires dates), so each cookie must + // remain a separate header line. + if key == "Set-Cookie" { + propagation.header[key] = append(propagation.header[key], values...) + } else { + all := append(propagation.header[key], values...) + propagation.header.Set(key, strings.Join(all, ",")) + } propagation.m.Unlock() case config.ResponseHeaderRuleAlgorithmMostRestrictiveCacheControl: h.applyResponseRuleMostRestrictiveCacheControl(res, propagation, rule) diff --git a/router/core/header_rule_engine_test.go b/router/core/header_rule_engine_test.go new file mode 100644 index 0000000000..6a8f2711f5 --- /dev/null +++ b/router/core/header_rule_engine_test.go @@ -0,0 +1,386 @@ +package core + +import ( + "net/http" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + cachedirective "github.com/pquerna/cachecontrol/cacheobject" + "github.com/wundergraph/cosmo/router/pkg/config" +) + +func TestCreateMostRestrictivePolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policies []*cachedirective.Object + expectedHeader string + }{ + { + name: "empty policies", + policies: []*cachedirective.Object{}, + expectedHeader: "", + }, + { + name: "single policy max-age", + policies: []*cachedirective.Object{ + {RespDirectives: &cachedirective.ResponseCacheDirectives{MaxAge: 60}}, + }, + expectedHeader: "max-age=60", + }, + { + name: "no-store short-circuits", + policies: []*cachedirective.Object{ + {RespDirectives: &cachedirective.ResponseCacheDirectives{NoStore: true}}, + {RespDirectives: &cachedirective.ResponseCacheDirectives{MaxAge: 300}}, + }, + expectedHeader: "no-store", + }, + { + name: "no-cache wins over max-age", + policies: []*cachedirective.Object{ + {RespDirectives: &cachedirective.ResponseCacheDirectives{NoCachePresent: true}}, + {RespDirectives: &cachedirective.ResponseCacheDirectives{MaxAge: 300}}, + }, + expectedHeader: "no-cache", + }, + { + name: "shortest max-age wins", + policies: []*cachedirective.Object{ + {RespDirectives: &cachedirective.ResponseCacheDirectives{MaxAge: 600}}, + {RespDirectives: &cachedirective.ResponseCacheDirectives{MaxAge: 300}}, + }, + expectedHeader: "max-age=300", + }, + { + name: "private wins over public", + policies: []*cachedirective.Object{ + {RespDirectives: &cachedirective.ResponseCacheDirectives{MaxAge: 300, Public: true}}, + {RespDirectives: &cachedirective.ResponseCacheDirectives{MaxAge: 600, PrivatePresent: true}}, + }, + expectedHeader: "max-age=300, private", + }, + { + name: "public without private", + policies: []*cachedirective.Object{ + {RespDirectives: &cachedirective.ResponseCacheDirectives{MaxAge: 300, Public: true}}, + {RespDirectives: &cachedirective.ResponseCacheDirectives{MaxAge: 600, Public: true}}, + }, + expectedHeader: "max-age=300, public", + }, + { + name: "no-cache with private", + policies: []*cachedirective.Object{ + {RespDirectives: &cachedirective.ResponseCacheDirectives{NoCachePresent: true, PrivatePresent: true}}, + }, + expectedHeader: "no-cache, private", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result, header := createMostRestrictivePolicy(tt.policies) + assert.Equal(t, tt.expectedHeader, header) + assert.NotNil(t, result) + }) + } + + t.Run("expires header - earlier wins", func(t *testing.T) { + t.Parallel() + policies := []*cachedirective.Object{ + { + RespDirectives: &cachedirective.ResponseCacheDirectives{}, + RespExpiresHeader: time.Now().Add(10 * time.Minute), + }, + { + RespDirectives: &cachedirective.ResponseCacheDirectives{}, + RespExpiresHeader: time.Now().Add(5 * time.Minute), + }, + } + result, header := createMostRestrictivePolicy(policies) + assert.Equal(t, "", header) + assert.NotNil(t, result) + assert.False(t, result.RespExpiresHeader.IsZero()) + assert.True(t, result.RespExpiresHeader.Before(time.Now().Add(6*time.Minute))) + }) +} + +func TestAddCacheControlPolicyToRules(t *testing.T) { + t.Parallel() + + t.Run("nil rules and disabled cache returns nil", func(t *testing.T) { + t.Parallel() + result := AddCacheControlPolicyToRules(nil, config.CacheControlPolicy{ + Enabled: false, + }) + assert.Nil(t, result) + }) + + t.Run("nil rules and enabled cache creates rules", func(t *testing.T) { + t.Parallel() + result := AddCacheControlPolicyToRules(nil, config.CacheControlPolicy{ + Enabled: true, + Value: "max-age=300", + }) + require.NotNil(t, result) + require.NotNil(t, result.All) + require.Len(t, result.All.Response, 1) + assert.Equal(t, config.ResponseHeaderRuleAlgorithmMostRestrictiveCacheControl, result.All.Response[0].Algorithm) + assert.Equal(t, "max-age=300", result.All.Response[0].Default) + }) + + t.Run("existing rules with enabled cache appends", func(t *testing.T) { + t.Parallel() + existing := &config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Response: []*config.ResponseHeaderRule{ + {Operation: config.HeaderRuleOperationPropagate, Named: "X-Existing"}, + }, + }, + } + result := AddCacheControlPolicyToRules(existing, config.CacheControlPolicy{ + Enabled: true, + Value: "max-age=300", + }) + require.NotNil(t, result) + require.Len(t, result.All.Response, 2) + assert.Equal(t, "X-Existing", result.All.Response[0].Named) + assert.Equal(t, config.ResponseHeaderRuleAlgorithmMostRestrictiveCacheControl, result.All.Response[1].Algorithm) + }) + + t.Run("subgraph-specific cache creates per-subgraph response rule", func(t *testing.T) { + t.Parallel() + result := AddCacheControlPolicyToRules(nil, config.CacheControlPolicy{ + Subgraphs: []config.SubgraphCacheControlRule{ + {Name: "sg1", Value: "max-age=60"}, + }, + }) + require.NotNil(t, result) + require.Contains(t, result.Subgraphs, "sg1") + require.Len(t, result.Subgraphs["sg1"].Response, 1) + assert.Equal(t, "max-age=60", result.Subgraphs["sg1"].Response[0].Default) + }) + + t.Run("existing subgraph gets cache rule appended", func(t *testing.T) { + t.Parallel() + existing := &config.HeaderRules{ + All: &config.GlobalHeaderRule{}, + Subgraphs: map[string]*config.GlobalHeaderRule{ + "sg1": { + Response: []*config.ResponseHeaderRule{ + {Operation: config.HeaderRuleOperationPropagate, Named: "X-Existing"}, + }, + }, + }, + } + result := AddCacheControlPolicyToRules(existing, config.CacheControlPolicy{ + Subgraphs: []config.SubgraphCacheControlRule{ + {Name: "sg1", Value: "max-age=60"}, + }, + }) + require.NotNil(t, result) + require.Len(t, result.Subgraphs["sg1"].Response, 2) + assert.Equal(t, "X-Existing", result.Subgraphs["sg1"].Response[0].Named) + assert.Equal(t, config.ResponseHeaderRuleAlgorithmMostRestrictiveCacheControl, result.Subgraphs["sg1"].Response[1].Algorithm) + }) +} + +func TestApplyResponseRuleKeyValue(t *testing.T) { + t.Parallel() + + newPropagation := func() *responseHeaderPropagation { + return &responseHeaderPropagation{ + header: make(http.Header), + m: &sync.Mutex{}, + } + } + + // We need a minimal HeaderPropagation to call the method + hp := &HeaderPropagation{} + + t.Run("first write sets initial value", func(t *testing.T) { + t.Parallel() + prop := newPropagation() + rule := &config.ResponseHeaderRule{Algorithm: config.ResponseHeaderRuleAlgorithmFirstWrite} + hp.applyResponseRuleKeyValue(nil, prop, rule, "X-Test", []string{"first"}) + assert.Equal(t, []string{"first"}, prop.header.Values("X-Test")) + }) + + t.Run("first write ignores second value", func(t *testing.T) { + t.Parallel() + prop := newPropagation() + rule := &config.ResponseHeaderRule{Algorithm: config.ResponseHeaderRuleAlgorithmFirstWrite} + hp.applyResponseRuleKeyValue(nil, prop, rule, "X-Test", []string{"first"}) + hp.applyResponseRuleKeyValue(nil, prop, rule, "X-Test", []string{"second"}) + assert.Equal(t, []string{"first"}, prop.header.Values("X-Test")) + }) + + t.Run("last write overwrites", func(t *testing.T) { + t.Parallel() + prop := newPropagation() + rule := &config.ResponseHeaderRule{Algorithm: config.ResponseHeaderRuleAlgorithmLastWrite} + hp.applyResponseRuleKeyValue(nil, prop, rule, "X-Test", []string{"first"}) + hp.applyResponseRuleKeyValue(nil, prop, rule, "X-Test", []string{"second"}) + assert.Equal(t, []string{"second"}, prop.header.Values("X-Test")) + }) + + t.Run("append accumulates values", func(t *testing.T) { + t.Parallel() + prop := newPropagation() + rule := &config.ResponseHeaderRule{Algorithm: config.ResponseHeaderRuleAlgorithmAppend} + hp.applyResponseRuleKeyValue(nil, prop, rule, "X-Test", []string{"a"}) + hp.applyResponseRuleKeyValue(nil, prop, rule, "X-Test", []string{"b", "c"}) + assert.Equal(t, []string{"a,b,c"}, prop.header.Values("X-Test")) + }) + + t.Run("append to empty header", func(t *testing.T) { + t.Parallel() + prop := newPropagation() + rule := &config.ResponseHeaderRule{Algorithm: config.ResponseHeaderRuleAlgorithmAppend} + hp.applyResponseRuleKeyValue(nil, prop, rule, "X-Test", []string{"only"}) + assert.Equal(t, []string{"only"}, prop.header.Values("X-Test")) + }) + + t.Run("append with Set-Cookie preserves multiple header lines", func(t *testing.T) { + t.Parallel() + prop := newPropagation() + rule := &config.ResponseHeaderRule{Algorithm: config.ResponseHeaderRuleAlgorithmAppend} + hp.applyResponseRuleKeyValue(nil, prop, rule, "Set-Cookie", []string{"a=1; Path=/"}) + hp.applyResponseRuleKeyValue(nil, prop, rule, "Set-Cookie", []string{"b=2; Path=/"}) + // Set-Cookie must NOT be comma-joined (RFC 6265) + assert.Equal(t, []string{"a=1; Path=/", "b=2; Path=/"}, prop.header.Values("Set-Cookie")) + }) +} + +func TestPropagatedHeaders(t *testing.T) { + t.Parallel() + + t.Run("set rule returns header name", func(t *testing.T) { + t.Parallel() + names, regexps, err := PropagatedHeaders([]*config.RequestHeaderRule{ + {Operation: config.HeaderRuleOperationSet, Name: "X-A", Value: "v"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{"X-A"}, names) + assert.Nil(t, regexps) + }) + + t.Run("propagate named returns name", func(t *testing.T) { + t.Parallel() + names, regexps, err := PropagatedHeaders([]*config.RequestHeaderRule{ + {Operation: config.HeaderRuleOperationPropagate, Named: "X-B"}, + }) + require.NoError(t, err) + assert.Equal(t, []string{"X-B"}, names) + assert.Nil(t, regexps) + }) + + t.Run("propagate matching returns compiled regex", func(t *testing.T) { + t.Parallel() + names, regexps, err := PropagatedHeaders([]*config.RequestHeaderRule{ + {Operation: config.HeaderRuleOperationPropagate, Matching: "^X-.*"}, + }) + require.NoError(t, err) + assert.Nil(t, names) + require.Len(t, regexps, 1) + assert.True(t, regexps[0].Pattern.MatchString("X-Custom")) + assert.False(t, regexps[0].NegateMatch) + }) + + t.Run("propagate matching with negate", func(t *testing.T) { + t.Parallel() + _, regexps, err := PropagatedHeaders([]*config.RequestHeaderRule{ + {Operation: config.HeaderRuleOperationPropagate, Matching: "^X-.*", NegateMatch: true}, + }) + require.NoError(t, err) + require.Len(t, regexps, 1) + assert.True(t, regexps[0].NegateMatch) + }) + + t.Run("set with empty name errors", func(t *testing.T) { + t.Parallel() + _, _, err := PropagatedHeaders([]*config.RequestHeaderRule{ + {Operation: config.HeaderRuleOperationSet, Name: ""}, + }) + require.Error(t, err) + }) + + t.Run("propagate with no name or match errors", func(t *testing.T) { + t.Parallel() + _, _, err := PropagatedHeaders([]*config.RequestHeaderRule{ + {Operation: config.HeaderRuleOperationPropagate}, + }) + require.Error(t, err) + }) + + t.Run("invalid operation errors", func(t *testing.T) { + t.Parallel() + _, _, err := PropagatedHeaders([]*config.RequestHeaderRule{ + {Operation: "invalid"}, + }) + require.Error(t, err) + }) + + t.Run("invalid regex errors", func(t *testing.T) { + t.Parallel() + _, _, err := PropagatedHeaders([]*config.RequestHeaderRule{ + {Operation: config.HeaderRuleOperationPropagate, Matching: "[invalid"}, + }) + require.Error(t, err) + }) +} + +func TestNewHeaderPropagation(t *testing.T) { + t.Parallel() + + t.Run("nil rules returns nil", func(t *testing.T) { + t.Parallel() + hp, err := NewHeaderPropagation(nil) + require.NoError(t, err) + assert.Nil(t, hp) + }) + + t.Run("empty rules returns valid instance", func(t *testing.T) { + t.Parallel() + hp, err := NewHeaderPropagation(&config.HeaderRules{}) + require.NoError(t, err) + require.NotNil(t, hp) + }) + + t.Run("invalid regex in request rule returns error", func(t *testing.T) { + t.Parallel() + _, err := NewHeaderPropagation(&config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Request: []*config.RequestHeaderRule{ + {Operation: config.HeaderRuleOperationPropagate, Matching: "[invalid"}, + }, + }, + }) + require.Error(t, err) + }) + + t.Run("invalid regex in response rule returns error", func(t *testing.T) { + t.Parallel() + _, err := NewHeaderPropagation(&config.HeaderRules{ + All: &config.GlobalHeaderRule{ + Response: []*config.ResponseHeaderRule{ + {Operation: config.HeaderRuleOperationPropagate, Matching: "[invalid"}, + }, + }, + }) + require.Error(t, err) + }) + + t.Run("nil receiver returns false for Has*Rules", func(t *testing.T) { + t.Parallel() + var hp *HeaderPropagation + assert.False(t, hp.HasRequestRules()) + assert.False(t, hp.HasResponseRules()) + }) +}