openai_opencode_session_test.go 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. package service
  2. import (
  3. "context"
  4. "io"
  5. "net/http"
  6. "net/http/httptest"
  7. "strings"
  8. "testing"
  9. "github.com/Wei-Shaw/sub2api/internal/config"
  10. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  11. "github.com/gin-gonic/gin"
  12. "github.com/stretchr/testify/require"
  13. )
  14. func newOpenCodeSessionTestContext(t *testing.T, value string) *gin.Context {
  15. t.Helper()
  16. recorder := httptest.NewRecorder()
  17. c, _ := gin.CreateTestContext(recorder)
  18. c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
  19. if value != "" {
  20. c.Request.Header.Set(openCodeSessionHeader, value)
  21. }
  22. return c
  23. }
  24. func openCodeSessionTestService() *OpenAIGatewayService {
  25. return &OpenAIGatewayService{cfg: &config.Config{
  26. Security: config.SecurityConfig{
  27. URLAllowlist: config.URLAllowlistConfig{Enabled: false},
  28. },
  29. }}
  30. }
  31. func openCodeSessionTestAccount(baseURL string) *Account {
  32. return &Account{
  33. ID: 1,
  34. Platform: PlatformOpenAI,
  35. Type: AccountTypeAPIKey,
  36. Credentials: map[string]any{
  37. "base_url": baseURL,
  38. credKeyHeaderOverrideEnabled: true,
  39. credKeyHeaderOverrides: map[string]any{"x-opencode-session": "fixed-account-value"},
  40. },
  41. }
  42. }
  43. func requireSingleOpenCodeSessionHeader(t *testing.T, headers http.Header, want string) {
  44. t.Helper()
  45. count := 0
  46. for key, values := range headers {
  47. if strings.EqualFold(key, openCodeSessionHeader) {
  48. count += len(values)
  49. require.Equal(t, []string{want}, values)
  50. }
  51. }
  52. require.Equal(t, 1, count)
  53. }
  54. func TestApplyOpenCodeSessionHeaderTrustBoundary(t *testing.T) {
  55. tests := []struct {
  56. name string
  57. account *Account
  58. targetURL string
  59. incoming string
  60. want string
  61. }{
  62. {
  63. name: "official origin",
  64. account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"),
  65. targetURL: "https://opencode.ai/zen/v1/chat/completions",
  66. incoming: " conversation-123 ",
  67. want: "conversation-123",
  68. },
  69. {
  70. name: "lookalike origin",
  71. account: openCodeSessionTestAccount("https://opencode.ai.evil.example/v1"),
  72. targetURL: "https://opencode.ai.evil.example/v1/responses",
  73. incoming: "conversation-123",
  74. },
  75. {
  76. name: "subdomain is not implicitly trusted",
  77. account: openCodeSessionTestAccount("https://api.opencode.ai/v1"),
  78. targetURL: "https://api.opencode.ai/v1/responses",
  79. incoming: "conversation-123",
  80. },
  81. {
  82. name: "insecure official origin",
  83. account: openCodeSessionTestAccount("http://opencode.ai/zen/v1"),
  84. targetURL: "http://opencode.ai/zen/v1/responses",
  85. incoming: "conversation-123",
  86. },
  87. {
  88. name: "missing caller value",
  89. account: openCodeSessionTestAccount("https://opencode.ai/zen/v1"),
  90. targetURL: "https://opencode.ai/zen/v1/responses",
  91. },
  92. {
  93. name: "oauth account",
  94. account: &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth},
  95. targetURL: "https://opencode.ai/zen/v1/responses",
  96. incoming: "conversation-123",
  97. },
  98. }
  99. for _, tt := range tests {
  100. t.Run(tt.name, func(t *testing.T) {
  101. headers := make(http.Header)
  102. applyOpenCodeSessionHeader(newOpenCodeSessionTestContext(t, tt.incoming), tt.account, tt.targetURL, headers)
  103. require.Equal(t, tt.want, headers.Get(openCodeSessionHeader))
  104. })
  105. }
  106. }
  107. func TestOpenCodeSessionForwardedByResponsesBuildersAfterAccountOverride(t *testing.T) {
  108. gin.SetMode(gin.TestMode)
  109. svc := openCodeSessionTestService()
  110. account := openCodeSessionTestAccount("https://opencode.ai/zen/v1")
  111. body := []byte(`{"model":"gpt-5","input":"hello"}`)
  112. tests := []struct {
  113. name string
  114. build func(*gin.Context) (*http.Request, error)
  115. }{
  116. {
  117. name: "normal responses",
  118. build: func(c *gin.Context) (*http.Request, error) {
  119. return svc.buildUpstreamRequest(context.Background(), c, account, body, "token", false, "", false)
  120. },
  121. },
  122. {
  123. name: "passthrough responses",
  124. build: func(c *gin.Context) (*http.Request, error) {
  125. return svc.buildUpstreamRequestOpenAIPassthrough(context.Background(), c, account, body, "token")
  126. },
  127. },
  128. }
  129. for _, tt := range tests {
  130. t.Run(tt.name, func(t *testing.T) {
  131. c := newOpenCodeSessionTestContext(t, "conversation-456")
  132. req, err := tt.build(c)
  133. require.NoError(t, err)
  134. requireSingleOpenCodeSessionHeader(t, req.Header, "conversation-456")
  135. })
  136. }
  137. }
  138. func TestOpenCodeSessionMissingCallerValueKeepsExistingOverrideBehavior(t *testing.T) {
  139. gin.SetMode(gin.TestMode)
  140. svc := openCodeSessionTestService()
  141. account := openCodeSessionTestAccount("https://opencode.ai/zen/v1")
  142. c := newOpenCodeSessionTestContext(t, "")
  143. req, err := svc.buildUpstreamRequest(
  144. context.Background(), c, account,
  145. []byte(`{"model":"gpt-5","input":"hello"}`), "token", false, "", false,
  146. )
  147. require.NoError(t, err)
  148. require.Equal(t, "fixed-account-value", getHeaderRaw(req.Header, "x-opencode-session"))
  149. }
  150. type openCodeSessionHTTPUpstream struct {
  151. request *http.Request
  152. }
  153. func (u *openCodeSessionHTTPUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  154. u.request = req
  155. return &http.Response{
  156. StatusCode: http.StatusOK,
  157. Header: make(http.Header),
  158. Body: io.NopCloser(strings.NewReader(`{}`)),
  159. }, nil
  160. }
  161. func (u *openCodeSessionHTTPUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
  162. return u.Do(req, proxyURL, accountID, concurrency)
  163. }
  164. func TestOpenCodeSessionForwardedByRawChatCompletionsAfterAccountOverride(t *testing.T) {
  165. gin.SetMode(gin.TestMode)
  166. upstream := &openCodeSessionHTTPUpstream{}
  167. svc := openCodeSessionTestService()
  168. svc.httpUpstream = upstream
  169. account := openCodeSessionTestAccount("https://opencode.ai/zen/v1")
  170. c := newOpenCodeSessionTestContext(t, "conversation-789")
  171. resp, err := svc.sendCCUpstreamRequest(
  172. context.Background(), c, account,
  173. "https://opencode.ai/zen/v1/chat/completions", []byte(`{"model":"gpt-5"}`),
  174. false, "token", "", "",
  175. )
  176. require.NoError(t, err)
  177. require.NoError(t, resp.Body.Close())
  178. require.NotNil(t, upstream.request)
  179. requireSingleOpenCodeSessionHeader(t, upstream.request.Header, "conversation-789")
  180. }
  181. func TestOpenCodeSessionIsNotForwardedToOtherUpstreams(t *testing.T) {
  182. gin.SetMode(gin.TestMode)
  183. svc := openCodeSessionTestService()
  184. body := []byte(`{"model":"gpt-5","input":"hello"}`)
  185. for _, baseURL := range []string{
  186. "https://api.openai.com/v1",
  187. "https://opencode.ai.evil.example/v1",
  188. "https://api.opencode.ai/v1",
  189. } {
  190. t.Run(baseURL, func(t *testing.T) {
  191. account := &Account{
  192. ID: 1,
  193. Platform: PlatformOpenAI,
  194. Type: AccountTypeAPIKey,
  195. Credentials: map[string]any{"base_url": baseURL},
  196. }
  197. c := newOpenCodeSessionTestContext(t, "private-conversation")
  198. req, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "token", false, "", false)
  199. require.NoError(t, err)
  200. require.Empty(t, req.Header.Get(openCodeSessionHeader))
  201. })
  202. }
  203. }