package service import ( "bytes" "context" "encoding/base64" "io" "net/http" "net/http/httptest" "strings" "testing" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) // 8 字节 PNG 魔数足以让字节嗅探判定为 image/png。 var b64BackfillPNGBytes = []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0, 0, 0, 0} func b64BackfillImageResponse(status int, contentType string, payload []byte) *http.Response { header := http.Header{} if contentType != "" { header.Set("Content-Type", contentType) } return &http.Response{ StatusCode: status, Header: header, Body: io.NopCloser(bytes.NewReader(payload)), } } func b64BackfillAccount(enabled bool) *Account { account := &Account{ ID: 7, Name: "openai-apikey", Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{ "api_key": "sk-test", "base_url": "https://relay.example.com/v1", }, } if enabled { account.Extra = map[string]any{AccountExtraImagesURLToB64JSON: true} } return account } func TestImagesURLToB64JSONEnabled(t *testing.T) { require.False(t, ImagesURLToB64JSONEnabled(nil)) require.False(t, ImagesURLToB64JSONEnabled(&Account{})) require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: "true"}})) require.False(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: false}})) require.True(t, ImagesURLToB64JSONEnabled(&Account{Extra: map[string]any{AccountExtraImagesURLToB64JSON: true}})) } func TestBackfillOpenAIImagesB64JSON(t *testing.T) { wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes) tests := []struct { name string enabled bool parsed *OpenAIImagesRequest body string upstream *httpUpstreamRecorder wantB64 []string wantDownloads int }{ { name: "开关关闭时原样返回", enabled: false, body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, wantB64: []string{""}, wantDownloads: 0, }, { name: "缺少 b64_json 且有 url 时下载回填并保留 url", enabled: true, body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png","revised_prompt":"a cat"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, wantB64: []string{wantB64}, wantDownloads: 1, }, { name: "b64_json 为空串时同样回填", enabled: true, body: `{"created":1,"data":[{"b64_json":"","url":"https://cdn.example.com/a.png"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, wantB64: []string{wantB64}, wantDownloads: 1, }, { name: "b64_json 已有值时不下载", enabled: true, body: `{"created":1,"data":[{"b64_json":"aW1n","url":"https://cdn.example.com/a.png"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, wantB64: []string{"aW1n"}, wantDownloads: 0, }, { name: "多项混合时只回填缺失项", enabled: true, body: `{"created":1,"data":[{"b64_json":"aW1n"},{"url":"https://cdn.example.com/b.png"},{"revised_prompt":"none"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, wantB64: []string{"aW1n", wantB64, ""}, wantDownloads: 1, }, { name: "data URL 直接取载荷不下载", enabled: true, body: `{"created":1,"data":[{"url":"data:image/png;base64,` + wantB64 + `"}]}`, upstream: &httpUpstreamRecorder{}, wantB64: []string{wantB64}, wantDownloads: 0, }, { name: "客户端显式要求 url 时不回填", enabled: true, parsed: &OpenAIImagesRequest{ResponseFormat: "url"}, body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, wantB64: []string{""}, wantDownloads: 0, }, { name: "下载非 2xx 时保留原样", enabled: true, body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusNotFound, "text/plain", []byte("gone"))}, wantB64: []string{""}, wantDownloads: 1, }, { name: "下载内容不是图片时保留原样", enabled: true, body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "text/html", []byte("login required"))}, wantB64: []string{""}, wantDownloads: 1, }, { name: "响应头未声明图片类型但字节嗅探为图片时回填", enabled: true, body: `{"created":1,"data":[{"url":"https://cdn.example.com/a"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "application/octet-stream", b64BackfillPNGBytes)}, wantB64: []string{wantB64}, wantDownloads: 1, }, { name: "响应头声明图片但字节不是图片时保留原样", enabled: true, body: `{"created":1,"data":[{"url":"https://cdn.example.com/a.png"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/svg+xml", []byte(``))}, wantB64: []string{""}, wantDownloads: 1, }, { name: "非 http(s) 协议的 url 不下载", enabled: true, body: `{"created":1,"data":[{"url":"ftp://cdn.example.com/a.png"}]}`, upstream: &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)}, wantB64: []string{""}, wantDownloads: 0, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: tt.upstream} got := svc.backfillOpenAIImagesB64JSON(context.Background(), b64BackfillAccount(tt.enabled), tt.parsed, []byte(tt.body)) require.True(t, gjson.ValidBytes(got)) items := gjson.GetBytes(got, "data").Array() require.Len(t, items, len(tt.wantB64)) for i, want := range tt.wantB64 { require.Equal(t, want, items[i].Get("b64_json").String(), "data.%d.b64_json", i) } require.Len(t, tt.upstream.requests, tt.wantDownloads) // 除 b64_json 外的字段必须原样保留。 original := gjson.Parse(tt.body) original.Get("data").ForEach(func(key, item gjson.Result) bool { item.ForEach(func(field, value gjson.Result) bool { if field.String() == "b64_json" { return true } require.Equal(t, value.Raw, gjson.GetBytes(got, "data."+key.String()+"."+field.String()).Raw) return true }) return true }) require.Equal(t, original.Get("created").Raw, gjson.GetBytes(got, "created").Raw) }) } } func TestBackfillOpenAIImagesB64JSON_DownloadRequestShape(t *testing.T) { upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := b64BackfillAccount(true) proxyID := int64(3) account.ProxyID = &proxyID account.Proxy = &Proxy{Protocol: "http", Host: "127.0.0.1", Port: 7890} body := []byte(`{"created":1,"data":[{"url":"https://cdn.example.com/a.png?sig=abc"}]}`) got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, body) require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String()) require.Len(t, upstream.requests, 1) req := upstream.requests[0] require.Equal(t, http.MethodGet, req.Method) require.Equal(t, "https://cdn.example.com/a.png?sig=abc", req.URL.String()) require.Equal(t, "http://127.0.0.1:7890", upstream.lastProxyURL) _, hasDeadline := req.Context().Deadline() require.True(t, hasDeadline) // 目的地与重定向各跳都必须解析到公网地址;重定向本身保持跟随。 require.True(t, HTTPUpstreamPublicHostsOnly(req.Context())) require.False(t, HTTPUpstreamRedirectsDisabled(req.Context())) } func TestBackfillOpenAIImagesB64JSON_RejectsPrivateHosts(t *testing.T) { upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} cfg := &config.Config{} cfg.Security.URLAllowlist.AllowInsecureHTTP = true svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream} account := b64BackfillAccount(true) for _, rawURL := range []string{ "http://localhost:8080/api/v1/admin/accounts", "http://Foo.LocalHost/a.png", "http://127.0.0.1:8080/a.png", "https://127.0.0.1/a.png", "http://[::1]:8080/a.png", "http://10.0.0.5/a.png", "http://172.16.0.9:9000/bucket/a.png", "http://192.168.1.10:9000/bucket/a.png", "http://169.254.169.254/latest/meta-data/", "http://0.0.0.0:8080/a.png", "http://[fe80::1]/a.png", "http://[::ffff:127.0.0.1]/a.png", } { body := `{"created":1,"data":[{"url":"` + rawURL + `"}]}` got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body)) require.Equal(t, body, string(got), "url=%s", rawURL) } require.Empty(t, upstream.requests, "private destinations must never be requested") // 同一配置下公网主机照常下载,证明拒绝依据是目的地而非协议。 got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(`{"created":1,"data":[{"url":"http://cdn.example.com/a.png"}]}`)) require.Equal(t, base64.StdEncoding.EncodeToString(b64BackfillPNGBytes), gjson.GetBytes(got, "data.0.b64_json").String()) require.Len(t, upstream.requests, 1) } func TestIsBackfillImageContent(t *testing.T) { webp := append([]byte("RIFF\x24\x00\x00\x00WEBPVP8 "), make([]byte, 8)...) tests := []struct { name string data []byte want bool }{ {name: "png", data: b64BackfillPNGBytes, want: true}, {name: "jpeg", data: []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F', 0x00}, want: true}, {name: "webp", data: webp, want: true}, {name: "gif", data: []byte("GIF89a\x01\x00\x01\x00"), want: true}, {name: "bmp 不在允许列表", data: []byte("BM\x00\x00\x00\x00\x00\x00\x00\x00"), want: false}, {name: "ico 不在允许列表", data: []byte{0x00, 0x00, 0x01, 0x00, 0x01, 0x00}, want: false}, {name: "svg 文本", data: []byte(``), want: false}, {name: "html", data: []byte("login required"), want: false}, {name: "json", data: []byte(`{"error":"unauthorized"}`), want: false}, {name: "空", data: nil, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, isBackfillImageContent(tt.data)) }) } } func TestBackfillOpenAIImagesB64JSON_NonObjectBodies(t *testing.T) { upstream := &httpUpstreamRecorder{resp: b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes)} svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream} account := b64BackfillAccount(true) for _, body := range []string{``, `not json`, `{"created":1}`, `{"created":1,"data":{}}`, `{"created":1,"data":[]}`, `{"created":1,"data":["x"]}`} { got := svc.backfillOpenAIImagesB64JSON(context.Background(), account, nil, []byte(body)) require.Equal(t, body, string(got), "body=%q", body) } require.Empty(t, upstream.requests) } func TestOpenAIGatewayServiceForwardImages_APIKeyBackfillsB64JSONFromURL(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`) req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = req c.Set("api_key", &APIKey{ID: 42}) svc := &OpenAIGatewayService{cfg: &config.Config{}} parsed, err := svc.ParseOpenAIImagesRequest(c, body) require.NoError(t, err) upstream := &httpUpstreamRecorder{ responses: []*http.Response{ { StatusCode: http.StatusOK, Header: http.Header{ "Content-Type": []string{"application/json"}, "X-Request-Id": []string{"req_img_url"}, }, Body: io.NopCloser(strings.NewReader( `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png","revised_prompt":"a cat"}],"usage":{"input_tokens":10,"output_tokens":20}}`, )), }, b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes), }, } svc.httpUpstream = upstream result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(true), body, parsed, "") require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 1, result.ImageCount) require.False(t, result.Stream) require.Len(t, upstream.requests, 2) require.Equal(t, http.MethodPost, upstream.requests[0].Method) require.Equal(t, "https://relay.example.com/v1/images/generations", upstream.requests[0].URL.String()) require.Equal(t, http.MethodGet, upstream.requests[1].Method) require.Equal(t, "https://cdn.example.com/cat.png", upstream.requests[1].URL.String()) require.True(t, HTTPUpstreamPublicHostsOnly(upstream.requests[1].Context())) require.Equal(t, http.StatusOK, rec.Code) wantB64 := base64.StdEncoding.EncodeToString(b64BackfillPNGBytes) require.Equal(t, wantB64, gjson.Get(rec.Body.String(), "data.0.b64_json").String()) require.Equal(t, "https://cdn.example.com/cat.png", gjson.Get(rec.Body.String(), "data.0.url").String()) require.Equal(t, "a cat", gjson.Get(rec.Body.String(), "data.0.revised_prompt").String()) require.Equal(t, int64(1710000000), gjson.Get(rec.Body.String(), "created").Int()) } func TestOpenAIGatewayServiceForwardImages_APIKeyLeavesURLOnlyResponseWhenDisabled(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","size":"1024x1024"}`) req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = req c.Set("api_key", &APIKey{ID: 42}) svc := &OpenAIGatewayService{cfg: &config.Config{}} parsed, err := svc.ParseOpenAIImagesRequest(c, body) require.NoError(t, err) upstreamBody := `{"created":1710000000,"data":[{"url":"https://cdn.example.com/cat.png"}]}` upstream := &httpUpstreamRecorder{ responses: []*http.Response{ { StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(upstreamBody)), }, b64BackfillImageResponse(http.StatusOK, "image/png", b64BackfillPNGBytes), }, } svc.httpUpstream = upstream result, err := svc.ForwardImages(context.Background(), c, b64BackfillAccount(false), body, parsed, "") require.NoError(t, err) require.NotNil(t, result) require.Equal(t, 1, result.ImageCount) require.Len(t, upstream.requests, 1) require.Equal(t, http.StatusOK, rec.Code) require.Equal(t, upstreamBody, rec.Body.String()) }