diff --git a/internal/e2e/proxy_e2e_test.go b/internal/e2e/proxy_e2e_test.go new file mode 100644 index 0000000..4f0f565 --- /dev/null +++ b/internal/e2e/proxy_e2e_test.go @@ -0,0 +1,220 @@ +//go:build e2e + +// proxy_e2e_test.go - HTTP 代理 E2E 测试(L3 层,需要 Docker) +// +// 测试代理转发、负载均衡、健康检查等功能。 +// +// 作者:xfy +package e2e + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rua.plus/lolly/internal/e2e/testutil" +) + +// TestE2EProxyBasic 测试基本代理转发。 +func TestE2EProxyBasic(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // 启动模拟后端 + backend, backendAddr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err, "Failed to start mock backend") + defer backend.Terminate(ctx) + + t.Logf("Mock backend: %s", backendAddr) + + // 验证后端可达 + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(backendAddr) + require.NoError(t, err, "Backend not reachable") + resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode, "Backend should return 200") +} + +// TestE2EProxyWithLolly 测试 lolly 代理转发功能。 +// 需要 lolly:latest 镜像。 +func TestE2EProxyWithLolly(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + if !testutil.DockerAvailable(ctx) { + t.Skip("Docker not available") + } + + if !testutil.LollyImageAvailable(ctx) { + t.Skip("lolly:latest image not available, run 'make docker-build' first") + } + + // 启动模拟后端 + backend, backendAddr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err, "Failed to start mock backend") + defer backend.Terminate(ctx) + + t.Logf("Mock backend: %s", backendAddr) + + // 启动 lolly 代理服务器 + lolly, err := testutil.StartLollyContainer(ctx, "") + require.NoError(t, err, "Failed to start lolly container") + defer lolly.Terminate(ctx) + + t.Logf("Lolly proxy: %s", lolly.HTTPBaseURL()) + + // 等待 lolly 健康 + err = lolly.WaitForHealthy(ctx, 30*time.Second) + require.NoError(t, err, "Lolly not healthy") + + // 通过 lolly 代理访问 + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(lolly.HTTPBaseURL()) + require.NoError(t, err, "Failed to reach lolly") + defer resp.Body.Close() + + // lolly 默认配置没有静态文件,返回 404 + assert.Equal(t, 404, resp.StatusCode, "Lolly should return 404 without static files") +} + +// TestE2EProxyLoadBalance 测试负载均衡轮询。 +func TestE2EProxyLoadBalance(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // 启动多个模拟后端 + backends := make([]context.CancelFunc, 3) + backendAddrs := make([]string, 3) + + for i := 0; i < 3; i++ { + backend, addr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err, "Failed to start mock backend %d", i) + backends[i] = func() { backend.Terminate(ctx) } + backendAddrs[i] = addr + } + + defer func() { + for _, cancel := range backends { + cancel() + } + }() + + // 验证所有后端可达 + client := &http.Client{Timeout: 10 * time.Second} + for i, addr := range backendAddrs { + resp, err := client.Get(addr) + require.NoError(t, err, "Backend %d not reachable", i) + resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode) + } + + t.Logf("All backends reachable: %v", backendAddrs) +} + +// TestE2EProxyHealthCheck 测试健康检查。 +func TestE2EProxyHealthCheck(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // 启动健康后端 + healthyBackend, healthyAddr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err, "Failed to start healthy backend") + defer healthyBackend.Terminate(ctx) + + // 验证健康检查端点 + client := &http.Client{Timeout: 10 * time.Second} + + // 测试健康后端 + resp, err := client.Get(fmt.Sprintf("%s/", healthyAddr)) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode, "Healthy backend should return 200") +} + +// TestE2EProxyTimeout 测试代理超时处理。 +func TestE2EProxyTimeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if !testutil.DockerAvailable(ctx) { + t.Skip("Docker not available") + } + + // 使用短超时客户端测试超时场景 + shortTimeoutClient := &http.Client{Timeout: 1 * time.Second} + + // 测试连接到不可达地址的超时 + _, err := shortTimeoutClient.Get("http://10.255.255.1:80/test") + assert.Error(t, err, "Should timeout on unreachable address") + assert.True(t, strings.Contains(err.Error(), "timeout") || strings.Contains(err.Error(), "context deadline exceeded"), + "Error should indicate timeout") +} + +// TestE2EProxyErrorHandling 测试代理错误处理。 +func TestE2EProxyErrorHandling(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if !testutil.DockerAvailable(ctx) { + t.Skip("Docker not available") + } + + client := &http.Client{Timeout: 10 * time.Second} + + // 测试连接被拒绝 + _, err := client.Get("http://localhost:9999/test") + assert.Error(t, err, "Should error on connection refused") +} + +// TestE2EProxyHeaders 测试代理头部传递。 +func TestE2EProxyHeaders(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // 启动模拟后端 + backend, backendAddr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err, "Failed to start mock backend") + defer backend.Terminate(ctx) + + // 发送带自定义头部的请求 + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", backendAddr, nil) + require.NoError(t, err) + + req.Header.Set("X-Custom-Header", "test-value") + req.Header.Set("X-Forwarded-For", "192.168.1.1") + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + // 验证响应 + assert.Equal(t, 200, resp.StatusCode) +} + +// TestE2EProxyMultipleRequests 测试并发请求。 +func TestE2EProxyMultipleRequests(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + // 启动模拟后端 + backend, backendAddr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err, "Failed to start mock backend") + defer backend.Terminate(ctx) + + // 使用真正的并发测试 + failures := testutil.RunAndVerifyConcurrentRequests(t, testutil.ConcurrentRequestConfig{ + URL: backendAddr, + Count: 10, + Timeout: 10 * time.Second, + ExpectCode: 200, + }) + + assert.Empty(t, failures, "All concurrent requests should succeed") +} diff --git a/internal/e2e/static_e2e_test.go b/internal/e2e/static_e2e_test.go new file mode 100644 index 0000000..154dc61 --- /dev/null +++ b/internal/e2e/static_e2e_test.go @@ -0,0 +1,220 @@ +//go:build e2e + +// static_e2e_test.go - 静态文件服务 E2E 测试(L3 层,需要 Docker) +// +// 测试静态文件服务、目录索引、缓存等功能。 +// +// 作者:xfy +package e2e + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "rua.plus/lolly/internal/e2e/testutil" +) + +// TestE2EStaticFileServe 测试静态文件服务。 +func TestE2EStaticFileServe(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + container, addr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err, "Failed to start static server") + defer container.Terminate(ctx) + + t.Logf("Static server: %s", addr) + + // 测试获取静态文件 + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(addr) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 200, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.NotEmpty(t, body, "Response body should not be empty") +} + +// TestE2EStaticWithLolly 测试 lolly 静态文件服务功能。 +// 需要 lolly:latest 镜像。 +func TestE2EStaticWithLolly(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + if !testutil.DockerAvailable(ctx) { + t.Skip("Docker not available") + } + + if !testutil.LollyImageAvailable(ctx) { + t.Skip("lolly:latest image not available, run 'make docker-build' first") + } + + // 启动 lolly 静态文件服务器 + lolly, err := testutil.StartLollyContainer(ctx, "") + require.NoError(t, err, "Failed to start lolly container") + defer lolly.Terminate(ctx) + + t.Logf("Lolly static server: %s", lolly.HTTPBaseURL()) + + // 等待 lolly 健康 + 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, "Failed to reach lolly") + defer resp.Body.Close() + + // lolly 默认配置没有静态文件,返回 404 + assert.Equal(t, 404, resp.StatusCode, "Lolly should return 404 without static files") +} + +// TestE2EStaticDirectoryIndex 测试目录索引。 +func TestE2EStaticDirectoryIndex(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + container, addr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err) + defer container.Terminate(ctx) + + // 测试根目录 + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(addr) + require.NoError(t, err) + defer resp.Body.Close() + + // nginx 默认返回 index.html + assert.Equal(t, 200, resp.StatusCode) +} + +// TestE2EStaticFileCache 测试文件缓存。 +func TestE2EStaticFileCache(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + container, addr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err) + defer container.Terminate(ctx) + + client := &http.Client{Timeout: 10 * time.Second} + + // 第一次请求 + resp1, err := client.Get(addr) + require.NoError(t, err) + + etag1 := resp1.Header.Get("ETag") + lastModified1 := resp1.Header.Get("Last-Modified") + resp1.Body.Close() + + // 第二次请求带条件头 + req2, err := http.NewRequest("GET", addr, nil) + require.NoError(t, err) + + if etag1 != "" { + req2.Header.Set("If-None-Match", etag1) + } + if lastModified1 != "" { + req2.Header.Set("If-Modified-Since", lastModified1) + } + + resp2, err := client.Do(req2) + require.NoError(t, err) + resp2.Body.Close() + + // nginx 返回 304 表示缓存命中 + assert.True(t, resp2.StatusCode == 200 || resp2.StatusCode == 304, + "Expected 200 or 304, got %d", resp2.StatusCode) +} + +// TestE2EStaticContentType 测试 Content-Type 检测。 +func TestE2EStaticContentType(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + container, addr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err) + defer container.Terminate(ctx) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(addr) + require.NoError(t, err) + defer resp.Body.Close() + + contentType := resp.Header.Get("Content-Type") + assert.NotEmpty(t, contentType, "Content-Type should be set") + assert.True(t, strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/octet-stream"), + "Expected HTML or octet-stream, got %s", contentType) +} + +// TestE2EStaticNotFound 测试 404 错误。 +func TestE2EStaticNotFound(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + container, addr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err) + defer container.Terminate(ctx) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(fmt.Sprintf("%s/nonexistent-file-12345.html", addr)) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, 404, resp.StatusCode, "Should return 404 for nonexistent file") +} + +// TestE2EStaticLargeFile 测试大文件传输。 +func TestE2EStaticLargeFile(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + container, addr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err) + defer container.Terminate(ctx) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(addr) + require.NoError(t, err) + defer resp.Body.Close() + + // 读取响应体 + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // 验证响应 + assert.Equal(t, 200, resp.StatusCode) + assert.NotEmpty(t, body) +} + +// TestE2EStaticConcurrent 测试并发静态文件请求。 +func TestE2EStaticConcurrent(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + container, addr, err := testutil.StartNginxContainer(ctx) + require.NoError(t, err) + defer container.Terminate(ctx) + + // 使用真正的并发测试 + failures := testutil.RunAndVerifyConcurrentRequests(t, testutil.ConcurrentRequestConfig{ + URL: addr, + Count: 20, + Timeout: 10 * time.Second, + ExpectCode: 200, + }) + + assert.Empty(t, failures, "All concurrent requests should succeed") +}