grok_media_content_test.go 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. //go:build unit
  2. package service
  3. import (
  4. "context"
  5. "io"
  6. "net/http"
  7. "net/http/httptest"
  8. "strings"
  9. "testing"
  10. "github.com/Wei-Shaw/sub2api/internal/config"
  11. "github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
  12. "github.com/gin-gonic/gin"
  13. "github.com/stretchr/testify/require"
  14. "github.com/tidwall/gjson"
  15. )
  16. type grokMediaContentUpstreamStub struct {
  17. request *http.Request
  18. requests []*http.Request
  19. response *http.Response
  20. responses []*http.Response
  21. }
  22. func (s *grokMediaContentUpstreamStub) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
  23. s.request = req
  24. s.requests = append(s.requests, req)
  25. if len(s.responses) > 0 {
  26. resp := s.responses[0]
  27. s.responses = s.responses[1:]
  28. return resp, nil
  29. }
  30. return s.response, nil
  31. }
  32. func (s *grokMediaContentUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
  33. return s.Do(req, proxyURL, accountID, accountConcurrency)
  34. }
  35. func grokMediaContentTestAccount() *Account {
  36. return &Account{
  37. ID: 9,
  38. Platform: PlatformGrok,
  39. Type: AccountTypeAPIKey,
  40. Credentials: map[string]any{
  41. "api_key": "upstream-key",
  42. "base_url": "https://relay.example/v1",
  43. },
  44. }
  45. }
  46. func grokMediaContentTestContext(method, target string, headers map[string]string) (*gin.Context, *httptest.ResponseRecorder) {
  47. gin.SetMode(gin.TestMode)
  48. recorder := httptest.NewRecorder()
  49. c, _ := gin.CreateTestContext(recorder)
  50. c.Request = httptest.NewRequest(method, target, nil)
  51. for name, value := range headers {
  52. c.Request.Header.Set(name, value)
  53. }
  54. return c, recorder
  55. }
  56. func grokMediaContentStatusResponse(body string) *http.Response {
  57. return &http.Response{
  58. StatusCode: http.StatusOK,
  59. Header: http.Header{"Content-Type": []string{"application/json"}},
  60. Body: io.NopCloser(strings.NewReader(body)),
  61. }
  62. }
  63. func TestForwardGrokMediaContentUsesUpstreamCredentialAndStreamsRange(t *testing.T) {
  64. upstream := &grokMediaContentUpstreamStub{
  65. responses: []*http.Response{grokMediaContentStatusResponse(`{"status":"completed"}`), {
  66. StatusCode: http.StatusPartialContent,
  67. Header: http.Header{
  68. "Content-Type": []string{"video/mp4"},
  69. "Content-Length": []string{"13"},
  70. "Content-Range": []string{"bytes 0-12/100"},
  71. "Accept-Ranges": []string{"bytes"},
  72. "Content-Disposition": []string{
  73. `attachment; filename="task-1.mp4"`,
  74. },
  75. },
  76. Body: io.NopCloser(strings.NewReader("video-payload")),
  77. }},
  78. }
  79. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  80. c, recorder := grokMediaContentTestContext(http.MethodGet, "https://api.example/v1/videos/task-1/content", map[string]string{
  81. "Range": "bytes=0-12",
  82. })
  83. result, err := svc.ForwardGrokMedia(
  84. context.Background(), c, grokMediaContentTestAccount(),
  85. GrokMediaEndpointVideoContent, "task-1", nil, "",
  86. )
  87. require.NoError(t, err)
  88. require.NotNil(t, result)
  89. require.Equal(t, http.StatusPartialContent, recorder.Code)
  90. require.Equal(t, "video-payload", recorder.Body.String())
  91. require.Len(t, upstream.requests, 2)
  92. require.Equal(t, "https://relay.example/v1/videos/task-1", upstream.requests[0].URL.String())
  93. require.Equal(t, "Bearer upstream-key", upstream.requests[0].Header.Get("Authorization"))
  94. require.Equal(t, "https://relay.example/v1/videos/task-1/content", upstream.requests[1].URL.String())
  95. require.Equal(t, "Bearer upstream-key", upstream.requests[1].Header.Get("Authorization"))
  96. require.Equal(t, "bytes=0-12", upstream.requests[1].Header.Get("Range"))
  97. require.Equal(t, "*/*", upstream.requests[1].Header.Get("Accept"))
  98. require.Equal(t, "video/mp4", recorder.Header().Get("Content-Type"))
  99. require.Equal(t, "13", recorder.Header().Get("Content-Length"))
  100. require.Equal(t, "bytes 0-12/100", recorder.Header().Get("Content-Range"))
  101. require.Equal(t, "bytes", recorder.Header().Get("Accept-Ranges"))
  102. require.Equal(t, `attachment; filename="task-1.mp4"`, recorder.Header().Get("Content-Disposition"))
  103. require.True(t, IsResponseCommitted(c))
  104. }
  105. func TestForwardGrokMediaContentStreamsFullResponseWithSafeDefaults(t *testing.T) {
  106. upstream := &grokMediaContentUpstreamStub{
  107. responses: []*http.Response{grokMediaContentStatusResponse(`{"status":"completed"}`), {
  108. StatusCode: http.StatusOK,
  109. Header: http.Header{"Set-Cookie": []string{"secret=upstream"}, "X-Upstream-Secret": []string{"hidden"}},
  110. Body: io.NopCloser(strings.NewReader("full-video")),
  111. ContentLength: -1,
  112. }},
  113. }
  114. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  115. c, recorder := grokMediaContentTestContext(http.MethodGet, "https://api.example/v1/videos/task-1/content", nil)
  116. _, err := svc.ForwardGrokMedia(
  117. context.Background(), c, grokMediaContentTestAccount(),
  118. GrokMediaEndpointVideoContent, "task-1", nil, "",
  119. )
  120. require.NoError(t, err)
  121. require.Equal(t, http.StatusOK, recorder.Code)
  122. require.Equal(t, "full-video", recorder.Body.String())
  123. require.Len(t, upstream.requests, 2)
  124. require.Empty(t, upstream.requests[1].Header.Get("Range"))
  125. require.Equal(t, "application/octet-stream", recorder.Header().Get("Content-Type"))
  126. require.Empty(t, recorder.Header().Get("Content-Length"))
  127. require.Empty(t, recorder.Header().Get("Set-Cookie"))
  128. require.Empty(t, recorder.Header().Get("X-Upstream-Secret"))
  129. require.True(t, IsResponseCommitted(c))
  130. }
  131. func TestForwardGrokMediaContentPreservesRangeNotSatisfiable(t *testing.T) {
  132. upstream := &grokMediaContentUpstreamStub{
  133. responses: []*http.Response{grokMediaContentStatusResponse(`{"status":"completed"}`), {
  134. StatusCode: http.StatusRequestedRangeNotSatisfiable,
  135. Header: http.Header{
  136. "Content-Type": []string{"text/plain"},
  137. "Content-Length": []string{"11"},
  138. "Content-Range": []string{"bytes */100"},
  139. "Accept-Ranges": []string{"bytes"},
  140. },
  141. Body: io.NopCloser(strings.NewReader("bad-range!!")),
  142. }},
  143. }
  144. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  145. c, recorder := grokMediaContentTestContext(http.MethodGet, "https://api.example/v1/videos/task-1/content", map[string]string{
  146. "Range": "bytes=500-600",
  147. })
  148. _, err := svc.ForwardGrokMedia(
  149. context.Background(), c, grokMediaContentTestAccount(),
  150. GrokMediaEndpointVideoContent, "task-1", nil, "",
  151. )
  152. require.NoError(t, err)
  153. require.Equal(t, http.StatusRequestedRangeNotSatisfiable, recorder.Code)
  154. require.Equal(t, "bad-range!!", recorder.Body.String())
  155. require.Len(t, upstream.requests, 2)
  156. require.Equal(t, "bytes=500-600", upstream.requests[1].Header.Get("Range"))
  157. require.Equal(t, "bytes */100", recorder.Header().Get("Content-Range"))
  158. require.Equal(t, "bytes", recorder.Header().Get("Accept-Ranges"))
  159. require.True(t, IsResponseCommitted(c))
  160. }
  161. func TestForwardGrokMediaContentFetchesValidatedSignedURLWithoutCredentials(t *testing.T) {
  162. upstream := &grokMediaContentUpstreamStub{
  163. responses: []*http.Response{
  164. grokMediaContentStatusResponse(`{"status":"done","video":{"url":"https://vidgen.x.ai/signed-token/xai-video-task-1.mp4"}}`),
  165. {
  166. StatusCode: http.StatusPartialContent,
  167. Header: http.Header{
  168. "Content-Type": []string{"video/mp4"},
  169. "Content-Length": []string{"13"},
  170. "Content-Range": []string{"bytes 0-12/100"},
  171. },
  172. Body: io.NopCloser(strings.NewReader("video-payload")),
  173. },
  174. },
  175. }
  176. account := grokMediaContentTestAccount()
  177. account.Credentials[credKeyHeaderOverrideEnabled] = true
  178. account.Credentials[credKeyHeaderOverrides] = map[string]any{"user-agent": "private-agent"}
  179. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  180. c, recorder := grokMediaContentTestContext(http.MethodGet, "https://api.example/v1/videos/task-1/content", map[string]string{
  181. "Range": "bytes=0-12",
  182. })
  183. _, err := svc.ForwardGrokMedia(
  184. context.Background(), c, account,
  185. GrokMediaEndpointVideoContent, "task-1", nil, "",
  186. )
  187. require.NoError(t, err)
  188. require.Equal(t, http.StatusPartialContent, recorder.Code)
  189. require.Equal(t, "video-payload", recorder.Body.String())
  190. require.Len(t, upstream.requests, 2)
  191. require.Equal(t, "https://relay.example/v1/videos/task-1", upstream.requests[0].URL.String())
  192. require.Equal(t, "Bearer upstream-key", upstream.requests[0].Header.Get("Authorization"))
  193. require.Equal(t, "private-agent", upstream.requests[0].Header.Get("User-Agent"))
  194. require.True(t, HTTPUpstreamRedirectsDisabled(upstream.requests[0].Context()))
  195. require.Equal(t, "https://vidgen.x.ai/signed-token/xai-video-task-1.mp4", upstream.requests[1].URL.String())
  196. require.Empty(t, upstream.requests[1].Header.Get("Authorization"))
  197. require.Empty(t, upstream.requests[1].Header.Get("User-Agent"))
  198. require.Equal(t, "bytes=0-12", upstream.requests[1].Header.Get("Range"))
  199. require.True(t, HTTPUpstreamRedirectsDisabled(upstream.requests[1].Context()))
  200. }
  201. func TestForwardGrokMediaContentFollowsAuthenticatedSub2APIRelay(t *testing.T) {
  202. for _, statusURL := range []string{
  203. `/v1/videos/task-1/content`,
  204. `https://relay.example/v1/videos/task-1/content`,
  205. } {
  206. t.Run(statusURL, func(t *testing.T) {
  207. upstream := &grokMediaContentUpstreamStub{
  208. responses: []*http.Response{
  209. grokMediaContentStatusResponse(`{"status":"completed","video":{"url":"` + statusURL + `"}}`),
  210. {
  211. StatusCode: http.StatusOK,
  212. Header: http.Header{"Content-Type": []string{"video/mp4"}},
  213. Body: io.NopCloser(strings.NewReader("video-payload")),
  214. },
  215. },
  216. }
  217. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  218. c, recorder := grokMediaContentTestContext(http.MethodGet, "https://api.example/v1/videos/task-1/content", nil)
  219. _, err := svc.ForwardGrokMedia(
  220. context.Background(), c, grokMediaContentTestAccount(),
  221. GrokMediaEndpointVideoContent, "task-1", nil, "",
  222. )
  223. require.NoError(t, err)
  224. require.Equal(t, http.StatusOK, recorder.Code)
  225. require.Equal(t, "video-payload", recorder.Body.String())
  226. require.Len(t, upstream.requests, 2)
  227. require.Equal(t, "https://relay.example/v1/videos/task-1/content", upstream.requests[1].URL.String())
  228. require.Equal(t, "Bearer upstream-key", upstream.requests[1].Header.Get("Authorization"))
  229. })
  230. }
  231. }
  232. func TestForwardGrokMediaContentRejectsUntrustedSignedURL(t *testing.T) {
  233. upstream := &grokMediaContentUpstreamStub{
  234. responses: []*http.Response{
  235. grokMediaContentStatusResponse(`{"status":"done","video":{"url":"http://169.` + `254.169.254/latest/meta-data"}}`),
  236. },
  237. }
  238. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  239. c, _ := grokMediaContentTestContext(http.MethodGet, "https://api.example/v1/videos/task-1/content", nil)
  240. _, err := svc.ForwardGrokMedia(
  241. context.Background(), c, grokMediaContentTestAccount(),
  242. GrokMediaEndpointVideoContent, "task-1", nil, "",
  243. )
  244. require.ErrorContains(t, err, "unsupported video content URL")
  245. require.Len(t, upstream.requests, 1)
  246. }
  247. func TestGrokMediaSignedVideoContentURLRejectsDeceptiveOrigins(t *testing.T) {
  248. for _, rawURL := range []string{
  249. "https://vidgen.x.ai.attacker.invalid/video.mp4",
  250. "https://vidgen.x.ai" + "@attacker.invalid/video.mp4",
  251. "https://vidgen.x.ai:444/video.mp4",
  252. "http://vidgen.x.ai/video.mp4",
  253. } {
  254. t.Run(rawURL, func(t *testing.T) {
  255. _, err := grokMediaSignedVideoContentURL([]byte(`{"video":{"url":"`+rawURL+`"}}`), "task-1")
  256. require.ErrorContains(t, err, "unsupported video content URL")
  257. })
  258. }
  259. }
  260. func TestGrokMediaSignedVideoContentURLRejectsDifferentRelayTask(t *testing.T) {
  261. _, err := grokMediaSignedVideoContentURL(
  262. []byte(`{"video":{"url":"/v1/videos/task-2/content"}}`),
  263. "task-1",
  264. )
  265. require.ErrorContains(t, err, "unsupported video content URL")
  266. }
  267. func TestForwardGrokVideoStatusRewritesOnlyProtectedContentURL(t *testing.T) {
  268. statusBody := `{"id":"task-1","status":"completed","url":"https://relay.example/v1/videos/task-1/content","download_url":"/v1/videos/task-1/content","video_url":"https://vidgen.x.ai/task-1.mp4","counter":9007199254740993}`
  269. upstream := &grokMediaContentUpstreamStub{
  270. response: &http.Response{
  271. StatusCode: http.StatusOK,
  272. Header: http.Header{"Content-Type": []string{"application/json"}},
  273. Body: io.NopCloser(strings.NewReader(statusBody)),
  274. },
  275. }
  276. svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
  277. c, recorder := grokMediaContentTestContext(http.MethodGet, "https://api.example/v1/videos/task-1", map[string]string{
  278. "X-Forwarded-Host": "malicious.invalid",
  279. "X-Forwarded-Proto": "https",
  280. })
  281. _, err := svc.ForwardGrokMedia(
  282. context.Background(), c, grokMediaContentTestAccount(),
  283. GrokMediaEndpointVideoStatus, "task-1", nil, "",
  284. )
  285. require.NoError(t, err)
  286. require.Equal(t, http.StatusOK, recorder.Code)
  287. require.Equal(t, "/v1/videos/task-1/content", gjson.Get(recorder.Body.String(), "url").String())
  288. require.Equal(t, "/v1/videos/task-1/content", gjson.Get(recorder.Body.String(), "download_url").String())
  289. require.Equal(t, "https://vidgen.x.ai/task-1.mp4", gjson.Get(recorder.Body.String(), "video_url").String())
  290. require.Equal(t, "9007199254740993", gjson.Get(recorder.Body.String(), "counter").String())
  291. require.NotContains(t, recorder.Body.String(), "malicious.invalid")
  292. }
  293. func TestRewriteGrokMediaVideoContentURLsPreservesOtherIDsAndHandlesNestedEscapedID(t *testing.T) {
  294. body := []byte(`{"nested":[{"url":"https://relay.example/v1/videos/task%2Fone/content"},{"url":"https://relay.example/v1/videos/task-two/content"}]}`)
  295. rewritten := rewriteGrokMediaVideoContentURLs(body, "task/one", "/v1/videos/task%2Fone/content")
  296. require.Equal(t, "/v1/videos/task%2Fone/content", gjson.GetBytes(rewritten, "nested.0.url").String())
  297. require.Equal(t, "https://relay.example/v1/videos/task-two/content", gjson.GetBytes(rewritten, "nested.1.url").String())
  298. }
  299. func TestRewriteGrokMediaVideoContentURLsRewritesSignedVideoURL(t *testing.T) {
  300. body := []byte(`{"status":"done","video":{"url":"https://vidgen.x.ai/signed-token/xai-video-request-1.mp4","duration":8}}`)
  301. rewritten := rewriteGrokMediaVideoContentURLs(body, "request-1", "/v1/videos/request-1/content")
  302. require.Equal(t, "/v1/videos/request-1/content", gjson.GetBytes(rewritten, "video.url").String())
  303. require.Equal(t, "8", gjson.GetBytes(rewritten, "video.duration").String())
  304. require.Equal(t, "done", gjson.GetBytes(rewritten, "status").String())
  305. }