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
98 changes: 98 additions & 0 deletions internal/api/context_propagation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package api

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

"github.com/sablierapp/sablier/pkg/sablier"
"go.uber.org/mock/gomock"
"gotest.tools/v3/assert"
)

type ctxSentinelKey struct{}

// TestStartDynamicPropagatesRequestContext pins that the dynamic handler hands
// the HTTP request's context to the session layer. Passing the *gin.Context
// itself instead of c.Request.Context() silently breaks two things (gin's
// ContextWithFallback is not enabled): Done() returns a nil channel, so
// client disconnects and server shutdown never cancel the session work, and
// Value() does not reach the request context, so the otelgin span is
// invisible downstream and every provider call becomes an orphan root span.
func TestStartDynamicPropagatesRequestContext(t *testing.T) {
t.Run("names path", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartDynamic(router, strategy)

base := context.WithValue(context.Background(), ctxSentinelKey{}, "sentinel")
reqCtx, cancel := context.WithCancel(base)
cancel() // pre-cancelled: the handler must observe it

var got context.Context
m.EXPECT().RequestSession(gomock.Any(), []string{"test"}, gomock.Any()).
DoAndReturn(func(ctx context.Context, _ []string, _ time.Duration) (*sablier.SessionState, error) {
got = ctx
return session(), nil
})

req := httptest.NewRequest("GET", "/api/strategies/dynamic?names=test", nil).WithContext(reqCtx)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)

assert.Assert(t, got != nil, "session layer was not called")
assert.Equal(t, "sentinel", got.Value(ctxSentinelKey{}), "request context values must reach the session layer")
assert.ErrorIs(t, got.Err(), context.Canceled, "request cancellation must reach the session layer")
})

t.Run("group path", func(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartDynamic(router, strategy)

base := context.WithValue(context.Background(), ctxSentinelKey{}, "sentinel")
reqCtx, cancel := context.WithCancel(base)
cancel()

var got context.Context
m.EXPECT().RequestSessionGroup(gomock.Any(), "test", gomock.Any()).
DoAndReturn(func(ctx context.Context, _ string, _ time.Duration) (*sablier.SessionState, error) {
got = ctx
return session(), nil
})

req := httptest.NewRequest("GET", "/api/strategies/dynamic?group=test", nil).WithContext(reqCtx)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)

assert.Assert(t, got != nil, "session layer was not called")
assert.Equal(t, "sentinel", got.Value(ctxSentinelKey{}), "request context values must reach the session layer")
assert.ErrorIs(t, got.Err(), context.Canceled, "request cancellation must reach the session layer")
})
}

// TestStartBlockingPropagatesRequestContext pins the same contract on the
// blocking handler, which already passed c.Request.Context() and must not
// regress to the raw gin context.
func TestStartBlockingPropagatesRequestContext(t *testing.T) {
app, router, strategy, m := NewApiTest(t)
StartBlocking(router, strategy)

base := context.WithValue(context.Background(), ctxSentinelKey{}, "sentinel")
reqCtx, cancel := context.WithCancel(base)
cancel()

var got context.Context
m.EXPECT().RequestReadySession(gomock.Any(), []string{"test"}, gomock.Any(), gomock.Any()).
DoAndReturn(func(ctx context.Context, _ []string, _, _ time.Duration) (*sablier.SessionState, error) {
got = ctx
return session(), nil
})

req := httptest.NewRequest("GET", "/api/strategies/blocking?names=test", nil).WithContext(reqCtx)
w := httptest.NewRecorder()
app.ServeHTTP(w, req)

assert.Assert(t, got != nil, "session layer was not called")
assert.Equal(t, "sentinel", got.Value(ctxSentinelKey{}), "request context values must reach the session layer")
assert.ErrorIs(t, got.Err(), context.Canceled, "request cancellation must reach the session layer")
}
8 changes: 6 additions & 2 deletions internal/api/start_dynamic.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,14 @@ func StartDynamic(router *gin.RouterGroup, s *ServeStrategy) {

var sessionState *sablier.SessionState
var err error
// c.Request.Context(), never the *gin.Context itself: without gin's
// ContextWithFallback, the gin context has a nil Done() channel and its
// Value() does not reach the request context, which silently breaks
// cancellation and otel trace propagation for everything downstream.
if len(request.Names) > 0 {
sessionState, err = s.Sablier.RequestSession(c, request.Names, request.SessionDuration)
sessionState, err = s.Sablier.RequestSession(c.Request.Context(), request.Names, request.SessionDuration)
} else {
sessionState, err = s.Sablier.RequestSessionGroup(c, request.Group, request.SessionDuration)
sessionState, err = s.Sablier.RequestSessionGroup(c.Request.Context(), request.Group, request.SessionDuration)
if groupNotFoundError, ok := errors.AsType[sablier.ErrGroupNotFound](err); ok {
AbortWithProblemDetail(c, ProblemGroupNotFound(groupNotFoundError))
return
Expand Down
Loading