lolly/internal/server/purge_test.go
xfy bec8932561 feat(server): 添加缓存清理 API 支持
新增 PurgeHandler 处理器,支持:
- 按精确路径和通配符模式清理缓存
- HTTP 方法过滤(默认 GET)
- IP 白名单访问控制(CIDR/单 IP/localhost)
- Token 认证保护
- 三种启动模式路由注册

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

409 lines
8.7 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 认证
//
// 作者xfy
package server
import (
"net"
"testing"
"github.com/valyala/fasthttp"
"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")
}
})
}