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:
xfy 2026-04-27 17:06:55 +08:00
parent 1be6480f5c
commit 5574339d28
7 changed files with 1850 additions and 0 deletions

View File

@ -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)
}
}

View 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)
}
})
}

View 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")
}
}

View 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()
}

View 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")
}

View 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)
}

View File

@ -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)
}
})
}
}