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:
parent
f123018f2d
commit
05a414d1bb
763
internal/cache/purge_test.go
vendored
Normal file
763
internal/cache/purge_test.go
vendored
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user