Files
sub2api/backend/internal/service/openai_gateway_response_failed_passthrough_test.go
T
shaw 7918b1a9c5 fix: 落实 #3867-#3870 合并审计的全部跟进项
透传规则(跟进 #3868/#3870,refs #3857):
- CC/Messages 4 条协议转换路径改用共享 helper,走语义状态推断 +
  body 归一化,使按错误码配置的透传规则也能命中(原先传 0 恒不命中)
- helper 增加 platform 参数:本服务同时承载 openai/grok 平台账号,
  规则须按 account.Platform 匹配,消除硬编码平台错配
- /v1/responses 两条路径命中透传规则时补记 ops 上游错误事件,
  对齐 CC/Messages 与 antigravity 先例,消除监控盲区

用户角色管理(跟进 #3869):
- 补齐 EN 语言包缺失的 admin.users.form.roleLabel
- 新增"最后一个管理员不可降级"守卫,覆盖跨管理员互降致零 admin 锁死
- 角色变更/创建管理员落审计日志(含操作者 actor_admin_id)
2026-07-09 20:06:09 +08:00

225 lines
8.7 KiB
Go

//go:build unit
package service
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/model"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func buildContextLengthFailedSSE() string {
failed := `{"type":"response.failed","response":{"id":"resp_err","object":"response","status":"failed","error":{"code":"context_length_exceeded","type":"invalid_request_error","message":"Your input exceeds the context window of this model. Please adjust your input and try again."},"output":[],"usage":{"input_tokens":100000,"output_tokens":0,"total_tokens":100000}}}`
return fmt.Sprintf("data: %s\n\n", failed)
}
func bindPassthroughRule(c *gin.Context, platform string, keywords []string, responseCode int) {
svc := &ErrorPassthroughService{}
rules := make([]*cachedPassthroughRule, 0, len(keywords))
for i, kw := range keywords {
code := responseCode
rules = append(rules, &cachedPassthroughRule{
ErrorPassthroughRule: &model.ErrorPassthroughRule{
ID: int64(i + 1),
Enabled: true,
Platforms: []string{platform},
MatchMode: model.MatchModeAny,
Keywords: []string{kw},
ResponseCode: &code,
PassthroughBody: true,
},
lowerKeywords: []string{strings.ToLower(kw)},
lowerPlatforms: []string{strings.ToLower(platform)},
})
}
svc.localCacheMu.Lock()
svc.localCache = rules
svc.localCacheMu.Unlock()
BindErrorPassthroughService(c, svc)
}
func TestForwardAsChatCompletions_ResponseFailed_PassthroughRule(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
bindPassthroughRule(c, "openai", []string{"context_length_exceeded"}, 400)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.Error(t, err)
require.Contains(t, err.Error(), "passthrough")
require.Equal(t, 400, rec.Code, "passthrough rule should override 502 to 400")
respBody := rec.Body.String()
errType := gjson.Get(respBody, "error.type").String()
require.Equal(t, "upstream_error", errType)
errMsg := gjson.Get(respBody, "error.message").String()
require.NotEmpty(t, errMsg, "passthrough should preserve error message")
require.Contains(t, errMsg, "context window")
}
func TestForwardAsAnthropic_ResponseFailed_PassthroughRule(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
bindPassthroughRule(c, "openai", []string{"context_length_exceeded"}, 400)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err)
require.Contains(t, err.Error(), "passthrough")
require.Equal(t, 400, rec.Code, "passthrough rule should override 502 to 400")
respBody := rec.Body.String()
errMsg := gjson.Get(respBody, "error.message").String()
require.NotEmpty(t, errMsg, "passthrough should preserve error message")
}
func TestForwardAsChatCompletions_ResponseFailed_NoRule_Still502(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.Error(t, err)
require.Equal(t, http.StatusBadGateway, rec.Code, "without passthrough rule should still be 502")
}
// bindStatusCodePassthroughRule 绑定一条按错误码+关键词双条件(MatchModeAll)匹配的规则。
// 此类规则依赖语义状态码推断才能在协议转换路径命中(response.failed 无真实 HTTP 状态码)。
func bindStatusCodePassthroughRule(c *gin.Context, platform string, statusCode int, keyword string, responseCode int) {
rule := &model.ErrorPassthroughRule{
ID: 1,
Name: "status-code-rule",
Enabled: true,
Priority: 1,
Platforms: []string{platform},
ErrorCodes: []int{statusCode},
Keywords: []string{keyword},
MatchMode: model.MatchModeAll,
ResponseCode: &responseCode,
PassthroughBody: true,
}
svc := &ErrorPassthroughService{}
svc.setLocalCache([]*model.ErrorPassthroughRule{rule})
BindErrorPassthroughService(c, svc)
}
func TestForwardAsChatCompletions_ResponseFailed_ErrorCodeRuleMatchesViaSemanticStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
bindStatusCodePassthroughRule(c, "openai", http.StatusBadRequest, "context_length_exceeded", http.StatusBadRequest)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, rec.Code, "error-code-conditioned rule should match via semantic status inference")
respBody := rec.Body.String()
require.Equal(t, "upstream_error", gjson.Get(respBody, "error.type").String())
require.Contains(t, gjson.Get(respBody, "error.message").String(), "context window")
}
func TestForwardAsAnthropic_ResponseFailed_ErrorCodeRuleMatchesViaSemanticStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"gpt-5.4","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
bindStatusCodePassthroughRule(c, "openai", http.StatusBadRequest, "context_length_exceeded", http.StatusBadRequest)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(buildContextLengthFailedSSE())),
}}
svc := &OpenAIGatewayService{
cfg: rawChatCompletionsTestConfig(),
httpUpstream: upstream,
}
account := rawChatCompletionsTestAccount()
_, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.Error(t, err)
require.Equal(t, http.StatusBadRequest, rec.Code, "error-code-conditioned rule should match via semantic status inference")
respBody := rec.Body.String()
require.NotEmpty(t, gjson.Get(respBody, "error.message").String())
}