Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 @@ -26,7 +26,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.250
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.250.0.20260217082956-accd82298d12
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 @@ -354,8 +354,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.250 h1:dggpvV2VI+2zMVXRm/1NsasT7Nxne6SRYR+GSshl5P8=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.250/go.mod h1:MFbY0QI8ncF60DHs7yyyiyyhWyld0WE1JokiyTVY8j4=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.250.0.20260217082956-accd82298d12 h1:JfyqGcFSZR1tF8sIZMGYmfcnbEFDBCAM/sgdVoqriKE=
github.com/wundergraph/graphql-go-tools/v2 v2.0.0-rc.250.0.20260217082956-accd82298d12/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
308 changes: 308 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,306 @@ func TestGRPCSubgraph(t *testing.T) {
})
})

t.Run("Should sent http headers as gRPC metadata to subgraphs", func(t *testing.T) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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 overwritten by grpc client
require.Equal(t, []string{"application/grpc"}, captured.Get("content-type"))
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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("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"))
})
})
})
}
20 changes: 13 additions & 7 deletions router-tests/testenv/testenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,10 @@ type SubgraphsConfig struct {
}

type SubgraphConfig struct {
Middleware func(http.Handler) http.Handler
Delay time.Duration
CloseOnStart bool
Middleware func(http.Handler) http.Handler
GRPCInterceptor grpc.UnaryServerInterceptor
Delay time.Duration
CloseOnStart bool
}

type LogObservationConfig struct {
Expand Down Expand Up @@ -597,7 +598,7 @@ func CreateTestSupervisorEnv(t testing.TB, cfg *Config) (*Environment, error) {
)

if cfg.EnableGRPC {
projectServer, endpoint = makeSafeGRPCServer(t, &projects.ProjectsService_ServiceDesc, &service.ProjectsService{})
projectServer, endpoint = makeSafeGRPCServer(t, &projects.ProjectsService_ServiceDesc, &service.ProjectsService{}, cfg.Subgraphs.Projects.GRPCInterceptor)
}

replacements := map[string]string{
Expand Down Expand Up @@ -1027,7 +1028,7 @@ func CreateTestEnv(t testing.TB, cfg *Config) (*Environment, error) {
)

if cfg.EnableGRPC {
projectServer, endpoint = makeSafeGRPCServer(t, &projects.ProjectsService_ServiceDesc, &service.ProjectsService{})
projectServer, endpoint = makeSafeGRPCServer(t, &projects.ProjectsService_ServiceDesc, &service.ProjectsService{}, cfg.Subgraphs.Projects.GRPCInterceptor)
}

replacements := map[string]string{
Expand Down Expand Up @@ -1673,7 +1674,7 @@ func makeSafeHttpTestServer(t testing.TB, handler http.Handler) *httptest.Server
return s
}

func makeSafeGRPCServer(t testing.TB, sd *grpc.ServiceDesc, service any) (*grpc.Server, string) {
func makeSafeGRPCServer(t testing.TB, sd *grpc.ServiceDesc, service any, interceptor grpc.UnaryServerInterceptor) (*grpc.Server, string) {
t.Helper()

// We could use freeport here, but it is easy to use ephemeral port and get the endpoint
Expand All @@ -1684,7 +1685,12 @@ func makeSafeGRPCServer(t testing.TB, sd *grpc.ServiceDesc, service any) (*grpc.

require.NotNil(t, service)

s := grpc.NewServer()
var opts []grpc.ServerOption
if interceptor != nil {
opts = append(opts, grpc.ChainUnaryInterceptor(interceptor))
}

s := grpc.NewServer(opts...)
s.RegisterService(sd, service)

go func() {
Expand Down
Loading
Loading