lolly/internal/server/purge_test.go
xfy 05a414d1bb feat(server): 添加 Purge 测试导出方法和安全检查
- purgeByPath/purgeByPattern 添加 nil server 检查
- 导出 PurgeByPathForTest/PurgeByPatternForTest 用于测试
- 添加 purge.go 全面单元测试覆盖
- 添加 cache purge 测试验证缓存清理功能

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:12:16 +08:00

675 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package server 提供缓存清理处理器功能的测试。
//
// 该文件测试 PurgeHandler 模块的各项功能,包括:
// - 路径配置(默认和自定义)
// - 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"
)
func TestPurgeHandler_Path(t *testing.T) {
tests := []struct {
name string
cfgPath string
wantPath string
}{
{
name: "default path",
cfgPath: "",
wantPath: "/_cache/purge",
},
{
name: "custom path",
cfgPath: "/api/purge",
wantPath: "/api/purge",
},
{
name: "custom path with version prefix",
cfgPath: "/api/v1/cache/purge",
wantPath: "/api/v1/cache/purge",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: tt.cfgPath,
Allow: []string{},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if h.Path() != tt.wantPath {
t.Errorf("expected path %s, got %s", tt.wantPath, h.Path())
}
})
}
}
func TestPurgeHandler_NewPurgeHandler(t *testing.T) {
t.Run("localhost special handling", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{"localhost"},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(h.allowed) != 2 {
t.Fatalf("expected 2 allowed networks for localhost, got %d", len(h.allowed))
}
// 验证包含 127.0.0.1/32
_, v4Net, _ := net.ParseCIDR("127.0.0.1/32")
if v4Net == nil {
t.Fatal("failed to parse 127.0.0.1/32")
}
foundV4 := false
for _, n := range h.allowed {
if n.String() == v4Net.String() {
foundV4 = true
break
}
}
if !foundV4 {
t.Error("expected 127.0.0.1/32 in allowed networks")
}
// 验证包含 ::1/128
_, v6Net, _ := net.ParseCIDR("::1/128")
if v6Net == nil {
t.Fatal("failed to parse ::1/128")
}
foundV6 := false
for _, n := range h.allowed {
if n.String() == v6Net.String() {
foundV6 = true
break
}
}
if !foundV6 {
t.Error("expected ::1/128 in allowed networks")
}
})
t.Run("CIDR parsing", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{"10.0.0.0/8", "172.16.0.0/12"},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(h.allowed) != 2 {
t.Errorf("expected 2 allowed networks, got %d", len(h.allowed))
}
})
t.Run("single IP parsed as CIDR", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{"192.168.1.100"},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(h.allowed) != 1 {
t.Fatalf("expected 1 allowed network, got %d", len(h.allowed))
}
// 单 IP 应转换为 /32 CIDR
if h.allowed[0].String() != "192.168.1.100/32" {
t.Errorf("expected 192.168.1.100/32, got %s", h.allowed[0].String())
}
})
t.Run("invalid IP returns error", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: []string{"not-an-ip"},
}
_, err := NewPurgeHandler(nil, cfg)
if err == nil {
t.Error("expected error for invalid IP, got nil")
}
})
}
func TestPurgeHandler_checkAccess(t *testing.T) {
tests := []struct {
name string
allow []string
clientIP string
wantAccess bool
}{
{
name: "no allow list - open access",
allow: []string{},
clientIP: "1.2.3.4",
wantAccess: true,
},
{
name: "CIDR match",
allow: []string{"192.168.0.0/16"},
clientIP: "192.168.1.100",
wantAccess: true,
},
{
name: "CIDR no match",
allow: []string{"10.0.0.0/8"},
clientIP: "192.168.1.100",
wantAccess: false,
},
{
name: "single IP match",
allow: []string{"127.0.0.1"},
clientIP: "127.0.0.1",
wantAccess: true,
},
{
name: "single IP no match",
allow: []string{"127.0.0.1"},
clientIP: "127.0.0.2",
wantAccess: false,
},
{
name: "localhost allows 127.0.0.1",
allow: []string{"localhost"},
clientIP: "127.0.0.1",
wantAccess: true,
},
{
name: "localhost allows ::1",
allow: []string{"localhost"},
clientIP: "::1",
wantAccess: true,
},
{
name: "localhost denies other IP",
allow: []string{"localhost"},
clientIP: "10.0.0.1",
wantAccess: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Allow: tt.allow,
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(h.allowed) == 0 {
// 无白名单时应允许所有访问
if !h.checkAccess(nil) {
t.Error("expected access to be true when no allow list configured")
}
return
}
// 直接测试 IP 是否在 allowed 列表中
ip := net.ParseIP(tt.clientIP)
if ip == nil {
t.Fatalf("failed to parse client IP: %s", tt.clientIP)
}
found := false
for _, network := range h.allowed {
if network.Contains(ip) {
found = true
break
}
}
if found != tt.wantAccess {
t.Errorf("expected access %v, got %v", tt.wantAccess, found)
}
})
}
}
func TestPurgeHandler_checkAuth(t *testing.T) {
t.Run("no auth configured", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Auth: config.CacheAPIAuthConfig{
Type: "",
Token: "",
},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
if !h.checkAuth(ctx) {
t.Error("expected auth to pass when no auth configured")
}
})
t.Run("auth type none", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Auth: config.CacheAPIAuthConfig{
Type: "none",
Token: "",
},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
if !h.checkAuth(ctx) {
t.Error("expected auth to pass when type is none")
}
})
t.Run("token auth - correct Bearer token", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Auth: config.CacheAPIAuthConfig{
Type: "token",
Token: "secret-token",
},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Authorization", "Bearer secret-token")
if !h.checkAuth(ctx) {
t.Error("expected auth to pass with correct Bearer token")
}
})
t.Run("token auth - correct direct token", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Auth: config.CacheAPIAuthConfig{
Type: "token",
Token: "secret-token",
},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Authorization", "secret-token")
if !h.checkAuth(ctx) {
t.Error("expected auth to pass with correct direct token")
}
})
t.Run("token auth - wrong token", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Auth: config.CacheAPIAuthConfig{
Type: "token",
Token: "secret-token",
},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Authorization", "Bearer wrong-token")
if h.checkAuth(ctx) {
t.Error("expected auth to fail with wrong token")
}
})
t.Run("token auth - missing header", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Auth: config.CacheAPIAuthConfig{
Type: "token",
Token: "secret-token",
},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
if h.checkAuth(ctx) {
t.Error("expected auth to fail when Authorization header is missing")
}
})
t.Run("token auth - unknown type", func(t *testing.T) {
cfg := &config.CacheAPIConfig{
Path: "/_cache/purge",
Auth: config.CacheAPIAuthConfig{
Type: "basic",
Token: "secret-token",
},
}
h, err := NewPurgeHandler(nil, cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
ctx := &fasthttp.RequestCtx{}
ctx.Request.Header.Set("Authorization", "Bearer secret-token")
if h.checkAuth(ctx) {
t.Error("expected auth to fail for unknown auth type")
}
})
}
// 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)
}
}