Files
sub2api/backend/internal/service/upstream_path_guard_test.go
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

199 lines
6.0 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestSanitizedUpstreamPathSuffixRejectsNonConformingSegments(t *testing.T) {
// 到达业务代码的 URL.Path 已是百分号解码后的结果,因此用例按解码后的形态书写。
rejected := []string{
"/..",
"/../..",
"/../../x/y",
"/./compact",
"/compact/..",
`/..\..\x`,
`/compact\..`,
"/?a=b",
"/compact?a=b",
"/compact#frag",
"/compact%2f..",
"/100%",
"//double",
"/compact//detail",
"/compact/",
"/ compact",
"/compact\x00",
"/compact\nX-Injected: 1",
"/模型",
"compact",
"/a:b",
"/a;b",
"/a,b",
"/a=b",
"/a&b",
// 允许清单是闭集:`\w` + `-` + `.` 以外的字符一律拒绝,
// 不依赖任何"已知坏字符"清单。
"/a~b",
"/a@b",
"/a+b",
"/a|b",
"/a*b",
"/a$b",
"/a(b)",
"/a'b",
"/a\"b",
"/a<b",
"/a\tb",
"/a b",
"/ab", // DIVISION SLASH
"/ab", // FULLWIDTH SOLIDUS
// 只由点组成的片段一律拒绝(各实现对其解释不一致)。
"/...",
"/....",
"/compact/...",
}
for _, suffix := range rejected {
t.Run("reject_"+suffix, func(t *testing.T) {
got, ok := sanitizedUpstreamPathSuffix(suffix)
require.False(t, ok, "suffix %q must be rejected", suffix)
require.Empty(t, got)
})
}
accepted := map[string]string{
"": "",
"/compact": "/compact",
"/compact/detail": "/compact/detail",
"/resp_68f0a1b2c3d4/cancel": "/resp_68f0a1b2c3d4/cancel",
"/gemini-2.5-pro_v1.2": "/gemini-2.5-pro_v1.2",
"/a.b.c": "/a.b.c",
}
for suffix, want := range accepted {
t.Run("accept_"+suffix, func(t *testing.T) {
got, ok := sanitizedUpstreamPathSuffix(suffix)
require.True(t, ok, "suffix %q must be accepted", suffix)
require.Equal(t, want, got)
})
}
}
func TestSanitizedUpstreamPathSuffixEnforcesBounds(t *testing.T) {
longSegment := "/"
for i := 0; i < maxUpstreamPathSegmentLen+1; i++ {
longSegment += "a"
}
_, ok := sanitizedUpstreamPathSuffix(longSegment)
require.False(t, ok, "over-long segment must be rejected")
deep := ""
for i := 0; i <= maxUpstreamPathSegments; i++ {
deep += "/a"
}
_, ok = sanitizedUpstreamPathSuffix(deep)
require.False(t, ok, "over-deep suffix must be rejected")
}
// TestOpenAIResponsesRequestPathSuffixRejectsNonConformingSubpaths 锁定不变式:
// /responses/*subpath 的子路径不得改变上游请求的路径结构;不合规时既不参与拼接,
// 也不会被误判成 compact 请求。
func TestOpenAIResponsesRequestPathSuffixRejectsNonConformingSubpaths(t *testing.T) {
gin.SetMode(gin.TestMode)
nonConformingPaths := []string{
"/v1/responses/../../x/y",
"/v1/responses/..%2f..%2fx/y",
"/v1/responses/%2e%2e/%2e%2e/x",
"/responses/%2e%2e%2fx",
"/backend-api/codex/responses/../../../x",
`/v1/responses/..\..\x`,
"/v1/responses/%3fa=b",
"/v1/responses/x%23frag",
"/v1/responses//double",
}
for _, path := range nonConformingPaths {
t.Run(path, func(t *testing.T) {
c := newResponsesSuffixTestContext(t, path)
require.False(t, IsForwardableOpenAIResponsesRequestPath(c),
"path %q must be rejected at the gateway edge", path)
require.Empty(t, openAIResponsesRequestPathSuffix(c),
"path %q must never contribute an upstream path suffix", path)
require.Equal(t, chatgptCodexURL,
appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, openAIResponsesRequestPathSuffix(c)))
require.False(t, isOpenAIResponsesCompactPath(c))
})
}
// 合法子路径必须保持原样转发。
for path, want := range map[string]string{
"/v1/responses": "",
"/v1/responses/compact": "/compact",
"/v1/responses/input_tokens": "/input_tokens",
"/responses/compact/": "/compact",
"/backend-api/codex/responses/compact": "/compact",
} {
t.Run("forwardable_"+path, func(t *testing.T) {
c := newResponsesSuffixTestContext(t, path)
require.True(t, IsForwardableOpenAIResponsesRequestPath(c))
require.Equal(t, want, openAIResponsesRequestPathSuffix(c))
})
}
}
func TestIsOpenAIResponsesInputTokensRequestPath(t *testing.T) {
for _, path := range []string{"/v1/responses/input_tokens", "/responses/input_tokens", "/backend-api/codex/responses/input_tokens"} {
c := newResponsesSuffixTestContext(t, path)
require.True(t, IsOpenAIResponsesInputTokensRequestPath(c), "path=%s", path)
}
c := newResponsesSuffixTestContext(t, "/v1/responses/compact")
require.False(t, IsOpenAIResponsesInputTokensRequestPath(c))
}
func TestIsOpenAIResponsesCompactPathUsesLegacyEndpointShape(t *testing.T) {
legacyPaths := []string{
"/v1/responses/compact",
"/v1/responses/compact/detail",
"/responses/compact/",
}
for _, path := range legacyPaths {
t.Run("legacy_"+path, func(t *testing.T) {
c := newResponsesSuffixTestContext(t, path)
require.True(t, IsOpenAIResponsesCompactPath(c))
})
}
nonLegacyPaths := []string{
"/v1/responses",
"/openai/v1/responses",
"/responses",
"/backend-api/codex/responses",
"/v1/responses/resp_123/cancel",
}
for _, path := range nonLegacyPaths {
t.Run("non_legacy_"+path, func(t *testing.T) {
c := newResponsesSuffixTestContext(t, path)
require.False(t, IsOpenAIResponsesCompactPath(c))
})
}
}
func TestAppendOpenAIResponsesRequestPathSuffixRefusesUnsafeSuffix(t *testing.T) {
// 调用方漏了校验时,拼接函数本身也不得把不合规片段带进上游 URL。
require.Equal(t, chatgptCodexURL, appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, "/../../x"))
require.Equal(t, chatgptCodexURL, appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, "/?a=b"))
require.Equal(t, chatgptCodexURL+"/compact", appendOpenAIResponsesRequestPathSuffix(chatgptCodexURL, "/compact"))
}
func newResponsesSuffixTestContext(t *testing.T, path string) *gin.Context {
t.Helper()
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, path, nil)
return c
}