mirror of
https://github.com/lukaszraczylo/traefikoidc.git
synced 2026-10-14 04:07:46 +00:00
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.
This commit is contained in:
@@ -1,433 +0,0 @@
|
||||
package traefikoidc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCircuitBreaker(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
config := DefaultCircuitBreakerConfig()
|
||||
config.MaxFailures = 2
|
||||
config.Timeout = 100 * time.Millisecond
|
||||
|
||||
cb := NewCircuitBreaker(config, logger)
|
||||
|
||||
t.Run("Initial state is closed", func(t *testing.T) {
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Errorf("Expected initial state to be closed, got %v", cb.GetState())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Successful execution", func(t *testing.T) {
|
||||
err := cb.Execute(func() error {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Circuit opens after max failures", func(t *testing.T) {
|
||||
// Trigger failures to open circuit
|
||||
for i := 0; i < config.MaxFailures; i++ {
|
||||
cb.Execute(func() error {
|
||||
return errors.New("test error")
|
||||
})
|
||||
}
|
||||
|
||||
if cb.GetState() != CircuitBreakerOpen {
|
||||
t.Errorf("Expected circuit to be open, got %v", cb.GetState())
|
||||
}
|
||||
|
||||
// Should reject requests when open
|
||||
err := cb.Execute(func() error {
|
||||
return nil
|
||||
})
|
||||
if err == nil || err.Error() != "circuit breaker is open" {
|
||||
t.Errorf("Expected circuit breaker open error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Circuit transitions to half-open after timeout", func(t *testing.T) {
|
||||
// Wait for timeout
|
||||
time.Sleep(config.Timeout + 10*time.Millisecond)
|
||||
|
||||
// Next request should transition to half-open
|
||||
cb.Execute(func() error {
|
||||
return nil
|
||||
})
|
||||
|
||||
if cb.GetState() != CircuitBreakerClosed {
|
||||
t.Errorf("Expected circuit to be closed after successful request, got %v", cb.GetState())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get metrics", func(t *testing.T) {
|
||||
metrics := cb.GetMetrics()
|
||||
if metrics["state"] == nil {
|
||||
t.Error("Expected metrics to contain state")
|
||||
}
|
||||
if metrics["total_requests"] == nil {
|
||||
t.Error("Expected metrics to contain total_requests")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRetryExecutor(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
config := DefaultRetryConfig()
|
||||
config.MaxAttempts = 3
|
||||
config.InitialDelay = 10 * time.Millisecond
|
||||
|
||||
re := NewRetryExecutor(config, logger)
|
||||
|
||||
t.Run("Successful execution on first attempt", func(t *testing.T) {
|
||||
attempts := 0
|
||||
err := re.Execute(context.Background(), func() error {
|
||||
attempts++
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, got %v", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("Expected 1 attempt, got %d", attempts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Retry on retryable error", func(t *testing.T) {
|
||||
attempts := 0
|
||||
err := re.Execute(context.Background(), func() error {
|
||||
attempts++
|
||||
if attempts < 2 {
|
||||
return errors.New("connection refused")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error after retry, got %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Errorf("Expected 2 attempts, got %d", attempts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No retry on non-retryable error", func(t *testing.T) {
|
||||
attempts := 0
|
||||
err := re.Execute(context.Background(), func() error {
|
||||
attempts++
|
||||
return errors.New("non-retryable error")
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error to be returned")
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("Expected 1 attempt, got %d", attempts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Max attempts reached", func(t *testing.T) {
|
||||
attempts := 0
|
||||
err := re.Execute(context.Background(), func() error {
|
||||
attempts++
|
||||
return errors.New("timeout")
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error after max attempts")
|
||||
}
|
||||
if attempts != config.MaxAttempts {
|
||||
t.Errorf("Expected %d attempts, got %d", config.MaxAttempts, attempts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Context cancellation", func(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
err := re.Execute(ctx, func() error {
|
||||
return errors.New("timeout")
|
||||
})
|
||||
|
||||
if err != context.Canceled {
|
||||
t.Errorf("Expected context canceled error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Network error handling", func(t *testing.T) {
|
||||
// Test timeout error
|
||||
timeoutErr := &net.OpError{Op: "dial", Err: errors.New("timeout")}
|
||||
if !re.isRetryableError(timeoutErr) {
|
||||
t.Error("Expected timeout error to be retryable")
|
||||
}
|
||||
|
||||
// Test connection refused
|
||||
connErr := errors.New("connection refused")
|
||||
if !re.isRetryableError(connErr) {
|
||||
t.Error("Expected connection refused to be retryable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("HTTP error handling", func(t *testing.T) {
|
||||
// Test 500 error (retryable)
|
||||
httpErr500 := &HTTPError{StatusCode: 500, Message: "Internal Server Error"}
|
||||
if !re.isRetryableError(httpErr500) {
|
||||
t.Error("Expected 500 error to be retryable")
|
||||
}
|
||||
|
||||
// Test 429 error (retryable)
|
||||
httpErr429 := &HTTPError{StatusCode: 429, Message: "Too Many Requests"}
|
||||
if !re.isRetryableError(httpErr429) {
|
||||
t.Error("Expected 429 error to be retryable")
|
||||
}
|
||||
|
||||
// Test 400 error (not retryable)
|
||||
httpErr400 := &HTTPError{StatusCode: 400, Message: "Bad Request"}
|
||||
if re.isRetryableError(httpErr400) {
|
||||
t.Error("Expected 400 error to not be retryable")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGracefulDegradation(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
config := DefaultGracefulDegradationConfig()
|
||||
config.HealthCheckInterval = 50 * time.Millisecond
|
||||
config.RecoveryTimeout = 100 * time.Millisecond
|
||||
|
||||
gd := NewGracefulDegradation(config, logger)
|
||||
defer func() {
|
||||
// Clean up goroutine
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}()
|
||||
|
||||
t.Run("Register fallback and health check", func(t *testing.T) {
|
||||
gd.RegisterFallback("test-service", func() (interface{}, error) {
|
||||
return "fallback-result", nil
|
||||
})
|
||||
|
||||
gd.RegisterHealthCheck("test-service", func() bool {
|
||||
return true
|
||||
})
|
||||
|
||||
// Should not be degraded initially
|
||||
if gd.isServiceDegraded("test-service") {
|
||||
t.Error("Service should not be degraded initially")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Execute with fallback on failure", func(t *testing.T) {
|
||||
gd.RegisterFallback("failing-service", func() (interface{}, error) {
|
||||
return "fallback-result", nil
|
||||
})
|
||||
|
||||
// First call should fail and mark service as degraded
|
||||
result, err := gd.ExecuteWithFallback("failing-service", func() (interface{}, error) {
|
||||
return nil, errors.New("service failure")
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Expected fallback to succeed, got error: %v", err)
|
||||
}
|
||||
if result != "fallback-result" {
|
||||
t.Errorf("Expected fallback result, got %v", result)
|
||||
}
|
||||
|
||||
// Service should now be degraded
|
||||
if !gd.isServiceDegraded("failing-service") {
|
||||
t.Error("Service should be marked as degraded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("No fallback available", func(t *testing.T) {
|
||||
_, err := gd.ExecuteWithFallback("no-fallback-service", func() (interface{}, error) {
|
||||
return nil, errors.New("service failure")
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error when no fallback available")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get degraded services", func(t *testing.T) {
|
||||
degraded := gd.GetDegradedServices()
|
||||
found := false
|
||||
for _, service := range degraded {
|
||||
if service == "failing-service" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("Expected failing-service to be in degraded list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Service recovery after timeout", func(t *testing.T) {
|
||||
// Wait for recovery timeout
|
||||
time.Sleep(config.RecoveryTimeout + 20*time.Millisecond)
|
||||
|
||||
// Service should no longer be degraded
|
||||
if gd.isServiceDegraded("failing-service") {
|
||||
t.Error("Service should have recovered after timeout")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestErrorRecoveryManager(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
erm := NewErrorRecoveryManager(logger)
|
||||
|
||||
t.Run("Get circuit breaker", func(t *testing.T) {
|
||||
cb1 := erm.GetCircuitBreaker("service1")
|
||||
cb2 := erm.GetCircuitBreaker("service1")
|
||||
|
||||
// Should return the same instance
|
||||
if cb1 != cb2 {
|
||||
t.Error("Expected same circuit breaker instance for same service")
|
||||
}
|
||||
|
||||
cb3 := erm.GetCircuitBreaker("service2")
|
||||
if cb1 == cb3 {
|
||||
t.Error("Expected different circuit breaker instances for different services")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Execute with recovery", func(t *testing.T) {
|
||||
attempts := 0
|
||||
err := erm.ExecuteWithRecovery(context.Background(), "test-service", func() error {
|
||||
attempts++
|
||||
if attempts < 2 {
|
||||
return errors.New("temporary failure")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("Expected recovery to succeed, got %v", err)
|
||||
}
|
||||
if attempts < 2 {
|
||||
t.Errorf("Expected at least 2 attempts, got %d", attempts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Get recovery metrics", func(t *testing.T) {
|
||||
metrics := erm.GetRecoveryMetrics()
|
||||
|
||||
if metrics["circuit_breakers"] == nil {
|
||||
t.Error("Expected circuit_breakers in metrics")
|
||||
}
|
||||
if metrics["degraded_services"] == nil {
|
||||
t.Error("Expected degraded_services in metrics")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHTTPError(t *testing.T) {
|
||||
err := &HTTPError{StatusCode: 500, Message: "Internal Server Error"}
|
||||
expected := "HTTP 500: Internal Server Error"
|
||||
if err.Error() != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHelperFunctions(t *testing.T) {
|
||||
t.Run("contains function", func(t *testing.T) {
|
||||
if !contains("hello world", "hello") {
|
||||
t.Error("Expected contains to find substring at start")
|
||||
}
|
||||
if !contains("hello world", "world") {
|
||||
t.Error("Expected contains to find substring at end")
|
||||
}
|
||||
if !contains("hello world", "lo wo") {
|
||||
t.Error("Expected contains to find substring in middle")
|
||||
}
|
||||
if contains("hello world", "xyz") {
|
||||
t.Error("Expected contains to not find non-existent substring")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("containsSubstring function", func(t *testing.T) {
|
||||
if !containsSubstring("hello world", "lo wo") {
|
||||
t.Error("Expected containsSubstring to find substring")
|
||||
}
|
||||
if containsSubstring("hello", "hello world") {
|
||||
t.Error("Expected containsSubstring to not find longer substring")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultConfigs(t *testing.T) {
|
||||
t.Run("DefaultCircuitBreakerConfig", func(t *testing.T) {
|
||||
config := DefaultCircuitBreakerConfig()
|
||||
if config.MaxFailures <= 0 {
|
||||
t.Error("Expected positive MaxFailures")
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
t.Error("Expected positive Timeout")
|
||||
}
|
||||
if config.ResetTimeout <= 0 {
|
||||
t.Error("Expected positive ResetTimeout")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DefaultRetryConfig", func(t *testing.T) {
|
||||
config := DefaultRetryConfig()
|
||||
if config.MaxAttempts <= 0 {
|
||||
t.Error("Expected positive MaxAttempts")
|
||||
}
|
||||
if config.InitialDelay <= 0 {
|
||||
t.Error("Expected positive InitialDelay")
|
||||
}
|
||||
if config.BackoffFactor <= 1 {
|
||||
t.Error("Expected BackoffFactor > 1")
|
||||
}
|
||||
if len(config.RetryableErrors) == 0 {
|
||||
t.Error("Expected some retryable errors")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("DefaultGracefulDegradationConfig", func(t *testing.T) {
|
||||
config := DefaultGracefulDegradationConfig()
|
||||
if config.HealthCheckInterval <= 0 {
|
||||
t.Error("Expected positive HealthCheckInterval")
|
||||
}
|
||||
if config.RecoveryTimeout <= 0 {
|
||||
t.Error("Expected positive RecoveryTimeout")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Mock network error for testing
|
||||
type mockNetError struct {
|
||||
timeout bool
|
||||
temp bool
|
||||
}
|
||||
|
||||
func (e *mockNetError) Error() string { return "mock network error" }
|
||||
func (e *mockNetError) Timeout() bool { return e.timeout }
|
||||
func (e *mockNetError) Temporary() bool { return e.temp }
|
||||
|
||||
func TestNetworkErrorHandling(t *testing.T) {
|
||||
logger := NewLogger("debug")
|
||||
config := DefaultRetryConfig()
|
||||
re := NewRetryExecutor(config, logger)
|
||||
|
||||
t.Run("Timeout error is retryable", func(t *testing.T) {
|
||||
err := &mockNetError{timeout: true}
|
||||
if !re.isRetryableError(err) {
|
||||
t.Error("Expected timeout error to be retryable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Non-timeout network error with retryable pattern", func(t *testing.T) {
|
||||
err := &mockNetError{timeout: false}
|
||||
// This should not be retryable since it doesn't match patterns and isn't timeout
|
||||
if re.isRetryableError(err) {
|
||||
t.Error("Expected non-timeout network error without pattern to not be retryable")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user