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
2 changes: 1 addition & 1 deletion router-tests/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ require (
github.com/wundergraph/cosmo/demo/pkg/subgraphs/projects v0.0.0-20250715110703-10f2e5f9c79e
github.com/wundergraph/cosmo/router v0.0.0-20260213130455-6e3277e7b850
github.com/wundergraph/cosmo/router-plugin v0.0.0-20250808194725-de123ba1c65e
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.251
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.252
go.opentelemetry.io/otel v1.36.0
go.opentelemetry.io/otel/sdk v1.36.0
go.opentelemetry.io/otel/sdk/metric v1.36.0
Expand Down
4 changes: 2 additions & 2 deletions router-tests/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -356,8 +356,8 @@ github.com/wundergraph/astjson v1.0.0 h1:rETLJuQkMWWW03HCF6WBttEBOu8gi5vznj5KEUP
github.com/wundergraph/astjson v1.0.0/go.mod h1:h12D/dxxnedtLzsKyBLK7/Oe4TAoGpRVC9nDpDrZSWw=
github.com/wundergraph/go-arena v1.1.0 h1:9+wSRkJAkA2vbYHp6s8tEGhPViRGQNGXqPHT0QzhdIc=
github.com/wundergraph/go-arena v1.1.0/go.mod h1:ROOysEHWJjLQ8FSfNxZCziagb7Qw2nXY3/vgKRh7eWw=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.251 h1:avZIXjYGTLliqS+RCZXlnFAaJdEr9HizulP1qhNLR1U=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.251/go.mod h1:MFbY0QI8ncF60DHs7yyyiyyhWyld0WE1JokiyTVY8j4=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.252 h1:qhm6obHtRwgZm84Gu3G63ywGz2ys2xLBwC0gloDKZuo=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.252/go.mod h1:MFbY0QI8ncF60DHs7yyyiyyhWyld0WE1JokiyTVY8j4=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
Expand Down
349 changes: 349 additions & 0 deletions router-tests/grpc_subgraph_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
package integration

import (
"context"
"net/http"
"testing"

"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"

"github.com/wundergraph/cosmo/router-tests/testenv"
"github.com/wundergraph/cosmo/router/core"
"github.com/wundergraph/cosmo/router/pkg/config"
)

func TestGRPCSubgraph(t *testing.T) {
Expand Down Expand Up @@ -278,4 +284,347 @@ func TestGRPCSubgraph(t *testing.T) {
})
})

t.Run("Should send http headers as gRPC metadata to subgraphs", func(t *testing.T) {
t.Parallel()

captureInterceptor := func(captured *metadata.MD) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
md, _ := metadata.FromIncomingContext(ctx)
*captured = md.Copy()
return handler(ctx, req)
}
}

t.Run("header arrives as metadata with correct value", func(t *testing.T) {
// Assert that headers included in propagation rules are part
// of gRPC metadata.
t.Parallel()

var captured metadata.MD

testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithGRPCJSONTemplate,
EnableGRPC: true,
RouterOptions: []core.Option{
core.WithHeaderRules(config.HeaderRules{
All: &config.GlobalHeaderRule{
Request: []*config.RequestHeaderRule{
{
Operation: config.HeaderRuleOperationPropagate,
Named: "X-Tenant-Id",
},
{
Operation: config.HeaderRuleOperationPropagate,
Named: "X-Region-Name",
},
},
},
}),
},
Subgraphs: testenv.SubgraphsConfig{
Projects: testenv.SubgraphConfig{
GRPCInterceptor: captureInterceptor(&captured),
},
},
}, func(t *testing.T, xEnv *testenv.Environment) {
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { projects { id name } }`,
Header: http.Header{
"X-Tenant-Id": []string{"acme"},
"X-Region-Name": []string{"frankfurt"},
},
})

require.Equal(t, []string{"acme"}, captured.Get("x-tenant-id"))
require.Equal(t, []string{"frankfurt"}, captured.Get("x-region-name"))
})
})

t.Run("header not in propagation rules is absent from metadata", func(t *testing.T) {
// Assert that headers not included in propagation rules are not part
// of gRPC metadata.
t.Parallel()

var captured metadata.MD

testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithGRPCJSONTemplate,
EnableGRPC: true,
RouterOptions: []core.Option{
core.WithHeaderRules(config.HeaderRules{
All: &config.GlobalHeaderRule{
Request: []*config.RequestHeaderRule{
{
Operation: config.HeaderRuleOperationPropagate,
Named: "X-Allowed",
},
},
},
}),
},
Subgraphs: testenv.SubgraphsConfig{
Projects: testenv.SubgraphConfig{
GRPCInterceptor: captureInterceptor(&captured),
},
},
}, func(t *testing.T, xEnv *testenv.Environment) {
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { projects { id name } }`,
Header: http.Header{
"X-Allowed": []string{"yes"},
"X-Not-Allowed": []string{"secret"},
},
})

require.Equal(t, []string{"yes"}, captured.Get("x-allowed"))
require.Empty(t, captured.Get("x-not-allowed"))
})
})

t.Run("header with multiple values arrives as multiple metadata values", func(t *testing.T) {
// HTTP headers can be set with multiple values. They should appear with all values
// on the gRPC metadata as well.
t.Parallel()

var captured metadata.MD

testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithGRPCJSONTemplate,
EnableGRPC: true,
RouterOptions: []core.Option{
core.WithHeaderRules(config.HeaderRules{
All: &config.GlobalHeaderRule{
Request: []*config.RequestHeaderRule{
{
Operation: config.HeaderRuleOperationPropagate,
Named: "X-Role",
},
},
},
}),
},
Subgraphs: testenv.SubgraphsConfig{
Projects: testenv.SubgraphConfig{
GRPCInterceptor: captureInterceptor(&captured),
},
},
}, func(t *testing.T, xEnv *testenv.Environment) {
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { projects { id name } }`,
Header: http.Header{
"X-Role": []string{"admin", "editor"},
},
})

require.Equal(t, []string{"admin", "editor"}, captured.Get("x-role"))
})
})

t.Run("unsafe headers are absent from metadata", func(t *testing.T) {
// The router avoids passing certain headers to datasources,
// see router/core/header_rule_engine.go.
// This test ensures grpc datasources are covered by this as well.
t.Parallel()

var captured metadata.MD

testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithGRPCJSONTemplate,
EnableGRPC: true,
RouterOptions: []core.Option{
core.WithHeaderRules(config.HeaderRules{
All: &config.GlobalHeaderRule{
Request: []*config.RequestHeaderRule{
{
Operation: config.HeaderRuleOperationPropagate,
Matching: ".*", // mark all headers as forwardable
},
},
},
}),
},
Subgraphs: testenv.SubgraphsConfig{
Projects: testenv.SubgraphConfig{
GRPCInterceptor: captureInterceptor(&captured),
},
},
}, func(t *testing.T, xEnv *testenv.Environment) {
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { projects { id name } }`,
Header: http.Header{
// safe header — must arrive
"X-Custom": []string{"value"},

// handled by HTTP stack, never in r.Header
"Host": []string{"evil.example.com"},

// hop-by-hop / connection headers
"Alt-Svc": []string{"h3=\":443\""},
"Connection": []string{"keep-alive"},
"Keep-Alive": []string{"timeout=5"},
"Proxy-Authenticate": []string{"Basic"},
"Proxy-Authorization": []string{"Basic dXNlcjpwYXNz"},
"Proxy-Connection": []string{"keep-alive"},
"Te": []string{"trailers"},
"Trailer": []string{"Expires"},
"Transfer-Encoding": []string{"chunked"},
"Upgrade": []string{"websocket"},

// content negotiation
"Accept": []string{"application/json"},
"Accept-Charset": []string{"utf-8"},
"Accept-Encoding": []string{"gzip, deflate"},
"Content-Encoding": []string{"gzip"},
"Content-Length": []string{"42"},
"Content-Type": []string{"application/json"},

// WebSocket upgrade
"Sec-Websocket-Extensions": []string{"permessage-deflate"},
"Sec-Websocket-Key": []string{"dGhlIHNhbXBsZSBub25jZQ=="},
"Sec-Websocket-Protocol": []string{"chat"},
"Sec-Websocket-Version": []string{"13"},
},
})

// custom header should arrive
require.Equal(t, []string{"value"}, captured.Get("x-custom"))

// ensure content-type is present with the correct value
// even if the request headers have a different value
require.Equal(t, []string{"application/grpc"}, captured.Get("content-type"))

// host is handled by the HTTP stack and never forwarded
require.Empty(t, captured.Get("host"))

// hop-by-hop / connection headers
require.Empty(t, captured.Get("alt-svc"))
require.Empty(t, captured.Get("connection"))
require.Empty(t, captured.Get("keep-alive"))
require.Empty(t, captured.Get("proxy-authenticate"))
require.Empty(t, captured.Get("proxy-authorization"))
require.Empty(t, captured.Get("proxy-connection"))
require.Empty(t, captured.Get("te"))
require.Empty(t, captured.Get("trailer"))
require.Empty(t, captured.Get("transfer-encoding"))
require.Empty(t, captured.Get("upgrade"))

// content negotiation
require.Empty(t, captured.Get("accept"))
require.Empty(t, captured.Get("accept-charset"))
require.Empty(t, captured.Get("accept-encoding"))
require.Empty(t, captured.Get("content-encoding"))
require.Empty(t, captured.Get("content-length"))

// WebSocket upgrade
require.Empty(t, captured.Get("sec-websocket-extensions"))
require.Empty(t, captured.Get("sec-websocket-key"))
require.Empty(t, captured.Get("sec-websocket-protocol"))
require.Empty(t, captured.Get("sec-websocket-version"))

// gRPC client correctness:
// HTTP/2 pseudo-headers — the gRPC transport explicitly whitelists
// :authority and user-agent so they appear in metadata.
require.NotEmpty(t, captured.Get(":authority"))
require.NotEmpty(t, captured.Get("user-agent"))

// gRPC client correctness:
// All other pseudo-headers (:method, :path, :scheme) are classified
// as reserved and stripped by the gRPC transport before reaching
// user-space, so they never appear regardless of router rules.
require.Empty(t, captured.Get(":method"))
require.Empty(t, captured.Get(":path"))
require.Empty(t, captured.Get(":scheme"))
})
})

t.Run("grpc-reserved headers never reach the subgraph", func(t *testing.T) {
// Headers prefixed with "grpc-" are reserved by the gRPC protocol spec.
// Even when wildcard propagation is configured, they must never appear
// on the subgraph.
t.Parallel()

var captured metadata.MD

testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithGRPCJSONTemplate,
EnableGRPC: true,
RouterOptions: []core.Option{
core.WithHeaderRules(config.HeaderRules{
All: &config.GlobalHeaderRule{
Request: []*config.RequestHeaderRule{
{
Operation: config.HeaderRuleOperationPropagate,
Matching: ".*",
},
},
},
}),
},
Subgraphs: testenv.SubgraphsConfig{
Projects: testenv.SubgraphConfig{
GRPCInterceptor: captureInterceptor(&captured),
},
},
}, func(t *testing.T, xEnv *testenv.Environment) {
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { projects { id name } }`,
Header: http.Header{
"Grpc-ReservedHeader": []string{"should be ignored"},
},
})

require.Empty(t, captured.Get("grpc-reservedheader"))
})
})

t.Run("safe headers are present in metadata", func(t *testing.T) {
// Ensure that http standard headers, which are safe and useful for subgraphs,
// are included in the metadata unmodified.
t.Parallel()

var captured metadata.MD

testenv.Run(t, &testenv.Config{
RouterConfigJSONTemplate: testenv.ConfigWithGRPCJSONTemplate,
EnableGRPC: true,
RouterOptions: []core.Option{
core.WithHeaderRules(config.HeaderRules{
All: &config.GlobalHeaderRule{
Request: []*config.RequestHeaderRule{
// Propagate each header explicitly so the test is
// independent of any default propagation behaviour.
{Operation: config.HeaderRuleOperationPropagate, Named: "Authorization"},
{Operation: config.HeaderRuleOperationPropagate, Named: "Cookie"},
{Operation: config.HeaderRuleOperationPropagate, Named: "Traceparent"},
{Operation: config.HeaderRuleOperationPropagate, Named: "Tracestate"},
{Operation: config.HeaderRuleOperationPropagate, Named: "Accept-Language"},
},
},
}),
},
Subgraphs: testenv.SubgraphsConfig{
Projects: testenv.SubgraphConfig{
GRPCInterceptor: captureInterceptor(&captured),
},
},
}, func(t *testing.T, xEnv *testenv.Environment) {
xEnv.MakeGraphQLRequestOK(testenv.GraphQLRequest{
Query: `query { projects { id name } }`,
Header: http.Header{
"Authorization": []string{"Bearer eyJhbGciOiJSUzI1NiJ9"},
"Cookie": []string{"session=abc123; theme=dark"},
"Traceparent": []string{"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"},
"Tracestate": []string{"rojo=00f067aa0ba902b7"},
"Accept-Language": []string{"de-DE,de;q=0.9,en;q=0.8"},
},
})

require.Equal(t, []string{"Bearer eyJhbGciOiJSUzI1NiJ9"}, captured.Get("authorization"))
require.Equal(t, []string{"session=abc123; theme=dark"}, captured.Get("cookie"))
require.Equal(t, []string{"00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"}, captured.Get("traceparent"))
require.Equal(t, []string{"rojo=00f067aa0ba902b7"}, captured.Get("tracestate"))
require.Equal(t, []string{"de-DE,de;q=0.9,en;q=0.8"}, captured.Get("accept-language"))
})
})
})
}
Loading
Loading