From 5574339d2835a35ba3c54bad139c057c77a341fd Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 27 Apr 2026 17:06:55 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=AE=8C=E5=96=84=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=A6=86=E7=9B=96=E7=8E=87=E5=92=8C=20E2E=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E5=9C=BA=E6=99=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/cache/cache_test.go | 142 +++++++++ internal/config/loader_test.go | 442 +++++++++++++++++++++++++++ internal/e2e/access_e2e_test.go | 283 +++++++++++++++++ internal/e2e/compression_e2e_test.go | 289 ++++++++++++++++++ internal/e2e/ratelimit_e2e_test.go | 267 ++++++++++++++++ internal/e2e/rewrite_e2e_test.go | 296 ++++++++++++++++++ internal/handler/static_test.go | 131 ++++++++ 7 files changed, 1850 insertions(+) create mode 100644 internal/config/loader_test.go create mode 100644 internal/e2e/access_e2e_test.go create mode 100644 internal/e2e/compression_e2e_test.go create mode 100644 internal/e2e/ratelimit_e2e_test.go create mode 100644 internal/e2e/rewrite_e2e_test.go diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index ba94463..e0643b5 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -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) + } +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go new file mode 100644 index 0000000..65d2298 --- /dev/null +++ b/internal/config/loader_test.go @@ -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) + } + }) +} diff --git a/internal/e2e/access_e2e_test.go b/internal/e2e/access_e2e_test.go new file mode 100644 index 0000000..d42b0dc --- /dev/null +++ b/internal/e2e/access_e2e_test.go @@ -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") + } +} diff --git a/internal/e2e/compression_e2e_test.go b/internal/e2e/compression_e2e_test.go new file mode 100644 index 0000000..f57ac29 --- /dev/null +++ b/internal/e2e/compression_e2e_test.go @@ -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 := `` + repeatString("

Hello World

", 100) + `` + + 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 := `

Test Content

` + + 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("

Hello World

", 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("

Hello World

", 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() +} diff --git a/internal/e2e/ratelimit_e2e_test.go b/internal/e2e/ratelimit_e2e_test.go new file mode 100644 index 0000000..f2ce0e6 --- /dev/null +++ b/internal/e2e/ratelimit_e2e_test.go @@ -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") +} diff --git a/internal/e2e/rewrite_e2e_test.go b/internal/e2e/rewrite_e2e_test.go new file mode 100644 index 0000000..7cbf4db --- /dev/null +++ b/internal/e2e/rewrite_e2e_test.go @@ -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) +} diff --git a/internal/handler/static_test.go b/internal/handler/static_test.go index bc5818d..db24905 100644 --- a/internal/handler/static_test.go +++ b/internal/handler/static_test.go @@ -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) + } + }) + } +}