Files
traefikoidc/internal/pool/transport_test.go
T
lukaszraczylo 1b49e133da Complete rebuild of the plugin
* Fix bug affecting Azure OIDC authentication ( and most likely others )

* Fixes issue #51

* Ensure that appended roles are unique. Update the documentation.

* Improvements targetting possible memory usage spikes.

* Additional fixes and cleanup

* Refactoring code to fix the issues identified by the users.

* Modernize run

* Fieldalignment

* Multiple changes to improve performance and reduce complexity.
- Optimise the errors and recovery.
- Deduplicate code in metadata cache.
- Remove unused performance monitoring code.
- Simplify session management and settings handling.

* Fix claims issue.

* Add ability to overwrite the default scopes in the settings file

* Well.. that escalated quickly.

Completely forgot that Traefik uses outdated Yaegi and requires compatibility with 1.20 ( pre-generic Go code ).

* Bugfix #51: Ensures that user provided scopes overrides work.

* fixup! Bugfix #51: Ensures that user provided scopes overrides work.

* fixup! fixup! Bugfix #51: Ensures that user provided scopes overrides work.

* Abstract the provider logic into a separate package.

* Additional micro fixes and cleanups.

* Simplify all the things.

* fixup! Simplify all the things.

* fixup! fixup! Simplify all the things.

* fixup! fixup! fixup! Simplify all the things.

* fixup! fixup! fixup! fixup! Simplify all the things.

* ...

* Cleanup tests.

* fixup! Cleanup tests.

* fixup! fixup! fixup! Cleanup tests.

* fixup! fixup! fixup! fixup! Cleanup tests.

* fixup! fixup! fixup! fixup! fixup! Cleanup tests.

* Issue #53: Fix CSRF token handling in reverse proxy

1.  HTTPS Detection Fixed (session.go:723)
- Now uses X-Forwarded-Proto header instead of r.URL.Scheme
- Properly detects HTTPS in reverse proxy environments
2.  SameSite Cookie Attribute Fixed
- Removed automatic SameSiteStrictMode for HTTPS (would break OAuth)
- Keeps SameSiteLaxMode to allow OAuth callbacks from external domains
- Only uses Strict for AJAX requests which don't involve OAuth redirects
3.  Cookie Domain Handling Fixed
- Now respects X-Forwarded-Host header for cookie domain
- Ensures cookies are set for the public domain, not internal proxy domain
4.  EnhanceSessionSecurity Properly Integrated
- Function is now actually called during session save
- Applies security enhancements without breaking OAuth flow

Why Issue #53 Failed Before:

1. Cookies were not marked Secure in HTTPS environments (browser wouldn't send them back)
2. If they had been Secure with SameSite=Strict, Azure callbacks would still fail
3. Cookie domain might have been wrong (internal vs public domain)

Why It Works Now:

1. Cookies are properly marked Secure for HTTPS
2. Uses SameSite=Lax to allow OAuth provider callbacks
3. Cookie domain uses public domain from X-Forwarded-Host
4. CSRF token persists through the entire OAuth flow

* Next set of enhancements together with memory usage improvements.

* Memory leak fixes and optimisations.

* CSRF and Cookie Domain fixes

* fixup! CSRF and Cookie Domain fixes

* Metadata cache leak fix + profiling

* fixup! Metadata cache leak fix + profiling

* Memory leaks hunting, part 1337.

* Further pursue of perfection.

* fixup! Further pursue of perfection.

* fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! Further pursue of perfection.

* Clear race conditions

* fixup! Clear race conditions

* Weekend fun with memory leaks

* Splitting code into multiple files with reasonable testing coverage.

```
ok      github.com/lukaszraczylo/traefikoidc    117.017s        coverage: 72.6% of statements
ok      github.com/lukaszraczylo/traefikoidc/auth       0.505s  coverage: 87.1% of statements
ok      github.com/lukaszraczylo/traefikoidc/circuit_breaker    0.283s  coverage: 99.0% of statements
        github.com/lukaszraczylo/traefikoidc/config             coverage: 0.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/handlers   0.349s  coverage: 98.2% of statements
ok      github.com/lukaszraczylo/traefikoidc/internal/providers (cached)        coverage: 94.3% of statements
ok      github.com/lukaszraczylo/traefikoidc/middleware 0.808s  coverage: 78.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/recovery   0.653s  coverage: 100.0% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/chunking   (cached)        coverage: 87.8% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/core       (cached)        coverage: 85.6% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/crypto     (cached)        coverage: 81.8% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/storage    (cached)        coverage: 93.5% of statements
ok      github.com/lukaszraczylo/traefikoidc/session/validators (cached)        coverage: 98.8% of statements
````

* fixup! Splitting code into multiple files with reasonable testing coverage.

* fixup! fixup! Splitting code into multiple files with reasonable testing coverage.

* Weekend fun with further optimisations.

* fixup! Weekend fun with further optimisations.

* fixup! fixup! Weekend fun with further optimisations.

* fixup! fixup! fixup! Weekend fun with further optimisations.

* fixup! fixup! fixup! fixup! Weekend fun with further optimisations.

* fixup! fixup! fixup! fixup! fixup! Weekend fun with further optimisations.

* Pre-release cleanup.

* Enhance test coverage.

* fixup! Enhance test coverage.

* fixup! fixup! Enhance test coverage.

* fixup! fixup! fixup! Enhance test coverage.
2025-09-18 11:01:30 +01:00

594 lines
14 KiB
Go

package pool
import (
"context"
"crypto/tls"
"net/http"
"sync"
"testing"
"time"
)
// TestGetTransportPool_Singleton tests that GetTransportPool returns the same instance
func TestGetTransportPool_Singleton(t *testing.T) {
pool1 := GetTransportPool()
pool2 := GetTransportPool()
if pool1 != pool2 {
t.Error("GetTransportPool() should return the same instance (singleton)")
}
if pool1 == nil {
t.Error("GetTransportPool() should not return nil")
}
}
// TestDefaultTransportConfig tests the default transport configuration
func TestDefaultTransportConfig(t *testing.T) {
config := DefaultTransportConfig()
// Verify security defaults
if config.MinTLSVersion != tls.VersionTLS12 {
t.Errorf("Default MinTLSVersion should be TLS 1.2, got %d", config.MinTLSVersion)
}
if config.InsecureSkipVerify {
t.Error("Default should not skip TLS verification")
}
if !config.ForceHTTP2 {
t.Error("Default should force HTTP/2")
}
// Verify reasonable timeouts
if config.DialTimeout <= 0 {
t.Error("DialTimeout should be positive")
}
if config.TLSHandshakeTimeout <= 0 {
t.Error("TLSHandshakeTimeout should be positive")
}
if config.ResponseHeaderTimeout <= 0 {
t.Error("ResponseHeaderTimeout should be positive")
}
// Verify connection limits
if config.MaxIdleConns <= 0 {
t.Error("MaxIdleConns should be positive")
}
if config.MaxIdleConnsPerHost <= 0 {
t.Error("MaxIdleConnsPerHost should be positive")
}
if config.MaxConnsPerHost <= 0 {
t.Error("MaxConnsPerHost should be positive")
}
}
// TestTransportPool_GetTransport tests transport creation and reuse
func TestTransportPool_GetTransport(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 5,
}
config := DefaultTransportConfig()
// First call should create new transport
transport1 := pool.GetTransport(config)
if transport1 == nil {
t.Error("GetTransport should not return nil")
}
// Second call with same config should return same transport
transport2 := pool.GetTransport(config)
if transport2 == nil {
t.Error("GetTransport should not return nil")
}
if transport1 != transport2 {
t.Error("GetTransport should return same transport for same config")
}
// Verify reference counting
pool.mu.RLock()
key := pool.configKey(config)
shared := pool.transports[key]
refCount := shared.refCount
pool.mu.RUnlock()
if refCount != 2 {
t.Errorf("Reference count should be 2, got %d", refCount)
}
}
// TestTransportPool_GetTransport_DifferentConfigs tests transport creation with different configs
func TestTransportPool_GetTransport_DifferentConfigs(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 5,
}
config1 := DefaultTransportConfig()
config2 := DefaultTransportConfig()
config2.MaxConnsPerHost = 10 // Different from default
transport1 := pool.GetTransport(config1)
transport2 := pool.GetTransport(config2)
if transport1 == transport2 {
t.Error("Different configs should produce different transports")
}
}
// TestTransportPool_GetTransport_ClientLimit tests client limit enforcement
func TestTransportPool_GetTransport_ClientLimit(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 2, // Low limit for testing
clientCount: 2, // Already at limit
}
config := DefaultTransportConfig()
// Should return existing transport when limit reached
transport := pool.GetTransport(config)
// Transport might be nil if no existing transports
if transport != nil && pool.clientCount > pool.maxClients {
t.Error("Should not exceed client limit")
}
}
// TestTransportPool_ReleaseTransport tests transport reference counting
func TestTransportPool_ReleaseTransport(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 5,
}
config := DefaultTransportConfig()
// Get transport
transport := pool.GetTransport(config)
if transport == nil {
t.Error("GetTransport should not return nil")
}
// Release transport
pool.ReleaseTransport(transport)
// Verify reference count decreased
pool.mu.RLock()
key := pool.configKey(config)
shared := pool.transports[key]
refCount := shared.refCount
pool.mu.RUnlock()
if refCount != 0 {
t.Errorf("Reference count should be 0 after release, got %d", refCount)
}
}
// TestTransportPool_ReleaseTransport_Nil tests releasing nil transport
func TestTransportPool_ReleaseTransport_Nil(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 5,
}
// Should not panic
pool.ReleaseTransport(nil)
}
// TestTransportPool_ReleaseTransport_Unknown tests releasing unknown transport
func TestTransportPool_ReleaseTransport_Unknown(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 5,
}
// Create a transport not from the pool
transport := &http.Transport{}
// Should not panic
pool.ReleaseTransport(transport)
}
// TestTransportPool_createTransport tests transport creation with different configs
func TestTransportPool_createTransport(t *testing.T) {
pool := &TransportPool{}
tests := []struct {
name string
config TransportConfig
}{
{
"default config",
DefaultTransportConfig(),
},
{
"custom timeouts",
TransportConfig{
DialTimeout: 10 * time.Second,
TLSHandshakeTimeout: 5 * time.Second,
MinTLSVersion: tls.VersionTLS13,
},
},
{
"insecure config",
TransportConfig{
InsecureSkipVerify: true,
MinTLSVersion: tls.VersionTLS10,
},
},
{
"no HTTP/2",
TransportConfig{
ForceHTTP2: false,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
transport := pool.createTransport(test.config)
if transport == nil {
t.Error("createTransport should not return nil")
return
}
// Verify TLS config
if transport.TLSClientConfig == nil {
t.Error("Transport should have TLS config")
return
}
// Verify minimum TLS version
expectedMinVersion := test.config.MinTLSVersion
if expectedMinVersion == 0 {
expectedMinVersion = tls.VersionTLS12 // Default
}
if transport.TLSClientConfig.MinVersion != expectedMinVersion {
t.Errorf("TLS MinVersion should be %d, got %d", expectedMinVersion, transport.TLSClientConfig.MinVersion)
}
// Verify max TLS version
if transport.TLSClientConfig.MaxVersion != tls.VersionTLS13 {
t.Errorf("TLS MaxVersion should be %d, got %d", tls.VersionTLS13, transport.TLSClientConfig.MaxVersion)
}
// Verify InsecureSkipVerify
if transport.TLSClientConfig.InsecureSkipVerify != test.config.InsecureSkipVerify {
t.Errorf("InsecureSkipVerify should be %v, got %v", test.config.InsecureSkipVerify, transport.TLSClientConfig.InsecureSkipVerify)
}
// Verify HTTP/2
if transport.ForceAttemptHTTP2 != test.config.ForceHTTP2 {
t.Errorf("ForceAttemptHTTP2 should be %v, got %v", test.config.ForceHTTP2, transport.ForceAttemptHTTP2)
}
// Verify timeouts
if test.config.TLSHandshakeTimeout > 0 && transport.TLSHandshakeTimeout != test.config.TLSHandshakeTimeout {
t.Errorf("TLSHandshakeTimeout should be %v, got %v", test.config.TLSHandshakeTimeout, transport.TLSHandshakeTimeout)
}
})
}
}
// TestTransportPool_configKey tests configuration key generation
func TestTransportPool_configKey(t *testing.T) {
pool := &TransportPool{}
config1 := DefaultTransportConfig()
config2 := DefaultTransportConfig()
key1 := pool.configKey(config1)
key2 := pool.configKey(config2)
if key1 != key2 {
t.Error("Same configs should generate same key")
}
// Different config
config3 := config1
config3.MaxConnsPerHost = 999
key3 := pool.configKey(config3)
if key1 == key3 {
t.Error("Different configs should generate different keys")
}
}
// TestTransportPool_cleanupIdle tests idle transport cleanup
func TestTransportPool_cleanupIdle(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 5,
}
config := DefaultTransportConfig()
transport := pool.createTransport(config)
// Add transport to pool with old timestamp
shared := &sharedTransport{
transport: transport,
refCount: 0,
lastUsed: time.Now().Add(-5 * time.Minute), // Old
config: config,
}
key := pool.configKey(config)
pool.transports[key] = shared
// Run cleanup
pool.cleanupIdle()
// Transport should be removed
if _, exists := pool.transports[key]; exists {
t.Error("Old idle transport should be cleaned up")
}
}
// TestTransportPool_cleanup tests full cleanup
func TestTransportPool_cleanup(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 5,
clientCount: 3,
}
config := DefaultTransportConfig()
transport := pool.createTransport(config)
// Add transport to pool
shared := &sharedTransport{
transport: transport,
refCount: 1,
lastUsed: time.Now(),
config: config,
}
key := pool.configKey(config)
pool.transports[key] = shared
// Run cleanup
pool.cleanup()
// All transports should be removed
if len(pool.transports) != 0 {
t.Error("All transports should be cleaned up")
}
// Client count should be reset
if pool.clientCount != 0 {
t.Error("Client count should be reset")
}
}
// TestTransportPool_Shutdown tests graceful shutdown
func TestTransportPool_Shutdown(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 5,
ctx: ctx,
cancel: cancel,
}
// Should not panic
pool.Shutdown()
}
// TestTransportPool_GetStats tests statistics
func TestTransportPool_GetStats(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 5,
clientCount: 3,
}
config := DefaultTransportConfig()
// Add some transports
for i := 0; i < 3; i++ {
transport := pool.createTransport(config)
shared := &sharedTransport{
transport: transport,
refCount: int32(i % 2), // Some active, some idle
lastUsed: time.Now(),
config: config,
}
pool.transports[string(rune(i))] = shared
}
stats := pool.GetStats()
if stats.TotalClients != 3 {
t.Errorf("TotalClients should be 3, got %d", stats.TotalClients)
}
if stats.MaxClients != 5 {
t.Errorf("MaxClients should be 5, got %d", stats.MaxClients)
}
if stats.ActiveTransports < 0 || stats.ActiveTransports > 3 {
t.Errorf("ActiveTransports should be between 0 and 3, got %d", stats.ActiveTransports)
}
}
// TestCreateHTTPClient tests HTTP client creation
func TestCreateHTTPClient(t *testing.T) {
config := DefaultTransportConfig()
timeout := 30 * time.Second
client := CreateHTTPClient(config, timeout)
if client == nil {
t.Error("CreateHTTPClient should not return nil")
return
}
if client.Timeout != timeout {
t.Errorf("Client timeout should be %v, got %v", timeout, client.Timeout)
}
if client.Transport == nil {
t.Error("Client should have transport")
}
if client.CheckRedirect == nil {
t.Error("Client should have redirect policy")
}
// Test redirect policy
req := &http.Request{}
var via []*http.Request
// Should allow up to 9 redirects (10 total requests)
for i := 0; i < 9; i++ {
via = append(via, &http.Request{})
err := client.CheckRedirect(req, via)
if err != nil {
t.Errorf("Should allow %d redirects, got error: %v", i+1, err)
}
}
// Should reject 10th redirect (11th total request)
via = append(via, &http.Request{})
err := client.CheckRedirect(req, via)
if err != http.ErrUseLastResponse {
t.Error("Should reject too many redirects")
}
}
// TestCreateHTTPClient_Fallback tests fallback when pool is exhausted
func TestCreateHTTPClient_Fallback(t *testing.T) {
// Override global pool with limited one
originalPool := globalTransportPool
defer func() {
globalTransportPool = originalPool
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
globalTransportPool = &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
ctx: ctx,
cancel: cancel,
clientCount: 10,
maxClients: 1, // Very low limit
}
config := DefaultTransportConfig()
timeout := 30 * time.Second
client := CreateHTTPClient(config, timeout)
if client == nil {
t.Error("CreateHTTPClient should not return nil even when pool is exhausted")
return
}
if client.Timeout != timeout {
t.Errorf("Client timeout should be %v, got %v", timeout, client.Timeout)
}
}
// TestTransportPool_ConcurrentAccess tests concurrent access to transport pool
func TestTransportPool_ConcurrentAccess(t *testing.T) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 50, // High limit for concurrent test
}
// Use different configs to reduce contention on single transport
baseConfig := DefaultTransportConfig()
configs := make([]TransportConfig, 10)
for i := range configs {
configs[i] = baseConfig
configs[i].MaxConnsPerHost = 5 + i // Make each config unique
}
var wg sync.WaitGroup
numGoroutines := 10
operationsPerGoroutine := 3
wg.Add(numGoroutines)
for i := 0; i < numGoroutines; i++ {
go func(goroutineID int) {
defer wg.Done()
config := configs[goroutineID%len(configs)]
for j := 0; j < operationsPerGoroutine; j++ {
transport := pool.GetTransport(config)
if transport == nil {
continue
}
// Use transport briefly
time.Sleep(time.Millisecond)
pool.ReleaseTransport(transport)
}
}(i)
}
wg.Wait()
// Should not panic and should have reasonable stats
stats := pool.GetStats()
if stats.TotalClients < 0 || stats.TotalClients > int32(numGoroutines) {
t.Errorf("Unexpected client count: %d", stats.TotalClients)
}
}
// Benchmark tests for performance verification
func BenchmarkTransportPool_GetTransport(b *testing.B) {
pool := &TransportPool{
transports: make(map[string]*sharedTransport),
maxConns: 20,
maxClients: 100,
}
config := DefaultTransportConfig()
b.ResetTimer()
for i := 0; i < b.N; i++ {
transport := pool.GetTransport(config)
pool.ReleaseTransport(transport)
}
}
func BenchmarkCreateHTTPClient(b *testing.B) {
config := DefaultTransportConfig()
timeout := 30 * time.Second
b.ResetTimer()
for i := 0; i < b.N; i++ {
CreateHTTPClient(config, timeout)
}
}
func BenchmarkTransportPool_configKey(b *testing.B) {
pool := &TransportPool{}
config := DefaultTransportConfig()
b.ResetTimer()
for i := 0; i < b.N; i++ {
pool.configKey(config)
}
}