diff --git a/autocleanup_additional_test.go b/autocleanup_additional_test.go new file mode 100644 index 0000000..57468df --- /dev/null +++ b/autocleanup_additional_test.go @@ -0,0 +1,224 @@ +package traefikoidc + +import ( + "errors" + "sync" + "testing" + "time" +) + +// globalRegistryMutex protects only the global registry operations +var globalRegistryMutex sync.Mutex + +// TestTaskCircuitBreakerOnTaskFailure tests the OnTaskFailure method +func TestTaskCircuitBreakerOnTaskFailure(t *testing.T) { + logger := NewLogger("debug") // Create a real logger + cb := NewTaskCircuitBreaker(3, time.Minute, logger) + + // Test failure doesn't trigger open state before threshold + cb.OnTaskFailure("test-task", errors.New("test error")) + if err := cb.CanCreateTask("test-task"); err != nil { + t.Error("Circuit breaker should allow task creation after 1 failure (threshold: 3)") + } + + // Test failure count reaches threshold and opens circuit + cb.OnTaskFailure("test-task", errors.New("test error 2")) + cb.OnTaskFailure("test-task", errors.New("test error 3")) + + if err := cb.CanCreateTask("test-task"); err == nil { + t.Error("Circuit breaker should prevent task creation after reaching failure threshold") + } +} + +// TestResetGlobalTaskRegistry tests the reset functionality +func TestResetGlobalTaskRegistry(t *testing.T) { + globalRegistryMutex.Lock() + defer globalRegistryMutex.Unlock() + + // Get the global registry first + registry := GetGlobalTaskRegistry() + + // Create and register a dummy task + logger := NewLogger("debug") + task := NewBackgroundTask("test-task", time.Second, func() { + // Do nothing + }, logger) + + registry.RegisterTask("test-task", task) + + // Verify task is registered + if registry.GetTaskCount() == 0 { + t.Error("Expected task to be registered") + } + + // Reset the registry + ResetGlobalTaskRegistry() + + // Get registry again and verify it's empty + newRegistry := GetGlobalTaskRegistry() + if newRegistry.GetTaskCount() != 0 { + t.Error("Expected registry to be empty after reset") + } +} + +// TestGetTask tests the GetTask method +func TestGetTask(t *testing.T) { + globalRegistryMutex.Lock() + defer globalRegistryMutex.Unlock() + + // Reset registry to ensure clean state + ResetGlobalTaskRegistry() + registry := GetGlobalTaskRegistry() + + // Test getting non-existent task + task, exists := registry.GetTask("non-existent") + if task != nil || exists { + t.Error("Expected nil and false for non-existent task") + } + + // Create and register a task + logger := NewLogger("debug") + newTask := NewBackgroundTask("test-task", time.Second, func() { + // Do nothing + }, logger) + + registry.RegisterTask("test-task", newTask) + + // Test getting existing task + retrievedTask, exists := registry.GetTask("test-task") + if retrievedTask == nil || !exists { + t.Error("Expected to retrieve registered task") + return + } + + if retrievedTask.name != "test-task" { + t.Errorf("Expected task name 'test-task', got '%s'", retrievedTask.name) + } +} + +// TestNewTaskMemoryMonitor tests the NewTaskMemoryMonitor function +func TestNewTaskMemoryMonitor(t *testing.T) { + // No mutex needed - this doesn't modify global state + logger := NewLogger("debug") + registry := GetGlobalTaskRegistry() + monitor := NewTaskMemoryMonitor(logger, registry) + + if monitor == nil { + t.Error("Expected NewTaskMemoryMonitor to return non-nil monitor") + } +} + +// TestGetCurrentStats tests the GetCurrentStats method +func TestGetCurrentStats(t *testing.T) { + // Don't hold mutex during background task execution to avoid deadlocks + logger := NewLogger("debug") + registry := GetGlobalTaskRegistry() + monitor := NewTaskMemoryMonitor(logger, registry) + + // Start the monitor and let it collect at least one statistic + err := monitor.Start(50 * time.Millisecond) + if err != nil { + t.Fatalf("Failed to start monitor: %v", err) + } + + // Ensure monitor is stopped even if test fails + defer func() { + monitor.Stop() + // Give extra time for cleanup + time.Sleep(50 * time.Millisecond) + }() + + // Wait a bit for the monitor to collect stats + time.Sleep(150 * time.Millisecond) + + stats, err := monitor.GetCurrentStats() + if err != nil { + // If no stats are available yet, that's acceptable for this test + t.Logf("No memory statistics available yet: %v", err) + return + } + + // TaskMemoryStats is a struct, not a pointer, so it can't be nil + if stats.Timestamp.IsZero() { + t.Error("Expected GetCurrentStats to return valid timestamp") + } +} + +// TestGetStatsHistory tests the GetStatsHistory method +func TestGetStatsHistory(t *testing.T) { + // No mutex needed - this just creates a monitor and checks its initial state + logger := NewLogger("debug") + registry := GetGlobalTaskRegistry() + monitor := NewTaskMemoryMonitor(logger, registry) + + history := monitor.GetStatsHistory() + if history == nil { + t.Error("Expected GetStatsHistory to return non-nil history") + } + + // A fresh monitor should have empty history + if len(history) != 0 { + t.Logf("History length: %d (may be non-empty due to shared global state)", len(history)) + } +} + +// TestForceGC tests the ForceGC method +func TestForceGC(t *testing.T) { + // No mutex needed - this doesn't modify global state + logger := NewLogger("debug") + registry := GetGlobalTaskRegistry() + monitor := NewTaskMemoryMonitor(logger, registry) + + // This should not panic and should work + monitor.ForceGC() + // No specific verification needed, just ensuring it doesn't crash +} + +// TestShutdownAllTasks tests the ShutdownAllTasks function +func TestShutdownAllTasks(t *testing.T) { + // Use a unique task name prefix to avoid conflicts with other tests + taskPrefix := "shutdown-test-" + + // Create a temporary clean registry state + func() { + globalRegistryMutex.Lock() + defer globalRegistryMutex.Unlock() + ResetGlobalTaskRegistry() + }() + + registry := GetGlobalTaskRegistry() + logger := NewLogger("debug") + + // Create some test tasks with unique names + task1 := NewBackgroundTask(taskPrefix+"task1", time.Millisecond, func() { + time.Sleep(100 * time.Millisecond) // Simulate work + }, logger) + + task2 := NewBackgroundTask(taskPrefix+"task2", time.Millisecond, func() { + time.Sleep(100 * time.Millisecond) // Simulate work + }, logger) + + // Register tasks under mutex protection + func() { + globalRegistryMutex.Lock() + defer globalRegistryMutex.Unlock() + registry.RegisterTask(taskPrefix+"task1", task1) + registry.RegisterTask(taskPrefix+"task2", task2) + }() + + // Start the tasks (outside mutex to avoid deadlock) + task1.Start() + task2.Start() + + // Give tasks time to start + time.Sleep(50 * time.Millisecond) + + // Shutdown all tasks + ShutdownAllTasks() + + // Give shutdown time to complete + time.Sleep(200 * time.Millisecond) + + // Note: We can't reliably verify task count due to other tests + // Just ensure shutdown doesn't panic +} diff --git a/config/config_test.go b/config/config_test.go index 7e9948d..deaf3e1 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -2,6 +2,10 @@ package config import ( + "crypto/tls" + "net/http" + "net/http/httptest" + "reflect" "testing" ) @@ -248,3 +252,757 @@ func TestConstants(t *testing.T) { t.Errorf("Expected ConstSessionTimeout 86400, got %d", ConstSessionTimeout) } } + +func TestCreateDefaultSecurityConfig(t *testing.T) { + config := createDefaultSecurityConfig() + + if config == nil { + t.Fatal("createDefaultSecurityConfig() returned nil") + } + + // Test default values + if !config.Enabled { + t.Error("Expected Enabled to be true") + } + + if config.Profile != "default" { + t.Errorf("Expected Profile 'default', got '%s'", config.Profile) + } + + if !config.StrictTransportSecurity { + t.Error("Expected StrictTransportSecurity to be true") + } + + if config.StrictTransportSecurityMaxAge != 31536000 { + t.Errorf("Expected StrictTransportSecurityMaxAge 31536000, got %d", config.StrictTransportSecurityMaxAge) + } + + if config.FrameOptions != "DENY" { + t.Errorf("Expected FrameOptions 'DENY', got '%s'", config.FrameOptions) + } + + if config.ContentTypeOptions != "nosniff" { + t.Errorf("Expected ContentTypeOptions 'nosniff', got '%s'", config.ContentTypeOptions) + } + + if config.XSSProtection != "1; mode=block" { + t.Errorf("Expected XSSProtection '1; mode=block', got '%s'", config.XSSProtection) + } + + if config.CORSEnabled { + t.Error("Expected CORSEnabled to be false") + } + + if !config.DisableServerHeader { + t.Error("Expected DisableServerHeader to be true") + } +} + +func TestToInternalSecurityConfig(t *testing.T) { + tests := []struct { + name string + config *SecurityHeadersConfig + expected map[string]interface{} + }{ + { + name: "nil config", + config: nil, + expected: nil, + }, + { + name: "disabled config", + config: &SecurityHeadersConfig{ + Enabled: false, + }, + expected: nil, + }, + { + name: "default profile", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "default", + }, + expected: map[string]interface{}{ + "DevelopmentMode": false, + "ContentSecurityPolicy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';", + "FrameOptions": "DENY", + }, + }, + { + name: "strict profile", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "strict", + }, + expected: map[string]interface{}{ + "DevelopmentMode": false, + "ContentSecurityPolicy": "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';", + }, + }, + { + name: "development profile", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "development", + }, + expected: map[string]interface{}{ + "DevelopmentMode": true, + "FrameOptions": "SAMEORIGIN", + }, + }, + { + name: "api profile", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "api", + }, + expected: map[string]interface{}{ + "DevelopmentMode": false, + "ContentSecurityPolicy": "default-src 'none'; frame-ancestors 'none';", + "FrameOptions": "DENY", + }, + }, + { + name: "custom config with overrides", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "custom", + ContentSecurityPolicy: "custom-csp", + FrameOptions: "SAMEORIGIN", + StrictTransportSecurity: true, + StrictTransportSecurityMaxAge: 86400, + }, + expected: map[string]interface{}{ + "DevelopmentMode": false, + "ContentSecurityPolicy": "custom-csp", + "FrameOptions": "SAMEORIGIN", + "StrictTransportSecurityMaxAge": 86400, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.ToInternalSecurityConfig() + + if tt.expected == nil { + if result != nil { + t.Errorf("Expected nil result, got %v", result) + } + return + } + + if result == nil { + t.Fatal("Expected non-nil result") + } + + configMap, ok := result.(map[string]interface{}) + if !ok { + t.Fatalf("Expected map[string]interface{}, got %T", result) + } + + // Check a few key values + for key, expectedValue := range tt.expected { + if actualValue, exists := configMap[key]; !exists { + t.Errorf("Expected key '%s' not found", key) + } else if actualValue != expectedValue { + t.Errorf("For key '%s': expected %v, got %v", key, expectedValue, actualValue) + } + } + }) + } +} + +func TestGetSecurityHeadersApplier(t *testing.T) { + tests := []struct { + name string + config *Config + expected bool // whether applier should be nil + }{ + { + name: "nil security headers", + config: &Config{ + SecurityHeaders: nil, + }, + expected: true, // applier should be nil + }, + { + name: "disabled security headers", + config: &Config{ + SecurityHeaders: &SecurityHeadersConfig{ + Enabled: false, + }, + }, + expected: true, // applier should be nil + }, + { + name: "enabled security headers", + config: &Config{ + SecurityHeaders: &SecurityHeadersConfig{ + Enabled: true, + }, + }, + expected: false, // applier should not be nil + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + applier := tt.config.GetSecurityHeadersApplier() + + if tt.expected && applier != nil { + t.Error("Expected applier to be nil") + } + if !tt.expected && applier == nil { + t.Error("Expected applier to not be nil") + } + }) + } +} + +func TestIsOriginAllowed(t *testing.T) { + tests := []struct { + name string + origin string + allowedOrigins []string + expected bool + }{ + { + name: "exact match", + origin: "https://example.com", + allowedOrigins: []string{"https://example.com", "https://other.com"}, + expected: true, + }, + { + name: "wildcard match", + origin: "https://test.example.com", + allowedOrigins: []string{"https://*.example.com"}, + expected: true, + }, + { + name: "root domain match with wildcard", + origin: "https://example.com", + allowedOrigins: []string{"https://*.example.com"}, + expected: true, + }, + { + name: "http wildcard match", + origin: "http://test.example.com", + allowedOrigins: []string{"http://*.example.com"}, + expected: true, + }, + { + name: "catch-all wildcard", + origin: "https://anything.com", + allowedOrigins: []string{"*"}, + expected: true, + }, + { + name: "no match", + origin: "https://notallowed.com", + allowedOrigins: []string{"https://example.com"}, + expected: false, + }, + { + name: "empty allowed origins", + origin: "https://example.com", + allowedOrigins: []string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isOriginAllowed(tt.origin, tt.allowedOrigins) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestSecurityHeadersConfigValidation(t *testing.T) { + tests := []struct { + name string + config *SecurityHeadersConfig + valid bool + }{ + { + name: "valid default config", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "default", + }, + valid: true, + }, + { + name: "valid strict config", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "strict", + }, + valid: true, + }, + { + name: "valid development config", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "development", + }, + valid: true, + }, + { + name: "valid api config", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "api", + }, + valid: true, + }, + { + name: "valid custom config", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "custom", + ContentSecurityPolicy: "default-src 'self'", + }, + valid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Basic validation - ensure config can be processed + if tt.config == nil && tt.valid { + t.Error("Expected valid config, got nil") + } + + // Test ToInternalSecurityConfig doesn't panic + result := tt.config.ToInternalSecurityConfig() + + if tt.config.Enabled && result == nil { + t.Error("Expected non-nil result for enabled config") + } + }) + } +} + +func TestConfigWithSecurityHeaders(t *testing.T) { + config := CreateConfig() + + // Test that default config has security headers + if config.SecurityHeaders == nil { + t.Fatal("Expected SecurityHeaders to be initialized") + } + + if !config.SecurityHeaders.Enabled { + t.Error("Expected SecurityHeaders to be enabled by default") + } + + // Test security headers applier + applier := config.GetSecurityHeadersApplier() + if applier == nil { + t.Error("Expected security headers applier to be non-nil") + } + + // Test with custom security config + config.SecurityHeaders = &SecurityHeadersConfig{ + Enabled: true, + Profile: "strict", + ContentSecurityPolicy: "default-src 'self'", + FrameOptions: "DENY", + StrictTransportSecurity: true, + StrictTransportSecurityMaxAge: 31536000, + CORSEnabled: false, + CustomHeaders: map[string]string{"X-Custom": "value"}, + } + + applier = config.GetSecurityHeadersApplier() + if applier == nil { + t.Error("Expected custom security headers applier to be non-nil") + } +} + +func TestConfigEdgeCases(t *testing.T) { + // Test config with empty values + config := &Config{ + ProviderURL: "", + ClientID: "", + ClientSecret: "", + LogLevel: "", + Scopes: []string{}, + Headers: []HeaderConfig{}, + } + + if config.LogLevel != "" { + t.Errorf("Expected empty LogLevel, got '%s'", config.LogLevel) + } + + if len(config.Scopes) != 0 { + t.Errorf("Expected empty Scopes, got %d", len(config.Scopes)) + } + + // Test config with nil slices + config = &Config{ + Scopes: nil, + Headers: nil, + } + + if len(config.Scopes) != 0 { + t.Errorf("Expected empty Scopes, got %v", config.Scopes) + } +} + +func TestSecurityHeadersApplierComprehensive(t *testing.T) { + tests := []struct { + name string + config *Config + setup func(*http.Request) *http.Request + check func(*testing.T, http.Header) + }{ + { + name: "All security headers with HTTPS", + config: &Config{ + SecurityHeaders: &SecurityHeadersConfig{ + Enabled: true, + FrameOptions: "SAMEORIGIN", + ContentTypeOptions: "nosniff", + XSSProtection: "1; mode=block", + ReferrerPolicy: "strict-origin-when-cross-origin", + ContentSecurityPolicy: "default-src 'self'", + StrictTransportSecurity: true, + StrictTransportSecurityMaxAge: 31536000, + StrictTransportSecuritySubdomains: true, + StrictTransportSecurityPreload: true, + CORSEnabled: true, + CORSAllowedOrigins: []string{"https://example.com"}, + CORSAllowedMethods: []string{"GET", "POST"}, + CORSAllowedHeaders: []string{"Authorization", "Content-Type"}, + CORSAllowCredentials: true, + CORSMaxAge: 86400, + CustomHeaders: map[string]string{"X-Custom": "value"}, + DisableServerHeader: true, + DisablePoweredByHeader: true, + }, + }, + setup: func(req *http.Request) *http.Request { + req.Header.Set("Origin", "https://example.com") + req.Header.Set("X-Forwarded-Proto", "https") + return req + }, + check: func(t *testing.T, headers http.Header) { + expectedHeaders := map[string]string{ + "X-Frame-Options": "SAMEORIGIN", + "X-Content-Type-Options": "nosniff", + "X-XSS-Protection": "1; mode=block", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Content-Security-Policy": "default-src 'self'", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload", + "Access-Control-Allow-Origin": "https://example.com", + "Access-Control-Allow-Methods": "GET, POST", + "Access-Control-Allow-Headers": "Authorization, Content-Type", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "86400", + "X-Custom": "value", + } + + for key, expected := range expectedHeaders { + if actual := headers.Get(key); actual != expected { + t.Errorf("Expected header %s: '%s', got '%s'", key, expected, actual) + } + } + }, + }, + { + name: "CORS with wildcard origin", + config: &Config{ + SecurityHeaders: &SecurityHeadersConfig{ + Enabled: true, + CORSEnabled: true, + CORSAllowedOrigins: []string{"*"}, + }, + }, + setup: func(req *http.Request) *http.Request { + req.Header.Set("Origin", "https://anywhere.com") + return req + }, + check: func(t *testing.T, headers http.Header) { + if origin := headers.Get("Access-Control-Allow-Origin"); origin != "https://anywhere.com" { + t.Errorf("Expected CORS origin 'https://anywhere.com', got '%s'", origin) + } + }, + }, + { + name: "HSTS with TLS", + config: &Config{ + SecurityHeaders: &SecurityHeadersConfig{ + Enabled: true, + StrictTransportSecurity: true, + StrictTransportSecurityMaxAge: 63072000, + StrictTransportSecurityPreload: false, + }, + }, + setup: func(req *http.Request) *http.Request { + // Simulate TLS request + req.TLS = &tls.ConnectionState{} + return req + }, + check: func(t *testing.T, headers http.Header) { + hsts := headers.Get("Strict-Transport-Security") + expected := "max-age=63072000" + if hsts != expected { + t.Errorf("Expected HSTS '%s', got '%s'", expected, hsts) + } + }, + }, + { + name: "Disabled security headers", + config: &Config{ + SecurityHeaders: &SecurityHeadersConfig{ + Enabled: false, + }, + }, + setup: func(req *http.Request) *http.Request { + return req + }, + check: func(t *testing.T, headers http.Header) { + // Since applier should be nil, this won't be called + // but we include it for completeness + }, + }, + { + name: "Remove server headers", + config: &Config{ + SecurityHeaders: &SecurityHeadersConfig{ + Enabled: true, + DisableServerHeader: true, + DisablePoweredByHeader: true, + }, + }, + setup: func(req *http.Request) *http.Request { + return req + }, + check: func(t *testing.T, headers http.Header) { + // Headers should be explicitly deleted + // We can't easily test deletion, but we ensure they're not set + if server := headers.Get("Server"); server != "" { + t.Errorf("Expected Server header to be removed, got '%s'", server) + } + if powered := headers.Get("X-Powered-By"); powered != "" { + t.Errorf("Expected X-Powered-By header to be removed, got '%s'", powered) + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + applier := tt.config.GetSecurityHeadersApplier() + + if !tt.config.SecurityHeaders.Enabled { + if applier != nil { + t.Error("Expected nil applier for disabled security headers") + } + return + } + + if applier == nil { + t.Fatal("Expected non-nil applier for enabled security headers") + } + + req := httptest.NewRequest("GET", "https://example.com/test", nil) + req = tt.setup(req) + rw := httptest.NewRecorder() + + // Pre-set some headers that should be removed + rw.Header().Set("Server", "nginx/1.0") + rw.Header().Set("X-Powered-By", "Express") + + applier(rw, req) + tt.check(t, rw.Header()) + }) + } +} + +func TestToInternalSecurityConfigComprehensive(t *testing.T) { + tests := []struct { + name string + config *SecurityHeadersConfig + expected map[string]interface{} + }{ + { + name: "Nil config", + config: nil, + expected: nil, + }, + { + name: "Disabled config", + config: &SecurityHeadersConfig{ + Enabled: false, + }, + expected: nil, + }, + { + name: "Custom profile with all options", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "custom", + ContentSecurityPolicy: "default-src 'none'", + FrameOptions: "ALLOW-FROM https://example.com", + ContentTypeOptions: "nosniff", + XSSProtection: "0", + ReferrerPolicy: "no-referrer", + PermissionsPolicy: "camera=(), microphone=()", + CrossOriginEmbedderPolicy: "require-corp", + CrossOriginOpenerPolicy: "same-origin", + CrossOriginResourcePolicy: "cross-origin", + StrictTransportSecurity: true, + StrictTransportSecurityMaxAge: 15552000, + StrictTransportSecuritySubdomains: false, + StrictTransportSecurityPreload: true, + CORSEnabled: true, + CORSAllowedOrigins: []string{"https://api.example.com"}, + CORSAllowedMethods: []string{"PUT", "DELETE"}, + CORSAllowedHeaders: []string{"X-API-Key"}, + CORSAllowCredentials: false, + CORSMaxAge: 3600, + CustomHeaders: map[string]string{"X-API-Version": "v1"}, + DisableServerHeader: true, + DisablePoweredByHeader: false, + }, + expected: map[string]interface{}{ + "DevelopmentMode": false, + "ContentSecurityPolicy": "default-src 'none'", + "FrameOptions": "ALLOW-FROM https://example.com", + "ContentTypeOptions": "nosniff", + "XSSProtection": "0", + "ReferrerPolicy": "no-referrer", + "PermissionsPolicy": "camera=(), microphone=()", + "CrossOriginEmbedderPolicy": "require-corp", + "CrossOriginOpenerPolicy": "same-origin", + "CrossOriginResourcePolicy": "cross-origin", + "StrictTransportSecurityMaxAge": 15552000, + "StrictTransportSecuritySubdomains": false, + "StrictTransportSecurityPreload": true, + "CORSEnabled": true, + "CORSAllowedOrigins": []string{"https://api.example.com"}, + "CORSAllowedMethods": []string{"PUT", "DELETE"}, + "CORSAllowedHeaders": []string{"X-API-Key"}, + "CORSAllowCredentials": false, + "CORSMaxAge": 3600, + "CustomHeaders": map[string]string{"X-API-Version": "v1"}, + "DisableServerHeader": true, + "DisablePoweredByHeader": false, + }, + }, + { + name: "Development profile", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "development", + }, + expected: map[string]interface{}{ + "DevelopmentMode": true, + "ContentSecurityPolicy": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' data: https: http:; connect-src 'self' ws: wss:;", + "FrameOptions": "SAMEORIGIN", + "ContentTypeOptions": "nosniff", + "XSSProtection": "1; mode=block", + "ReferrerPolicy": "strict-origin-when-cross-origin", + "CrossOriginOpenerPolicy": "unsafe-none", + "CrossOriginResourcePolicy": "cross-origin", + "CORSEnabled": false, + "CORSAllowCredentials": false, + "DisableServerHeader": false, + "DisablePoweredByHeader": false, + }, + }, + { + name: "API profile", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "api", + }, + expected: map[string]interface{}{ + "DevelopmentMode": false, + "ContentSecurityPolicy": "default-src 'none'; frame-ancestors 'none';", + "FrameOptions": "DENY", + "ContentTypeOptions": "nosniff", + "XSSProtection": "1; mode=block", + "ReferrerPolicy": "strict-origin-when-cross-origin", + "CrossOriginResourcePolicy": "cross-origin", + "CORSEnabled": false, + "CORSAllowCredentials": false, + "DisableServerHeader": false, + "DisablePoweredByHeader": false, + }, + }, + { + name: "Partial configuration", + config: &SecurityHeadersConfig{ + Enabled: true, + Profile: "default", + FrameOptions: "SAMEORIGIN", // Override default + CORSEnabled: true, // Enable CORS + }, + expected: map[string]interface{}{ + "DevelopmentMode": false, + "ContentSecurityPolicy": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';", + "FrameOptions": "SAMEORIGIN", // Overridden + "ContentTypeOptions": "nosniff", + "XSSProtection": "1; mode=block", + "ReferrerPolicy": "strict-origin-when-cross-origin", + "PermissionsPolicy": "geolocation=(), microphone=(), camera=(), payment=(), usb=()", + "CrossOriginEmbedderPolicy": "require-corp", + "CrossOriginOpenerPolicy": "same-origin", + "CrossOriginResourcePolicy": "same-origin", + "CORSEnabled": true, // Explicitly set + "CORSAllowCredentials": false, + "DisableServerHeader": false, + "DisablePoweredByHeader": false, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.ToInternalSecurityConfig() + + if tt.expected == nil { + if result != nil { + t.Errorf("Expected nil result, got %+v", result) + } + return + } + + if result == nil { + t.Fatal("Expected non-nil result") + } + + resultMap, ok := result.(map[string]interface{}) + if !ok { + t.Errorf("Expected result to be map[string]interface{}, got %T", result) + return + } + + for key, expectedValue := range tt.expected { + actualValue, exists := resultMap[key] + if !exists { + t.Errorf("Expected key '%s' not found in result", key) + continue + } + + if !reflect.DeepEqual(actualValue, expectedValue) { + t.Errorf("For key '%s': expected %v (%T), got %v (%T)", + key, expectedValue, expectedValue, actualValue, actualValue) + } + } + + // Check that no unexpected keys are present + for key := range resultMap { + if _, expected := tt.expected[key]; !expected { + t.Errorf("Unexpected key '%s' found in result with value %v", key, resultMap[key]) + } + } + }) + } +} diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go new file mode 100644 index 0000000..109e324 --- /dev/null +++ b/internal/errors/errors_test.go @@ -0,0 +1,529 @@ +package errors + +import ( + "errors" + "net/http" + "reflect" + "testing" +) + +func TestOIDCError_Error(t *testing.T) { + tests := []struct { + name string + oidcErr *OIDCError + expected string + }{ + { + name: "Error with details", + oidcErr: &OIDCError{ + Code: ErrCodeTokenInvalid, + Message: "Token validation failed", + Details: "JWT signature invalid", + }, + expected: "TOKEN_INVALID: Token validation failed (JWT signature invalid)", + }, + { + name: "Error without details", + oidcErr: &OIDCError{ + Code: ErrCodeAuthenticationFailed, + Message: "Authentication failed", + }, + expected: "AUTH_FAILED: Authentication failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.oidcErr.Error() + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestOIDCError_Unwrap(t *testing.T) { + internalErr := errors.New("internal error") + oidcErr := &OIDCError{ + Code: ErrCodeTokenInvalid, + Message: "Token validation failed", + Internal: internalErr, + } + + unwrapped := oidcErr.Unwrap() + if unwrapped != internalErr { + t.Errorf("Expected internal error, got %v", unwrapped) + } + + // Test with nil internal error + oidcErrNoInternal := &OIDCError{ + Code: ErrCodeTokenInvalid, + Message: "Token validation failed", + } + + unwrappedNil := oidcErrNoInternal.Unwrap() + if unwrappedNil != nil { + t.Errorf("Expected nil, got %v", unwrappedNil) + } +} + +func TestOIDCError_IsRetryable(t *testing.T) { + tests := []struct { + name string + code ErrorCode + expected bool + }{ + {"Network timeout", ErrCodeNetworkTimeout, true}, + {"Service unavailable", ErrCodeServiceUnavailable, true}, + {"Provider unreachable", ErrCodeProviderUnreachable, true}, + {"Authentication failed", ErrCodeAuthenticationFailed, false}, + {"Token invalid", ErrCodeTokenInvalid, false}, + {"Rate limited", ErrCodeRateLimited, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oidcErr := &OIDCError{Code: tt.code} + result := oidcErr.IsRetryable() + if result != tt.expected { + t.Errorf("Expected %v, got %v for code %s", tt.expected, result, tt.code) + } + }) + } +} + +func TestOIDCError_IsAuthenticationError(t *testing.T) { + tests := []struct { + name string + code ErrorCode + expected bool + }{ + {"Authentication failed", ErrCodeAuthenticationFailed, true}, + {"Token expired", ErrCodeTokenExpired, true}, + {"Token invalid", ErrCodeTokenInvalid, true}, + {"Session expired", ErrCodeSessionExpired, true}, + {"CSRF mismatch", ErrCodeCSRFMismatch, true}, + {"Nonce mismatch", ErrCodeNonceMismatch, true}, + {"Config invalid", ErrCodeConfigInvalid, false}, + {"Domain not allowed", ErrCodeDomainNotAllowed, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oidcErr := &OIDCError{Code: tt.code} + result := oidcErr.IsAuthenticationError() + if result != tt.expected { + t.Errorf("Expected %v, got %v for code %s", tt.expected, result, tt.code) + } + }) + } +} + +func TestOIDCError_IsAuthorizationError(t *testing.T) { + tests := []struct { + name string + code ErrorCode + expected bool + }{ + {"Domain not allowed", ErrCodeDomainNotAllowed, true}, + {"User not allowed", ErrCodeUserNotAllowed, true}, + {"Role not allowed", ErrCodeRoleNotAllowed, true}, + {"Authentication failed", ErrCodeAuthenticationFailed, false}, + {"Token expired", ErrCodeTokenExpired, false}, + {"Config invalid", ErrCodeConfigInvalid, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + oidcErr := &OIDCError{Code: tt.code} + result := oidcErr.IsAuthorizationError() + if result != tt.expected { + t.Errorf("Expected %v, got %v for code %s", tt.expected, result, tt.code) + } + }) + } +} + +func TestOIDCError_ToJSON(t *testing.T) { + tests := []struct { + name string + oidcErr *OIDCError + expected map[string]any + }{ + { + name: "Error with details", + oidcErr: &OIDCError{ + Code: ErrCodeTokenInvalid, + Message: "Token validation failed", + Details: "JWT signature invalid", + }, + expected: map[string]any{ + "error": map[string]any{ + "code": "TOKEN_INVALID", + "message": "Token validation failed", + "details": "JWT signature invalid", + }, + }, + }, + { + name: "Error without details", + oidcErr: &OIDCError{ + Code: ErrCodeAuthenticationFailed, + Message: "Authentication failed", + }, + expected: map[string]any{ + "error": map[string]any{ + "code": "AUTH_FAILED", + "message": "Authentication failed", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.oidcErr.ToJSON() + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("Expected %+v, got %+v", tt.expected, result) + } + }) + } +} + +func TestNewAuthenticationError(t *testing.T) { + internalErr := errors.New("internal error") + + tests := []struct { + name string + code ErrorCode + message string + internal error + expectedHTTP int + }{ + { + name: "Regular auth error", + code: ErrCodeAuthenticationFailed, + message: "Auth failed", + internal: internalErr, + expectedHTTP: http.StatusUnauthorized, + }, + { + name: "Session expired error", + code: ErrCodeSessionExpired, + message: "Session expired", + internal: internalErr, + expectedHTTP: http.StatusForbidden, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NewAuthenticationError(tt.code, tt.message, tt.internal) + + if err.Code != tt.code { + t.Errorf("Expected code %s, got %s", tt.code, err.Code) + } + if err.Message != tt.message { + t.Errorf("Expected message '%s', got '%s'", tt.message, err.Message) + } + if err.Internal != tt.internal { + t.Errorf("Expected internal error %v, got %v", tt.internal, err.Internal) + } + if err.HTTPStatus != tt.expectedHTTP { + t.Errorf("Expected HTTP status %d, got %d", tt.expectedHTTP, err.HTTPStatus) + } + }) + } +} + +func TestNewAuthorizationError(t *testing.T) { + err := NewAuthorizationError(ErrCodeDomainNotAllowed, "Domain not allowed", "example.com not in whitelist") + + if err.Code != ErrCodeDomainNotAllowed { + t.Errorf("Expected code %s, got %s", ErrCodeDomainNotAllowed, err.Code) + } + if err.Message != "Domain not allowed" { + t.Errorf("Expected message 'Domain not allowed', got '%s'", err.Message) + } + if err.Details != "example.com not in whitelist" { + t.Errorf("Expected details 'example.com not in whitelist', got '%s'", err.Details) + } + if err.HTTPStatus != http.StatusForbidden { + t.Errorf("Expected HTTP status %d, got %d", http.StatusForbidden, err.HTTPStatus) + } +} + +func TestNewConfigurationError(t *testing.T) { + internalErr := errors.New("config parse error") + err := NewConfigurationError(ErrCodeConfigInvalid, "Invalid config", internalErr) + + if err.Code != ErrCodeConfigInvalid { + t.Errorf("Expected code %s, got %s", ErrCodeConfigInvalid, err.Code) + } + if err.HTTPStatus != http.StatusInternalServerError { + t.Errorf("Expected HTTP status %d, got %d", http.StatusInternalServerError, err.HTTPStatus) + } + if err.Internal != internalErr { + t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal) + } +} + +func TestNewNetworkError(t *testing.T) { + internalErr := errors.New("network error") + + tests := []struct { + name string + code ErrorCode + expectedHTTP int + }{ + { + name: "Rate limited", + code: ErrCodeRateLimited, + expectedHTTP: http.StatusTooManyRequests, + }, + { + name: "Service unavailable", + code: ErrCodeServiceUnavailable, + expectedHTTP: http.StatusServiceUnavailable, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := NewNetworkError(tt.code, "Network error", internalErr) + + if err.Code != tt.code { + t.Errorf("Expected code %s, got %s", tt.code, err.Code) + } + if err.HTTPStatus != tt.expectedHTTP { + t.Errorf("Expected HTTP status %d, got %d", tt.expectedHTTP, err.HTTPStatus) + } + }) + } +} + +func TestNewValidationError(t *testing.T) { + err := NewValidationError(ErrCodeValidationFailed, "Validation failed", "field 'email' is required") + + if err.Code != ErrCodeValidationFailed { + t.Errorf("Expected code %s, got %s", ErrCodeValidationFailed, err.Code) + } + if err.HTTPStatus != http.StatusBadRequest { + t.Errorf("Expected HTTP status %d, got %d", http.StatusBadRequest, err.HTTPStatus) + } + if err.Details != "field 'email' is required" { + t.Errorf("Expected details 'field 'email' is required', got '%s'", err.Details) + } +} + +func TestWrapAuthenticationError(t *testing.T) { + internalErr := errors.New("original error") + err := WrapAuthenticationError(internalErr, "Custom auth message") + + if err.Code != ErrCodeAuthenticationFailed { + t.Errorf("Expected code %s, got %s", ErrCodeAuthenticationFailed, err.Code) + } + if err.Message != "Custom auth message" { + t.Errorf("Expected message 'Custom auth message', got '%s'", err.Message) + } + if err.Internal != internalErr { + t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal) + } +} + +func TestWrapTokenError(t *testing.T) { + internalErr := errors.New("token error") + err := WrapTokenError(internalErr, "ID token") + + if err.Code != ErrCodeTokenInvalid { + t.Errorf("Expected code %s, got %s", ErrCodeTokenInvalid, err.Code) + } + if err.Message != "Token validation failed: ID token" { + t.Errorf("Expected message 'Token validation failed: ID token', got '%s'", err.Message) + } + if err.Internal != internalErr { + t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal) + } +} + +func TestWrapProviderError(t *testing.T) { + internalErr := errors.New("provider error") + err := WrapProviderError(internalErr, "https://provider.example.com") + + if err.Code != ErrCodeProviderUnreachable { + t.Errorf("Expected code %s, got %s", ErrCodeProviderUnreachable, err.Code) + } + if err.Message != "Provider communication failed: https://provider.example.com" { + t.Errorf("Expected specific message, got '%s'", err.Message) + } + if err.Internal != internalErr { + t.Errorf("Expected internal error %v, got %v", internalErr, err.Internal) + } +} + +func TestIsOIDCError(t *testing.T) { + // Test with OIDCError + oidcErr := &OIDCError{Code: ErrCodeTokenInvalid, Message: "test"} + result, ok := IsOIDCError(oidcErr) + if !ok { + t.Error("Expected IsOIDCError to return true for OIDCError") + } + if result != oidcErr { + t.Error("Expected to get the same OIDCError back") + } + + // Test with regular error + regularErr := errors.New("regular error") + result, ok = IsOIDCError(regularErr) + if ok { + t.Error("Expected IsOIDCError to return false for regular error") + } + if result != nil { + t.Error("Expected nil result for regular error") + } +} + +func TestGetHTTPStatus(t *testing.T) { + // Test with OIDCError + oidcErr := &OIDCError{ + Code: ErrCodeTokenInvalid, + HTTPStatus: http.StatusUnauthorized, + } + status := GetHTTPStatus(oidcErr) + if status != http.StatusUnauthorized { + t.Errorf("Expected %d, got %d", http.StatusUnauthorized, status) + } + + // Test with regular error + regularErr := errors.New("regular error") + status = GetHTTPStatus(regularErr) + if status != http.StatusInternalServerError { + t.Errorf("Expected %d, got %d", http.StatusInternalServerError, status) + } +} + +func TestFormatUserMessage(t *testing.T) { + tests := []struct { + name string + err error + expected string + }{ + { + name: "Domain not allowed", + err: &OIDCError{Code: ErrCodeDomainNotAllowed}, + expected: "Your email domain is not authorized for this application", + }, + { + name: "User not allowed", + err: &OIDCError{Code: ErrCodeUserNotAllowed}, + expected: "Your account is not authorized for this application", + }, + { + name: "Role not allowed", + err: &OIDCError{Code: ErrCodeRoleNotAllowed}, + expected: "You do not have the required permissions for this application", + }, + { + name: "Session expired", + err: &OIDCError{Code: ErrCodeSessionExpired}, + expected: "Your session has expired. Please log in again", + }, + { + name: "Token expired", + err: &OIDCError{Code: ErrCodeTokenExpired}, + expected: "Your authentication has expired. Please log in again", + }, + { + name: "Provider unreachable", + err: &OIDCError{Code: ErrCodeProviderUnreachable}, + expected: "Authentication service is temporarily unavailable. Please try again later", + }, + { + name: "Rate limited", + err: &OIDCError{Code: ErrCodeRateLimited}, + expected: "Too many requests. Please wait a moment and try again", + }, + { + name: "Unknown OIDC error", + err: &OIDCError{Code: ErrCodeConfigInvalid}, + expected: "Authentication failed. Please try again", + }, + { + name: "Regular error", + err: errors.New("regular error"), + expected: "An unexpected error occurred. Please try again", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FormatUserMessage(tt.err) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestErrorCodes(t *testing.T) { + // Test that all error codes are defined correctly + codes := []ErrorCode{ + ErrCodeAuthenticationFailed, + ErrCodeTokenExpired, + ErrCodeTokenInvalid, + ErrCodeSessionExpired, + ErrCodeCSRFMismatch, + ErrCodeNonceMismatch, + ErrCodeConfigInvalid, + ErrCodeProviderUnreachable, + ErrCodeMetadataFailed, + ErrCodeNetworkTimeout, + ErrCodeRateLimited, + ErrCodeServiceUnavailable, + ErrCodeValidationFailed, + ErrCodeDomainNotAllowed, + ErrCodeUserNotAllowed, + ErrCodeRoleNotAllowed, + } + + for _, code := range codes { + if string(code) == "" { + t.Errorf("Error code %v is empty", code) + } + } +} + +func TestErrorConstructorCompleteness(t *testing.T) { + // Test each constructor function to ensure they set all required fields + internalErr := errors.New("test error") + + // Test NewAuthenticationError + authErr := NewAuthenticationError(ErrCodeAuthenticationFailed, "auth message", internalErr) + if authErr.Code == "" || authErr.Message == "" || authErr.HTTPStatus == 0 { + t.Error("NewAuthenticationError did not set all required fields") + } + + // Test NewAuthorizationError + authzErr := NewAuthorizationError(ErrCodeDomainNotAllowed, "authz message", "details") + if authzErr.Code == "" || authzErr.Message == "" || authzErr.HTTPStatus == 0 { + t.Error("NewAuthorizationError did not set all required fields") + } + + // Test NewConfigurationError + configErr := NewConfigurationError(ErrCodeConfigInvalid, "config message", internalErr) + if configErr.Code == "" || configErr.Message == "" || configErr.HTTPStatus == 0 { + t.Error("NewConfigurationError did not set all required fields") + } + + // Test NewNetworkError + netErr := NewNetworkError(ErrCodeNetworkTimeout, "network message", internalErr) + if netErr.Code == "" || netErr.Message == "" || netErr.HTTPStatus == 0 { + t.Error("NewNetworkError did not set all required fields") + } + + // Test NewValidationError + validErr := NewValidationError(ErrCodeValidationFailed, "validation message", "details") + if validErr.Code == "" || validErr.Message == "" || validErr.HTTPStatus == 0 { + t.Error("NewValidationError did not set all required fields") + } +} diff --git a/internal/handlers/auth_flow_test.go b/internal/handlers/auth_flow_test.go new file mode 100644 index 0000000..2e4ee18 --- /dev/null +++ b/internal/handlers/auth_flow_test.go @@ -0,0 +1,588 @@ +package handlers + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// Mock implementations that embed SessionHandler +type MockSessionHandlerWrapper struct { + *SessionHandler +} + +func NewMockSessionHandlerWrapper() *MockSessionHandlerWrapper { + sessionManager := &MockSessionManager{} + logger := &MockLogger{} + + sessionHandler := NewSessionHandler( + sessionManager, + logger, + "/logout", + "https://example.com/post-logout", + "https://provider.example.com/logout", + "test-client-id", + ) + + return &MockSessionHandlerWrapper{ + SessionHandler: sessionHandler, + } +} + +type MockSessionManager struct { + session Session + err error +} + +func (m *MockSessionManager) GetSession(req *http.Request) (Session, error) { + return m.session, m.err +} + +func (m *MockSessionManager) CleanupOldCookies(rw http.ResponseWriter, req *http.Request) { + // Mock implementation +} + +type MockSession struct { + authenticated bool + email string + idToken string + accessToken string + refreshToken string + saveError error + clearError error +} + +func (m *MockSession) GetAuthenticated() bool { return m.authenticated } +func (m *MockSession) SetAuthenticated(auth bool) error { m.authenticated = auth; return nil } +func (m *MockSession) GetEmail() string { return m.email } +func (m *MockSession) SetEmail(email string) { m.email = email } +func (m *MockSession) GetIDToken() string { return m.idToken } +func (m *MockSession) GetAccessToken() string { return m.accessToken } +func (m *MockSession) GetRefreshToken() string { return m.refreshToken } +func (m *MockSession) SetRefreshToken(token string) { m.refreshToken = token } +func (m *MockSession) Clear(req *http.Request, rw http.ResponseWriter) error { return m.clearError } +func (m *MockSession) Save(req *http.Request, rw http.ResponseWriter) error { return m.saveError } +func (m *MockSession) ReturnToPoolSafely() {} + +type MockTokenHandler struct { + verifyError error + refreshError error + tokenResponse *TokenResponse +} + +func (m *MockTokenHandler) VerifyToken(token string) error { + return m.verifyError +} + +func (m *MockTokenHandler) RefreshToken(refreshToken string) (*TokenResponse, error) { + return m.tokenResponse, m.refreshError +} + +type MockLogger struct { + debugMessages []string + errorMessages []string +} + +func (m *MockLogger) Debug(msg string) { + m.debugMessages = append(m.debugMessages, msg) +} + +func (m *MockLogger) Debugf(format string, args ...interface{}) { + m.debugMessages = append(m.debugMessages, format) +} + +func (m *MockLogger) Info(msg string) {} + +func (m *MockLogger) Infof(format string, args ...interface{}) {} + +func (m *MockLogger) Error(msg string) { + m.errorMessages = append(m.errorMessages, msg) +} + +func (m *MockLogger) Errorf(format string, args ...interface{}) { + m.errorMessages = append(m.errorMessages, format) +} + +func TestNewAuthFlowHandler(t *testing.T) { + sessionHandler := NewMockSessionHandlerWrapper() + tokenHandler := &MockTokenHandler{} + logger := &MockLogger{} + excludedURLs := map[string]struct{}{"/health": {}} + initComplete := make(chan struct{}) + issuerURL := "https://issuer.example.com" + + handler := NewAuthFlowHandler(sessionHandler.SessionHandler, tokenHandler, logger, excludedURLs, initComplete, issuerURL) + + if handler == nil { + t.Fatal("NewAuthFlowHandler returned nil") + } + + if handler.sessionHandler == nil { + t.Error("SessionHandler not set correctly") + } + + if handler.tokenHandler != tokenHandler { + t.Error("TokenHandler not set correctly") + } + + if handler.logger != logger { + t.Error("Logger not set correctly") + } + + if handler.issuerURL != issuerURL { + t.Error("IssuerURL not set correctly") + } +} + +func TestAuthFlowHandler_shouldExcludeURL(t *testing.T) { + excludedURLs := map[string]struct{}{ + "/health": {}, + "/metrics": {}, + "/api/public": {}, + } + + handler := &AuthFlowHandler{excludedURLs: excludedURLs} + + tests := []struct { + path string + expected bool + }{ + {"/health", true}, + {"/health/check", true}, + {"/metrics", true}, + {"/metrics/prometheus", true}, + {"/api/public", true}, + {"/api/public/endpoint", true}, + {"/api/private", false}, + {"/login", false}, + {"/dashboard", false}, + } + + for _, test := range tests { + result := handler.shouldExcludeURL(test.path) + if result != test.expected { + t.Errorf("For path '%s': expected %v, got %v", test.path, test.expected, result) + } + } +} + +func TestAuthFlowHandler_isStreamingRequest(t *testing.T) { + handler := &AuthFlowHandler{} + + tests := []struct { + name string + accept string + expected bool + }{ + { + name: "SSE request", + accept: "text/event-stream", + expected: true, + }, + { + name: "Regular HTML request", + accept: "text/html,application/xhtml+xml", + expected: false, + }, + { + name: "JSON request", + accept: "application/json", + expected: false, + }, + { + name: "Empty accept header", + accept: "", + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("Accept", test.accept) + + result := handler.isStreamingRequest(req) + if result != test.expected { + t.Errorf("Expected %v, got %v", test.expected, result) + } + }) + } +} + +func TestAuthFlowHandler_waitForInitialization(t *testing.T) { + tests := []struct { + name string + setupHandler func() (*AuthFlowHandler, context.CancelFunc) + expectedResult bool + }{ + { + name: "Initialization complete successfully", + setupHandler: func() (*AuthFlowHandler, context.CancelFunc) { + initComplete := make(chan struct{}) + close(initComplete) // Already complete + handler := &AuthFlowHandler{ + initComplete: initComplete, + issuerURL: "https://issuer.example.com", + } + return handler, nil + }, + expectedResult: true, + }, + { + name: "Initialization complete but no issuer URL", + setupHandler: func() (*AuthFlowHandler, context.CancelFunc) { + initComplete := make(chan struct{}) + close(initComplete) + handler := &AuthFlowHandler{ + initComplete: initComplete, + issuerURL: "", + logger: &MockLogger{}, + } + return handler, nil + }, + expectedResult: false, + }, + { + name: "Request cancelled", + setupHandler: func() (*AuthFlowHandler, context.CancelFunc) { + initComplete := make(chan struct{}) + handler := &AuthFlowHandler{ + initComplete: initComplete, + logger: &MockLogger{}, + } + _, cancel := context.WithCancel(context.Background()) + return handler, cancel + }, + expectedResult: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler, cancelFunc := test.setupHandler() + + req := httptest.NewRequest("GET", "/", nil) + if cancelFunc != nil { + ctx, cancel := context.WithCancel(context.Background()) + req = req.WithContext(ctx) + cancel() // Cancel immediately + } + + result := handler.waitForInitialization(req) + if result != test.expectedResult { + t.Errorf("Expected %v, got %v", test.expectedResult, result) + } + }) + } +} + +func TestAuthFlowHandler_ProcessRequest(t *testing.T) { + tests := []struct { + name string + setupRequest func() *http.Request + setupHandler func() *AuthFlowHandler + expectedResult AuthFlowResult + }{ + { + name: "Excluded URL bypasses authentication", + setupRequest: func() *http.Request { + return httptest.NewRequest("GET", "/health", nil) + }, + setupHandler: func() *AuthFlowHandler { + return &AuthFlowHandler{ + excludedURLs: map[string]struct{}{"/health": {}}, + logger: &MockLogger{}, + } + }, + expectedResult: AuthFlowResult{Authenticated: true}, + }, + { + name: "Streaming request bypasses authentication", + setupRequest: func() *http.Request { + req := httptest.NewRequest("GET", "/events", nil) + req.Header.Set("Accept", "text/event-stream") + return req + }, + setupHandler: func() *AuthFlowHandler { + return &AuthFlowHandler{ + excludedURLs: map[string]struct{}{}, + logger: &MockLogger{}, + } + }, + expectedResult: AuthFlowResult{Authenticated: true}, + }, + { + name: "Initialization timeout", + setupRequest: func() *http.Request { + return httptest.NewRequest("GET", "/dashboard", nil) + }, + setupHandler: func() *AuthFlowHandler { + return &AuthFlowHandler{ + excludedURLs: map[string]struct{}{}, + initComplete: make(chan struct{}), // Never closes + logger: &MockLogger{}, + } + }, + expectedResult: AuthFlowResult{ + Error: ErrInitializationTimeout, + StatusCode: http.StatusServiceUnavailable, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := test.setupRequest() + handler := test.setupHandler() + rw := httptest.NewRecorder() + + // For timeout test, use context with timeout + if test.name == "Initialization timeout" { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + req = req.WithContext(ctx) + } + + result := handler.ProcessRequest(rw, req) + + if result.Authenticated != test.expectedResult.Authenticated { + t.Errorf("Expected Authenticated %v, got %v", test.expectedResult.Authenticated, result.Authenticated) + } + + if result.StatusCode != test.expectedResult.StatusCode { + t.Errorf("Expected StatusCode %d, got %d", test.expectedResult.StatusCode, result.StatusCode) + } + + if test.expectedResult.Error != nil && result.Error == nil { + t.Error("Expected error but got nil") + } + }) + } +} + +func TestAuthFlowHandler_validateAndRefreshTokens(t *testing.T) { + tests := []struct { + name string + session *MockSession + tokenHandler *MockTokenHandler + expectedResult AuthFlowResult + }{ + { + name: "Valid access token", + session: &MockSession{ + authenticated: true, + accessToken: "valid-access-token", + }, + tokenHandler: &MockTokenHandler{ + verifyError: nil, + }, + expectedResult: AuthFlowResult{Authenticated: true}, + }, + { + name: "Invalid access token, successful refresh", + session: &MockSession{ + authenticated: true, + accessToken: "invalid-access-token", + refreshToken: "valid-refresh-token", + }, + tokenHandler: &MockTokenHandler{ + verifyError: errors.New("token expired"), + refreshError: nil, + tokenResponse: &TokenResponse{ + IDToken: "new-id-token", + AccessToken: "new-access-token", + }, + }, + expectedResult: AuthFlowResult{Authenticated: true}, + }, + { + name: "Invalid access token, no refresh token", + session: &MockSession{ + authenticated: true, + accessToken: "invalid-access-token", + refreshToken: "", + }, + tokenHandler: &MockTokenHandler{ + verifyError: errors.New("token expired"), + }, + expectedResult: AuthFlowResult{RequiresAuth: true}, + }, + { + name: "Valid ID token only", + session: &MockSession{ + authenticated: true, + idToken: "valid-id-token", + }, + tokenHandler: &MockTokenHandler{ + verifyError: nil, + }, + expectedResult: AuthFlowResult{Authenticated: true}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := &AuthFlowHandler{ + tokenHandler: test.tokenHandler, + logger: &MockLogger{}, + } + + req := httptest.NewRequest("GET", "/", nil) + rw := httptest.NewRecorder() + + result := handler.validateAndRefreshTokens(test.session, req, rw) + + if result.Authenticated != test.expectedResult.Authenticated { + t.Errorf("Expected Authenticated %v, got %v", test.expectedResult.Authenticated, result.Authenticated) + } + + if result.RequiresAuth != test.expectedResult.RequiresAuth { + t.Errorf("Expected RequiresAuth %v, got %v", test.expectedResult.RequiresAuth, result.RequiresAuth) + } + }) + } +} + +func TestAuthFlowHandler_attemptTokenRefresh(t *testing.T) { + tests := []struct { + name string + session *MockSession + tokenHandler *MockTokenHandler + isAjax bool + expectedResult AuthFlowResult + }{ + { + name: "No refresh token", + session: &MockSession{ + refreshToken: "", + }, + tokenHandler: &MockTokenHandler{}, + expectedResult: AuthFlowResult{RequiresAuth: true}, + }, + { + name: "AJAX request with expired session", + session: &MockSession{ + refreshToken: "refresh-token", + }, + tokenHandler: &MockTokenHandler{}, + isAjax: true, + expectedResult: AuthFlowResult{ + Error: ErrSessionExpiredAjax, + StatusCode: http.StatusUnauthorized, + }, + }, + { + name: "Successful token refresh", + session: &MockSession{ + refreshToken: "valid-refresh-token", + }, + tokenHandler: &MockTokenHandler{ + refreshError: nil, + tokenResponse: &TokenResponse{ + IDToken: "new-id-token", + AccessToken: "new-access-token", + }, + }, + expectedResult: AuthFlowResult{Authenticated: true}, + }, + { + name: "Failed token refresh", + session: &MockSession{ + refreshToken: "invalid-refresh-token", + }, + tokenHandler: &MockTokenHandler{ + refreshError: errors.New("refresh failed"), + }, + expectedResult: AuthFlowResult{RequiresAuth: true}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sessionHandlerWrapper := NewMockSessionHandlerWrapper() + handler := &AuthFlowHandler{ + sessionHandler: sessionHandlerWrapper.SessionHandler, + tokenHandler: test.tokenHandler, + logger: &MockLogger{}, + } + + req := httptest.NewRequest("GET", "/", nil) + if test.isAjax { + req.Header.Set("X-Requested-With", "XMLHttpRequest") + } + rw := httptest.NewRecorder() + + result := handler.attemptTokenRefresh(test.session, req, rw) + + if result.Authenticated != test.expectedResult.Authenticated { + t.Errorf("Expected Authenticated %v, got %v", test.expectedResult.Authenticated, result.Authenticated) + } + + if result.RequiresAuth != test.expectedResult.RequiresAuth { + t.Errorf("Expected RequiresAuth %v, got %v", test.expectedResult.RequiresAuth, result.RequiresAuth) + } + + if result.StatusCode != test.expectedResult.StatusCode { + t.Errorf("Expected StatusCode %d, got %d", test.expectedResult.StatusCode, result.StatusCode) + } + }) + } +} + +func TestAuthFlowError_Error(t *testing.T) { + err := &AuthFlowError{ + Code: "TEST_ERROR", + Message: "This is a test error", + } + + expected := "This is a test error" + result := err.Error() + + if result != expected { + t.Errorf("Expected '%s', got '%s'", expected, result) + } +} + +func TestAuthFlowResult(t *testing.T) { + // Test AuthFlowResult struct + result := AuthFlowResult{ + Authenticated: true, + RequiresAuth: false, + RequiresRefresh: false, + Error: nil, + RedirectURL: "https://example.com", + StatusCode: 200, + } + + if !result.Authenticated { + t.Error("Expected Authenticated to be true") + } + + if result.RequiresAuth { + t.Error("Expected RequiresAuth to be false") + } + + if result.StatusCode != 200 { + t.Errorf("Expected StatusCode 200, got %d", result.StatusCode) + } +} + +func TestTokenResponse(t *testing.T) { + response := &TokenResponse{ + IDToken: "id-token-value", + AccessToken: "access-token-value", + RefreshToken: "refresh-token-value", + ExpiresIn: 3600, + } + + if response.IDToken != "id-token-value" { + t.Errorf("Expected IDToken 'id-token-value', got '%s'", response.IDToken) + } + + if response.ExpiresIn != 3600 { + t.Errorf("Expected ExpiresIn 3600, got %d", response.ExpiresIn) + } +} diff --git a/internal/handlers/session_handler_test.go b/internal/handlers/session_handler_test.go new file mode 100644 index 0000000..d5e6f70 --- /dev/null +++ b/internal/handlers/session_handler_test.go @@ -0,0 +1,587 @@ +package handlers + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestNewSessionHandler(t *testing.T) { + sessionManager := &MockSessionManager{} + logger := &MockLogger{} + logoutURLPath := "/logout" + postLogoutRedirectURI := "https://example.com/post-logout" + endSessionURL := "https://provider.example.com/logout" + clientID := "test-client-id" + + handler := NewSessionHandler( + sessionManager, + logger, + logoutURLPath, + postLogoutRedirectURI, + endSessionURL, + clientID, + ) + + if handler == nil { + t.Fatal("NewSessionHandler returned nil") + } + + if handler.sessionManager != sessionManager { + t.Error("SessionManager not set correctly") + } + + if handler.logger != logger { + t.Error("Logger not set correctly") + } + + if handler.logoutURLPath != logoutURLPath { + t.Error("LogoutURLPath not set correctly") + } + + if handler.postLogoutRedirectURI != postLogoutRedirectURI { + t.Error("PostLogoutRedirectURI not set correctly") + } + + if handler.endSessionURL != endSessionURL { + t.Error("EndSessionURL not set correctly") + } + + if handler.clientID != clientID { + t.Error("ClientID not set correctly") + } +} + +func TestSessionHandler_HandleLogout(t *testing.T) { + tests := []struct { + name string + setupSession func() *MockSession + setupManager func() *MockSessionManager + expectedCode int + expectedURL string + }{ + { + name: "Successful logout with ID token", + setupSession: func() *MockSession { + return &MockSession{ + authenticated: true, + idToken: "test-id-token", + } + }, + setupManager: func() *MockSessionManager { + return &MockSessionManager{ + session: &MockSession{ + authenticated: true, + idToken: "test-id-token", + }, + } + }, + expectedCode: http.StatusFound, + expectedURL: "https://provider.example.com/logout?id_token_hint=test-id-token&post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id", + }, + { + name: "Logout without ID token", + setupSession: func() *MockSession { + return &MockSession{ + authenticated: true, + idToken: "", + } + }, + setupManager: func() *MockSessionManager { + return &MockSessionManager{ + session: &MockSession{ + authenticated: true, + idToken: "", + }, + } + }, + expectedCode: http.StatusFound, + expectedURL: "https://provider.example.com/logout?post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id", + }, + { + name: "Session retrieval error", + setupSession: func() *MockSession { return nil }, + setupManager: func() *MockSessionManager { + return &MockSessionManager{ + err: fmt.Errorf("session error"), + } + }, + expectedCode: http.StatusFound, + expectedURL: "https://provider.example.com/logout?post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := &SessionHandler{ + sessionManager: test.setupManager(), + logger: &MockLogger{}, + logoutURLPath: "/logout", + postLogoutRedirectURI: "https://example.com/post-logout", + endSessionURL: "https://provider.example.com/logout", + clientID: "test-client-id", + } + + req := httptest.NewRequest("POST", "/logout", nil) + rw := httptest.NewRecorder() + + handler.HandleLogout(rw, req) + + if rw.Code != test.expectedCode { + t.Errorf("Expected status code %d, got %d", test.expectedCode, rw.Code) + } + + location := rw.Header().Get("Location") + if location != test.expectedURL { + t.Errorf("Expected location '%s', got '%s'", test.expectedURL, location) + } + }) + } +} + +func TestSessionHandler_buildLogoutURL(t *testing.T) { + tests := []struct { + name string + endSessionURL string + postLogoutRedirectURI string + clientID string + idToken string + expected string + }{ + { + name: "Complete logout URL with all parameters", + endSessionURL: "https://provider.example.com/logout", + postLogoutRedirectURI: "https://example.com/post-logout", + clientID: "test-client-id", + idToken: "test-id-token", + expected: "https://provider.example.com/logout?id_token_hint=test-id-token&post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id", + }, + { + name: "Logout URL without ID token", + endSessionURL: "https://provider.example.com/logout", + postLogoutRedirectURI: "https://example.com/post-logout", + clientID: "test-client-id", + idToken: "", + expected: "https://provider.example.com/logout?post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id", + }, + { + name: "No end session URL", + endSessionURL: "", + postLogoutRedirectURI: "https://example.com/post-logout", + clientID: "test-client-id", + idToken: "test-id-token", + expected: "https://example.com/post-logout", + }, + { + name: "End session URL with existing query parameters", + endSessionURL: "https://provider.example.com/logout?foo=bar", + postLogoutRedirectURI: "https://example.com/post-logout", + clientID: "test-client-id", + idToken: "", + expected: "https://provider.example.com/logout?foo=bar&post_logout_redirect_uri=https://example.com/post-logout&client_id=test-client-id", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := &SessionHandler{ + endSessionURL: test.endSessionURL, + postLogoutRedirectURI: test.postLogoutRedirectURI, + clientID: test.clientID, + } + + result := handler.buildLogoutURL(test.idToken) + if result != test.expected { + t.Errorf("Expected '%s', got '%s'", test.expected, result) + } + }) + } +} + +func TestSessionHandler_ValidateSession(t *testing.T) { + handler := &SessionHandler{} + + tests := []struct { + name string + session Session + expectedValid bool + expectedAuth bool + expectedMessage string + }{ + { + name: "Nil session", + session: nil, + expectedValid: false, + expectedAuth: true, + expectedMessage: "session is nil", + }, + { + name: "Not authenticated session", + session: &MockSession{ + authenticated: false, + }, + expectedValid: false, + expectedAuth: true, + expectedMessage: "session not authenticated", + }, + { + name: "Authenticated session without email", + session: &MockSession{ + authenticated: true, + email: "", + }, + expectedValid: false, + expectedAuth: true, + expectedMessage: "no email in session", + }, + { + name: "Valid authenticated session with email", + session: &MockSession{ + authenticated: true, + email: "user@example.com", + }, + expectedValid: true, + expectedAuth: false, + expectedMessage: "", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := handler.ValidateSession(test.session) + + if result.Valid != test.expectedValid { + t.Errorf("Expected Valid %v, got %v", test.expectedValid, result.Valid) + } + + if result.NeedsAuth != test.expectedAuth { + t.Errorf("Expected NeedsAuth %v, got %v", test.expectedAuth, result.NeedsAuth) + } + + if result.ErrorMessage != test.expectedMessage { + t.Errorf("Expected ErrorMessage '%s', got '%s'", test.expectedMessage, result.ErrorMessage) + } + }) + } +} + +func TestSessionHandler_CleanupExpiredSession(t *testing.T) { + tests := []struct { + name string + session *MockSession + expectError bool + }{ + { + name: "Successful cleanup", + session: &MockSession{ + authenticated: true, + email: "user@example.com", + refreshToken: "refresh-token", + }, + expectError: false, + }, + { + name: "Save error during cleanup", + session: &MockSession{ + authenticated: true, + email: "user@example.com", + refreshToken: "refresh-token", + saveError: fmt.Errorf("save failed"), + }, + expectError: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + handler := &SessionHandler{ + logger: &MockLogger{}, + } + + req := httptest.NewRequest("GET", "/", nil) + rw := httptest.NewRecorder() + + err := handler.CleanupExpiredSession(rw, req, test.session) + + if test.expectError && err == nil { + t.Error("Expected error but got nil") + } + + if !test.expectError && err != nil { + t.Errorf("Expected no error but got: %v", err) + } + + if test.session != nil && !test.expectError { + if test.session.authenticated { + t.Error("Expected session authenticated to be false after cleanup") + } + + if test.session.email != "" { + t.Error("Expected session email to be empty after cleanup") + } + + if test.session.refreshToken != "" { + t.Error("Expected session refresh token to be empty after cleanup") + } + } + }) + } + + // Test nil session separately + t.Run("Nil session", func(t *testing.T) { + handler := &SessionHandler{ + logger: &MockLogger{}, + } + + req := httptest.NewRequest("GET", "/", nil) + rw := httptest.NewRecorder() + + var nilSession Session = nil + err := handler.CleanupExpiredSession(rw, req, nilSession) + + if err != nil { + t.Errorf("Expected no error for nil session, got: %v", err) + } + }) +} + +func TestSessionHandler_IsAjaxRequest(t *testing.T) { + handler := &SessionHandler{} + + tests := []struct { + name string + headers map[string]string + expected bool + }{ + { + name: "XMLHttpRequest header", + headers: map[string]string{ + "X-Requested-With": "XMLHttpRequest", + }, + expected: true, + }, + { + name: "JSON Accept header without HTML", + headers: map[string]string{ + "Accept": "application/json", + }, + expected: true, + }, + { + name: "JSON Accept header with HTML", + headers: map[string]string{ + "Accept": "application/json, text/html", + }, + expected: false, + }, + { + name: "Fetch API CORS mode", + headers: map[string]string{ + "Sec-Fetch-Mode": "cors", + }, + expected: true, + }, + { + name: "Regular browser request", + headers: map[string]string{ + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + }, + expected: false, + }, + { + name: "No special headers", + headers: map[string]string{}, + expected: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + for key, value := range test.headers { + req.Header.Set(key, value) + } + + result := handler.IsAjaxRequest(req) + if result != test.expected { + t.Errorf("Expected %v, got %v", test.expected, result) + } + }) + } +} + +func TestSessionHandler_SendErrorResponse(t *testing.T) { + tests := []struct { + name string + isAjax bool + message string + statusCode int + expectedContentType string + expectedBodyContains string + }{ + { + name: "AJAX error response", + isAjax: true, + message: "Authentication failed", + statusCode: http.StatusUnauthorized, + expectedContentType: "application/json", + expectedBodyContains: `{"error": "Authentication failed"}`, + }, + { + name: "Browser error response", + isAjax: false, + message: "Session expired", + statusCode: http.StatusForbidden, + expectedContentType: "text/html", + expectedBodyContains: "