| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155 |
- //go:build unit
- package service
- import (
- "context"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- )
- func newSessionHeaderContext(t *testing.T, headers map[string]string) *gin.Context {
- t.Helper()
- gin.SetMode(gin.TestMode)
- c, _ := gin.CreateTestContext(httptest.NewRecorder())
- req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
- for k, v := range headers {
- req.Header.Set(k, v)
- }
- c.Request = req
- return c
- }
- func TestSanitizeSessionID(t *testing.T) {
- longRunes := strings.Repeat("a", maxPersistedSessionIDLength+50)
- multibyte := strings.Repeat("好", maxPersistedSessionIDLength+10)
- tests := []struct {
- name string
- in string
- want string
- }{
- {"empty", "", ""},
- {"whitespace only", " \t ", ""},
- {"trims surrounding whitespace", " sess-123 ", "sess-123"},
- {"plain value", "conv_abc-123.XYZ", "conv_abc-123.XYZ"},
- {"uuid", "550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440000"},
- {"reject CR", "sess\r123", ""},
- {"reject LF", "sess\n123", ""},
- {"reject CRLF injection", "sess-1\r\nSet-Cookie: x=y", ""},
- {"reject tab inside", "sess\t123", ""},
- {"reject NUL", "sess\x00123", ""},
- {"reject DEL", "sess\x7f123", ""},
- {"reject invalid UTF-8", string([]byte{'s', 'e', 's', 's', '-', 0xff}), ""},
- {"accepts value at column bound", strings.Repeat("b", maxPersistedSessionIDLength), strings.Repeat("b", maxPersistedSessionIDLength)},
- {"rejects overlong value", longRunes, ""},
- {"rejects overlong multibyte value", multibyte, ""},
- }
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- got := sanitizeSessionID(tc.in)
- require.Equal(t, tc.want, got, "sanitizeSessionID(%q)", tc.in)
- // Sanitized output must never exceed the DB column bound (rune-counted).
- require.LessOrEqual(t, len([]rune(got)), maxPersistedSessionIDLength)
- })
- }
- }
- func TestExtractClientSessionID_NilContext(t *testing.T) {
- require.Equal(t, "", ExtractClientSessionID(nil))
- }
- func TestExtractClientSessionID_NilRequest(t *testing.T) {
- require.Equal(t, "", ExtractClientSessionID(&gin.Context{}))
- }
- func TestExtractClientSessionID_AbsentReturnsEmpty(t *testing.T) {
- c := newSessionHeaderContext(t, nil)
- require.Equal(t, "", ExtractClientSessionID(c))
- }
- func TestExtractClientSessionID_SupportedHeaders(t *testing.T) {
- tests := []struct {
- name string
- header string
- value string
- }{
- {"session_id", "session_id", "sess-A"},
- {"conversation_id", "conversation_id", "conv-B"},
- {"X-Session-Affinity", openCodeSessionAffinityHeader, "aff-C"},
- {"X-Session-Id", openCodeSessionIDHeader, "sid-D"},
- {"X-OpenCode-Session", openCodeNativeSessionHeader, "oc-E"},
- {"X-Conversation-ID", codeBuddyConversationHeader, "cb-F"},
- {"X-Claude-Code-Session-Id", claudeCodeSessionHeader, "cc-G"},
- }
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- c := newSessionHeaderContext(t, map[string]string{tc.header: tc.value})
- require.Equal(t, tc.value, ExtractClientSessionID(c))
- })
- }
- }
- func TestExtractClientSessionID_HeaderPrecedence(t *testing.T) {
- // session_id ranks ahead of conversation_id and the X-* variants.
- c := newSessionHeaderContext(t, map[string]string{
- "session_id": "primary",
- "conversation_id": "secondary",
- openCodeSessionIDHeader: "tertiary",
- codeBuddyConversationHeader: "quaternary",
- })
- require.Equal(t, "primary", ExtractClientSessionID(c))
- }
- func TestExtractClientSessionID_Sanitizes(t *testing.T) {
- c := newSessionHeaderContext(t, map[string]string{openCodeSessionIDHeader: " clean-123 "})
- require.Equal(t, "clean-123", ExtractClientSessionID(c))
- }
- func TestExtractClientSessionID_IgnoresNonSessionHeaders(t *testing.T) {
- // prompt_cache_key, request/message ids, and a Grok conversation header on a
- // non-Grok request are NOT persisted as session_id.
- c := newSessionHeaderContext(t, map[string]string{
- "prompt_cache_key": "cache-key-should-not-persist",
- "X-Request-Id": "req-should-not-persist",
- "x-grok-conv-id": "grok-conv-should-not-persist",
- })
- require.Equal(t, "", ExtractClientSessionID(c))
- }
- func TestExtractClientSessionID_GrokConversationHeader(t *testing.T) {
- c := newSessionHeaderContext(t, map[string]string{
- grokConversationIDHeader: "grok-native-session",
- })
- c.Set("api_key", &APIKey{
- ID: 42,
- Group: &Group{Platform: PlatformGrok},
- })
- require.Equal(t, "grok-native-session", ExtractClientSessionID(c))
- }
- func TestExtractClientSessionID_GrokConversationHeaderForCompositeRoute(t *testing.T) {
- c := newSessionHeaderContext(t, map[string]string{
- grokConversationIDHeader: "grok-composite-session",
- })
- c.Set("api_key", &APIKey{
- ID: 43,
- Group: &Group{Platform: PlatformComposite},
- })
- c.Request = c.Request.WithContext(WithResolvedTargetPlatform(context.Background(), PlatformGrok))
- require.Equal(t, "grok-composite-session", ExtractClientSessionID(c))
- }
- func TestExtractClientSessionID_InjectionHeaderDropped(t *testing.T) {
- // A supported header carrying a CRLF payload is rejected, not persisted mangled.
- c := newSessionHeaderContext(t, map[string]string{"session_id": "abc"})
- c.Request.Header.Set("session_id", "abc\r\nX-Injected: 1")
- require.Equal(t, "", ExtractClientSessionID(c))
- }
|