diff --git a/internal/cache/purge_test.go b/internal/cache/purge_test.go new file mode 100644 index 0000000..10c1116 --- /dev/null +++ b/internal/cache/purge_test.go @@ -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") + } + }) +} diff --git a/internal/server/purge.go b/internal/server/purge.go index 4e72de3..311f5e2 100644 --- a/internal/server/purge.go +++ b/internal/server/purge.go @@ -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) +} diff --git a/internal/server/purge_test.go b/internal/server/purge_test.go index 31360d4..43e860a 100644 --- a/internal/server/purge_test.go +++ b/internal/server/purge_test.go @@ -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) + } +}