forked from aegis/pyserveX
feat: Add reverse proxy functionality with enhanced routing capabilities
- Introduced IgnoreRequestPath option in proxy configuration to allow exact match routing. - Implemented proxy_pass directive in routing extension to handle backend requests. - Enhanced error handling for backend unavailability and timeouts. - Added integration tests for reverse proxy, including basic requests, exact match routes, regex routes, header forwarding, and query string preservation. - Created helper functions for setting up test servers and backends, along with assertion utilities for response validation. - Updated server initialization to support extension management and middleware chaining. - Improved logging for debugging purposes during request handling.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
# Integration Tests
|
||||
|
||||
Интеграционные тесты для Konduktor — полноценное тестирование сервера с реальными HTTP запросами.
|
||||
|
||||
## Отличие от unit-тестов
|
||||
|
||||
| Аспект | Unit-тесты | Интеграционные тесты |
|
||||
|--------|------------|---------------------|
|
||||
| Scope | Отдельный модуль в изоляции | Весь сервер целиком |
|
||||
| Backend | Mock (httptest.Server) | Реальные HTTP серверы |
|
||||
| Config | Программный | YAML конфигурация |
|
||||
| Extensions | Не тестируются | Полная цепочка обработки |
|
||||
|
||||
## Структура тестов
|
||||
|
||||
```
|
||||
tests/integration/
|
||||
├── README.md # Эта документация
|
||||
├── helpers_test.go # Общие хелперы и утилиты
|
||||
├── reverse_proxy_test.go # Тесты reverse proxy
|
||||
├── routing_test.go # Тесты маршрутизации (TODO)
|
||||
├── security_test.go # Тесты security extension (TODO)
|
||||
├── caching_test.go # Тесты caching extension (TODO)
|
||||
└── static_files_test.go # Тесты статических файлов (TODO)
|
||||
```
|
||||
|
||||
## Что тестируют интеграционные тесты
|
||||
|
||||
### 1. Reverse Proxy (`reverse_proxy_test.go`)
|
||||
|
||||
- [ ] Базовое проксирование GET/POST/PUT/DELETE
|
||||
- [ ] Exact match routes (`=/api/version`)
|
||||
- [ ] Regex routes с параметрами (`~^/api/resource/(?P<id>\d+)$`)
|
||||
- [ ] Подстановка параметров в target URL (`{id}`, `{tag}`)
|
||||
- [ ] Подстановка переменных в заголовки (`$remote_addr`)
|
||||
- [ ] Передача заголовков X-Forwarded-For, X-Real-IP
|
||||
- [ ] Сохранение query string
|
||||
- [ ] Обработка ошибок backend (502, 504)
|
||||
- [ ] Таймауты соединения
|
||||
|
||||
### 2. Routing Extension (`routing_test.go`)
|
||||
|
||||
- [ ] Приоритет маршрутов (exact > regex > default)
|
||||
- [ ] Case-sensitive regex (`~`)
|
||||
- [ ] Case-insensitive regex (`~*`)
|
||||
- [ ] Default route (`__default__`)
|
||||
- [ ] Return directive (`return 200 "OK"`)
|
||||
- [ ] Конфликт маршрутов
|
||||
|
||||
### 3. Security Extension (`security_test.go`)
|
||||
|
||||
- [ ] IP whitelist
|
||||
- [ ] IP blacklist
|
||||
- [ ] CIDR нотация (10.0.0.0/8)
|
||||
- [ ] Security headers (X-Frame-Options, X-Content-Type-Options)
|
||||
- [ ] Rate limiting
|
||||
- [ ] Комбинация с другими extensions
|
||||
|
||||
### 4. Caching Extension (`caching_test.go`)
|
||||
|
||||
- [ ] Cache hit/miss
|
||||
- [ ] TTL expiration
|
||||
- [ ] Pattern-based caching
|
||||
- [ ] Cache-Control headers
|
||||
- [ ] Cache invalidation
|
||||
- [ ] Max cache size и eviction
|
||||
|
||||
### 5. Static Files (`static_files_test.go`)
|
||||
|
||||
- [ ] Serving статических файлов
|
||||
- [ ] Index file (index.html)
|
||||
- [ ] MIME types
|
||||
- [ ] Cache-Control для static
|
||||
- [ ] SPA fallback
|
||||
- [ ] Directory traversal protection
|
||||
- [ ] 404 для несуществующих файлов
|
||||
|
||||
### 6. Extension Chain (`extension_chain_test.go`)
|
||||
|
||||
- [ ] Порядок выполнения extensions (security → caching → routing)
|
||||
- [ ] Прерывание цепочки при ошибке
|
||||
- [ ] Совместная работа extensions
|
||||
|
||||
## Запуск тестов
|
||||
|
||||
```bash
|
||||
# Все интеграционные тесты
|
||||
go test ./tests/integration/... -v
|
||||
|
||||
# Конкретный файл
|
||||
go test ./tests/integration/... -v -run TestReverseProxy
|
||||
|
||||
# С таймаутом (интеграционные тесты медленнее)
|
||||
go test ./tests/integration/... -v -timeout 60s
|
||||
|
||||
# С покрытием
|
||||
go test ./tests/integration/... -v -coverprofile=coverage.out
|
||||
```
|
||||
|
||||
## Требования
|
||||
|
||||
- Свободные порты: тесты используют случайные порты (`:0`)
|
||||
- Сетевой доступ: для localhost соединений
|
||||
- Время: интеграционные тесты занимают больше времени (~5-10 сек)
|
||||
|
||||
## Добавление новых тестов
|
||||
|
||||
1. Создайте файл `*_test.go` в `tests/integration/`
|
||||
2. Используйте хелперы из `helpers_test.go`:
|
||||
- `startTestServer()` — запуск Konduktor сервера
|
||||
- `startBackend()` — запуск mock backend
|
||||
- `makeRequest()` — отправка HTTP запроса
|
||||
3. Добавьте описание в этот README
|
||||
|
||||
## CI/CD
|
||||
|
||||
Интеграционные тесты запускаются отдельно от unit-тестов:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml
|
||||
jobs:
|
||||
unit-tests:
|
||||
run: go test ./internal/...
|
||||
|
||||
integration-tests:
|
||||
run: go test ./tests/integration/... -timeout 120s
|
||||
```
|
||||
@@ -0,0 +1,408 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/extension"
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
"github.com/konduktor/konduktor/internal/middleware"
|
||||
)
|
||||
|
||||
// TestServer represents a running Konduktor server for testing
|
||||
type TestServer struct {
|
||||
Server *http.Server
|
||||
URL string
|
||||
Port int
|
||||
listener net.Listener
|
||||
handler http.Handler
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// TestBackend represents a mock backend server
|
||||
type TestBackend struct {
|
||||
server *httptest.Server
|
||||
requestLog []RequestLogEntry
|
||||
mu sync.Mutex
|
||||
requestCount int64
|
||||
handler http.HandlerFunc
|
||||
}
|
||||
|
||||
// RequestLogEntry stores information about a received request
|
||||
type RequestLogEntry struct {
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Headers http.Header
|
||||
Body string
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
// ServerConfig holds configuration for starting a test server
|
||||
type ServerConfig struct {
|
||||
Extensions []extension.Extension
|
||||
StaticDir string
|
||||
Middleware []func(http.Handler) http.Handler
|
||||
}
|
||||
|
||||
// ============== Test Server ==============
|
||||
|
||||
// StartTestServer creates and starts a Konduktor server for testing
|
||||
func StartTestServer(t *testing.T, cfg *ServerConfig) *TestServer {
|
||||
t.Helper()
|
||||
|
||||
logger, err := logging.New(logging.Config{
|
||||
Level: "DEBUG",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create logger: %v", err)
|
||||
}
|
||||
|
||||
// Create extension manager
|
||||
extManager := extension.NewManager(logger)
|
||||
|
||||
// Add extensions if provided
|
||||
if cfg != nil && len(cfg.Extensions) > 0 {
|
||||
for _, ext := range cfg.Extensions {
|
||||
if err := extManager.AddExtension(ext); err != nil {
|
||||
t.Fatalf("Failed to add extension: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a fallback handler for when no extension handles the request
|
||||
fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
|
||||
// Create handler chain
|
||||
var handler http.Handler = extManager.Handler(fallback)
|
||||
|
||||
// Add middleware
|
||||
handler = middleware.AccessLog(handler, logger)
|
||||
handler = middleware.Recovery(handler, logger)
|
||||
|
||||
// Add custom middleware if provided
|
||||
if cfg != nil {
|
||||
for _, mw := range cfg.Middleware {
|
||||
handler = mw(handler)
|
||||
}
|
||||
}
|
||||
|
||||
// Find available port
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to find available port: %v", err)
|
||||
}
|
||||
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
server := &http.Server{
|
||||
Handler: handler,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
ts := &TestServer{
|
||||
Server: server,
|
||||
URL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
||||
Port: port,
|
||||
listener: listener,
|
||||
handler: handler,
|
||||
t: t,
|
||||
}
|
||||
|
||||
// Start server in goroutine
|
||||
go func() {
|
||||
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
// Don't fail test here as server might be intentionally closed
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for server to be ready
|
||||
ts.waitReady()
|
||||
|
||||
return ts
|
||||
}
|
||||
|
||||
// waitReady waits for the server to be ready to accept connections
|
||||
func (ts *TestServer) waitReady() {
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", ts.Port), 100*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
ts.t.Fatal("Server failed to start within timeout")
|
||||
}
|
||||
|
||||
// Close shuts down the test server
|
||||
func (ts *TestServer) Close() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
ts.Server.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// ============== Test Backend ==============
|
||||
|
||||
// StartBackend creates and starts a mock backend server
|
||||
func StartBackend(handler http.HandlerFunc) *TestBackend {
|
||||
tb := &TestBackend{
|
||||
requestLog: make([]RequestLogEntry, 0),
|
||||
handler: handler,
|
||||
}
|
||||
|
||||
if handler == nil {
|
||||
handler = tb.defaultHandler
|
||||
}
|
||||
|
||||
tb.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
tb.logRequest(r)
|
||||
handler(w, r)
|
||||
}))
|
||||
|
||||
return tb
|
||||
}
|
||||
|
||||
func (tb *TestBackend) logRequest(r *http.Request) {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
|
||||
tb.requestLog = append(tb.requestLog, RequestLogEntry{
|
||||
Method: r.Method,
|
||||
Path: r.URL.Path,
|
||||
Query: r.URL.RawQuery,
|
||||
Headers: r.Header.Clone(),
|
||||
Body: string(body),
|
||||
Timestamp: time.Now(),
|
||||
})
|
||||
atomic.AddInt64(&tb.requestCount, 1)
|
||||
}
|
||||
|
||||
func (tb *TestBackend) defaultHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"backend": "default",
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
"query": r.URL.RawQuery,
|
||||
"received": time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// URL returns the backend server URL
|
||||
func (tb *TestBackend) URL() string {
|
||||
return tb.server.URL
|
||||
}
|
||||
|
||||
// Close shuts down the backend server
|
||||
func (tb *TestBackend) Close() {
|
||||
tb.server.Close()
|
||||
}
|
||||
|
||||
// RequestCount returns the number of requests received
|
||||
func (tb *TestBackend) RequestCount() int64 {
|
||||
return atomic.LoadInt64(&tb.requestCount)
|
||||
}
|
||||
|
||||
// LastRequest returns the most recent request
|
||||
func (tb *TestBackend) LastRequest() *RequestLogEntry {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
if len(tb.requestLog) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &tb.requestLog[len(tb.requestLog)-1]
|
||||
}
|
||||
|
||||
// AllRequests returns all logged requests
|
||||
func (tb *TestBackend) AllRequests() []RequestLogEntry {
|
||||
tb.mu.Lock()
|
||||
defer tb.mu.Unlock()
|
||||
result := make([]RequestLogEntry, len(tb.requestLog))
|
||||
copy(result, tb.requestLog)
|
||||
return result
|
||||
}
|
||||
|
||||
// ============== HTTP Client Helpers ==============
|
||||
|
||||
// HTTPClient is a configured HTTP client for testing
|
||||
type HTTPClient struct {
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewHTTPClient creates a new test HTTP client
|
||||
func NewHTTPClient(baseURL string) *HTTPClient {
|
||||
return &HTTPClient{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse // Don't follow redirects
|
||||
},
|
||||
},
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// Get performs a GET request
|
||||
func (c *HTTPClient) Get(path string, headers map[string]string) (*http.Response, error) {
|
||||
return c.Do("GET", path, nil, headers)
|
||||
}
|
||||
|
||||
// Post performs a POST request
|
||||
func (c *HTTPClient) Post(path string, body []byte, headers map[string]string) (*http.Response, error) {
|
||||
return c.Do("POST", path, body, headers)
|
||||
}
|
||||
|
||||
// Do performs an HTTP request
|
||||
func (c *HTTPClient) Do(method, path string, body []byte, headers map[string]string) (*http.Response, error) {
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, c.baseURL+path, bodyReader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
|
||||
return c.client.Do(req)
|
||||
}
|
||||
|
||||
// GetJSON performs GET and decodes JSON response
|
||||
func (c *HTTPClient) GetJSON(path string, result interface{}) (*http.Response, error) {
|
||||
resp, err := c.Get(path, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(result); err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ============== File System Helpers ==============
|
||||
|
||||
// CreateTempDir creates a temporary directory for static files
|
||||
func CreateTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir, err := os.MkdirTemp("", "konduktor-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { os.RemoveAll(dir) })
|
||||
return dir
|
||||
}
|
||||
|
||||
// CreateTempFile creates a temporary file with given content
|
||||
func CreateTempFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
// Create parent directories if needed
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatalf("Failed to create directories: %v", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("Failed to write file: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// ============== Assertion Helpers ==============
|
||||
|
||||
// AssertStatus checks if response has expected status code
|
||||
func AssertStatus(t *testing.T, resp *http.Response, expected int) {
|
||||
t.Helper()
|
||||
if resp.StatusCode != expected {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Errorf("Expected status %d, got %d. Body: %s", expected, resp.StatusCode, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// AssertHeader checks if response has expected header value
|
||||
func AssertHeader(t *testing.T, resp *http.Response, header, expected string) {
|
||||
t.Helper()
|
||||
actual := resp.Header.Get(header)
|
||||
if actual != expected {
|
||||
t.Errorf("Expected header %s=%q, got %q", header, expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertHeaderContains checks if header contains substring
|
||||
func AssertHeaderContains(t *testing.T, resp *http.Response, header, substring string) {
|
||||
t.Helper()
|
||||
actual := resp.Header.Get(header)
|
||||
if actual == "" || !contains(actual, substring) {
|
||||
t.Errorf("Expected header %s to contain %q, got %q", header, substring, actual)
|
||||
}
|
||||
}
|
||||
|
||||
// AssertJSONField checks if JSON response has expected field value
|
||||
func AssertJSONField(t *testing.T, body []byte, field string, expected interface{}) {
|
||||
t.Helper()
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
t.Fatalf("Failed to parse JSON: %v", err)
|
||||
}
|
||||
|
||||
actual, ok := data[field]
|
||||
if !ok {
|
||||
t.Errorf("Field %q not found in JSON", field)
|
||||
return
|
||||
}
|
||||
|
||||
if actual != expected {
|
||||
t.Errorf("Expected %s=%v, got %v", field, expected, actual)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsAt(s, substr, 0))
|
||||
}
|
||||
|
||||
func containsAt(s, substr string, start int) bool {
|
||||
for i := start; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ReadBody reads and returns response body
|
||||
func ReadBody(t *testing.T, resp *http.Response) []byte {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read body: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/konduktor/konduktor/internal/extension"
|
||||
"github.com/konduktor/konduktor/internal/logging"
|
||||
)
|
||||
|
||||
// createTestLogger creates a logger for tests
|
||||
func createTestLogger(t *testing.T) *logging.Logger {
|
||||
t.Helper()
|
||||
logger, err := logging.New(logging.Config{Level: "DEBUG"})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create logger: %v", err)
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
// ============== Basic Reverse Proxy Tests ==============
|
||||
|
||||
func TestReverseProxy_BasicGET(t *testing.T) {
|
||||
// Start backend server
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"message": "Hello from backend",
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
// Create routing extension with proxy to backend
|
||||
logger := createTestLogger(t)
|
||||
routingExt, err := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create routing extension: %v", err)
|
||||
}
|
||||
|
||||
// Start Konduktor server
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
// Make request through Konduktor
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/api/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify response
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result["message"] != "Hello from backend" {
|
||||
t.Errorf("Unexpected message: %v", result["message"])
|
||||
}
|
||||
|
||||
if result["path"] != "/api/test" {
|
||||
t.Errorf("Expected path /api/test, got %v", result["path"])
|
||||
}
|
||||
|
||||
// Verify backend received request
|
||||
if backend.RequestCount() != 1 {
|
||||
t.Errorf("Expected 1 backend request, got %d", backend.RequestCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseProxy_POST(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"received": body,
|
||||
"method": r.Method,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
body := []byte(`{"name":"test","value":123}`)
|
||||
resp, err := client.Post("/api/data", body, map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var result map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["method"] != "POST" {
|
||||
t.Errorf("Expected method POST, got %v", result["method"])
|
||||
}
|
||||
|
||||
received := result["received"].(map[string]interface{})
|
||||
if received["name"] != "test" {
|
||||
t.Errorf("Expected name 'test', got %v", received["name"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Exact Match Routes ==============
|
||||
|
||||
func TestReverseProxy_ExactMatchRoute(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"endpoint": "version",
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
// Exact match - should use backend URL as-is
|
||||
"=/api/version": map[string]interface{}{
|
||||
"proxy_pass": backend.URL() + "/releases/latest",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Test exact match route
|
||||
resp, err := client.Get("/api/version", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
lastReq := backend.LastRequest()
|
||||
if lastReq == nil {
|
||||
t.Fatal("No request received by backend")
|
||||
}
|
||||
|
||||
// For exact match, the target path should be used as-is (IgnoreRequestPath=true)
|
||||
if lastReq.Path != "/releases/latest" {
|
||||
t.Errorf("Expected backend path /releases/latest, got %s", lastReq.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Regex Routes with Parameters ==============
|
||||
|
||||
func TestReverseProxy_RegexRouteWithParams(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
// Regex with named group
|
||||
"~^/api/users/(?P<id>\\d+)$": map[string]interface{}{
|
||||
"proxy_pass": backend.URL() + "/v2/users/{id}",
|
||||
},
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// Test regex route with parameter
|
||||
resp, err := client.Get("/api/users/42", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
lastReq := backend.LastRequest()
|
||||
if lastReq == nil {
|
||||
t.Fatal("No request received by backend")
|
||||
}
|
||||
|
||||
// Parameter {id} should be substituted
|
||||
if lastReq.Path != "/v2/users/42" {
|
||||
t.Errorf("Expected backend path /v2/users/42, got %s", lastReq.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Header Forwarding ==============
|
||||
|
||||
func TestReverseProxy_HeaderForwarding(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"x-forwarded-for": r.Header.Get("X-Forwarded-For"),
|
||||
"x-real-ip": r.Header.Get("X-Real-IP"),
|
||||
"x-custom": r.Header.Get("X-Custom"),
|
||||
"x-forwarded-host": r.Header.Get("X-Forwarded-Host"),
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
"headers": []interface{}{
|
||||
"X-Forwarded-For: $remote_addr",
|
||||
"X-Real-IP: $remote_addr",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/test", map[string]string{
|
||||
"X-Custom": "custom-value",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
// X-Custom should be forwarded
|
||||
if result["x-custom"] != "custom-value" {
|
||||
t.Errorf("Expected X-Custom header to be forwarded, got %v", result["x-custom"])
|
||||
}
|
||||
|
||||
// X-Forwarded-For should be set (will contain 127.0.0.1)
|
||||
if result["x-forwarded-for"] == "" {
|
||||
t.Error("Expected X-Forwarded-For header to be set")
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Query String ==============
|
||||
|
||||
func TestReverseProxy_QueryStringPreservation(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"query": r.URL.RawQuery,
|
||||
"foo": r.URL.Query().Get("foo"),
|
||||
"bar": r.URL.Query().Get("bar"),
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/search?foo=hello&bar=world", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["foo"] != "hello" {
|
||||
t.Errorf("Expected foo=hello, got %v", result["foo"])
|
||||
}
|
||||
|
||||
if result["bar"] != "world" {
|
||||
t.Errorf("Expected bar=world, got %v", result["bar"])
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Error Handling ==============
|
||||
|
||||
func TestReverseProxy_BackendUnavailable(t *testing.T) {
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
// Non-existent backend
|
||||
"proxy_pass": "http://127.0.0.1:59999",
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/test", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 502 Bad Gateway
|
||||
AssertStatus(t, resp, http.StatusBadGateway)
|
||||
}
|
||||
|
||||
func TestReverseProxy_BackendTimeout(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Simulate slow backend
|
||||
time.Sleep(3 * time.Second)
|
||||
w.Write([]byte("OK"))
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
"timeout": 0.5, // 500ms timeout
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get("/slow", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should return 504 Gateway Timeout
|
||||
AssertStatus(t, resp, http.StatusGatewayTimeout)
|
||||
}
|
||||
|
||||
// ============== HTTP Methods ==============
|
||||
|
||||
func TestReverseProxy_AllMethods(t *testing.T) {
|
||||
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"}
|
||||
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"method": r.Method,
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
for _, method := range methods {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
resp, err := client.Do(method, "/resource", nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
|
||||
if method != "HEAD" {
|
||||
var result map[string]string
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if result["method"] != method {
|
||||
t.Errorf("Expected method %s, got %v", method, result["method"])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============== Large Bodies ==============
|
||||
|
||||
func TestReverseProxy_LargeRequestBody(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
json.NewEncoder(w).Encode(map[string]int{
|
||||
"received": len(body),
|
||||
})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
client := NewHTTPClient(server.URL)
|
||||
|
||||
// 1MB body
|
||||
largeBody := []byte(strings.Repeat("x", 1024*1024))
|
||||
resp, err := client.Post("/upload", largeBody, map[string]string{
|
||||
"Content-Type": "application/octet-stream",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
AssertStatus(t, resp, http.StatusOK)
|
||||
}
|
||||
|
||||
// ============== Concurrent Requests ==============
|
||||
|
||||
func TestReverseProxy_ConcurrentRequests(t *testing.T) {
|
||||
backend := StartBackend(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Small delay to simulate work
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
})
|
||||
defer backend.Close()
|
||||
|
||||
logger := createTestLogger(t)
|
||||
routingExt, _ := extension.NewRoutingExtension(map[string]interface{}{
|
||||
"regex_locations": map[string]interface{}{
|
||||
"__default__": map[string]interface{}{
|
||||
"proxy_pass": backend.URL(),
|
||||
},
|
||||
},
|
||||
}, logger)
|
||||
|
||||
server := StartTestServer(t, &ServerConfig{
|
||||
Extensions: []extension.Extension{routingExt},
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
const numRequests = 50
|
||||
results := make(chan error, numRequests)
|
||||
|
||||
for i := 0; i < numRequests; i++ {
|
||||
go func(n int) {
|
||||
client := NewHTTPClient(server.URL)
|
||||
resp, err := client.Get(fmt.Sprintf("/concurrent/%d", n), nil)
|
||||
if err != nil {
|
||||
results <- err
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
results <- fmt.Errorf("unexpected status: %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
results <- nil
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Collect results
|
||||
var errors []error
|
||||
for i := 0; i < numRequests; i++ {
|
||||
if err := <-results; err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
t.Errorf("Got %d errors in concurrent requests: %v", len(errors), errors[:min(5, len(errors))])
|
||||
}
|
||||
|
||||
// Verify all requests reached backend
|
||||
if backend.RequestCount() != numRequests {
|
||||
t.Errorf("Expected %d backend requests, got %d", numRequests, backend.RequestCount())
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user