diff --git a/Gemfile b/Gemfile index 9aadcef4..542904f5 100644 --- a/Gemfile +++ b/Gemfile @@ -34,8 +34,8 @@ gem 'bootsnap', '>= 1.4.5', require: false gem 'rack-cors', require: 'rack/cors' gem 'devise' -gem 'devise_ldap_authenticatable' gem 'devise-security' +gem 'net-ldap' gem 'doorkeeper' gem 'pundit' diff --git a/Gemfile.lock b/Gemfile.lock index 326b2c58..edfd0259 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -102,9 +102,6 @@ GEM rails-i18n devise-security (0.18.0) devise (>= 4.3.0) - devise_ldap_authenticatable (0.8.7) - devise (>= 3.4.1) - net-ldap (>= 0.16.0) docile (1.4.1) doorkeeper (5.8.2) railties (>= 5) @@ -312,12 +309,12 @@ DEPENDENCIES devise devise-i18n devise-security - devise_ldap_authenticatable doorkeeper doorkeeper-i18n dotenv-rails factory_bot_rails mime-types (~> 3.7) + net-ldap pg puma pundit @@ -360,7 +357,6 @@ CHECKSUMS devise (4.9.4) sha256=920042fe5e704c548aa4eb65ebdd65980b83ffae67feb32c697206bfd975a7f8 devise-i18n (1.15.0) sha256=aff406e8a2941cd1d7614643b1619031a403b2855bb6057e391d1c7c273205cd devise-security (0.18.0) sha256=fc06be1624b5151044ff9c5d8e61abdfa7d56eb16bfdaec16a11235d54708513 - devise_ldap_authenticatable (0.8.7) sha256=8af6f839661e24ca9afc5a1508a7ec7e1327e93af4516f2baabacdf511ee5a2e docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e doorkeeper (5.8.2) sha256=a73d07aeaf590b1e7e2a35390446f23131c9f37bc0561653e514d3973f4d50d3 doorkeeper-i18n (5.2.8) sha256=e4054701df464892f3c0fd6c96507188e9b082cb52938da3aab45f4f9fdaf552 diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 90443b73..dcb61a94 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,8 +1,4 @@ class ApplicationController < ActionController::API - rescue_from DeviseLdapAuthenticatable::LdapException do |exception| - render text: exception, status: :internal_server_error - end - include Pundit::Authorization if ENV['ENABLE_AUTHENTICATION'].present? before_action :doorkeeper_authorize!, except: %i[info] before_action :active_storage_url_options diff --git a/app/models/user.rb b/app/models/user.rb index f640deb8..ad9d8d9c 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -31,7 +31,7 @@ def validate_login_uniqueness def check_ldap_email if email.blank? - mail = Devise::LDAP::Adapter.get_ldap_param(login, 'mail') + mail = LdapAdapter.get_ldap_param(login, 'mail') self.email = if mail.present? mail.first else # No email, use login @@ -42,7 +42,7 @@ def check_ldap_email end def self.check_ldap_credentials(login, password) - Devise::LDAP::Adapter.valid_credentials?(login, password) + LdapAdapter.valid_credentials?(login, password) end # create user with ldap diff --git a/app/services/ldap_adapter.rb b/app/services/ldap_adapter.rb new file mode 100644 index 00000000..1784ba99 --- /dev/null +++ b/app/services/ldap_adapter.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require 'net/ldap' + +class LdapAdapter + class << self + # Validates user credentials against LDAP server + # @param login [String] the user's login identifier + # @param password [String] the user's password + # @return [Boolean] true if credentials are valid + def valid_credentials?(login, password) + return false if login.blank? || password.blank? + + ldap = create_ldap_connection + return false unless ldap + + # Bind with admin credentials first (if configured) + unless bind_as_admin(ldap) + Rails.logger.error "LDAP: Failed to bind as admin" + return false + end + + # Search for user + user_dn = find_user_dn(ldap, login) + return false unless user_dn + + # Now attempt to bind as the user to validate their password + ldap.auth(user_dn, password) + result = ldap.bind + + if result + Rails.logger.info "LDAP: Successfully authenticated user: #{login}" + else + Rails.logger.warn "LDAP: Failed to authenticate user: #{login} - #{ldap.get_operation_result.message}" + end + + result + rescue StandardError => e + Rails.logger.error "LDAP: Error validating credentials for #{login}: #{e.message}" + false + end + + # Retrieves a specific LDAP parameter for a user + # @param login [String] the user's login identifier + # @param param [String] the LDAP attribute to retrieve (e.g., 'mail') + # @return [Array, nil] array of values for the attribute, or nil if not found + def get_ldap_param(login, param) + return nil if login.blank? || param.blank? + + ldap = create_ldap_connection + return nil unless ldap + + # Bind with admin credentials + unless bind_as_admin(ldap) + Rails.logger.error "LDAP: Failed to bind as admin" + return nil + end + + # Search for user and retrieve the specified parameter + filter = Net::LDAP::Filter.eq(config[:attribute], login) + result = ldap.search( + base: config[:base], + filter: filter, + attributes: [param] + ) + + if result && result.first + value = result.first[param] + Rails.logger.info "LDAP: Retrieved #{param} for user #{login}" + value + else + Rails.logger.warn "LDAP: Could not find #{param} for user #{login}" + nil + end + rescue StandardError => e + Rails.logger.error "LDAP: Error retrieving #{param} for #{login}: #{e.message}" + nil + end + + # Loads LDAP configuration from ldap.yml + # @return [Hash] LDAP configuration hash + def config + @config ||= begin + config_file = Rails.root.join('config', 'ldap.yml') + if File.exist?(config_file) + # Process ERB template before parsing YAML + erb_content = ERB.new(File.read(config_file)).result + yaml_config = YAML.safe_load(erb_content, aliases: true) + env_config = yaml_config[Rails.env] || {} + + # Convert string keys to symbols + env_config.transform_keys(&:to_sym) + else + Rails.logger.error "LDAP: Configuration file not found at #{config_file}" + {} + end + end + end + + # Resets the cached configuration (useful for testing) + def reset_config! + @config = nil + end + + private + + # Creates and returns an LDAP connection + # @return [Net::LDAP, nil] LDAP connection object or nil if config is invalid + def create_ldap_connection + return nil unless config[:host] + + ldap_options = { + host: config[:host], + port: config[:port] || 389, + base: config[:base] + } + + # Configure encryption + if config[:ssl].present? + case config[:ssl].to_s.downcase + when 'simple_tls', 'ssl', 'tls' + ldap_options[:encryption] = { method: :simple_tls } + when 'start_tls' + ldap_options[:encryption] = { method: :start_tls } + end + end + + Net::LDAP.new(ldap_options) + rescue StandardError => e + Rails.logger.error "LDAP: Error creating connection: #{e.message}" + nil + end + + # Binds to LDAP server with admin credentials + # @param ldap [Net::LDAP] the LDAP connection + # @return [Boolean] true if bind was successful + def bind_as_admin(ldap) + if config[:admin_user].present? && config[:admin_password].present? + ldap.auth(config[:admin_user], config[:admin_password]) + result = ldap.bind + unless result + Rails.logger.error "LDAP: Admin bind failed - #{ldap.get_operation_result.message}" + end + result + else + # If no admin credentials, attempt anonymous bind + true + end + end + + # Finds the distinguished name (DN) for a user + # @param ldap [Net::LDAP] the LDAP connection + # @param login [String] the user's login identifier + # @return [String, nil] the user's DN or nil if not found + def find_user_dn(ldap, login) + filter = Net::LDAP::Filter.eq(config[:attribute], login) + result = ldap.search( + base: config[:base], + filter: filter, + attributes: ['dn'] + ) + + if result && result.first + dn = result.first.dn + Rails.logger.info "LDAP: Found DN for user #{login}: #{dn}" + dn + else + Rails.logger.warn "LDAP: Could not find DN for user #{login}" + nil + end + rescue StandardError => e + Rails.logger.error "LDAP: Error finding DN for #{login}: #{e.message}" + nil + end + end +end diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index 9d676df9..defd6f4a 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -9,18 +9,6 @@ # Use this hook to configure devise mailer, warden hooks and so forth. # Many of these configuration options can be set straight in your model. Devise.setup do |config| - # ==> LDAP Configuration - config.ldap_logger = ENV['DEVISE_LDAP_LOGGER'] - config.ldap_create_user = true - config.ldap_update_password = true - config.ldap_config = "#{Rails.root}/config/ldap.yml" - config.ldap_check_group_membership = false - config.ldap_check_group_membership_without_admin = false - config.ldap_check_attributes = false - config.ldap_check_attributes_presence = false - config.ldap_use_admin_to_bind = ENV['DEVISE_LDAP_LOGGER_ADMIN_BIND'] - config.ldap_ad_group_check = false - # The secret key used by Devise. Devise uses this key to generate # random tokens. Changing this key will render invalid all existing # confirmation, reset password and unlock tokens in the database. diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index f5cb5829..42e58da3 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -371,11 +371,11 @@ user = nil # LDAP IS ACTIVE - if ENV['DEVISE_LDAP_LOGGER'].present? && User.check_ldap_credentials(login, password) # ldap valide + if ActiveModel::Type::Boolean.new.cast(ENV['DEVISE_LDAP_LOGGER']) && User.check_ldap_credentials(login, password) user = User.find_for_authentication(login: login) - # Check if user exists in database + # Check if the user exists in the database if user.blank? - # Create user in database + # Create the user in the database user = User.create_with_ldap(login) end end diff --git a/config/ldap.yml b/config/ldap.yml index 5b1b064e..6eecb1be 100644 --- a/config/ldap.yml +++ b/config/ldap.yml @@ -1,31 +1,15 @@ -## Authorizations -# Uncomment out the merging for each environment that you'd like to include. -# You can also just copy and paste the tree (do not include the "authorizations") to each -# environment if you need something different per environment. -authorizations: &AUTHORIZATIONS - allow_unauthenticated_bind: false - group_base: ou=groups,dc=test,dc=com - ## Requires config.ldap_check_group_membership in devise.rb be true - # Can have multiple values, must match all to be authorized - required_groups: - # If only a group name is given, membership will be checked against "uniqueMember" - - cn=admins,ou=groups,dc=test,dc=com - - cn=users,ou=groups,dc=test,dc=com - # If an array is given, the first element will be the attribute to check against, the second the group name - - ["moreMembers", "cn=users,ou=groups,dc=test,dc=com"] - ## Requires config.ldap_check_attributes in devise.rb to be true - ## Can have multiple attributes and values, must match all to be authorized - require_attribute: - objectClass: inetOrgPerson - authorizationRole: postsAdmin - ## Requires config.ldap_check_attributes_presence in devise.rb to be true - ## Can have multiple attributes set to true or false to check presence, all must match all to be authorized - require_attribute_presence: - mail: true - telephoneNumber: true - serviceAccount: false - -## Environment +# LDAP Configuration +# This file configures LDAP authentication settings for each environment. +# All sensitive values should be stored in Rails encrypted credentials. +# +# Required credentials for each environment: +# - ldap_host: LDAP server hostname +# - ldap_port: LDAP server port (typically 389 for non-SSL, 636 for SSL) +# - ldap_attribute: Search attribute (e.g., 'cn', 'uid', 'sAMAccountName') +# - ldap_base: Base DN for user searches (e.g., 'ou=people,dc=example,dc=com') +# - ldap_admin_user: Admin bind DN (e.g., 'cn=admin,dc=example,dc=com') +# - ldap_admin_user_password: Admin bind password +# - ldap_ssl: Encryption method ('simple_tls', 'start_tls', or false for no encryption) development: host: <%= Rails.application.credentials.ldap_host %> @@ -35,24 +19,23 @@ development: admin_user: <%= Rails.application.credentials.ldap_admin_user %> admin_password: <%= Rails.application.credentials.ldap_admin_user_password %> ssl: <%= Rails.application.credentials.ldap_ssl %> - # <<: *AUTHORIZATIONS test: - host: localhost - port: 3389 - attribute: cn - base: ou=people,dc=test,dc=com - admin_user: cn=admin,dc=test,dc=com - admin_password: admin_password - ssl: simple_tls - # <<: *AUTHORIZATIONS + # Test environment can use mock LDAP server or real server configured via credentials + # If using mock server, update these values accordingly + host: <%= Rails.application.credentials.dig(:test, :ldap_host) || 'localhost' %> + port: <%= Rails.application.credentials.dig(:test, :ldap_port) || 389 %> + attribute: <%= Rails.application.credentials.dig(:test, :ldap_attribute) || 'cn' %> + base: <%= Rails.application.credentials.dig(:test, :ldap_base) || 'ou=people,dc=test,dc=com' %> + admin_user: <%= Rails.application.credentials.dig(:test, :ldap_admin_user) || 'cn=admin,dc=test,dc=com' %> + admin_password: <%= Rails.application.credentials.dig(:test, :ldap_admin_user_password) || 'admin_password' %> + ssl: <%= Rails.application.credentials.dig(:test, :ldap_ssl) || 'simple_tls' %> production: - host: localhost - port: 636 - attribute: cn - base: ou=people,dc=test,dc=com - admin_user: cn=admin,dc=test,dc=com - admin_password: admin_password - ssl: start_tls - # <<: *AUTHORIZATIONS + host: <%= Rails.application.credentials.ldap_host %> + port: <%= Rails.application.credentials.ldap_port %> + attribute: <%= Rails.application.credentials.ldap_attribute %> + base: <%= Rails.application.credentials.ldap_base %> + admin_user: <%= Rails.application.credentials.ldap_admin_user %> + admin_password: <%= Rails.application.credentials.ldap_admin_user_password %> + ssl: <%= Rails.application.credentials.ldap_ssl %> diff --git a/test/services/ldap_adapter_test.rb b/test/services/ldap_adapter_test.rb new file mode 100644 index 00000000..4fa9bea3 --- /dev/null +++ b/test/services/ldap_adapter_test.rb @@ -0,0 +1,475 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'minitest/mock' + +class LdapAdapterTest < ActiveSupport::TestCase + setup do + # Reset config before each test + LdapAdapter.reset_config! + end + + teardown do + LdapAdapter.reset_config! + end + + # Tests for valid_credentials? + + test "valid_credentials? returns false when login is blank" do + result = LdapAdapter.valid_credentials?('', 'password123') + assert_not result, "Expected valid_credentials? to return false for blank login" + end + + test "valid_credentials? returns false when password is blank" do + result = LdapAdapter.valid_credentials?('testuser', '') + assert_not result, "Expected valid_credentials? to return false for blank password" + end + + test "valid_credentials? returns false when both login and password are blank" do + result = LdapAdapter.valid_credentials?('', '') + assert_not result, "Expected valid_credentials? to return false for blank credentials" + end + + test "valid_credentials? returns false when LDAP host is not configured" do + LdapAdapter.stub(:config, { host: nil }) do + result = LdapAdapter.valid_credentials?('testuser', 'password123') + assert_not result, "Expected valid_credentials? to return false when host is not configured" + end + end + + test "valid_credentials? returns true when credentials are valid" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + admin_user: 'cn=admin,dc=test,dc=com', + admin_password: 'admin_password' + } + + ldap_mock = Minitest::Mock.new + search_result = [OpenStruct.new(dn: 'cn=testuser,ou=people,dc=test,dc=com')] + + # Expect admin bind + ldap_mock.expect :auth, nil, ['cn=admin,dc=test,dc=com', 'admin_password'] + ldap_mock.expect :bind, true + + # Expect user search + ldap_mock.expect :search, search_result do |args| + args.is_a?(Hash) + end + + # Expect user authentication + ldap_mock.expect :auth, nil, ['cn=testuser,ou=people,dc=test,dc=com', 'password123'] + ldap_mock.expect :bind, true + + Net::LDAP.stub :new, ldap_mock do + LdapAdapter.stub :config, mock_config do + result = LdapAdapter.valid_credentials?('testuser', 'password123') + assert result, "Expected valid_credentials? to return true" + end + end + + ldap_mock.verify + end + + test "valid_credentials? returns false when admin bind fails" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + admin_user: 'cn=admin,dc=test,dc=com', + admin_password: 'admin_password' + } + + ldap_mock = Minitest::Mock.new + operation_result = OpenStruct.new(message: 'Invalid credentials') + + # Expect admin bind to fail + ldap_mock.expect :auth, nil, ['cn=admin,dc=test,dc=com', 'admin_password'] + ldap_mock.expect :bind, false + ldap_mock.expect :get_operation_result, operation_result + + Net::LDAP.stub :new, ldap_mock do + LdapAdapter.stub :config, mock_config do + result = LdapAdapter.valid_credentials?('testuser', 'password123') + assert_not result, "Expected valid_credentials? to return false when admin bind fails" + end + end + + ldap_mock.verify + end + + test "valid_credentials? returns false when user DN is not found" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + admin_user: 'cn=admin,dc=test,dc=com', + admin_password: 'admin_password' + } + + ldap_mock = Minitest::Mock.new + + # Expect admin bind to succeed + ldap_mock.expect :auth, nil, ['cn=admin,dc=test,dc=com', 'admin_password'] + ldap_mock.expect :bind, true + + # Expect user search to return no results + ldap_mock.expect :search, nil do |args| + args.is_a?(Hash) + end + + Net::LDAP.stub :new, ldap_mock do + LdapAdapter.stub :config, mock_config do + result = LdapAdapter.valid_credentials?('nonexistent', 'password123') + assert_not result, "Expected valid_credentials? to return false when user is not found" + end + end + + ldap_mock.verify + end + + test "valid_credentials? returns false when user authentication fails" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + admin_user: 'cn=admin,dc=test,dc=com', + admin_password: 'admin_password' + } + + ldap_mock = Minitest::Mock.new + search_result = [OpenStruct.new(dn: 'cn=testuser,ou=people,dc=test,dc=com')] + operation_result = OpenStruct.new(message: 'Invalid credentials') + + # Expect admin bind + ldap_mock.expect :auth, nil, ['cn=admin,dc=test,dc=com', 'admin_password'] + ldap_mock.expect :bind, true + + # Expect user search + ldap_mock.expect :search, search_result do |args| + args.is_a?(Hash) + end + + # Expect user authentication to fail + ldap_mock.expect :auth, nil, ['cn=testuser,ou=people,dc=test,dc=com', 'wrongpassword'] + ldap_mock.expect :bind, false + ldap_mock.expect :get_operation_result, operation_result + + Net::LDAP.stub :new, ldap_mock do + LdapAdapter.stub :config, mock_config do + result = LdapAdapter.valid_credentials?('testuser', 'wrongpassword') + assert_not result, "Expected valid_credentials? to return false with wrong password" + end + end + + ldap_mock.verify + end + + test "valid_credentials? works with anonymous bind when admin credentials not configured" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + admin_user: nil, + admin_password: nil + } + + ldap_mock = Minitest::Mock.new + search_result = [OpenStruct.new(dn: 'cn=testuser,ou=people,dc=test,dc=com')] + + # No admin bind expected, just user search + ldap_mock.expect :search, search_result do |args| + args.is_a?(Hash) + end + + # Expect user authentication + ldap_mock.expect :auth, nil, ['cn=testuser,ou=people,dc=test,dc=com', 'password123'] + ldap_mock.expect :bind, true + + Net::LDAP.stub :new, ldap_mock do + LdapAdapter.stub :config, mock_config do + result = LdapAdapter.valid_credentials?('testuser', 'password123') + assert result, "Expected valid_credentials? to work with anonymous bind" + end + end + + ldap_mock.verify + end + + # Tests for get_ldap_param + + test "get_ldap_param returns nil when login is blank" do + result = LdapAdapter.get_ldap_param('', 'mail') + assert_nil result, "Expected get_ldap_param to return nil for blank login" + end + + test "get_ldap_param returns nil when param is blank" do + result = LdapAdapter.get_ldap_param('testuser', '') + assert_nil result, "Expected get_ldap_param to return nil for blank param" + end + + test "get_ldap_param returns nil when LDAP host is not configured" do + LdapAdapter.stub(:config, { host: nil }) do + result = LdapAdapter.get_ldap_param('testuser', 'mail') + assert_nil result, "Expected get_ldap_param to return nil when host is not configured" + end + end + + test "get_ldap_param returns attribute value when found" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + admin_user: 'cn=admin,dc=test,dc=com', + admin_password: 'admin_password' + } + + ldap_mock = Minitest::Mock.new + search_result = [OpenStruct.new(mail: ['user@test.com'])] + + # Expect admin bind + ldap_mock.expect :auth, nil, ['cn=admin,dc=test,dc=com', 'admin_password'] + ldap_mock.expect :bind, true + + # Expect search for parameter + ldap_mock.expect :search, search_result do |args| + args.is_a?(Hash) + end + + Net::LDAP.stub :new, ldap_mock do + LdapAdapter.stub :config, mock_config do + result = LdapAdapter.get_ldap_param('testuser', 'mail') + assert_equal ['user@test.com'], result, "Expected to retrieve mail attribute" + end + end + + ldap_mock.verify + end + + test "get_ldap_param returns nil when admin bind fails" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + admin_user: 'cn=admin,dc=test,dc=com', + admin_password: 'admin_password' + } + + ldap_mock = Minitest::Mock.new + operation_result = OpenStruct.new(message: 'Invalid credentials') + + # Expect admin bind to fail + ldap_mock.expect :auth, nil, ['cn=admin,dc=test,dc=com', 'admin_password'] + ldap_mock.expect :bind, false + ldap_mock.expect :get_operation_result, operation_result + + Net::LDAP.stub :new, ldap_mock do + LdapAdapter.stub :config, mock_config do + result = LdapAdapter.get_ldap_param('testuser', 'mail') + assert_nil result, "Expected get_ldap_param to return nil when admin bind fails" + end + end + + ldap_mock.verify + end + + test "get_ldap_param returns nil when user is not found" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + admin_user: 'cn=admin,dc=test,dc=com', + admin_password: 'admin_password' + } + + ldap_mock = Minitest::Mock.new + + # Expect admin bind + ldap_mock.expect :auth, nil, ['cn=admin,dc=test,dc=com', 'admin_password'] + ldap_mock.expect :bind, true + + # Expect search to return no results + ldap_mock.expect :search, nil do |args| + args.is_a?(Hash) + end + + Net::LDAP.stub :new, ldap_mock do + LdapAdapter.stub :config, mock_config do + result = LdapAdapter.get_ldap_param('nonexistent', 'mail') + assert_nil result, "Expected get_ldap_param to return nil when user not found" + end + end + + ldap_mock.verify + end + + test "get_ldap_param returns nil when user exists but attribute is not present" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + admin_user: 'cn=admin,dc=test,dc=com', + admin_password: 'admin_password' + } + + ldap_mock = Minitest::Mock.new + search_result = [OpenStruct.new(cn: ['testuser'])] # Has cn but not mail + + # Expect admin bind + ldap_mock.expect :auth, nil, ['cn=admin,dc=test,dc=com', 'admin_password'] + ldap_mock.expect :bind, true + + # Expect search + ldap_mock.expect :search, search_result do |args| + args.is_a?(Hash) + end + + Net::LDAP.stub :new, ldap_mock do + LdapAdapter.stub :config, mock_config do + result = LdapAdapter.get_ldap_param('testuser', 'mail') + assert_nil result, "Expected get_ldap_param to return nil when attribute not present" + end + end + + ldap_mock.verify + end + + # Tests for config method + + test "config loads and parses LDAP configuration from ldap.yml" do + config = LdapAdapter.config + + assert config.is_a?(Hash), "Expected config to return a Hash" + assert config.key?(:host), "Expected config to have :host key" + end + + test "config returns symbolized keys" do + config = LdapAdapter.config + + # All keys should be symbols + config.each_key do |key| + assert key.is_a?(Symbol), "Expected all config keys to be symbols, got #{key.class} for #{key}" + end + end + + test "config caches the result" do + config1 = LdapAdapter.config + config2 = LdapAdapter.config + + assert_same config1, config2, "Expected config to cache the result" + end + + test "reset_config! clears the cached configuration" do + config1 = LdapAdapter.config + LdapAdapter.reset_config! + config2 = LdapAdapter.config + + assert_not_same config1, config2, "Expected reset_config! to clear the cache" + end + + # Tests for SSL configuration + + test "creates LDAP connection with simple_tls encryption" do + mock_config = { + host: 'ldap.test.com', + port: 636, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + ssl: 'simple_tls' + } + + connection_created = false + Net::LDAP.stub :new, ->(options) { + connection_created = true + assert_equal 'ldap.test.com', options[:host] + assert_equal 636, options[:port] + assert_equal :simple_tls, options.dig(:encryption, :method), "Expected SSL method to be :simple_tls" + Minitest::Mock.new + } do + LdapAdapter.stub :config, mock_config do + # Use get_ldap_param which will create a connection + LdapAdapter.get_ldap_param('testuser', 'mail') + end + end + + assert connection_created, "Expected LDAP connection to be created" + end + + test "creates LDAP connection with start_tls encryption" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + ssl: 'start_tls' + } + + connection_created = false + Net::LDAP.stub :new, ->(options) { + connection_created = true + assert_equal :start_tls, options.dig(:encryption, :method), "Expected SSL method to be :start_tls" + Minitest::Mock.new + } do + LdapAdapter.stub :config, mock_config do + LdapAdapter.get_ldap_param('testuser', 'mail') + end + end + + assert connection_created, "Expected LDAP connection to be created" + end + + test "creates LDAP connection without encryption when ssl is false" do + mock_config = { + host: 'ldap.test.com', + port: 389, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn', + ssl: false + } + + connection_created = false + Net::LDAP.stub :new, ->(options) { + connection_created = true + assert_nil options[:encryption], "Expected no encryption when ssl is false" + Minitest::Mock.new + } do + LdapAdapter.stub :config, mock_config do + LdapAdapter.get_ldap_param('testuser', 'mail') + end + end + + assert connection_created, "Expected LDAP connection to be created" + end + + test "uses default port 389 when port is not configured" do + mock_config = { + host: 'ldap.test.com', + port: nil, + base: 'ou=people,dc=test,dc=com', + attribute: 'cn' + } + + connection_created = false + Net::LDAP.stub :new, ->(options) { + connection_created = true + assert_equal 389, options[:port], "Expected default port 389" + Minitest::Mock.new + } do + LdapAdapter.stub :config, mock_config do + LdapAdapter.get_ldap_param('testuser', 'mail') + end + end + + assert connection_created, "Expected LDAP connection to be created" + end +end