image_storage_test.go 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. package service
  2. import (
  3. "context"
  4. "encoding/base64"
  5. "encoding/json"
  6. "errors"
  7. "net/http"
  8. "net/http/httptest"
  9. "testing"
  10. "time"
  11. "github.com/stretchr/testify/require"
  12. )
  13. // pngBytes is a minimal payload whose signature makes http.DetectContentType
  14. // report image/png.
  15. var pngBytes = []byte("\x89PNG\r\n\x1a\nfake-png-payload")
  16. type savedImage struct {
  17. key string
  18. contentType string
  19. data []byte
  20. }
  21. type fakeImageStorage struct {
  22. saved []savedImage
  23. url string
  24. err error
  25. }
  26. type roundTripFunc func(*http.Request) (*http.Response, error)
  27. func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
  28. return f(req)
  29. }
  30. func (f *fakeImageStorage) Save(_ context.Context, key, contentType string, data []byte) (string, error) {
  31. if f.err != nil {
  32. return "", f.err
  33. }
  34. f.saved = append(f.saved, savedImage{key: key, contentType: contentType, data: append([]byte(nil), data...)})
  35. if f.url != "" {
  36. return f.url, nil
  37. }
  38. return "https://cdn.test/" + key, nil
  39. }
  40. func TestImageResultUploaderRewritesB64JSON(t *testing.T) {
  41. storage := &fakeImageStorage{}
  42. uploader := NewImageResultUploader(storage, "images/", 0, nil)
  43. b64 := base64.StdEncoding.EncodeToString(pngBytes)
  44. result := json.RawMessage(`{"created":1,"data":[{"b64_json":"` + b64 + `","revised_prompt":"a cat"}]}`)
  45. out, err := uploader.Rewrite(context.Background(), "imgtask_abc", result)
  46. require.NoError(t, err)
  47. require.Len(t, storage.saved, 1)
  48. require.Equal(t, "images/imgtask_abc-0.png", storage.saved[0].key)
  49. require.Equal(t, "image/png", storage.saved[0].contentType)
  50. require.Equal(t, pngBytes, storage.saved[0].data)
  51. var parsed struct {
  52. Data []map[string]json.RawMessage `json:"data"`
  53. }
  54. require.NoError(t, json.Unmarshal(out, &parsed))
  55. require.Len(t, parsed.Data, 1)
  56. require.JSONEq(t, `"https://cdn.test/images/imgtask_abc-0.png"`, string(parsed.Data[0]["url"]))
  57. _, hasB64 := parsed.Data[0]["b64_json"]
  58. require.False(t, hasB64, "b64_json must be stripped after offload")
  59. require.JSONEq(t, `"a cat"`, string(parsed.Data[0]["revised_prompt"]), "unrelated fields preserved")
  60. }
  61. func TestImageResultUploaderRewritesURL(t *testing.T) {
  62. upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  63. w.Header().Set("Content-Type", "image/png")
  64. _, _ = w.Write(pngBytes)
  65. }))
  66. defer upstream.Close()
  67. storage := &fakeImageStorage{}
  68. uploader := NewImageResultUploader(storage, "images/", 0, nil)
  69. result := json.RawMessage(`{"created":1,"data":[{"url":"` + upstream.URL + `/pic.png"}]}`)
  70. out, err := uploader.Rewrite(context.Background(), "imgtask_xyz", result)
  71. require.NoError(t, err)
  72. require.Len(t, storage.saved, 1)
  73. require.Equal(t, pngBytes, storage.saved[0].data)
  74. require.Equal(t, "image/png", storage.saved[0].contentType)
  75. var parsed struct {
  76. Data []map[string]json.RawMessage `json:"data"`
  77. }
  78. require.NoError(t, json.Unmarshal(out, &parsed))
  79. require.JSONEq(t, `"https://cdn.test/images/imgtask_xyz-0.png"`, string(parsed.Data[0]["url"]))
  80. }
  81. func TestImageResultUploaderRewritesImageDataURLWithoutHTTP(t *testing.T) {
  82. httpCalls := 0
  83. client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
  84. httpCalls++
  85. return nil, errors.New("HTTP must not be called for data URLs")
  86. })}
  87. storage := &fakeImageStorage{}
  88. uploader := NewImageResultUploader(storage, "images/", 0, client)
  89. b64 := base64.StdEncoding.EncodeToString(pngBytes)
  90. result := json.RawMessage(`{"data":[{"url":"DATA:image/jpeg;name=photo.jpg;BaSe64,` + b64 + `","revised_prompt":"kept"}]}`)
  91. out, err := uploader.Rewrite(context.Background(), "imgtask_data", result)
  92. require.NoError(t, err)
  93. require.Zero(t, httpCalls)
  94. require.Len(t, storage.saved, 1)
  95. require.Equal(t, pngBytes, storage.saved[0].data)
  96. require.Equal(t, "image/png", storage.saved[0].contentType, "detected bytes take precedence over a conflicting declaration")
  97. require.Equal(t, "images/imgtask_data-0.png", storage.saved[0].key)
  98. var parsed struct {
  99. Data []map[string]json.RawMessage `json:"data"`
  100. }
  101. require.NoError(t, json.Unmarshal(out, &parsed))
  102. require.JSONEq(t, `"https://cdn.test/images/imgtask_data-0.png"`, string(parsed.Data[0]["url"]))
  103. require.JSONEq(t, `"kept"`, string(parsed.Data[0]["revised_prompt"]))
  104. }
  105. func TestImageResultUploaderDataURLValidation(t *testing.T) {
  106. tests := []struct {
  107. name string
  108. url string
  109. wantErr string
  110. }{
  111. {name: "missing comma", url: "data:image/png;base64", wantErr: "missing comma"},
  112. {name: "non image", url: "data:text/plain;base64,aGVsbG8=", wantErr: "is not an image"},
  113. {name: "non base64", url: "data:image/png,raw", wantErr: "not base64"},
  114. {name: "invalid base64", url: "data:image/png;base64,%%%", wantErr: "base64 payload"},
  115. {name: "invalid media type", url: "data:image/png;bad parameter;base64,aGVsbG8=", wantErr: "invalid media type"},
  116. {name: "parameter after base64", url: "data:image/png;base64;name=photo.png,aGVsbG8=", wantErr: "base64 marker must be the final header token"},
  117. {name: "duplicate base64 marker", url: "data:image/png;base64;base64,aGVsbG8=", wantErr: "duplicate base64 marker"},
  118. }
  119. for _, tt := range tests {
  120. t.Run(tt.name, func(t *testing.T) {
  121. httpCalls := 0
  122. client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
  123. httpCalls++
  124. return nil, errors.New("HTTP must not be called for data URLs")
  125. })}
  126. uploader := NewImageResultUploader(&fakeImageStorage{}, "images/", 0, client)
  127. result, err := json.Marshal(map[string]any{"data": []map[string]string{{"url": tt.url}}})
  128. require.NoError(t, err)
  129. _, err = uploader.Rewrite(context.Background(), "imgtask_bad", result)
  130. require.ErrorContains(t, err, tt.wantErr)
  131. require.Zero(t, httpCalls)
  132. })
  133. }
  134. }
  135. func TestImageResultUploaderRejectsOversizedImageDataURL(t *testing.T) {
  136. storage := &fakeImageStorage{}
  137. uploader := NewImageResultUploader(storage, "images/", 3, nil)
  138. payload := base64.StdEncoding.EncodeToString([]byte("four"))
  139. result := json.RawMessage(`{"data":[{"url":"data:image/png;base64,` + payload + `"}]}`)
  140. _, err := uploader.Rewrite(context.Background(), "imgtask_large", result)
  141. require.ErrorContains(t, err, "decoded image data URL exceeds 3 bytes")
  142. require.Empty(t, storage.saved)
  143. }
  144. func TestImageResultUploaderB64JSONTakesPrecedenceOverDataURL(t *testing.T) {
  145. storage := &fakeImageStorage{}
  146. uploader := NewImageResultUploader(storage, "images/", 0, nil)
  147. b64 := base64.StdEncoding.EncodeToString(pngBytes)
  148. result := json.RawMessage(`{"data":[{"b64_json":"` + b64 + `","url":"data:text/plain,not-an-image"}]}`)
  149. _, err := uploader.Rewrite(context.Background(), "imgtask_precedence", result)
  150. require.NoError(t, err)
  151. require.Len(t, storage.saved, 1)
  152. require.Equal(t, pngBytes, storage.saved[0].data)
  153. }
  154. func TestImageResultUploaderPropagatesStorageError(t *testing.T) {
  155. storage := &fakeImageStorage{err: errors.New("bucket unreachable")}
  156. uploader := NewImageResultUploader(storage, "images/", 0, nil)
  157. b64 := base64.StdEncoding.EncodeToString(pngBytes)
  158. result := json.RawMessage(`{"data":[{"b64_json":"` + b64 + `"}]}`)
  159. _, err := uploader.Rewrite(context.Background(), "imgtask_err", result)
  160. require.Error(t, err)
  161. require.Contains(t, err.Error(), "bucket unreachable")
  162. }
  163. func TestImageResultUploaderNilStoragePassthrough(t *testing.T) {
  164. var uploader *ImageResultUploader
  165. result := json.RawMessage(`{"data":[{"url":"https://example.test/x.png"}]}`)
  166. out, err := uploader.Rewrite(context.Background(), "imgtask_nil", result)
  167. require.NoError(t, err)
  168. require.JSONEq(t, string(result), string(out))
  169. }
  170. func TestImageTaskServiceCompleteOffloadsToStorage(t *testing.T) {
  171. store := &imageTaskMemoryStore{}
  172. storage := &fakeImageStorage{}
  173. uploader := NewImageResultUploader(storage, "images/", 0, nil)
  174. svc := NewImageTaskServiceWithUploader(store, uploader, time.Hour, time.Minute)
  175. require.True(t, svc.Enabled())
  176. owner := ImageTaskOwner{UserID: 1, APIKeyID: 2}
  177. created, err := svc.Create(context.Background(), owner)
  178. require.NoError(t, err)
  179. b64 := base64.StdEncoding.EncodeToString(pngBytes)
  180. result := json.RawMessage(`{"created":1,"data":[{"b64_json":"` + b64 + `"}]}`)
  181. require.NoError(t, svc.Complete(context.Background(), created.ID, http.StatusOK, result))
  182. got, err := svc.Get(context.Background(), owner, created.ID)
  183. require.NoError(t, err)
  184. require.Equal(t, ImageTaskStatusCompleted, got.Status)
  185. require.Equal(t, "https://cdn.test/images/"+created.ID+"-0.png", got.ImageURL)
  186. require.NotContains(t, string(got.Result), "b64_json", "large base64 must not be persisted to Redis")
  187. require.Len(t, storage.saved, 1)
  188. }
  189. func TestImageTaskServiceCompleteOffloadFailureMarksFailed(t *testing.T) {
  190. store := &imageTaskMemoryStore{}
  191. storage := &fakeImageStorage{err: errors.New("bucket unreachable")}
  192. uploader := NewImageResultUploader(storage, "images/", 0, nil)
  193. svc := NewImageTaskServiceWithUploader(store, uploader, time.Hour, time.Minute)
  194. owner := ImageTaskOwner{UserID: 1, APIKeyID: 2}
  195. created, err := svc.Create(context.Background(), owner)
  196. require.NoError(t, err)
  197. b64 := base64.StdEncoding.EncodeToString(pngBytes)
  198. result := json.RawMessage(`{"data":[{"b64_json":"` + b64 + `"}]}`)
  199. require.NoError(t, svc.Complete(context.Background(), created.ID, http.StatusOK, result))
  200. got, err := svc.Get(context.Background(), owner, created.ID)
  201. require.NoError(t, err)
  202. require.Equal(t, ImageTaskStatusFailed, got.Status)
  203. require.Equal(t, http.StatusBadGateway, got.HTTPStatus)
  204. require.Contains(t, string(got.Error), "object storage")
  205. require.NotContains(t, string(got.Result), "b64_json", "failed offload must not persist base64 to Redis")
  206. }