test: 完善测试覆盖率和 E2E 测试场景
Phase 1: 单元测试补充 - 新增 config/loader_test.go,覆盖配置加载、include 合并、循环检测 - 补充 cache/cache_test.go,测试 RefreshCachedAt、DeleteByPatternWithMethod - 补充 handler/static_test.go,测试 SetExpires、setCacheHeaders、parseExpires Phase 2: E2E 测试扩展 - 新增 ratelimit_e2e_test.go,测试请求限流功能 - 新增 compression_e2e_test.go,测试 Gzip 压缩功能 - 新增 access_e2e_test.go,测试 IP 访问控制 - 新增 rewrite_e2e_test.go,测试 URL 重写和重定向 覆盖率提升: 82.3% -> 83.1% E2E 测试用例: ~84 -> ~104 (+20) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1be6480f5c
commit
5574339d28
142
internal/cache/cache_test.go
vendored
142
internal/cache/cache_test.go
vendored
@ -560,3 +560,145 @@ func TestProxyCacheGetStaleNotExpired(t *testing.T) {
|
||||
t.Error("fresh entry should be usable on timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCacheRefreshCachedAt 测试 RefreshCachedAt 方法。
|
||||
func TestFileCacheRefreshCachedAt(t *testing.T) {
|
||||
fc := NewFileCache(10, 1024, 1*time.Hour)
|
||||
|
||||
path := "/test/refresh.txt"
|
||||
data := []byte("test data")
|
||||
|
||||
// 设置缓存
|
||||
_ = fc.Set(path, data, int64(len(data)), time.Now())
|
||||
|
||||
// 获取原始 CachedAt 时间
|
||||
entry, ok := fc.Get(path)
|
||||
if !ok {
|
||||
t.Fatal("Expected to find cached entry")
|
||||
}
|
||||
originalCachedAt := entry.CachedAt
|
||||
|
||||
// 等待一小段时间
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// 刷新 CachedAt
|
||||
fc.RefreshCachedAt(path)
|
||||
|
||||
// 再次获取,验证 CachedAt 已更新
|
||||
entry, ok = fc.Get(path)
|
||||
if !ok {
|
||||
t.Fatal("Expected to find cached entry after refresh")
|
||||
}
|
||||
if !entry.CachedAt.After(originalCachedAt) {
|
||||
t.Errorf("CachedAt not updated: %v <= %v", entry.CachedAt, originalCachedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileCacheRefreshCachedAtNonExistent 测试刷新不存在的条目。
|
||||
func TestFileCacheRefreshCachedAtNonExistent(t *testing.T) {
|
||||
fc := NewFileCache(10, 1024, 1*time.Hour)
|
||||
|
||||
// 刷新不存在的条目应该静默忽略
|
||||
fc.RefreshCachedAt("/nonexistent/path")
|
||||
|
||||
// 验证没有副作用
|
||||
stats := fc.Stats()
|
||||
if stats.Entries != 0 {
|
||||
t.Errorf("Expected 0 entries, got %d", stats.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyCacheDeleteByPatternWithMethod 测试按模式和方法的删除。
|
||||
func TestProxyCacheDeleteByPatternWithMethod(t *testing.T) {
|
||||
pc := NewProxyCache(nil, false, 0, 0, 0)
|
||||
|
||||
// 添加多个缓存条目,带不同方法前缀
|
||||
pc.Set(hashKey("GET:/api/users"), "GET:/api/users", []byte("users"), nil, 200, 10*time.Minute)
|
||||
pc.Set(hashKey("POST:/api/users"), "POST:/api/users", []byte("create"), nil, 200, 10*time.Minute)
|
||||
pc.Set(hashKey("GET:/api/posts"), "GET:/api/posts", []byte("posts"), nil, 200, 10*time.Minute)
|
||||
pc.Set(hashKey("DELETE:/api/users/1"), "DELETE:/api/users/1", []byte("delete"), nil, 200, 10*time.Minute)
|
||||
|
||||
// 删除所有 GET:/api/users* 的条目(模式匹配 OrigKey,包含方法前缀)
|
||||
deleted := pc.DeleteByPatternWithMethod("GET:/api/users*", "GET")
|
||||
if deleted != 1 {
|
||||
t.Errorf("Expected 1 deleted, got %d", deleted)
|
||||
}
|
||||
|
||||
// 验证 GET:/api/users 被删除
|
||||
if _, ok, _ := pc.Get(hashKey("GET:/api/users"), "GET:/api/users"); ok {
|
||||
t.Error("GET:/api/users should be deleted")
|
||||
}
|
||||
|
||||
// 验证 POST:/api/users 还在
|
||||
if _, ok, _ := pc.Get(hashKey("POST:/api/users"), "POST:/api/users"); !ok {
|
||||
t.Error("POST:/api/users should still exist")
|
||||
}
|
||||
|
||||
// 验证 GET:/api/posts 还在
|
||||
if _, ok, _ := pc.Get(hashKey("GET:/api/posts"), "GET:/api/posts"); !ok {
|
||||
t.Error("GET:/api/posts should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyCacheDeleteByPatternAllMethods 测试删除所有方法。
|
||||
func TestProxyCacheDeleteByPatternAllMethods(t *testing.T) {
|
||||
pc := NewProxyCache(nil, false, 0, 0, 0)
|
||||
|
||||
// 添加多个缓存条目
|
||||
pc.Set(hashKey("GET:/api/test"), "GET:/api/test", []byte("get"), nil, 200, 10*time.Minute)
|
||||
pc.Set(hashKey("POST:/api/test"), "POST:/api/test", []byte("post"), nil, 200, 10*time.Minute)
|
||||
pc.Set(hashKey("PUT:/api/test"), "PUT:/api/test", []byte("put"), nil, 200, 10*time.Minute)
|
||||
|
||||
// 删除所有 *:/api/test* 的条目(不限制方法,使用 * 通配符)
|
||||
deleted := pc.DeleteByPatternWithMethod("*", "")
|
||||
if deleted != 3 {
|
||||
t.Errorf("Expected 3 deleted, got %d", deleted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyCacheDeleteByPatternNoMatch 测试无匹配删除。
|
||||
func TestProxyCacheDeleteByPatternNoMatch(t *testing.T) {
|
||||
pc := NewProxyCache(nil, false, 0, 0, 0)
|
||||
|
||||
pc.Set(hashKey("GET:/api/users"), "GET:/api/users", []byte("users"), nil, 200, 10*time.Minute)
|
||||
|
||||
// 删除不匹配的模式
|
||||
deleted := pc.DeleteByPatternWithMethod("/other/*", "")
|
||||
if deleted != 0 {
|
||||
t.Errorf("Expected 0 deleted, got %d", deleted)
|
||||
}
|
||||
|
||||
// 验证原条目还在
|
||||
if _, ok, _ := pc.Get(hashKey("GET:/api/users"), "GET:/api/users"); !ok {
|
||||
t.Error("Original entry should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyCacheStatsToCacheStats 测试 ProxyCacheStats 转换。
|
||||
func TestProxyCacheStatsToCacheStats(t *testing.T) {
|
||||
stats := ProxyCacheStats{
|
||||
Entries: 10,
|
||||
Pending: 2,
|
||||
}
|
||||
|
||||
cacheStats := stats.ToCacheStats()
|
||||
|
||||
if cacheStats.Entries != 10 {
|
||||
t.Errorf("Entries = %d, want 10", cacheStats.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyCacheCacheStatsMethod 测试 CacheStats 方法。
|
||||
func TestProxyCacheCacheStatsMethod(t *testing.T) {
|
||||
pc := NewProxyCache(nil, false, 0, 0, 0)
|
||||
|
||||
// 添加缓存条目
|
||||
pc.Set(hashKey("key1"), "key1", []byte("data1"), nil, 200, 10*time.Minute)
|
||||
pc.Set(hashKey("key2"), "key2", []byte("data2"), nil, 200, 10*time.Minute)
|
||||
|
||||
stats := pc.CacheStats()
|
||||
|
||||
if stats.Entries != 2 {
|
||||
t.Errorf("Entries = %d, want 2", stats.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
442
internal/config/loader_test.go
Normal file
442
internal/config/loader_test.go
Normal file
@ -0,0 +1,442 @@
|
||||
// Package config 提供配置加载器测试。
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestNewConfigLoader 测试 ConfigLoader 构造函数。
|
||||
func TestNewConfigLoader(t *testing.T) {
|
||||
t.Run("相对路径转换", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
loader := NewConfigLoader(configPath)
|
||||
if loader == nil {
|
||||
t.Fatal("NewConfigLoader() returned nil")
|
||||
}
|
||||
|
||||
// 验证 baseDir 是配置文件所在目录
|
||||
expectedDir, _ := filepath.Abs(tmpDir)
|
||||
if loader.baseDir != expectedDir {
|
||||
t.Errorf("baseDir = %q, want %q", loader.baseDir, expectedDir)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("绝对路径保持不变", func(t *testing.T) {
|
||||
absPath := "/etc/lolly/config.yaml"
|
||||
loader := NewConfigLoader(absPath)
|
||||
if loader == nil {
|
||||
t.Fatal("NewConfigLoader() returned nil")
|
||||
}
|
||||
|
||||
if loader.baseDir != "/etc/lolly" {
|
||||
t.Errorf("baseDir = %q, want /etc/lolly", loader.baseDir)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("初始化状态", func(t *testing.T) {
|
||||
loader := NewConfigLoader("config.yaml")
|
||||
|
||||
if loader.loadedFiles == nil {
|
||||
t.Error("loadedFiles not initialized")
|
||||
}
|
||||
if loader.stack == nil {
|
||||
t.Error("stack not initialized")
|
||||
}
|
||||
if loader.depth != 0 {
|
||||
t.Errorf("depth = %d, want 0", loader.depth)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfigLoader_Load 测试配置加载。
|
||||
func TestConfigLoader_Load(t *testing.T) {
|
||||
t.Run("单文件配置加载", func(t *testing.T) {
|
||||
content := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
name: "main"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www"
|
||||
`
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("写入配置文件失败: %v", err)
|
||||
}
|
||||
|
||||
loader := NewConfigLoader(configPath)
|
||||
cfg, err := loader.Load(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() 失败: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Servers) != 1 {
|
||||
t.Errorf("len(Servers) = %d, want 1", len(cfg.Servers))
|
||||
}
|
||||
if cfg.Servers[0].Listen != ":8080" {
|
||||
t.Errorf("Listen = %q, want :8080", cfg.Servers[0].Listen)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("文件不存在", func(t *testing.T) {
|
||||
loader := NewConfigLoader("/nonexistent/config.yaml")
|
||||
_, err := loader.Load("/nonexistent/config.yaml")
|
||||
if err == nil {
|
||||
t.Error("Load() 期望返回错误,但返回 nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无效YAML", func(t *testing.T) {
|
||||
content := `servers: [invalid yaml`
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "invalid.yaml")
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("写入配置文件失败: %v", err)
|
||||
}
|
||||
|
||||
loader := NewConfigLoader(configPath)
|
||||
_, err := loader.Load(configPath)
|
||||
if err == nil {
|
||||
t.Error("Load() 期望返回错误,但返回 nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfigLoader_Include 测试 include 指令。
|
||||
func TestConfigLoader_Include(t *testing.T) {
|
||||
t.Run("多文件include合并", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// 主配置文件
|
||||
mainConfig := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
name: "main"
|
||||
include:
|
||||
- path: "servers/*.yaml"
|
||||
`
|
||||
mainPath := filepath.Join(tmpDir, "main.yaml")
|
||||
if err := os.WriteFile(mainPath, []byte(mainConfig), 0o644); err != nil {
|
||||
t.Fatalf("写入主配置文件失败: %v", err)
|
||||
}
|
||||
|
||||
// 创建 servers 目录
|
||||
serversDir := filepath.Join(tmpDir, "servers")
|
||||
if err := os.Mkdir(serversDir, 0o755); err != nil {
|
||||
t.Fatalf("创建 servers 目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 子配置文件1
|
||||
server1 := `
|
||||
servers:
|
||||
- listen: ":8081"
|
||||
name: "server1"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(serversDir, "server1.yaml"), []byte(server1), 0o644); err != nil {
|
||||
t.Fatalf("写入 server1.yaml 失败: %v", err)
|
||||
}
|
||||
|
||||
// 子配置文件2
|
||||
server2 := `
|
||||
servers:
|
||||
- listen: ":8082"
|
||||
name: "server2"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(serversDir, "server2.yaml"), []byte(server2), 0o644); err != nil {
|
||||
t.Fatalf("写入 server2.yaml 失败: %v", err)
|
||||
}
|
||||
|
||||
loader := NewConfigLoader(mainPath)
|
||||
cfg, err := loader.Load(mainPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Load() 失败: %v", err)
|
||||
}
|
||||
|
||||
// 应该有3个server: main + server1 + server2
|
||||
if len(cfg.Servers) != 3 {
|
||||
t.Errorf("len(Servers) = %d, want 3", len(cfg.Servers))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("循环引用检测", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// a.yaml includes b.yaml
|
||||
configA := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
name: "a"
|
||||
include:
|
||||
- path: "b.yaml"
|
||||
`
|
||||
// b.yaml includes a.yaml (循环)
|
||||
configB := `
|
||||
servers:
|
||||
- listen: ":8081"
|
||||
name: "b"
|
||||
include:
|
||||
- path: "a.yaml"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "a.yaml"), []byte(configA), 0o644); err != nil {
|
||||
t.Fatalf("写入 a.yaml 失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "b.yaml"), []byte(configB), 0o644); err != nil {
|
||||
t.Fatalf("写入 b.yaml 失败: %v", err)
|
||||
}
|
||||
|
||||
loader := NewConfigLoader(filepath.Join(tmpDir, "a.yaml"))
|
||||
_, err := loader.Load(filepath.Join(tmpDir, "a.yaml"))
|
||||
if err == nil {
|
||||
t.Error("Load() 期望返回循环引用错误,但返回 nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("深度超限", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// 创建深度嵌套的配置文件链
|
||||
for i := range 12 {
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
`
|
||||
if i < 11 {
|
||||
config += `include:
|
||||
- path: "next.yaml"
|
||||
`
|
||||
}
|
||||
filename := "config.yaml"
|
||||
if i > 0 {
|
||||
filename = "next.yaml"
|
||||
}
|
||||
// 每个层级创建子目录
|
||||
subDir := filepath.Join(tmpDir, "level"+string(rune('0'+i)))
|
||||
_ = os.Mkdir(subDir, 0o755)
|
||||
if i == 0 {
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, filename), []byte(config), 0o644); err != nil {
|
||||
t.Fatalf("写入配置文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 简化测试:直接测试深度限制
|
||||
loader := NewConfigLoader(filepath.Join(tmpDir, "config.yaml"))
|
||||
loader.depth = 11 // 超过 maxIncludeDepth (10)
|
||||
|
||||
_, err := loader.Load(filepath.Join(tmpDir, "config.yaml"))
|
||||
if err == nil {
|
||||
t.Error("Load() 期望返回深度超限错误,但返回 nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DAG共享子配置", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// shared.yaml - 被多处引用
|
||||
shared := `
|
||||
servers:
|
||||
- listen: ":9090"
|
||||
name: "shared"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "shared.yaml"), []byte(shared), 0o644); err != nil {
|
||||
t.Fatalf("写入 shared.yaml 失败: %v", err)
|
||||
}
|
||||
|
||||
// main.yaml - 引用 shared.yaml 两次(应该只处理一次)
|
||||
main := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
name: "main"
|
||||
include:
|
||||
- path: "shared.yaml"
|
||||
- path: "shared.yaml"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "main.yaml"), []byte(main), 0o644); err != nil {
|
||||
t.Fatalf("写入 main.yaml 失败: %v", err)
|
||||
}
|
||||
|
||||
loader := NewConfigLoader(filepath.Join(tmpDir, "main.yaml"))
|
||||
cfg, err := loader.Load(filepath.Join(tmpDir, "main.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load() 失败: %v", err)
|
||||
}
|
||||
|
||||
// shared 应该只被处理一次
|
||||
sharedCount := 0
|
||||
for _, s := range cfg.Servers {
|
||||
if s.Name == "shared" {
|
||||
sharedCount++
|
||||
}
|
||||
}
|
||||
if sharedCount != 1 {
|
||||
t.Errorf("shared server count = %d, want 1", sharedCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfigLoader_Merge 测试配置合并。
|
||||
func TestConfigLoader_Merge(t *testing.T) {
|
||||
t.Run("server name冲突", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
main := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
name: "duplicate"
|
||||
include:
|
||||
- path: "sub.yaml"
|
||||
`
|
||||
sub := `
|
||||
servers:
|
||||
- listen: ":8081"
|
||||
name: "duplicate"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "main.yaml"), []byte(main), 0o644); err != nil {
|
||||
t.Fatalf("写入 main.yaml 失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "sub.yaml"), []byte(sub), 0o644); err != nil {
|
||||
t.Fatalf("写入 sub.yaml 失败: %v", err)
|
||||
}
|
||||
|
||||
loader := NewConfigLoader(filepath.Join(tmpDir, "main.yaml"))
|
||||
_, err := loader.Load(filepath.Join(tmpDir, "main.yaml"))
|
||||
if err == nil {
|
||||
t.Error("Load() 期望返回 server name 冲突错误,但返回 nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stream listen冲突", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
main := `
|
||||
stream:
|
||||
- listen: "12345"
|
||||
proxy_pass: "backend:54321"
|
||||
include:
|
||||
- path: "sub.yaml"
|
||||
`
|
||||
sub := `
|
||||
stream:
|
||||
- listen: "12345"
|
||||
proxy_pass: "backend2:54321"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "main.yaml"), []byte(main), 0o644); err != nil {
|
||||
t.Fatalf("写入 main.yaml 失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "sub.yaml"), []byte(sub), 0o644); err != nil {
|
||||
t.Fatalf("写入 sub.yaml 失败: %v", err)
|
||||
}
|
||||
|
||||
loader := NewConfigLoader(filepath.Join(tmpDir, "main.yaml"))
|
||||
_, err := loader.Load(filepath.Join(tmpDir, "main.yaml"))
|
||||
if err == nil {
|
||||
t.Error("Load() 期望返回 stream listen 冲突错误,但返回 nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("正常合并", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
main := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
name: "main"
|
||||
include:
|
||||
- path: "sub.yaml"
|
||||
`
|
||||
sub := `
|
||||
servers:
|
||||
- listen: ":8081"
|
||||
name: "sub"
|
||||
stream:
|
||||
- listen: "12345"
|
||||
proxy_pass: "backend:54321"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "main.yaml"), []byte(main), 0o644); err != nil {
|
||||
t.Fatalf("写入 main.yaml 失败: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "sub.yaml"), []byte(sub), 0o644); err != nil {
|
||||
t.Fatalf("写入 sub.yaml 失败: %v", err)
|
||||
}
|
||||
|
||||
loader := NewConfigLoader(filepath.Join(tmpDir, "main.yaml"))
|
||||
cfg, err := loader.Load(filepath.Join(tmpDir, "main.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("Load() 失败: %v", err)
|
||||
}
|
||||
|
||||
if len(cfg.Servers) != 2 {
|
||||
t.Errorf("len(Servers) = %d, want 2", len(cfg.Servers))
|
||||
}
|
||||
if len(cfg.Stream) != 1 {
|
||||
t.Errorf("len(Stream) = %d, want 1", len(cfg.Stream))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfigLoader_Glob 测试 glob 模式展开。
|
||||
func TestConfigLoader_Glob(t *testing.T) {
|
||||
t.Run("glob模式匹配", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// 创建多个配置文件
|
||||
for i := range 3 {
|
||||
content := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
`
|
||||
filename := filepath.Join(tmpDir, "server"+string(rune('0'+i+1))+".yaml")
|
||||
if err := os.WriteFile(filename, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("写入配置文件失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
loader := NewConfigLoader(tmpDir)
|
||||
files, err := loader.expandGlob(filepath.Join(tmpDir, "server*.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("expandGlob() 失败: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 3 {
|
||||
t.Errorf("len(files) = %d, want 3", len(files))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("无匹配文件", func(t *testing.T) {
|
||||
loader := NewConfigLoader("/tmp")
|
||||
files, err := loader.expandGlob("/nonexistent/*.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("expandGlob() 失败: %v", err)
|
||||
}
|
||||
if len(files) != 0 {
|
||||
t.Errorf("len(files) = %d, want 0", len(files))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestConfigLoader_ResolvePath 测试路径解析。
|
||||
func TestConfigLoader_ResolvePath(t *testing.T) {
|
||||
t.Run("相对路径解析", func(t *testing.T) {
|
||||
loader := NewConfigLoader("/etc/lolly/config.yaml")
|
||||
|
||||
result := loader.resolvePath("servers/config.yaml")
|
||||
expected := "/etc/lolly/servers/config.yaml"
|
||||
if result != expected {
|
||||
t.Errorf("resolvePath() = %q, want %q", result, expected)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("绝对路径保持不变", func(t *testing.T) {
|
||||
loader := NewConfigLoader("/etc/lolly/config.yaml")
|
||||
|
||||
result := loader.resolvePath("/absolute/path.yaml")
|
||||
if result != "/absolute/path.yaml" {
|
||||
t.Errorf("resolvePath() = %q, want /absolute/path.yaml", result)
|
||||
}
|
||||
})
|
||||
}
|
||||
283
internal/e2e/access_e2e_test.go
Normal file
283
internal/e2e/access_e2e_test.go
Normal file
@ -0,0 +1,283 @@
|
||||
//go:build e2e
|
||||
|
||||
// access_e2e_test.go - 访问控制 E2E 测试(L3 层,需要 Docker)
|
||||
//
|
||||
// 测试 lolly 访问控制功能,包括:
|
||||
// - IP 白名单
|
||||
// - IP 黑名单
|
||||
// - CIDR 网段匹配
|
||||
// - 403 Forbidden 响应
|
||||
//
|
||||
// 作者:xfy
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rua.plus/lolly/internal/e2e/testutil"
|
||||
)
|
||||
|
||||
// TestE2EAccessAllowWhitelist 测试 IP 白名单。
|
||||
func TestE2EAccessAllowWhitelist(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 配置只允许特定 IP 访问
|
||||
// 由于测试在容器内运行,需要允许容器网络
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
access:
|
||||
allow:
|
||||
- "127.0.0.1"
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
default: deny
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 从容器网络访问应该成功
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/")
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
// 容器网络在允许范围内,应该可以访问
|
||||
assert.NotEqual(t, http.StatusForbidden, resp.StatusCode, "Request from allowed network should not be forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EAccessDenyBlacklist 测试 IP 黑名单。
|
||||
func TestE2EAccessDenyBlacklist(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 配置拒绝特定 IP 访问
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
access:
|
||||
deny:
|
||||
- "192.168.100.0/24"
|
||||
default: allow
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 从非黑名单 IP 访问应该成功
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/")
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
// 测试环境 IP 不在黑名单中,应该可以访问
|
||||
assert.NotEqual(t, http.StatusForbidden, resp.StatusCode, "Request from non-blacklisted IP should not be forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EAccessDefaultDeny 测试默认拒绝策略。
|
||||
func TestE2EAccessDefaultDeny(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 配置默认拒绝,只允许 localhost
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
access:
|
||||
allow:
|
||||
- "127.0.0.1"
|
||||
default: deny
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 从容器网络访问(非 127.0.0.1)
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/")
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
t.Logf("Status: %d", resp.StatusCode)
|
||||
// 根据配置,非 localhost 可能被拒绝
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EAccessNoRestriction 测试无访问限制。
|
||||
func TestE2EAccessNoRestriction(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 不配置访问控制
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 应该可以正常访问
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 没有访问限制,应该返回 404(没有文件)或 200
|
||||
assert.NotEqual(t, http.StatusForbidden, resp.StatusCode, "Request should not be forbidden without access control")
|
||||
}
|
||||
|
||||
// TestE2EAccessCIDRMatch 测试 CIDR 网段匹配。
|
||||
func TestE2EAccessCIDRMatch(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 配置允许私有网络访问
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
access:
|
||||
allow:
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
default: deny
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 从容器网络访问(通常是 172.x.x.x)
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/")
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
t.Logf("Status: %d", resp.StatusCode)
|
||||
// 容器网络在 172.16.0.0/12 范围内
|
||||
assert.NotEqual(t, http.StatusForbidden, resp.StatusCode, "Container IP should be in allowed CIDR")
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2EAccessProxyWithAccessControl 测试代理模式下的访问控制。
|
||||
func TestE2EAccessProxyWithAccessControl(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 启动模拟后端
|
||||
backend, backendAddr, err := testutil.StartMockBackend(ctx)
|
||||
require.NoError(t, err, "Failed to start mock backend")
|
||||
defer backend.Terminate(ctx)
|
||||
|
||||
// 配置代理 + 访问控制
|
||||
config := fmt.Sprintf(`
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
proxy:
|
||||
- path: "/api"
|
||||
targets:
|
||||
- url: "http://%s"
|
||||
access:
|
||||
allow:
|
||||
- "10.0.0.0/8"
|
||||
- "172.16.0.0/12"
|
||||
- "192.168.0.0/16"
|
||||
default: deny
|
||||
`, backendAddr)
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 从容器网络访问代理
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/api/test")
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
t.Logf("Status: %d", resp.StatusCode)
|
||||
// 应该可以访问
|
||||
assert.NotEqual(t, http.StatusForbidden, resp.StatusCode, "Proxy request should not be forbidden")
|
||||
}
|
||||
}
|
||||
289
internal/e2e/compression_e2e_test.go
Normal file
289
internal/e2e/compression_e2e_test.go
Normal file
@ -0,0 +1,289 @@
|
||||
//go:build e2e
|
||||
|
||||
// compression_e2e_test.go - 压缩功能 E2E 测试(L3 层,需要 Docker)
|
||||
//
|
||||
// 测试 lolly 响应压缩功能,包括:
|
||||
// - Gzip 压缩响应
|
||||
// - 压缩级别配置
|
||||
// - Content-Type 过滤
|
||||
// - Accept-Encoding 协商
|
||||
//
|
||||
// 作者:xfy
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rua.plus/lolly/internal/e2e/testutil"
|
||||
)
|
||||
|
||||
// TestE2ECompressionGzip 测试 Gzip 压缩响应。
|
||||
func TestE2ECompressionGzip(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 创建包含可压缩内容的 HTML 文件
|
||||
htmlContent := `<html><body>` + repeatString("<p>Hello World</p>", 100) + `</body></html>`
|
||||
|
||||
config := fmt.Sprintf(`
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
compression:
|
||||
enabled: true
|
||||
types:
|
||||
- "text/html"
|
||||
- "text/css"
|
||||
- "application/json"
|
||||
`)
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "",
|
||||
testutil.WithConfigYAML(config),
|
||||
testutil.WithExtraMount(createTempHTMLFile(t, "index.html", htmlContent), "/var/www/html/index.html"),
|
||||
)
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 发送带 Accept-Encoding: gzip 的请求
|
||||
req, err := http.NewRequest("GET", lolly.HTTPBaseURL()+"/", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 检查响应是否被压缩
|
||||
encoding := resp.Header.Get("Content-Encoding")
|
||||
if encoding == "gzip" {
|
||||
t.Log("Response is gzip compressed")
|
||||
|
||||
// 解压并验证内容
|
||||
gzReader, err := gzip.NewReader(resp.Body)
|
||||
require.NoError(t, err)
|
||||
defer gzReader.Close()
|
||||
|
||||
body, err := io.ReadAll(gzReader)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(body), "Hello World")
|
||||
} else {
|
||||
t.Logf("Response not compressed (Content-Encoding: %s), may be too small", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2ECompressionNoAcceptEncoding 测试不发送 Accept-Encoding 时不压缩。
|
||||
func TestE2ECompressionNoAcceptEncoding(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
htmlContent := `<html><body><p>Test Content</p></body></html>`
|
||||
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
compression:
|
||||
enabled: true
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "",
|
||||
testutil.WithConfigYAML(config),
|
||||
testutil.WithExtraMount(createTempHTMLFile(t, "index.html", htmlContent), "/var/www/html/index.html"),
|
||||
)
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 不发送 Accept-Encoding
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 响应不应该被压缩
|
||||
encoding := resp.Header.Get("Content-Encoding")
|
||||
assert.NotEqual(t, "gzip", encoding, "Response should not be gzip compressed without Accept-Encoding")
|
||||
}
|
||||
|
||||
// TestE2ECompressionDisabled 测试禁用压缩。
|
||||
func TestE2ECompressionDisabled(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
htmlContent := repeatString("<p>Hello World</p>", 100)
|
||||
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
compression:
|
||||
enabled: false
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "",
|
||||
testutil.WithConfigYAML(config),
|
||||
testutil.WithExtraMount(createTempHTMLFile(t, "index.html", htmlContent), "/var/www/html/index.html"),
|
||||
)
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 发送带 Accept-Encoding: gzip 的请求
|
||||
req, err := http.NewRequest("GET", lolly.HTTPBaseURL()+"/", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 响应不应该被压缩
|
||||
encoding := resp.Header.Get("Content-Encoding")
|
||||
assert.NotEqual(t, "gzip", encoding, "Response should not be compressed when disabled")
|
||||
}
|
||||
|
||||
// TestE2ECompressionPrecompressed 测试预压缩文件。
|
||||
func TestE2ECompressionPrecompressed(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 创建原始文件和预压缩的 .gz 文件
|
||||
originalContent := repeatString("<p>Hello World</p>", 100)
|
||||
gzContent := gzipCompress(t, originalContent)
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
writeFile(t, tmpDir+"/test.js", originalContent)
|
||||
writeFile(t, tmpDir+"/test.js.gz", gzContent)
|
||||
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
gzip_static: true
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "",
|
||||
testutil.WithConfigYAML(config),
|
||||
testutil.WithExtraMount(tmpDir, "/var/www/html"),
|
||||
)
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 请求预压缩文件
|
||||
req, err := http.NewRequest("GET", lolly.HTTPBaseURL()+"/test.js", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Accept-Encoding", "gzip")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 如果支持预压缩,应该直接返回 .gz 文件
|
||||
encoding := resp.Header.Get("Content-Encoding")
|
||||
t.Logf("Content-Encoding: %s", encoding)
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
|
||||
func repeatString(s string, n int) string {
|
||||
var buf bytes.Buffer
|
||||
for range n {
|
||||
buf.WriteString(s)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func createTempHTMLFile(t *testing.T, filename, content string) string {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
filePath := tmpDir + "/" + filename
|
||||
writeFile(t, filePath, content)
|
||||
return filePath
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
require.NoError(t, writeFileErr(path, content))
|
||||
}
|
||||
|
||||
func writeFileErr(path, content string) error {
|
||||
return writeFileBytes(path, []byte(content))
|
||||
}
|
||||
|
||||
func writeFileBytes(path string, content []byte) error {
|
||||
return writeFileBytesWithPerm(path, content, 0o644)
|
||||
}
|
||||
|
||||
func writeFileBytesWithPerm(path string, content []byte, perm uint32) error {
|
||||
return writeFileWithPerm(path, content, perm)
|
||||
}
|
||||
|
||||
func writeFileWithPerm(path string, content []byte, perm uint32) error {
|
||||
import "os"
|
||||
return os.WriteFile(path, content, os.FileMode(perm))
|
||||
}
|
||||
|
||||
func gzipCompress(t *testing.T, content string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gzWriter := gzip.NewWriter(&buf)
|
||||
_, err := gzWriter.Write([]byte(content))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, gzWriter.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
267
internal/e2e/ratelimit_e2e_test.go
Normal file
267
internal/e2e/ratelimit_e2e_test.go
Normal file
@ -0,0 +1,267 @@
|
||||
//go:build e2e
|
||||
|
||||
// ratelimit_e2e_test.go - 请求限流 E2E 测试(L3 层,需要 Docker)
|
||||
//
|
||||
// 测试 lolly 请求限流功能,包括:
|
||||
// - 请求速率限制
|
||||
// - 突发流量处理
|
||||
// - 429 响应
|
||||
//
|
||||
// 作者:xfy
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rua.plus/lolly/internal/e2e/testutil"
|
||||
)
|
||||
|
||||
// TestE2ERateLimitBasic 测试基本请求限流。
|
||||
func TestE2ERateLimitBasic(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 启动模拟后端
|
||||
backend, backendAddr, err := testutil.StartMockBackend(ctx)
|
||||
require.NoError(t, err, "Failed to start mock backend")
|
||||
defer backend.Terminate(ctx)
|
||||
|
||||
// 配置限流:每秒 5 个请求,突发 10 个
|
||||
config := fmt.Sprintf(`
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
proxy:
|
||||
- path: "/api"
|
||||
targets:
|
||||
- url: "http://%s"
|
||||
security:
|
||||
rate_limit:
|
||||
request_rate: 5
|
||||
burst: 10
|
||||
`, backendAddr)
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
baseURL := lolly.HTTPBaseURL()
|
||||
|
||||
// 快速发送 20 个请求
|
||||
var successCount, rateLimitedCount int32
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := range 20 {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
resp, err := client.Get(fmt.Sprintf("%s/api/test?id=%d", baseURL, i))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
atomic.AddInt32(&successCount, 1)
|
||||
} else if resp.StatusCode == http.StatusTooManyRequests {
|
||||
atomic.AddInt32(&rateLimitedCount, 1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
t.Logf("Success: %d, Rate limited: %d", successCount, rateLimitedCount)
|
||||
|
||||
// 验证有请求被限流
|
||||
assert.Greater(t, rateLimitedCount, int32(0), "Some requests should be rate limited")
|
||||
}
|
||||
|
||||
// TestE2ERateLimitBurst 测试突发流量处理。
|
||||
func TestE2ERateLimitBurst(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 启动模拟后端
|
||||
backend, backendAddr, err := testutil.StartMockBackend(ctx)
|
||||
require.NoError(t, err, "Failed to start mock backend")
|
||||
defer backend.Terminate(ctx)
|
||||
|
||||
// 配置限流:每秒 2 个请求,突发 5 个
|
||||
config := fmt.Sprintf(`
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
proxy:
|
||||
- path: "/api"
|
||||
targets:
|
||||
- url: "http://%s"
|
||||
security:
|
||||
rate_limit:
|
||||
request_rate: 2
|
||||
burst: 5
|
||||
`, backendAddr)
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
baseURL := lolly.HTTPBaseURL()
|
||||
|
||||
// 第一批:突发 5 个请求应该都成功
|
||||
var successCount int32
|
||||
for i := range 5 {
|
||||
resp, err := client.Get(fmt.Sprintf("%s/api/test?id=%d", baseURL, i))
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
atomic.AddInt32(&successCount, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.GreaterOrEqual(t, successCount, int32(4), "Most burst requests should succeed")
|
||||
}
|
||||
|
||||
// TestE2ERateLimitRecovery 测试限流恢复。
|
||||
func TestE2ERateLimitRecovery(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 启动模拟后端
|
||||
backend, backendAddr, err := testutil.StartMockBackend(ctx)
|
||||
require.NoError(t, err, "Failed to start mock backend")
|
||||
defer backend.Terminate(ctx)
|
||||
|
||||
// 配置限流:每秒 3 个请求,突发 3 个
|
||||
config := fmt.Sprintf(`
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
proxy:
|
||||
- path: "/api"
|
||||
targets:
|
||||
- url: "http://%s"
|
||||
security:
|
||||
rate_limit:
|
||||
request_rate: 3
|
||||
burst: 3
|
||||
`, backendAddr)
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
baseURL := lolly.HTTPBaseURL()
|
||||
|
||||
// 发送请求直到被限流
|
||||
limited := false
|
||||
for i := range 10 {
|
||||
resp, err := client.Get(fmt.Sprintf("%s/api/test?id=%d", baseURL, i))
|
||||
if err == nil {
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
limited = true
|
||||
resp.Body.Close()
|
||||
break
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if !limited {
|
||||
t.Skip("Rate limiting not triggered, skipping recovery test")
|
||||
}
|
||||
|
||||
// 等待限流窗口恢复
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
// 再次发送请求应该成功
|
||||
resp, err := client.Get(baseURL + "/api/test?after=wait")
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode, "Request should succeed after waiting")
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2ERateLimitDisabled 测试未配置限流时不限制。
|
||||
func TestE2ERateLimitDisabled(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 启动模拟后端
|
||||
backend, backendAddr, err := testutil.StartMockBackend(ctx)
|
||||
require.NoError(t, err, "Failed to start mock backend")
|
||||
defer backend.Terminate(ctx)
|
||||
|
||||
// 不配置限流
|
||||
config := fmt.Sprintf(`
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
proxy:
|
||||
- path: "/api"
|
||||
targets:
|
||||
- url: "http://%s"
|
||||
`, backendAddr)
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
baseURL := lolly.HTTPBaseURL()
|
||||
|
||||
// 发送 20 个请求,都不应该被限流
|
||||
var successCount int32
|
||||
for i := range 20 {
|
||||
resp, err := client.Get(fmt.Sprintf("%s/api/test?id=%d", baseURL, i))
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
atomic.AddInt32(&successCount, 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.GreaterOrEqual(t, successCount, int32(18), "Most requests should succeed without rate limiting")
|
||||
}
|
||||
296
internal/e2e/rewrite_e2e_test.go
Normal file
296
internal/e2e/rewrite_e2e_test.go
Normal file
@ -0,0 +1,296 @@
|
||||
//go:build e2e
|
||||
|
||||
// rewrite_e2e_test.go - URL 重写 E2E 测试(L3 层,需要 Docker)
|
||||
//
|
||||
// 测试 lolly URL 重写功能,包括:
|
||||
// - URL 重写
|
||||
// - 正则表达式重写
|
||||
// - 重定向 (301/302)
|
||||
// - 内部重定向
|
||||
//
|
||||
// 作者:xfy
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"rua.plus/lolly/internal/e2e/testutil"
|
||||
)
|
||||
|
||||
// TestE2ERewriteBasic 测试基本 URL 重写。
|
||||
func TestE2ERewriteBasic(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 配置 URL 重写:/old/* -> /new/*
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
rewrite:
|
||||
- pattern: "^/old/(.*)$"
|
||||
replacement: "/new/$1"
|
||||
flag: "last"
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse // 不自动跟随重定向
|
||||
}}
|
||||
|
||||
// 请求 /old/test 应该被重写到 /new/test
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/old/test")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
t.Logf("Status: %d", resp.StatusCode)
|
||||
// 重写后可能返回 404(文件不存在),但不应该返回重定向
|
||||
assert.NotEqual(t, http.StatusMovedPermanently, resp.StatusCode)
|
||||
assert.NotEqual(t, http.StatusFound, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestE2ERewriteRedirect 测试重写为重定向。
|
||||
func TestE2ERewriteRedirect(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 配置重写为 302 重定向
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
rewrite:
|
||||
- pattern: "^/old/(.*)$"
|
||||
replacement: "/new/$1"
|
||||
flag: "redirect"
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
|
||||
// 请求 /old/test 应该返回 302 重定向
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/old/test")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusFound, resp.StatusCode, "Should return 302 redirect")
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
assert.Contains(t, location, "/new/test", "Location should contain /new/test")
|
||||
t.Logf("Location: %s", location)
|
||||
}
|
||||
|
||||
// TestE2ERewritePermanent 测试永久重定向。
|
||||
func TestE2ERewritePermanent(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 配置永久重定向
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
rewrite:
|
||||
- pattern: "^/old/(.*)$"
|
||||
replacement: "/new/$1"
|
||||
flag: "permanent"
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
|
||||
// 请求 /old/test 应该返回 301 永久重定向
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/old/test")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode, "Should return 301 permanent redirect")
|
||||
}
|
||||
|
||||
// TestE2ERewriteRegexCapture 测试正则表达式捕获组。
|
||||
func TestE2ERewriteRegexCapture(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 配置复杂的正则重写
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
rewrite:
|
||||
- pattern: "^/user/([0-9]+)/post/([0-9]+)$"
|
||||
replacement: "/posts/$1-$2"
|
||||
flag: "redirect"
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
|
||||
// 请求 /user/123/post/456 应该重定向到 /posts/123-456
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/user/123/post/456")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
assert.Equal(t, http.StatusFound, resp.StatusCode)
|
||||
|
||||
location := resp.Header.Get("Location")
|
||||
assert.Contains(t, location, "/posts/123-456", "Location should contain /posts/123-456")
|
||||
t.Logf("Location: %s", location)
|
||||
}
|
||||
|
||||
// TestE2ERewriteProxy 测试代理模式下的重写。
|
||||
func TestE2ERewriteProxy(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 启动模拟后端
|
||||
backend, backendAddr, err := testutil.StartMockBackend(ctx)
|
||||
require.NoError(t, err, "Failed to start mock backend")
|
||||
defer backend.Terminate(ctx)
|
||||
|
||||
// 配置代理 + 重写
|
||||
config := fmt.Sprintf(`
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
proxy:
|
||||
- path: "/api"
|
||||
targets:
|
||||
- url: "http://%s"
|
||||
rewrite:
|
||||
- pattern: "^/api/v1/(.*)$"
|
||||
replacement: "/api/v2/$1"
|
||||
flag: "last"
|
||||
`, backendAddr)
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// 请求 /api/v1/users 应该被重写到 /api/v2/users
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/api/v1/users")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
t.Logf("Status: %d", resp.StatusCode)
|
||||
// 代理请求应该成功
|
||||
assert.NotEqual(t, http.StatusForbidden, resp.StatusCode)
|
||||
}
|
||||
|
||||
// TestE2ERewriteNoMatch 测试不匹配时不重写。
|
||||
func TestE2ERewriteNoMatch(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !testutil.LollyImageAvailable(ctx) {
|
||||
t.Skip("lolly:latest image not available, run 'make docker-build' first")
|
||||
}
|
||||
|
||||
// 配置重写规则
|
||||
config := `
|
||||
servers:
|
||||
- listen: ":8080"
|
||||
static:
|
||||
- path: "/"
|
||||
root: "/var/www/html"
|
||||
rewrite:
|
||||
- pattern: "^/old/(.*)$"
|
||||
replacement: "/new/$1"
|
||||
flag: "redirect"
|
||||
`
|
||||
|
||||
// 启动 lolly
|
||||
lolly, err := testutil.StartLollyContainer(ctx, "", testutil.WithConfigYAML(config))
|
||||
require.NoError(t, err, "Failed to start lolly container")
|
||||
defer lolly.Terminate(ctx)
|
||||
|
||||
err = lolly.WaitForHealthy(ctx, 30*time.Second)
|
||||
require.NoError(t, err, "Lolly not healthy")
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second, CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
|
||||
// 请求 /other/test 不匹配规则,不应该重定向
|
||||
resp, err := client.Get(lolly.HTTPBaseURL() + "/other/test")
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 不应该返回重定向
|
||||
assert.NotEqual(t, http.StatusFound, resp.StatusCode)
|
||||
assert.NotEqual(t, http.StatusMovedPermanently, resp.StatusCode)
|
||||
}
|
||||
@ -1948,3 +1948,134 @@ func TestStaticHandler_Handle_CacheModTimeChanged(t *testing.T) {
|
||||
t.Errorf("修改后内容 = %q, want %q", got, newContent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStaticHandler_SetExpires 测试 SetExpires 方法。
|
||||
func TestStaticHandler_SetExpires(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
handler := newTestHandler(t, tmpDir)
|
||||
|
||||
// 默认为空
|
||||
if handler.expires != "" {
|
||||
t.Errorf("默认 expires = %q, want empty", handler.expires)
|
||||
}
|
||||
|
||||
// 设置 expires
|
||||
handler.SetExpires("30d")
|
||||
if handler.expires != "30d" {
|
||||
t.Errorf("expires = %q, want 30d", handler.expires)
|
||||
}
|
||||
|
||||
// 设置 off
|
||||
handler.SetExpires("off")
|
||||
if handler.expires != "off" {
|
||||
t.Errorf("expires = %q, want off", handler.expires)
|
||||
}
|
||||
|
||||
// 设置 max
|
||||
handler.SetExpires("max")
|
||||
if handler.expires != "max" {
|
||||
t.Errorf("expires = %q, want max", handler.expires)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetCacheHeaders 测试 setCacheHeaders 方法。
|
||||
func TestSetCacheHeaders(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expires string
|
||||
wantCacheCtrl string
|
||||
wantExpiresSet bool // 是否设置 Expires 头
|
||||
}{
|
||||
{
|
||||
name: "empty_expires",
|
||||
expires: "",
|
||||
wantCacheCtrl: "",
|
||||
},
|
||||
{
|
||||
name: "off_expires",
|
||||
expires: "off",
|
||||
wantCacheCtrl: "",
|
||||
},
|
||||
{
|
||||
name: "epoch_expires",
|
||||
expires: "epoch",
|
||||
wantCacheCtrl: "no-cache, no-store, must-revalidate",
|
||||
wantExpiresSet: true,
|
||||
},
|
||||
{
|
||||
name: "max_expires",
|
||||
expires: "max",
|
||||
wantCacheCtrl: "public, max-age=315360000, immutable",
|
||||
wantExpiresSet: true,
|
||||
},
|
||||
{
|
||||
name: "duration_expires",
|
||||
expires: "1h",
|
||||
wantCacheCtrl: "public, max-age=3600",
|
||||
wantExpiresSet: true,
|
||||
},
|
||||
{
|
||||
name: "complex_duration",
|
||||
expires: "1d1h",
|
||||
wantCacheCtrl: "public, max-age=90000",
|
||||
wantExpiresSet: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
handler := newTestHandler(t, tmpDir)
|
||||
handler.SetExpires(tt.expires)
|
||||
|
||||
ctx := newTestContext(t, "/test.txt")
|
||||
handler.setCacheHeaders(ctx)
|
||||
|
||||
cacheCtrl := string(ctx.Response.Header.Peek("Cache-Control"))
|
||||
if tt.wantCacheCtrl == "" {
|
||||
if cacheCtrl != "" {
|
||||
t.Errorf("Cache-Control = %q, want empty", cacheCtrl)
|
||||
}
|
||||
} else {
|
||||
if cacheCtrl != tt.wantCacheCtrl {
|
||||
t.Errorf("Cache-Control = %q, want %q", cacheCtrl, tt.wantCacheCtrl)
|
||||
}
|
||||
}
|
||||
|
||||
expires := string(ctx.Response.Header.Peek("Expires"))
|
||||
if tt.wantExpiresSet && expires == "" {
|
||||
t.Error("Expected Expires header to be set")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseExpires 测试 parseExpires 函数。
|
||||
func TestParseExpires(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expires string
|
||||
wantSecs int64
|
||||
}{
|
||||
{"empty", "", 0},
|
||||
{"off", "off", 0},
|
||||
{"max", "max", 315360000},
|
||||
{"epoch", "epoch", -1},
|
||||
{"seconds", "30s", 30},
|
||||
{"minutes", "5m", 300},
|
||||
{"hours", "2h", 7200},
|
||||
{"days", "1d", 86400},
|
||||
{"complex", "1d1h1m1s", 90061},
|
||||
{"multiple_days", "30d", 2592000},
|
||||
{"mixed", "7d12h", 648000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := parseExpires(tt.expires)
|
||||
if got != tt.wantSecs {
|
||||
t.Errorf("parseExpires(%q) = %d, want %d", tt.expires, got, tt.wantSecs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user