Files
ghproxy/proxy_test.go
T
Geekertao d9d2bbb850 fix: sample limiter timestamp under lock and use per-request config snapshot
- rateLimiter.allow now samples time.Now() while holding the mutex, so entries[ip] stays time-ascending under concurrency; allowAt/allowLocked share the same locked logic for deterministic tests.
- handler passes its single config snapshot into proxy and render404 so one request never mixes config versions.
- rewrite sweep test to hit sweepLocked directly; restore config/log/gin global state in tests; add real-clock concurrency ordering test.
2026-10-10 23:04:41 +08:00

160 lines
4.7 KiB
Go

package main
import (
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// trackingBody 记录 Close 是否被调用,用于验证响应体被正确关闭。
type trackingBody struct {
r io.Reader
closed bool
}
func (b *trackingBody) Read(p []byte) (int, error) { return b.r.Read(p) }
func (b *trackingBody) Close() error {
b.closed = true
return nil
}
// setTestGinMode 设置测试模式并在测试结束后恢复原模式,避免污染全局状态。
func setTestGinMode(t *testing.T) {
t.Helper()
old := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(old) })
}
func withFakeTransport(t *testing.T, resp *http.Response, body *trackingBody) {
t.Helper()
old := httpClient
httpClient = &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
resp.Request = r
return resp, nil
}),
// 阻止自动跟随 302,以便直接观察到 proxy 对 Location 的处理。
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
}
t.Cleanup(func() { httpClient = old })
}
func newTestContext(method, target string) (*gin.Context, *httptest.ResponseRecorder) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(method, target, nil)
return c, w
}
// 9. 代理应正确转发响应内容,并关闭上游响应体。
func TestProxyForwardsAndClosesBody(t *testing.T) {
setTestGinMode(t)
const payload = "hello ghproxy streaming body"
body := &trackingBody{r: strings.NewReader(payload)}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Length": []string{strconv.Itoa(len(payload))}},
Body: body,
}
withFakeTransport(t, resp, body)
c, w := newTestContext(http.MethodGet, "/https://raw.githubusercontent.com/a/b/main/x.txt")
proxy(c, "https://raw.githubusercontent.com/a/b/main/x.txt", config.Load())
if w.Code != http.StatusOK {
t.Fatalf("状态码应为 200,实际 %d", w.Code)
}
if w.Body.String() != payload {
t.Fatalf("转发内容错误: %q", w.Body.String())
}
if !body.closed {
t.Fatal("上游响应体未被关闭")
}
}
// 超过文件大小限制时应返回 413 并关闭响应体(不读取整个文件)。
func TestProxyRejectsOversize(t *testing.T) {
setTestGinMode(t)
oldCfg := config.Load()
t.Cleanup(func() { config.Store(oldCfg) })
cfg := &Config{RequestLimit: RequestLimitConfig{LimitSize: 1}} // 1 MB
config.Store(cfg)
body := &trackingBody{r: strings.NewReader("tiny")}
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Length": []string{strconv.Itoa(2 * 1024 * 1024)}},
Body: body,
}
withFakeTransport(t, resp, body)
c, w := newTestContext(http.MethodGet, "/https://raw.githubusercontent.com/a/b/main/big.bin")
proxy(c, "https://raw.githubusercontent.com/a/b/main/big.bin", cfg)
if w.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("状态码应为 413,实际 %d", w.Code)
}
if !body.closed {
t.Fatal("上游响应体未被关闭")
}
}
// 入口链接的 302 应重写为代理地址,而不是内部跟随。
func TestProxyRewritesEntryLocation(t *testing.T) {
setTestGinMode(t)
body := &trackingBody{r: strings.NewReader("")}
resp := &http.Response{
StatusCode: http.StatusFound,
Header: http.Header{"Location": []string{"https://github.com/a/b/releases/download/v1/x.zip"}},
Body: body,
}
withFakeTransport(t, resp, body)
c, w := newTestContext(http.MethodGet, "/https://github.com/a/b/releases/download/v1/x.zip")
proxy(c, "https://github.com/a/b/releases/download/v1/x.zip", config.Load())
loc := w.Header().Get("Location")
if loc != "/https://github.com/a/b/releases/download/v1/x.zip" {
t.Fatalf("Location 重写错误: %q", loc)
}
}
// 达到限流上限后,handler 直接返回 429,不进入代理流程。
func TestHandlerReturns429WhenLimited(t *testing.T) {
setTestGinMode(t)
oldLimiter := limiter
oldCfg := config.Load()
t.Cleanup(func() {
limiter = oldLimiter
config.Store(oldCfg)
})
limiter = newRateLimiter()
config.Store(&Config{RequestLimit: RequestLimitConfig{LimitRate: 1}})
// 通过生产入口预先占满该 IP 的唯一额度。
if !limiter.allow("1.2.3.4", 1) {
t.Fatal("预填充失败")
}
c, w := newTestContext(http.MethodGet, "/https://raw.githubusercontent.com/a/b/main/x.txt")
c.Request.RemoteAddr = "1.2.3.4:12345"
handler(c)
if w.Code != http.StatusTooManyRequests {
t.Fatalf("应返回 429,实际 %d", w.Code)
}
}