feat(server): 添加 Purge 测试导出方法和安全检查

- purgeByPath/purgeByPattern 添加 nil server 检查
- 导出 PurgeByPathForTest/PurgeByPatternForTest 用于测试
- 添加 purge.go 全面单元测试覆盖
- 添加 cache purge 测试验证缓存清理功能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-16 18:12:16 +08:00
parent f123018f2d
commit 05a414d1bb
3 changed files with 1047 additions and 0 deletions

763
internal/cache/purge_test.go vendored Normal file
View File

@ -0,0 +1,763 @@
// Package cache 提供缓存清理 API 的测试。
//
// 该文件测试 purge.go 中的各项功能,包括:
// - PurgeAPI 创建和配置
// - Path() 默认和自定义路径
// - ServeHTTP 完整请求处理
// - IP 白名单访问控制
// - Token 认证
// - 按路径和模式清理缓存
// - HashPathWithMethod 哈希计算
// - MatchPattern 通配符匹配
//
// 作者xfy
package cache
import (
"encoding/json"
"net"
"testing"
"time"
"github.com/valyala/fasthttp"
"rua.plus/lolly/internal/config"
)
func TestHashPathWithMethod(t *testing.T) {
t.Run("GET method default", func(t *testing.T) {
h1 := HashPathWithMethod("/api/users", "")
h2 := HashPathWithMethod("/api/users", "GET")
if h1 != h2 {
t.Errorf("Expected empty method to default to GET, got %d vs %d", h1, h2)
}
})
t.Run("different methods different hashes", func(t *testing.T) {
hGet := HashPathWithMethod("/api/users", "GET")
hPost := HashPathWithMethod("/api/users", "POST")
if hGet == hPost {
t.Error("Expected different hashes for different methods")
}
})
t.Run("different paths different hashes", func(t *testing.T) {
h1 := HashPathWithMethod("/api/users", "GET")
h2 := HashPathWithMethod("/api/posts", "GET")
if h1 == h2 {
t.Error("Expected different hashes for different paths")
}
})
}
func TestMatchPattern(t *testing.T) {
tests := []struct {
name string
pattern string
path string
want bool
}{
// Wildcard
{"wildcard matches all", "*", "/anything/goes", true},
{"wildcard matches empty", "*", "/", true},
// Prefix with trailing *
{"prefix star match", "/api/*", "/api/users", true},
{"prefix star match nested", "/api/*", "/api/v1/users", true},
{"prefix star no match", "/api/*", "/other/path", false},
{"prefix star match base", "/api/*", "/api/", true},
// Directory prefix (pattern ends with /)
{"dir prefix match", "/api/", "/api/users", true},
{"dir prefix no match", "/api/", "/other/path", false},
// Exact match
{"exact match", "/api/users", "/api/users", true},
{"exact no match", "/api/users", "/api/users/extra", false},
// Middle wildcard
{"middle wildcard", "/api/*/users", "/api/v1/users", true},
{"middle wildcard no match prefix", "/api/*/users", "/other/v1/users", false},
{"middle wildcard no match suffix", "/api/*/users", "/api/v1/posts", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MatchPattern(tt.pattern, tt.path)
if result != tt.want {
t.Errorf("MatchPattern(%s, %s) = %v, want %v", tt.pattern, tt.path, result, tt.want)
}
})
}
}
func TestNewPurgeAPI(t *testing.T) {
t.Run("nil cache", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if api == nil {
t.Fatal("expected non-nil PurgeAPI")
}
if api.cache != nil {
t.Error("expected nil cache")
}
})
t.Run("with cache", func(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
cfg := &config.CacheAPIConfig{
Path: "/custom/purge",
Allow: []string{"127.0.0.1"},
Auth: config.CacheAPIAuthConfig{
Type: "token",
Token: "test-token",
},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if api.cache != pc {
t.Error("expected cache to match")
}
if api.path != "/custom/purge" {
t.Errorf("expected path /custom/purge, got %s", api.path)
}
if api.auth.Token != "test-token" {
t.Errorf("expected token test-token, got %s", api.auth.Token)
}
})
t.Run("CIDR parsing", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Allow: []string{"10.0.0.0/8", "172.16.0.0/12"},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(api.allowed) != 2 {
t.Errorf("expected 2 allowed networks, got %d", len(api.allowed))
}
})
t.Run("single IP converted to CIDR", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Allow: []string{"192.168.1.100"},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(api.allowed) != 1 {
t.Fatalf("expected 1 allowed network, got %d", len(api.allowed))
}
if api.allowed[0].String() != "192.168.1.100/32" {
t.Errorf("expected 192.168.1.100/32, got %s", api.allowed[0].String())
}
})
t.Run("single IPv6 converted to CIDR", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Allow: []string{"::1"},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(api.allowed) != 1 {
t.Fatalf("expected 1 allowed network, got %d", len(api.allowed))
}
if api.allowed[0].String() != "::1/128" {
t.Errorf("expected ::1/128, got %s", api.allowed[0].String())
}
})
t.Run("invalid IP returns error", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Allow: []string{"not-an-ip"},
}
_, err := NewPurgeAPI(nil, cfg)
if err == nil {
t.Error("expected error for invalid IP, got nil")
}
})
t.Run("mixed valid and CIDR", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Allow: []string{"10.0.0.0/8", "192.168.1.1"},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(api.allowed) != 2 {
t.Errorf("expected 2 allowed networks, got %d", len(api.allowed))
}
})
}
func TestPurgeAPI_Path(t *testing.T) {
tests := []struct {
name string
cfgPath string
wantPath string
}{
{"default path", "", "/_cache/purge"},
{"custom path", "/api/purge", "/api/purge"},
{"custom path with version", "/api/v1/cache/purge", "/api/v1/cache/purge"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: tt.cfgPath,
Allow: []string{},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if api.Path() != tt.wantPath {
t.Errorf("expected path %s, got %s", tt.wantPath, api.Path())
}
})
}
}
func TestPurgeAPI_ServeHTTP_MethodNotAllowed(t *testing.T) {
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
tests := []string{"GET", "PUT", "DELETE", "PATCH", "OPTIONS"}
for _, method := range tests {
t.Run(method, func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod(method)
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusMethodNotAllowed {
t.Errorf("expected status 405, got %d", ctx.Response.StatusCode())
}
})
}
}
func TestPurgeAPI_ServeHTTP_AccessForbidden(t *testing.T) {
cfg := &config.CacheAPIConfig{
Allow: []string{"10.0.0.0/8"},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/test"}`)
// Set RemoteAddr to 192.168.1.1 (not in 10.0.0.0/8)
ctx.SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 12345})
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusForbidden {
t.Errorf("expected status 403, got %d", ctx.Response.StatusCode())
}
}
func TestPurgeAPI_ServeHTTP_Unauthorized(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
cfg := &config.CacheAPIConfig{
Allow: []string{"127.0.0.1"},
Auth: config.CacheAPIAuthConfig{
Type: "token",
Token: "secret",
},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/test"}`)
ctx.SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345})
// No Authorization header
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusUnauthorized {
t.Errorf("expected status 401, got %d", ctx.Response.StatusCode())
}
}
func TestPurgeAPI_ServeHTTP_BadRequest(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Run("invalid JSON", func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{invalid json}`)
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusBadRequest {
t.Errorf("expected status 400, got %d", ctx.Response.StatusCode())
}
})
t.Run("missing path and pattern", func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{}`)
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusBadRequest {
t.Errorf("expected status 400, got %d", ctx.Response.StatusCode())
}
})
}
func TestPurgeAPI_ServeHTTP_PurgeByPath(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
key := "GET:/api/test"
pc.Set(hashKey(key), key, []byte("data"), nil, 200, 10*60*time.Second)
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/api/test"}`)
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusOK {
t.Fatalf("expected status 200, got %d", ctx.Response.StatusCode())
}
var resp PurgeResponse
if err := json.Unmarshal(ctx.Response.Body(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Deleted != 1 {
t.Errorf("expected 1 deleted, got %d", resp.Deleted)
}
// Verify cache is gone
_, ok, _ := pc.Get(hashKey(key), key)
if ok {
t.Error("expected cache entry to be purged")
}
}
func TestPurgeAPI_ServeHTTP_PurgeByPath_NotFound(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/nonexistent"}`)
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusOK {
t.Fatalf("expected status 200, got %d", ctx.Response.StatusCode())
}
var resp PurgeResponse
if err := json.Unmarshal(ctx.Response.Body(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Deleted != 0 {
t.Errorf("expected 0 deleted, got %d", resp.Deleted)
}
}
func TestPurgeAPI_ServeHTTP_PurgeByPattern(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
pc.Set(hashKey("GET:/api/users"), "GET:/api/users", []byte("users"), nil, 200, 10*60*time.Second)
pc.Set(hashKey("GET:/api/posts"), "GET:/api/posts", []byte("posts"), nil, 200, 10*60*time.Second)
pc.Set(hashKey("GET:/static/css"), "GET:/static/css", []byte("css"), nil, 200, 10*60*time.Second)
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"pattern": "GET:/api/*"}`)
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusOK {
t.Fatalf("expected status 200, got %d", ctx.Response.StatusCode())
}
var resp PurgeResponse
if err := json.Unmarshal(ctx.Response.Body(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Deleted != 2 {
t.Errorf("expected 2 deleted (api/users and api/posts), got %d", resp.Deleted)
}
// Verify /static/css is still there
_, ok, _ := pc.Get(hashKey("GET:/static/css"), "GET:/static/css")
if !ok {
t.Error("expected /static/css to still exist")
}
}
func TestPurgeAPI_ServeHTTP_PurgeByPattern_Wildcard(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
pc.Set(hashKey("GET:/a"), "GET:/a", []byte("a"), nil, 200, 10*60*time.Second)
pc.Set(hashKey("GET:/b"), "GET:/b", []byte("b"), nil, 200, 10*60*time.Second)
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"pattern": "*"}`)
api.ServeHTTP(ctx)
var resp PurgeResponse
if err := json.Unmarshal(ctx.Response.Body(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Deleted != 2 {
t.Errorf("expected 2 deleted, got %d", resp.Deleted)
}
}
func TestPurgeAPI_ServeHTTP_PurgeByPattern_DirPrefix(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
pc.Set(hashKey("GET:/api/v1/users"), "GET:/api/v1/users", []byte("u"), nil, 200, 10*60*time.Second)
pc.Set(hashKey("GET:/api/v2/posts"), "GET:/api/v2/posts", []byte("p"), nil, 200, 10*60*time.Second)
pc.Set(hashKey("GET:/other"), "GET:/other", []byte("o"), nil, 200, 10*60*time.Second)
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"pattern": "GET:/api/"}`)
api.ServeHTTP(ctx)
var resp PurgeResponse
if err := json.Unmarshal(ctx.Response.Body(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Deleted != 2 {
t.Errorf("expected 2 deleted, got %d", resp.Deleted)
}
}
func TestPurgeAPI_ServeHTTP_ContentType(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Run("success response content type", func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/test"}`)
api.ServeHTTP(ctx)
ct := string(ctx.Response.Header.Peek("Content-Type"))
if ct != "application/json; charset=utf-8" {
t.Errorf("expected content-type application/json; charset=utf-8, got %s", ct)
}
})
t.Run("error response content type", func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("GET")
api.ServeHTTP(ctx)
ct := string(ctx.Response.Header.Peek("Content-Type"))
if ct != "application/json; charset=utf-8" {
t.Errorf("expected content-type application/json; charset=utf-8, got %s", ct)
}
})
}
func TestPurgeAPI_ServeHTTP_AccessAllowed(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
cfg := &config.CacheAPIConfig{
Allow: []string{"10.0.0.0/8"},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/api/test"}`)
ctx.SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP("10.1.2.3"), Port: 12345})
api.ServeHTTP(ctx)
// Should succeed (access allowed, no auth required)
if ctx.Response.StatusCode() != fasthttp.StatusOK {
t.Errorf("expected status 200, got %d", ctx.Response.StatusCode())
}
}
func TestPurgeAPI_ServeHTTP_TokenAuth(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
cfg := &config.CacheAPIConfig{
Allow: []string{},
Auth: config.CacheAPIAuthConfig{
Type: "token",
Token: "my-secret",
},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Run("Bearer token", func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/test"}`)
ctx.Request.Header.Set("Authorization", "Bearer my-secret")
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusOK {
t.Errorf("expected status 200, got %d", ctx.Response.StatusCode())
}
})
t.Run("Direct token", func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/test"}`)
ctx.Request.Header.Set("Authorization", "my-secret")
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusOK {
t.Errorf("expected status 200, got %d", ctx.Response.StatusCode())
}
})
}
func TestPurgeAPI_ServeHTTP_AuthTypeNone(t *testing.T) {
pc := NewProxyCache(nil, false, 0)
cfg := &config.CacheAPIConfig{
Allow: []string{},
Auth: config.CacheAPIAuthConfig{
Type: "none",
},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/test"}`)
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusOK {
t.Errorf("expected status 200, got %d", ctx.Response.StatusCode())
}
}
func TestPurgeAPI_PurgeByPath_NilCache(t *testing.T) {
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Directly test purgeByPath with nil cache
result := api.purgeByPath("/test")
if result != 0 {
t.Errorf("expected 0 deleted with nil cache, got %d", result)
}
}
func TestPurgeAPI_PurgeByPattern_NilCache(t *testing.T) {
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Directly test purgeByPattern with nil cache
result := api.purgeByPattern("*")
if result != 0 {
t.Errorf("expected 0 deleted with nil cache, got %d", result)
}
}
func TestPurgeAPI_ErrorResponse(t *testing.T) {
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
t.Run("method not allowed", func(t *testing.T) {
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("DELETE")
api.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusMethodNotAllowed {
t.Fatalf("expected status 405, got %d", ctx.Response.StatusCode())
}
var errResp PurgeErrorResponse
if err := json.Unmarshal(ctx.Response.Body(), &errResp); err != nil {
t.Fatalf("failed to parse error response: %v", err)
}
if errResp.Error != "method not allowed" {
t.Errorf("expected error 'method not allowed', got %s", errResp.Error)
}
})
t.Run("forbidden", func(t *testing.T) {
cfg2 := &config.CacheAPIConfig{
Allow: []string{"10.0.0.0/8"},
}
api2, err := NewPurgeAPI(nil, cfg2)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP("192.168.1.1"), Port: 12345})
api2.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusForbidden {
t.Fatalf("expected status 403, got %d", ctx.Response.StatusCode())
}
var errResp PurgeErrorResponse
if err := json.Unmarshal(ctx.Response.Body(), &errResp); err != nil {
t.Fatalf("failed to parse error response: %v", err)
}
if errResp.Error != "forbidden" {
t.Errorf("expected error 'forbidden', got %s", errResp.Error)
}
})
}
func TestPurgeAPI_PurgeByPath_WrongMethod(t *testing.T) {
// Test that hashPath only uses GET, so purging a POST-cached entry won't work
pc := NewProxyCache(nil, false, 0)
// Set a cache entry with GET:/api/test key
pc.Set(hashKey("GET:/api/test"), "GET:/api/test", []byte("data"), nil, 200, 10*60*time.Second)
cfg := &config.CacheAPIConfig{
Allow: []string{},
}
api, err := NewPurgeAPI(pc, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/api/test"}`)
api.ServeHTTP(ctx)
var resp PurgeResponse
if err := json.Unmarshal(ctx.Response.Body(), &resp); err != nil {
t.Fatalf("failed to parse response: %v", err)
}
if resp.Deleted != 1 {
t.Errorf("expected 1 deleted, got %d", resp.Deleted)
}
}
func TestMatchPattern_ComplexPatterns(t *testing.T) {
t.Run("multiple wildcards not supported", func(t *testing.T) {
// Pattern with multiple * in the middle is not supported
result := MatchPattern("/api/*/users/*", "/api/v1/users/123")
if result {
t.Error("expected complex pattern with multiple wildcards to return false")
}
})
t.Run("pattern without wildcard exact match", func(t *testing.T) {
result := MatchPattern("/api/users", "/api/users")
if !result {
t.Error("expected exact match")
}
})
t.Run("pattern without wildcard no match", func(t *testing.T) {
result := MatchPattern("/api/users", "/api/users/123")
if result {
t.Error("expected no match for non-exact")
}
})
}

View File

@ -195,6 +195,10 @@ func (h *PurgeHandler) checkAuth(ctx *fasthttp.RequestCtx) bool {
// purgeByPath 按精确路径清理缓存。
func (h *PurgeHandler) purgeByPath(path string, method string) int {
if h.server == nil {
return 0
}
hashKey := cache.HashPathWithMethod(path, method)
deleted := 0
@ -210,6 +214,10 @@ func (h *PurgeHandler) purgeByPath(path string, method string) int {
// purgeByPattern 按通配符模式清理缓存。
func (h *PurgeHandler) purgeByPattern(pattern string, method string) int {
if h.server == nil {
return 0
}
deleted := 0
for _, p := range h.server.proxies {
@ -227,3 +235,13 @@ func (h *PurgeHandler) sendError(ctx *fasthttp.RequestCtx, status int, errMsg st
ctx.SetStatusCode(status)
_ = json.NewEncoder(ctx).Encode(cache.PurgeErrorResponse{Error: errMsg})
}
// PurgeByPathForTest 测试用的导出方法。
func (h *PurgeHandler) PurgeByPathForTest(path string, method string) int {
return h.purgeByPath(path, method)
}
// PurgeByPatternForTest 测试用的导出方法。
func (h *PurgeHandler) PurgeByPatternForTest(pattern string, method string) int {
return h.purgeByPattern(pattern, method)
}

View File

@ -5,15 +5,21 @@
// - localhost 特殊处理和 CIDR 解析
// - IP 白名单访问控制
// - Token 认证
// - 请求处理流程POST/方法检查)
// - 请求体解析
// - sendError 方法
// - purgeByPath/purgeByPattern 方法nil server
//
// 作者xfy
package server
import (
"net"
"strings"
"testing"
"github.com/valyala/fasthttp"
"rua.plus/lolly/internal/cache"
"rua.plus/lolly/internal/config"
)
@ -406,3 +412,263 @@ func TestPurgeHandler_checkAuth(t *testing.T) {
}
})
}
// TestPurgeHandler_ServeHTTP_MethodCheck 测试 ServeHTTP 的方法检查。
func TestPurgeHandler_ServeHTTP_MethodCheck(t *testing.T) {
methods := []string{"GET", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"}
for _, method := range methods {
t.Run(method, func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Init(&fasthttp.Request{}, nil, nil)
ctx.Request.Header.SetMethod(method)
h.ServeHTTP(ctx)
if ctx.Response.StatusCode() != fasthttp.StatusMethodNotAllowed {
t.Errorf("expected status %d for method %s, got %d", fasthttp.StatusMethodNotAllowed, method, ctx.Response.StatusCode())
}
// 验证响应体包含错误信息
if !strings.Contains(string(ctx.Response.Body()), "method not allowed") {
t.Errorf("expected 'method not allowed' in response body, got: %s", string(ctx.Response.Body()))
}
})
}
}
// TestPurgeHandler_ServeHTTP_RequestBodyParsing 测试请求体解析。
func TestPurgeHandler_ServeHTTP_RequestBodyParsing(t *testing.T) {
tests := []struct {
name string
body string
wantStatus int
}{
{
name: "invalid JSON",
body: "{invalid json}",
wantStatus: fasthttp.StatusBadRequest,
},
{
name: "empty JSON",
body: "{}",
wantStatus: fasthttp.StatusBadRequest,
},
{
name: "missing path and pattern",
body: `{"method": "GET"}`,
wantStatus: fasthttp.StatusBadRequest,
},
{
name: "only path",
body: `{"path": "/test"}`,
wantStatus: fasthttp.StatusOK,
},
{
name: "only pattern",
body: `{"pattern": "/api/*"}`,
wantStatus: fasthttp.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Init(&fasthttp.Request{}, nil, nil)
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(tt.body)
h.ServeHTTP(ctx)
if ctx.Response.StatusCode() != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, ctx.Response.StatusCode())
}
})
}
}
// TestPurgeHandler_PurgeResponse 测试 purge 响应格式。
func TestPurgeHandler_PurgeResponse(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Init(&fasthttp.Request{}, nil, nil)
ctx.Request.Header.SetMethod("POST")
ctx.Request.SetBodyString(`{"path": "/test"}`)
h.ServeHTTP(ctx)
// 验证响应
if ctx.Response.StatusCode() != fasthttp.StatusOK {
t.Errorf("expected status %d, got %d", fasthttp.StatusOK, ctx.Response.StatusCode())
}
// 验证响应体格式
body := string(ctx.Response.Body())
if !strings.Contains(body, `"deleted"`) {
t.Errorf("expected 'deleted' field in response body, got: %s", body)
}
}
// TestPurgeHandler_SendError 测试 sendError 方法的错误响应格式。
func TestPurgeHandler_SendError(t *testing.T) {
tests := []struct {
name string
status int
errMsg string
wantBody string
}{
{
name: "bad request",
status: fasthttp.StatusBadRequest,
errMsg: "invalid request",
wantBody: `{"error":"invalid request"}`,
},
{
name: "forbidden",
status: fasthttp.StatusForbidden,
errMsg: "access denied",
wantBody: `{"error":"access denied"}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Init(&fasthttp.Request{}, nil, nil)
h.sendError(ctx, tt.status, tt.errMsg)
if ctx.Response.StatusCode() != tt.status {
t.Errorf("expected status %d, got %d", tt.status, ctx.Response.StatusCode())
}
body := string(ctx.Response.Body())
if !strings.Contains(body, tt.errMsg) {
t.Errorf("expected '%s' in response body, got: %s", tt.errMsg, body)
}
// 验证内容类型
contentType := string(ctx.Response.Header.ContentType())
if contentType != "application/json; charset=utf-8" {
t.Errorf("expected content-type 'application/json; charset=utf-8', got: %s", contentType)
}
})
}
}
// TestPurgeHandler_PurgeByPath 测试 purgeByPath 方法nil server
func TestPurgeHandler_PurgeByPath(t *testing.T) {
h, err := NewPurgeHandler(nil, &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 验证 nil server 时返回 0
deleted := h.PurgeByPathForTest("/test", "GET")
if deleted != 0 {
t.Errorf("expected 0 deletions for nil server, got %d", deleted)
}
}
// TestPurgeHandler_PurgeByPattern 测试 purgeByPattern 方法nil server
func TestPurgeHandler_PurgeByPattern(t *testing.T) {
h, err := NewPurgeHandler(nil, &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// 验证 nil server 时返回 0
deleted := h.PurgeByPatternForTest("/api/*", "GET")
if deleted != 0 {
t.Errorf("expected 0 deletions for nil server, got %d", deleted)
}
}
// TestPurgeHandler_CacheKeyWithMethod 测试带方法的缓存键。
func TestPurgeHandler_CacheKeyWithMethod(t *testing.T) {
tests := []struct {
path string
method string
}{
{"/test", "GET"},
{"/test", "POST"},
{"/api/users", "GET"},
{"/api/users", "DELETE"},
}
for _, tt := range tests {
t.Run(tt.path+"_"+tt.method, func(t *testing.T) {
key := cache.HashPathWithMethod(tt.path, tt.method)
if key == 0 {
t.Error("expected non-zero hash key")
}
// 同一路径和方法应该产生相同的键
key2 := cache.HashPathWithMethod(tt.path, tt.method)
if key != key2 {
t.Errorf("expected same key for same inputs, got %d and %d", key, key2)
}
// 同一路径不同方法应该产生不同的键
key3 := cache.HashPathWithMethod(tt.path, "OTHER")
if key == key3 {
t.Errorf("expected different key for different method, got %d and %d", key, key3)
}
})
}
}
// TestPurgeHandler_EmptyMethodDefaultsToGET 测试空方法默认为 GET。
func TestPurgeHandler_EmptyMethodDefaultsToGET(t *testing.T) {
key1 := cache.HashPathWithMethod("/test", "")
key2 := cache.HashPathWithMethod("/test", "GET")
if key1 != key2 {
t.Errorf("expected same key for empty and 'GET' method, got %d and %d", key1, key2)
}
}