package providers import ( "errors" "net/url" "strings" "testing" "time" ) // TestAzureProvider_NewAzureProvider tests the constructor func TestAzureProvider_NewAzureProvider(t *testing.T) { provider := NewAzureProvider() if provider == nil { t.Fatal("Expected provider to be created, got nil") } if provider.BaseProvider == nil { t.Error("BaseProvider should be initialized") } } // TestAzureProvider_GetType tests provider type func TestAzureProvider_GetType(t *testing.T) { provider := NewAzureProvider() if provider.GetType() != ProviderTypeAzure { t.Errorf("Expected ProviderTypeAzure, got %v", provider.GetType()) } } // TestAzureProvider_GetCapabilities tests Azure-specific capabilities func TestAzureProvider_GetCapabilities(t *testing.T) { provider := NewAzureProvider() capabilities := provider.GetCapabilities() if !capabilities.SupportsRefreshTokens { t.Error("Expected SupportsRefreshTokens to be true") } if !capabilities.RequiresOfflineAccessScope { t.Error("Expected RequiresOfflineAccessScope to be true for Azure") } if capabilities.RequiresPromptConsent { t.Error("Expected RequiresPromptConsent to be false for Azure") } if capabilities.PreferredTokenValidation != "access" { t.Errorf("Expected PreferredTokenValidation 'access', got '%s'", capabilities.PreferredTokenValidation) } } // TestAzureProvider_BuildAuthParams tests Azure-specific auth parameters func TestAzureProvider_BuildAuthParams(t *testing.T) { provider := NewAzureProvider() tests := []struct { name string inputScopes []string expectedScopes []string shouldHaveResponseMode bool shouldAddOfflineAccess bool }{ { name: "Basic scopes without offline_access", inputScopes: []string{"openid", "profile", "email"}, expectedScopes: []string{"openid", "profile", "email", "offline_access"}, shouldHaveResponseMode: true, shouldAddOfflineAccess: true, }, { name: "Scopes with offline_access already present", inputScopes: []string{"openid", "profile", "offline_access", "email"}, expectedScopes: []string{"openid", "profile", "offline_access", "email"}, shouldHaveResponseMode: true, shouldAddOfflineAccess: false, }, { name: "Only offline_access scope", inputScopes: []string{"offline_access"}, expectedScopes: []string{"offline_access"}, shouldHaveResponseMode: true, shouldAddOfflineAccess: false, }, { name: "Empty scopes (should add offline_access)", inputScopes: []string{}, expectedScopes: []string{"offline_access"}, shouldHaveResponseMode: true, shouldAddOfflineAccess: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { baseParams := make(url.Values) baseParams.Set("client_id", "test-client") result, err := provider.BuildAuthParams(baseParams, tt.inputScopes) if err != nil { t.Errorf("Unexpected error: %v", err) } // Check Azure-specific parameters if tt.shouldHaveResponseMode { if result.URLValues.Get("response_mode") != "query" { t.Errorf("Expected response_mode 'query', got '%s'", result.URLValues.Get("response_mode")) } } // Check scopes if len(result.Scopes) != len(tt.expectedScopes) { t.Errorf("Expected %d scopes, got %d", len(tt.expectedScopes), len(result.Scopes)) } for _, expectedScope := range tt.expectedScopes { found := false for _, actualScope := range result.Scopes { if actualScope == expectedScope { found = true break } } if !found { t.Errorf("Expected scope '%s' not found in result", expectedScope) } } // Verify offline_access is present hasOfflineAccess := false for _, scope := range result.Scopes { if scope == "offline_access" { hasOfflineAccess = true break } } if !hasOfflineAccess { t.Error("Azure provider should always include offline_access scope") } // Verify original base parameters are preserved if result.URLValues.Get("client_id") != "test-client" { t.Errorf("Expected client_id 'test-client', got '%s'", result.URLValues.Get("client_id")) } }) } } // TestAzureProvider_ValidateTokens tests Azure-specific token validation logic func TestAzureProvider_ValidateTokens(t *testing.T) { provider := NewAzureProvider() tests := []struct { verifierError error session *mockSession cacheData map[string]interface{} name string expectedResult ValidationResult }{ { name: "Unauthenticated with refresh token", session: &mockSession{ authenticated: false, refreshToken: "refresh-token", }, expectedResult: ValidationResult{ Authenticated: false, NeedsRefresh: true, IsExpired: false, }, }, { name: "Unauthenticated without refresh token", session: &mockSession{ authenticated: false, }, expectedResult: ValidationResult{ Authenticated: false, NeedsRefresh: false, IsExpired: true, }, }, { name: "JWT access token valid", session: &mockSession{ authenticated: true, accessToken: "valid.jwt.token", refreshToken: "refresh-token", }, verifierError: nil, cacheData: map[string]interface{}{ "exp": float64(time.Now().Add(10 * time.Minute).Unix()), "sub": "user123", }, expectedResult: ValidationResult{ Authenticated: true, NeedsRefresh: false, IsExpired: false, }, }, { name: "JWT access token invalid, valid ID token", session: &mockSession{ authenticated: true, accessToken: "invalid.jwt.token", idToken: "valid.id.token", refreshToken: "refresh-token", }, verifierError: errors.New("invalid token"), cacheData: map[string]interface{}{ "exp": float64(time.Now().Add(10 * time.Minute).Unix()), "sub": "user123", }, expectedResult: ValidationResult{ Authenticated: true, NeedsRefresh: false, IsExpired: false, }, }, { name: "Opaque access token with valid ID token", session: &mockSession{ authenticated: true, accessToken: "opaque-token-no-dots", idToken: "valid.id.token", refreshToken: "refresh-token", }, cacheData: map[string]interface{}{ "exp": float64(time.Now().Add(10 * time.Minute).Unix()), "sub": "user123", }, expectedResult: ValidationResult{ Authenticated: true, NeedsRefresh: false, IsExpired: false, }, }, { name: "Opaque access token without ID token", session: &mockSession{ authenticated: true, accessToken: "opaque-token-no-dots", refreshToken: "refresh-token", }, expectedResult: ValidationResult{ Authenticated: true, NeedsRefresh: false, IsExpired: false, }, }, { name: "No access token, valid ID token", session: &mockSession{ authenticated: true, idToken: "valid.id.token", refreshToken: "refresh-token", }, verifierError: nil, cacheData: map[string]interface{}{ "exp": float64(time.Now().Add(10 * time.Minute).Unix()), "sub": "user123", }, expectedResult: ValidationResult{ Authenticated: true, NeedsRefresh: false, IsExpired: false, }, }, { name: "No access token, invalid ID token, with refresh token", session: &mockSession{ authenticated: true, idToken: "invalid.id.token", refreshToken: "refresh-token", }, verifierError: errors.New("invalid token"), expectedResult: ValidationResult{ Authenticated: false, NeedsRefresh: true, IsExpired: false, }, }, { name: "No tokens, with refresh token", session: &mockSession{ authenticated: true, refreshToken: "refresh-token", }, expectedResult: ValidationResult{ Authenticated: false, NeedsRefresh: true, IsExpired: false, }, }, { name: "No tokens, no refresh token", session: &mockSession{ authenticated: true, }, expectedResult: ValidationResult{ Authenticated: false, NeedsRefresh: false, IsExpired: true, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { verifier := &mockTokenVerifier{error: tt.verifierError} cache := &mockTokenCache{claims: make(map[string]map[string]interface{})} // Set up cache data if tt.cacheData != nil { if tt.session.accessToken != "" && strings.Count(tt.session.accessToken, ".") == 2 { cache.claims[tt.session.accessToken] = tt.cacheData } if tt.session.idToken != "" { cache.claims[tt.session.idToken] = tt.cacheData } } result, err := provider.ValidateTokens(tt.session, verifier, cache, time.Minute) if err != nil { t.Errorf("Unexpected error: %v", err) } if result.Authenticated != tt.expectedResult.Authenticated { t.Errorf("Expected Authenticated %v, got %v", tt.expectedResult.Authenticated, result.Authenticated) } if result.NeedsRefresh != tt.expectedResult.NeedsRefresh { t.Errorf("Expected NeedsRefresh %v, got %v", tt.expectedResult.NeedsRefresh, result.NeedsRefresh) } if result.IsExpired != tt.expectedResult.IsExpired { t.Errorf("Expected IsExpired %v, got %v", tt.expectedResult.IsExpired, result.IsExpired) } }) } } // TestAzureProvider_ValidateConfig tests configuration validation func TestAzureProvider_ValidateConfig(t *testing.T) { provider := NewAzureProvider() err := provider.ValidateConfig() if err != nil { t.Errorf("Expected no error, got %v", err) } } // TestAzureProvider_InterfaceCompliance tests that Azure provider implements OIDCProvider func TestAzureProvider_InterfaceCompliance(t *testing.T) { provider := NewAzureProvider() // Verify it implements the OIDCProvider interface var _ OIDCProvider = provider } // TestAzureProvider_OfflineAccessHandling tests comprehensive offline_access handling func TestAzureProvider_OfflineAccessHandling(t *testing.T) { provider := NewAzureProvider() tests := []struct { name string description string inputScopes []string expectedCount int }{ { name: "No offline_access - should add one", inputScopes: []string{"openid", "profile", "email"}, expectedCount: 1, description: "Should add offline_access when not present", }, { name: "One offline_access - should preserve", inputScopes: []string{"openid", "offline_access", "profile"}, expectedCount: 1, description: "Should preserve existing offline_access", }, { name: "Multiple offline_access - should deduplicate", inputScopes: []string{"openid", "offline_access", "profile", "offline_access"}, expectedCount: 1, description: "Should deduplicate multiple offline_access scopes", }, { name: "Only offline_access", inputScopes: []string{"offline_access"}, expectedCount: 1, description: "Should preserve when only offline_access is present", }, { name: "Empty scopes - should add offline_access", inputScopes: []string{}, expectedCount: 1, description: "Should add offline_access when no scopes provided", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { baseParams := make(url.Values) result, err := provider.BuildAuthParams(baseParams, tt.inputScopes) if err != nil { t.Errorf("Unexpected error: %v", err) } // Count offline_access occurrences in result offlineAccessCount := 0 for _, scope := range result.Scopes { if scope == "offline_access" { offlineAccessCount++ } } if offlineAccessCount != tt.expectedCount { t.Errorf("Expected %d offline_access scopes in result, got %d", tt.expectedCount, offlineAccessCount) } // Ensure at least one offline_access is always present if offlineAccessCount == 0 { t.Error("Azure provider should always have at least one offline_access scope") } // Verify other scopes are preserved (except for the empty case) if len(tt.inputScopes) > 0 { for _, originalScope := range tt.inputScopes { found := false for _, resultScope := range result.Scopes { if resultScope == originalScope { found = true break } } if !found { t.Errorf("Expected scope '%s' to be preserved", originalScope) } } } }) } } // TestAzureProvider_TokenValidationPriority tests access token vs ID token priority func TestAzureProvider_TokenValidationPriority(t *testing.T) { provider := NewAzureProvider() // Test that Azure prefers access tokens over ID tokens when both are JWT session := &mockSession{ authenticated: true, accessToken: "valid.access.token", idToken: "valid.id.token", refreshToken: "refresh-token", } verifier := &mockTokenVerifier{} // Valid tokens cache := &mockTokenCache{ claims: map[string]map[string]interface{}{ "valid.access.token": { "exp": float64(time.Now().Add(10 * time.Minute).Unix()), "sub": "user123", }, "valid.id.token": { "exp": float64(time.Now().Add(10 * time.Minute).Unix()), "sub": "user123", }, }, } result, err := provider.ValidateTokens(session, verifier, cache, time.Minute) if err != nil { t.Errorf("Unexpected error: %v", err) } if !result.Authenticated { t.Error("Should be authenticated with valid access token") } if result.NeedsRefresh { t.Error("Should not need refresh with valid access token") } } // TestAzureProvider_AuthParamsPreservation tests that base parameters are not overwritten func TestAzureProvider_AuthParamsPreservation(t *testing.T) { provider := NewAzureProvider() baseParams := make(url.Values) baseParams.Set("client_id", "test-client") baseParams.Set("redirect_uri", "https://example.com/callback") baseParams.Set("response_type", "code") baseParams.Set("state", "test-state") baseParams.Set("nonce", "test-nonce") scopes := []string{"openid", "profile"} result, err := provider.BuildAuthParams(baseParams, scopes) if err != nil { t.Errorf("Unexpected error: %v", err) } // Verify all original parameters are preserved expectedParams := map[string]string{ "client_id": "test-client", "redirect_uri": "https://example.com/callback", "response_type": "code", "state": "test-state", "nonce": "test-nonce", "response_mode": "query", // Added by Azure provider } for key, expectedValue := range expectedParams { actualValue := result.URLValues.Get(key) if actualValue != expectedValue { t.Errorf("Expected %s '%s', got '%s'", key, expectedValue, actualValue) } } // Verify scopes (should include offline_access) if len(result.Scopes) != 3 { t.Errorf("Expected 3 scopes (including offline_access), got %d", len(result.Scopes)) } expectedScopes := []string{"openid", "profile", "offline_access"} for _, expectedScope := range expectedScopes { found := false for _, actualScope := range result.Scopes { if actualScope == expectedScope { found = true break } } if !found { t.Errorf("Expected scope '%s' not found", expectedScope) } } } // Benchmark tests func BenchmarkAzureProvider_BuildAuthParams(b *testing.B) { provider := NewAzureProvider() baseParams := make(url.Values) baseParams.Set("client_id", "test-client") scopes := []string{"openid", "profile", "email"} b.ResetTimer() for i := 0; i < b.N; i++ { provider.BuildAuthParams(baseParams, scopes) } } func BenchmarkAzureProvider_ValidateTokens(b *testing.B) { provider := NewAzureProvider() session := &mockSession{ authenticated: true, accessToken: "valid.access.token", idToken: "valid.id.token", refreshToken: "refresh-token", } verifier := &mockTokenVerifier{} cache := &mockTokenCache{ claims: map[string]map[string]interface{}{ "valid.access.token": { "exp": float64(time.Now().Add(10 * time.Minute).Unix()), "sub": "user123", }, }, } b.ResetTimer() for i := 0; i < b.N; i++ { provider.ValidateTokens(session, verifier, cache, time.Minute) } }