session_id_test.go 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "github.com/gin-gonic/gin"
  10. "github.com/stretchr/testify/require"
  11. )
  12. func newSessionHeaderContext(t *testing.T, headers map[string]string) *gin.Context {
  13. t.Helper()
  14. gin.SetMode(gin.TestMode)
  15. c, _ := gin.CreateTestContext(httptest.NewRecorder())
  16. req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
  17. for k, v := range headers {
  18. req.Header.Set(k, v)
  19. }
  20. c.Request = req
  21. return c
  22. }
  23. func TestSanitizeSessionID(t *testing.T) {
  24. longRunes := strings.Repeat("a", maxPersistedSessionIDLength+50)
  25. multibyte := strings.Repeat("好", maxPersistedSessionIDLength+10)
  26. tests := []struct {
  27. name string
  28. in string
  29. want string
  30. }{
  31. {"empty", "", ""},
  32. {"whitespace only", " \t ", ""},
  33. {"trims surrounding whitespace", " sess-123 ", "sess-123"},
  34. {"plain value", "conv_abc-123.XYZ", "conv_abc-123.XYZ"},
  35. {"uuid", "550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440000"},
  36. {"reject CR", "sess\r123", ""},
  37. {"reject LF", "sess\n123", ""},
  38. {"reject CRLF injection", "sess-1\r\nSet-Cookie: x=y", ""},
  39. {"reject tab inside", "sess\t123", ""},
  40. {"reject NUL", "sess\x00123", ""},
  41. {"reject DEL", "sess\x7f123", ""},
  42. {"reject invalid UTF-8", string([]byte{'s', 'e', 's', 's', '-', 0xff}), ""},
  43. {"accepts value at column bound", strings.Repeat("b", maxPersistedSessionIDLength), strings.Repeat("b", maxPersistedSessionIDLength)},
  44. {"rejects overlong value", longRunes, ""},
  45. {"rejects overlong multibyte value", multibyte, ""},
  46. }
  47. for _, tc := range tests {
  48. t.Run(tc.name, func(t *testing.T) {
  49. got := sanitizeSessionID(tc.in)
  50. require.Equal(t, tc.want, got, "sanitizeSessionID(%q)", tc.in)
  51. // Sanitized output must never exceed the DB column bound (rune-counted).
  52. require.LessOrEqual(t, len([]rune(got)), maxPersistedSessionIDLength)
  53. })
  54. }
  55. }
  56. func TestExtractClientSessionID_NilContext(t *testing.T) {
  57. require.Equal(t, "", ExtractClientSessionID(nil))
  58. }
  59. func TestExtractClientSessionID_NilRequest(t *testing.T) {
  60. require.Equal(t, "", ExtractClientSessionID(&gin.Context{}))
  61. }
  62. func TestExtractClientSessionID_AbsentReturnsEmpty(t *testing.T) {
  63. c := newSessionHeaderContext(t, nil)
  64. require.Equal(t, "", ExtractClientSessionID(c))
  65. }
  66. func TestExtractClientSessionID_SupportedHeaders(t *testing.T) {
  67. tests := []struct {
  68. name string
  69. header string
  70. value string
  71. }{
  72. {"session_id", "session_id", "sess-A"},
  73. {"conversation_id", "conversation_id", "conv-B"},
  74. {"X-Session-Affinity", openCodeSessionAffinityHeader, "aff-C"},
  75. {"X-Session-Id", openCodeSessionIDHeader, "sid-D"},
  76. {"X-OpenCode-Session", openCodeNativeSessionHeader, "oc-E"},
  77. {"X-Conversation-ID", codeBuddyConversationHeader, "cb-F"},
  78. {"X-Claude-Code-Session-Id", claudeCodeSessionHeader, "cc-G"},
  79. }
  80. for _, tc := range tests {
  81. t.Run(tc.name, func(t *testing.T) {
  82. c := newSessionHeaderContext(t, map[string]string{tc.header: tc.value})
  83. require.Equal(t, tc.value, ExtractClientSessionID(c))
  84. })
  85. }
  86. }
  87. func TestExtractClientSessionID_HeaderPrecedence(t *testing.T) {
  88. // session_id ranks ahead of conversation_id and the X-* variants.
  89. c := newSessionHeaderContext(t, map[string]string{
  90. "session_id": "primary",
  91. "conversation_id": "secondary",
  92. openCodeSessionIDHeader: "tertiary",
  93. codeBuddyConversationHeader: "quaternary",
  94. })
  95. require.Equal(t, "primary", ExtractClientSessionID(c))
  96. }
  97. func TestExtractClientSessionID_Sanitizes(t *testing.T) {
  98. c := newSessionHeaderContext(t, map[string]string{openCodeSessionIDHeader: " clean-123 "})
  99. require.Equal(t, "clean-123", ExtractClientSessionID(c))
  100. }
  101. func TestExtractClientSessionID_IgnoresNonSessionHeaders(t *testing.T) {
  102. // prompt_cache_key, request/message ids, and a Grok conversation header on a
  103. // non-Grok request are NOT persisted as session_id.
  104. c := newSessionHeaderContext(t, map[string]string{
  105. "prompt_cache_key": "cache-key-should-not-persist",
  106. "X-Request-Id": "req-should-not-persist",
  107. "x-grok-conv-id": "grok-conv-should-not-persist",
  108. })
  109. require.Equal(t, "", ExtractClientSessionID(c))
  110. }
  111. func TestExtractClientSessionID_GrokConversationHeader(t *testing.T) {
  112. c := newSessionHeaderContext(t, map[string]string{
  113. grokConversationIDHeader: "grok-native-session",
  114. })
  115. c.Set("api_key", &APIKey{
  116. ID: 42,
  117. Group: &Group{Platform: PlatformGrok},
  118. })
  119. require.Equal(t, "grok-native-session", ExtractClientSessionID(c))
  120. }
  121. func TestExtractClientSessionID_GrokConversationHeaderForCompositeRoute(t *testing.T) {
  122. c := newSessionHeaderContext(t, map[string]string{
  123. grokConversationIDHeader: "grok-composite-session",
  124. })
  125. c.Set("api_key", &APIKey{
  126. ID: 43,
  127. Group: &Group{Platform: PlatformComposite},
  128. })
  129. c.Request = c.Request.WithContext(WithResolvedTargetPlatform(context.Background(), PlatformGrok))
  130. require.Equal(t, "grok-composite-session", ExtractClientSessionID(c))
  131. }
  132. func TestExtractClientSessionID_InjectionHeaderDropped(t *testing.T) {
  133. // A supported header carrying a CRLF payload is rejected, not persisted mangled.
  134. c := newSessionHeaderContext(t, map[string]string{"session_id": "abc"})
  135. c.Request.Header.Set("session_id", "abc\r\nX-Injected: 1")
  136. require.Equal(t, "", ExtractClientSessionID(c))
  137. }