diff --git a/app/services/dns_validator.rb b/app/services/dns_validator.rb new file mode 100644 index 000000000..1752bf816 --- /dev/null +++ b/app/services/dns_validator.rb @@ -0,0 +1,644 @@ +require 'resolv' +require 'dnsruby' + +class DNSValidator + include Dnsruby + + attr_reader :domain, :results + + def initialize(domain) + @domain = domain + @results = { + nameservers: {}, + dns_records: {}, + dnssec: {}, + csync: {}, + errors: [], + warnings: [] + } + end + + def self.validate(domain) + new(domain).validate + end + + def validate + Rails.logger.info "Starting DNS validation for domain: #{domain.name}" + + validate_nameservers + validate_dns_records + check_dnssec_sync_records + check_csync_records + apply_enforcement_actions + + Rails.logger.info "DNS validation completed for domain: #{domain.name}" + @results + rescue StandardError => e + Rails.logger.error "DNS validation failed for #{domain.name}: #{e.message}" + @results[:errors] << "Validation failed: #{e.message}" + @results + end + + private + + def validate_nameservers + Rails.logger.info "Validating nameservers for domain: #{domain.name}" + + domain.nameservers.each do |nameserver| + result = validate_single_nameserver(nameserver) + @results[:nameservers][nameserver.hostname] = result + + if result[:valid] + # Update nameserver validation status + nameserver.update_columns( + validation_datetime: Time.current, + validation_counter: 0, + failed_validation_reason: nil + ) + else + @results[:errors] << "Nameserver #{nameserver.hostname} failed validation: #{result[:reason]}" + + # Update failure counter + counter = (nameserver.validation_counter || 0) + 1 + nameserver.update_columns( + validation_datetime: Time.current, + validation_counter: counter, + failed_validation_reason: result[:reason] + ) + end + end + end + + def validate_single_nameserver(nameserver) + result = { + hostname: nameserver.hostname, + valid: false, + authoritative: false, + ns_records: [], + reason: nil + } + + begin + resolver = create_resolver(nameserver.hostname) + + # Query SOA to check if nameserver is authoritative for this domain + soa_response = resolver.query(domain.name, 'SOA') + + if soa_response.answer.empty? + result[:reason] = 'No SOA record found' + return result + end + + # Check for CNAME at domain apex (invalid) + if soa_response.answer.any? { |a| a.type == 'CNAME' } + result[:reason] = 'Domain has CNAME record at apex (invalid)' + return result + end + + # Check SOA record + soa_record = soa_response.answer.find { |a| a.type == 'SOA' } + if soa_record + result[:authoritative] = true + end + + # Query NS records to verify this nameserver is listed + ns_response = resolver.query(domain.name, 'NS') + result[:ns_records] = ns_response.answer.map { |a| a.nsdname.to_s if a.type == 'NS' }.compact + + # Check if this nameserver is in the NS records + if result[:ns_records].any? { |ns| ns.downcase == nameserver.hostname.downcase } + result[:valid] = true + else + result[:reason] = 'Nameserver not listed in zone NS records' + end + + rescue Dnsruby::NXDomain + result[:reason] = 'Domain not found' + rescue Dnsruby::Refused + result[:reason] = 'Query refused' + rescue StandardError => e + result[:reason] = "Query failed: #{e.message}" + end + + result + end + + # Story 2: Resolve and Validate DNS Records + def validate_dns_records + Rails.logger.info "Validating DNS records for domain: #{domain.name}" + + @results[:dns_records] = { + a_records: [], + aaaa_records: [], + cname_records: [] + } + + # Only check records from valid nameservers + valid_nameservers = domain.nameservers.select do |ns| + @results[:nameservers][ns.hostname]&.dig(:valid) + end + + if valid_nameservers.empty? + @results[:warnings] << "No valid nameservers found for DNS record validation" + return + end + + valid_nameservers.each do |nameserver| + validate_records_from_nameserver(nameserver) + end + + # Check for glue records if needed + validate_glue_records + end + + def validate_records_from_nameserver(nameserver) + resolver = create_resolver(nameserver.hostname) + + # Validate A records + begin + a_response = resolver.query(domain.name, 'A') + a_response.answer.each do |record| + next unless record.type == 'A' + @results[:dns_records][:a_records] << { + address: record.address.to_s, + ttl: record.ttl, + nameserver: nameserver.hostname + } + end + rescue Dnsruby::NXDomain + # No A records + rescue StandardError => e + @results[:warnings] << "Failed to query A records from #{nameserver.hostname}: #{e.message}" + end + + # Validate AAAA records + begin + aaaa_response = resolver.query(domain.name, 'AAAA') + aaaa_response.answer.each do |record| + next unless record.type == 'AAAA' + @results[:dns_records][:aaaa_records] << { + address: record.address.to_s, + ttl: record.ttl, + nameserver: nameserver.hostname + } + end + rescue Dnsruby::NXDomain + # No AAAA records + rescue StandardError => e + @results[:warnings] << "Failed to query AAAA records from #{nameserver.hostname}: #{e.message}" + end + + # Check for CNAME records (should not exist at apex) + begin + cname_response = resolver.query(domain.name, 'CNAME') + cname_response.answer.each do |record| + next unless record.type == 'CNAME' + @results[:dns_records][:cname_records] << { + target: record.cname.to_s, + ttl: record.ttl, + nameserver: nameserver.hostname + } + @results[:errors] << "CNAME record found at domain apex (invalid DNS configuration)" + end + rescue Dnsruby::NXDomain + # No CNAME records (good) + rescue StandardError => e + @results[:warnings] << "Failed to query CNAME records from #{nameserver.hostname}: #{e.message}" + end + end + + def validate_glue_records + domain.nameservers.each do |nameserver| + # Check if nameserver is in-bailiwick (subdomain of the domain) + next unless nameserver.hostname.end_with?(".#{domain.name}") + + # Validate IPv4 glue records + nameserver.ipv4.each do |ip| + if valid_ipv4?(ip) + @results[:dns_records][:a_records] << { + address: ip, + ttl: 0, + nameserver: 'glue', + type: 'glue_record' + } + else + @results[:errors] << "Invalid IPv4 glue record for #{nameserver.hostname}: #{ip}" + end + end + + # Validate IPv6 glue records + nameserver.ipv6.each do |ip| + if valid_ipv6?(ip) + @results[:dns_records][:aaaa_records] << { + address: ip, + ttl: 0, + nameserver: 'glue', + type: 'glue_record' + } + else + @results[:errors] << "Invalid IPv6 glue record for #{nameserver.hostname}: #{ip}" + end + end + end + end + + # Story 4: Check CDS/CDNSKEY records for DNSSEC synchronization + def check_dnssec_sync_records + Rails.logger.info "Checking DNSSEC synchronization records for domain: #{domain.name}" + + @results[:dnssec] = { + cds_records: [], + cdnskey_records: [], + ds_updates_needed: [] + } + + return unless domain.dnskeys.any? # Only check if DNSSEC is enabled + + domain.nameservers.each do |nameserver| + check_cds_records(nameserver) + check_cdnskey_records(nameserver) + end + end + + def check_cds_records(nameserver) + begin + resolver = create_resolver(nameserver.hostname) + response = resolver.query(domain.name, 'CDS') + + response.answer.each do |record| + next unless record.type == 'CDS' + + cds_data = { + key_tag: record.key_tag, + algorithm: record.algorithm, + digest_type: record.digest_type, + digest: record.digest, + nameserver: nameserver.hostname + } + + @results[:dnssec][:cds_records] << cds_data + + # Check if DS record update is needed + if record.algorithm == 0 + @results[:dnssec][:ds_updates_needed] << { + action: 'remove_ds', + reason: 'CDS record with algorithm 0 indicates DS removal' + } + else + # Check if we need to update DS records + existing_ds = domain.dnskeys.find_by(ds_key_tag: record.key_tag) + if !existing_ds || existing_ds.ds_digest != record.digest + @results[:dnssec][:ds_updates_needed] << { + action: 'update_ds', + cds_data: cds_data, + reason: 'CDS record indicates DS record update needed' + } + end + end + end + rescue Dnsruby::NXDomain + # No CDS records + rescue StandardError => e + @results[:warnings] << "Failed to query CDS records from #{nameserver.hostname}: #{e.message}" + end + end + + def check_cdnskey_records(nameserver) + begin + resolver = create_resolver(nameserver.hostname) + response = resolver.query(domain.name, 'CDNSKEY') + + response.answer.each do |record| + next unless record.type == 'CDNSKEY' + + cdnskey_data = { + flags: record.flags, + protocol: record.protocol, + algorithm: record.algorithm, + public_key: Base64.strict_encode64(record.key), + nameserver: nameserver.hostname + } + + @results[:dnssec][:cdnskey_records] << cdnskey_data + + # Check if this DNSKEY exists in our database + existing_key = domain.dnskeys.find_by( + flags: record.flags, + protocol: record.protocol, + alg: record.algorithm, + public_key: cdnskey_data[:public_key] + ) + + unless existing_key + @results[:dnssec][:ds_updates_needed] << { + action: 'add_dnskey', + cdnskey_data: cdnskey_data, + reason: 'CDNSKEY record indicates new DNSKEY should be added' + } + end + end + rescue Dnsruby::NXDomain + # No CDNSKEY records + rescue StandardError => e + @results[:warnings] << "Failed to query CDNSKEY records from #{nameserver.hostname}: #{e.message}" + end + end + + # Story 5: Check CSYNC records for delegation synchronization + def check_csync_records + Rails.logger.info "Checking CSYNC records for domain: #{domain.name}" + + @results[:csync] = { + csync_records: [], + delegation_updates_needed: [] + } + + domain.nameservers.each do |nameserver| + check_single_csync_record(nameserver) + end + end + + def check_single_csync_record(nameserver) + begin + resolver = create_resolver(nameserver.hostname) + response = resolver.query(domain.name, 'CSYNC') + + response.answer.each do |record| + next unless record.type == 'CSYNC' + + csync_data = { + serial: record.serial, + flags: record.flags, + type_bitmap: parse_type_bitmap(record.types), + nameserver: nameserver.hostname + } + + @results[:csync][:csync_records] << csync_data + + # Check what needs to be synchronized + if csync_data[:type_bitmap].include?('NS') + check_ns_sync_needed(nameserver) + end + + if csync_data[:type_bitmap].include?('A') + check_a_sync_needed(nameserver) + end + + if csync_data[:type_bitmap].include?('AAAA') + check_aaaa_sync_needed(nameserver) + end + end + rescue Dnsruby::NXDomain + # No CSYNC records + rescue StandardError => e + @results[:warnings] << "Failed to query CSYNC records from #{nameserver.hostname}: #{e.message}" + end + end + + def check_ns_sync_needed(nameserver) + # Get NS records from child zone + child_ns_records = @results[:nameservers].values + .select { |ns| ns[:valid] } + .flat_map { |ns| ns[:ns_records] } + .uniq + + # Compare with current nameservers + current_ns = domain.nameservers.map(&:hostname) + + to_add = child_ns_records - current_ns + to_remove = current_ns - child_ns_records + + if to_add.any? || to_remove.any? + @results[:csync][:delegation_updates_needed] << { + type: 'ns_records', + add: to_add, + remove: to_remove, + reason: 'CSYNC indicates NS record synchronization needed' + } + end + end + + def check_a_sync_needed(nameserver) + # Check A record synchronization for in-bailiwick nameservers + domain.nameservers.each do |ns| + next unless ns.hostname.end_with?(".#{domain.name}") + + # Query A records from child zone + child_a_records = query_a_records_for_host(ns.hostname) + + if child_a_records != ns.ipv4 + @results[:csync][:delegation_updates_needed] << { + type: 'a_records', + hostname: ns.hostname, + current_ips: ns.ipv4, + child_ips: child_a_records, + reason: 'CSYNC indicates A record glue synchronization needed' + } + end + end + end + + def check_aaaa_sync_needed(nameserver) + # Check AAAA record synchronization for in-bailiwick nameservers + domain.nameservers.each do |ns| + next unless ns.hostname.end_with?(".#{domain.name}") + + # Query AAAA records from child zone + child_aaaa_records = query_aaaa_records_for_host(ns.hostname) + + if child_aaaa_records != ns.ipv6 + @results[:csync][:delegation_updates_needed] << { + type: 'aaaa_records', + hostname: ns.hostname, + current_ips: ns.ipv6, + child_ips: child_aaaa_records, + reason: 'CSYNC indicates AAAA record glue synchronization needed' + } + end + end + end + + # Story 6: Apply enforcement actions based on validation results + def apply_enforcement_actions + Rails.logger.info "Applying enforcement actions for domain: #{domain.name}" + + # Handle failed nameservers + domain.nameservers.each do |nameserver| + if nameserver.validation_counter && nameserver.validation_counter >= 3 + @results[:warnings] << "Nameserver #{nameserver.hostname} has failed validation #{nameserver.validation_counter} times" + + # Auto-remove if configured and we have enough nameservers + if should_auto_remove_nameserver? && domain.nameservers.count > 2 + Rails.logger.info "Auto-removing failed nameserver: #{nameserver.hostname}" + + # Notify registrar + create_notification( + "Nameserver #{nameserver.hostname} was automatically removed from domain #{domain.name} due to repeated validation failures" + ) + + nameserver.destroy + @results[:warnings] << "Automatically removed nameserver #{nameserver.hostname}" + end + end + end + + # Apply DNSSEC updates if needed + @results[:dnssec][:ds_updates_needed].each do |update| + case update[:action] + when 'update_ds' + update_ds_record(update[:cds_data]) + when 'remove_ds' + remove_ds_records + when 'add_dnskey' + add_dnskey(update[:cdnskey_data]) + end + end + + # Apply delegation updates if needed + @results[:csync][:delegation_updates_needed].each do |update| + case update[:type] + when 'ns_records' + update_ns_records(update) + when 'a_records', 'aaaa_records' + update_glue_records(update) + end + end + + # Send notifications if there are errors + if @results[:errors].any? + create_notification( + "DNS validation errors found for domain #{domain.name}: #{@results[:errors].join(', ')}" + ) + end + end + + # Helper methods + def create_resolver(nameserver_ip) + resolver = Dnsruby::Resolver.new + resolver.nameserver = nameserver_ip + resolver.query_timeout = 5 + resolver.retry_times = 2 + resolver.recurse = 0 # Non-recursive queries + resolver.do_caching = false + resolver + end + + def valid_ipv4?(ip) + ip.match?(Nameserver::IPV4_REGEXP) + end + + def valid_ipv6?(ip) + ip.match?(Nameserver::IPV6_REGEXP) + end + + def parse_type_bitmap(types) + types.is_a?(Array) ? types : [types].compact + end + + def query_a_records_for_host(hostname) + resolver = Dnsruby::Resolver.new + response = resolver.query(hostname, 'A') + response.answer.select { |r| r.type == 'A' }.map { |r| r.address.to_s } + rescue StandardError + [] + end + + def query_aaaa_records_for_host(hostname) + resolver = Dnsruby::Resolver.new + response = resolver.query(hostname, 'AAAA') + response.answer.select { |r| r.type == 'AAAA' }.map { |r| r.address.to_s } + rescue StandardError + [] + end + + def should_auto_remove_nameserver? + # Check if auto-removal is enabled (you can add this setting) + false # Disabled by default for safety + end + + def create_notification(text) + begin + domain.registrar.notifications.create!( + text: text, + attached_obj_type: domain.class.to_s, + attached_obj_id: domain.id + ) + rescue StandardError => e + Rails.logger.warn "Failed to create notification: #{e.message}" + end + end + + def update_ds_record(cds_data) + dnskey = domain.dnskeys.find_or_initialize_by(ds_key_tag: cds_data[:key_tag]) + dnskey.ds_alg = cds_data[:algorithm] + dnskey.ds_digest_type = cds_data[:digest_type] + dnskey.ds_digest = cds_data[:digest] + + if dnskey.save + Rails.logger.info "Updated DS record for #{domain.name}" + else + Rails.logger.error "Failed to update DS record: #{dnskey.errors.full_messages.join(', ')}" + end + end + + def remove_ds_records + domain.dnskeys.update_all( + ds_digest: nil, + ds_alg: nil, + ds_digest_type: nil, + ds_key_tag: nil + ) + Rails.logger.info "Removed DS records for #{domain.name}" + end + + def add_dnskey(cdnskey_data) + dnskey = domain.dnskeys.build( + flags: cdnskey_data[:flags], + protocol: cdnskey_data[:protocol], + alg: cdnskey_data[:algorithm], + public_key: cdnskey_data[:public_key] + ) + + if dnskey.save + Rails.logger.info "Added DNSKEY for #{domain.name}" + else + Rails.logger.error "Failed to add DNSKEY: #{dnskey.errors.full_messages.join(', ')}" + end + end + + def update_ns_records(update) + # Add new nameservers + update[:add].each do |hostname| + domain.nameservers.find_or_create_by(hostname: hostname) + end + + # Remove old nameservers + update[:remove].each do |hostname| + domain.nameservers.where(hostname: hostname).destroy_all + end + + Rails.logger.info "Updated nameservers for #{domain.name}" + end + + def update_glue_records(update) + nameserver = domain.nameservers.find_by(hostname: update[:hostname]) + return unless nameserver + + if update[:type] == 'a_records' + nameserver.ipv4 = update[:child_ips] + else + nameserver.ipv6 = update[:child_ips] + end + + nameserver.save + Rails.logger.info "Updated glue records for #{nameserver.hostname}" + end + + # Class methods for easy usage + class << self + def validate_domain(domain) + validator = new(domain) + validator.validate + end + end +end \ No newline at end of file diff --git a/test/services/dns_validator_test.rb b/test/services/dns_validator_test.rb new file mode 100644 index 000000000..dec46c7b8 --- /dev/null +++ b/test/services/dns_validator_test.rb @@ -0,0 +1,474 @@ +require 'test_helper' + +class DNSValidatorTest < ActiveSupport::TestCase + + setup do + @domain = domains(:shop) + @nameserver1 = nameservers(:shop_ns1) + @nameserver2 = nameservers(:shop_ns2) + @dnskey = dnskeys(:one) + + # Ensure domain has fresh timestamps for validation + @domain.update(created_at: 1.day.ago) + @domain.reload + + # Associate dnskey with domain for DNSSEC tests + @dnskey.update(domain: @domain) if @dnskey + end + + # Basic functionality tests + + test 'initializes with correct structure' do + validator = DNSValidator.new(@domain) + + assert_equal @domain, validator.domain + assert_instance_of Hash, validator.results + + expected_keys = [:nameservers, :dns_records, :dnssec, :csync, :errors, :warnings] + expected_keys.each do |key| + assert_includes validator.results.keys, key + end + end + + test 'class method validate creates instance' do + validator = DNSValidator.new(@domain) + + # Mock validate method to avoid DNS calls + validator.define_singleton_method(:validate) { { test: 'result' } } + + # Temporarily override new method + original_new = DNSValidator.method(:new) + DNSValidator.define_singleton_method(:new) { |domain| validator } + + result = DNSValidator.validate(@domain) + + assert_equal({ test: 'result' }, result) + ensure + # Restore original new method + DNSValidator.define_singleton_method(:new, original_new) + end + + # Story 1: Nameserver Validation Tests + + test 'validates nameservers successfully' do + validator = DNSValidator.new(@domain) + + # Mock successful nameserver validation + validator.define_singleton_method(:validate_single_nameserver) do |ns| + { valid: true, authoritative: true, ns_records: [ns.hostname], reason: nil } + end + + validator.send(:validate_nameservers) + + @nameserver1.reload + assert_not_nil @nameserver1.validation_datetime + assert_equal 0, @nameserver1.validation_counter + assert_nil @nameserver1.failed_validation_reason + + assert validator.results[:nameservers][@nameserver1.hostname][:valid] + assert_empty validator.results[:errors] + end + + test 'handles failed nameserver validation' do + validator = DNSValidator.new(@domain) + + # Mock failed nameserver validation + validator.define_singleton_method(:validate_single_nameserver) do |ns| + { valid: false, authoritative: false, ns_records: [], reason: 'No SOA record found' } + end + + validator.send(:validate_nameservers) + + @nameserver1.reload + assert_not_nil @nameserver1.validation_datetime + assert_equal 1, @nameserver1.validation_counter + assert_equal 'No SOA record found', @nameserver1.failed_validation_reason + + assert_not validator.results[:nameservers][@nameserver1.hostname][:valid] + assert_includes validator.results[:errors], "Nameserver #{@nameserver1.hostname} failed validation: No SOA record found" + end + + test 'validates single nameserver with mocked DNS' do + validator = DNSValidator.new(@domain) + + # Create mock resolver + resolver = create_mock_resolver + + # Create references to helper methods in test context + test_context = self + nameserver_hostname = @nameserver1.hostname + + # Mock SOA response (authoritative) + soa_response = create_mock_dns_response([create_mock_soa_record]) + ns_response = create_mock_dns_response([create_mock_ns_record(nameserver_hostname)]) + + resolver.define_singleton_method(:query) do |domain, type| + if type == 'SOA' + soa_response + elsif type == 'NS' + ns_response + else + test_context.create_mock_dns_response([]) + end + end + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + + result = validator.send(:validate_single_nameserver, @nameserver1) + + assert result[:valid] + assert result[:authoritative] + assert_includes result[:ns_records], @nameserver1.hostname.downcase + assert_nil result[:reason] + end + + test 'detects nameserver not in NS records' do + validator = DNSValidator.new(@domain) + resolver = create_mock_resolver + + # Pre-create responses + test_context = self + soa_response = create_mock_dns_response([create_mock_soa_record]) + ns_response = create_mock_dns_response([create_mock_ns_record('other.nameserver.com')]) + + resolver.define_singleton_method(:query) do |domain, type| + if type == 'SOA' + soa_response + elsif type == 'NS' + ns_response + else + test_context.create_mock_dns_response([]) + end + end + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + + result = validator.send(:validate_single_nameserver, @nameserver1) + + assert_not result[:valid] + assert_equal 'Nameserver not listed in zone NS records', result[:reason] + end + + test 'detects CNAME at apex' do + validator = DNSValidator.new(@domain) + resolver = create_mock_resolver + + # Pre-create CNAME response + test_context = self + cname_response = create_mock_dns_response([create_mock_cname_record('example.com')]) + + # Mock CNAME response for SOA query + resolver.define_singleton_method(:query) do |domain, type| + cname_response + end + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + + result = validator.send(:validate_single_nameserver, @nameserver1) + + assert_not result[:valid] + assert_equal 'Domain has CNAME record at apex (invalid)', result[:reason] + end + + # Story 2: DNS Records Validation Tests + + test 'validates DNS records from valid nameservers' do + validator = DNSValidator.new(@domain) + + # Set up nameserver validation results + validator.instance_variable_set(:@results, { + nameservers: { @nameserver1.hostname => { valid: true } }, + dns_records: { a_records: [], aaaa_records: [], cname_records: [] }, + errors: [], + warnings: [] + }) + + # Pre-create responses + test_context = self + a_response = create_mock_dns_response([create_mock_a_record('192.0.2.1')]) + aaaa_response = create_mock_dns_response([create_mock_aaaa_record('2001:db8::1')]) + empty_response = create_mock_dns_response([]) + + resolver = create_mock_resolver + resolver.define_singleton_method(:query) do |domain, type| + case type + when 'A' + a_response + when 'AAAA' + aaaa_response + when 'CNAME' + empty_response + else + empty_response + end + end + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + + validator.send(:validate_dns_records) + + assert_not_empty validator.results[:dns_records][:a_records] + assert_not_empty validator.results[:dns_records][:aaaa_records] + + a_record = validator.results[:dns_records][:a_records].first + assert_equal '192.0.2.1', a_record[:address] + assert_equal @nameserver1.hostname, a_record[:nameserver] + end + + test 'skips DNS validation when no valid nameservers' do + validator = DNSValidator.new(@domain) + + # Set up scenario with no valid nameservers + validator.instance_variable_set(:@results, { + nameservers: { @nameserver1.hostname => { valid: false } }, + dns_records: { a_records: [], aaaa_records: [], cname_records: [] }, + errors: [], + warnings: [] + }) + + validator.send(:validate_dns_records) + + assert_includes validator.results[:warnings], 'No valid nameservers found for DNS record validation' + assert_empty validator.results[:dns_records][:a_records] + end + + # Story 4: DNSSEC Tests + + test 'processes CDS records for DNSSEC sync' do + validator = DNSValidator.new(@domain) + resolver = create_mock_resolver + + # Pre-create responses + test_context = self + cds_response = create_mock_dns_response([create_mock_cds_record(12345, 7, 1, 'ABC123')]) + empty_response = create_mock_dns_response([]) + + resolver.define_singleton_method(:query) do |domain, type| + case type + when 'CDS' + cds_response + when 'CDNSKEY' + empty_response + else + empty_response + end + end + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + + validator.send(:check_dnssec_sync_records) + + assert_not_empty validator.results[:dnssec][:cds_records] + + cds_record = validator.results[:dnssec][:cds_records].first + assert_equal 12345, cds_record[:key_tag] + assert_equal 7, cds_record[:algorithm] + end + + # Story 6: Enforcement Actions Tests + + test 'removes failed nameservers after threshold' do + @nameserver1.update!(validation_counter: 3, failed_validation_reason: 'Failed validation') + + # Ensure domain has enough nameservers + @domain.nameservers.create!(hostname: 'backup.ns.com') + + validator = DNSValidator.new(@domain) + + # Initialize complete results structure + validator.instance_variable_set(:@results, { + nameservers: {}, + dns_records: {}, + dnssec: { ds_updates_needed: [] }, + csync: { delegation_updates_needed: [] }, + errors: [], + warnings: [] + }) + + validator.define_singleton_method(:should_auto_remove_nameserver?) { true } + validator.define_singleton_method(:create_notification) { |text| nil } + + initial_count = @domain.nameservers.count + validator.send(:apply_enforcement_actions) + + assert_equal initial_count - 1, @domain.nameservers.count + assert_not @domain.nameservers.exists?(@nameserver1.id) + assert_includes validator.results[:warnings], "Automatically removed nameserver #{@nameserver1.hostname}" + end + + test 'does not remove nameserver if insufficient nameservers' do + @nameserver1.update!(validation_counter: 3, failed_validation_reason: 'Failed validation') + @nameserver2.destroy # Only one nameserver left + + validator = DNSValidator.new(@domain) + + validator.instance_variable_set(:@results, { + nameservers: {}, + dns_records: {}, + dnssec: { ds_updates_needed: [] }, + csync: { delegation_updates_needed: [] }, + errors: [], + warnings: [] + }) + + validator.define_singleton_method(:should_auto_remove_nameserver?) { true } + + initial_count = @domain.nameservers.count + validator.send(:apply_enforcement_actions) + + assert_equal initial_count, @domain.nameservers.count + assert @domain.nameservers.exists?(@nameserver1.id) + end + + # Integration Tests + + test 'full validation workflow' do + validator = DNSValidator.new(@domain) + + # Mock all validation methods to avoid DNS calls + validator.define_singleton_method(:validate_nameservers) { nil } + validator.define_singleton_method(:validate_dns_records) { nil } + validator.define_singleton_method(:check_dnssec_sync_records) { nil } + validator.define_singleton_method(:check_csync_records) { nil } + validator.define_singleton_method(:apply_enforcement_actions) { nil } + + results = validator.validate + + assert_instance_of Hash, results + assert_includes results.keys, :nameservers + assert_includes results.keys, :dns_records + assert_includes results.keys, :dnssec + assert_includes results.keys, :csync + end + + test 'handles validation exceptions gracefully' do + validator = DNSValidator.new(@domain) + + validator.define_singleton_method(:validate_nameservers) do + raise StandardError.new('DNS timeout') + end + + results = validator.validate + + assert_includes results[:errors], 'Validation failed: DNS timeout' + end + + # Helper method tests + + test 'helper methods work correctly' do + validator = DNSValidator.new(@domain) + + # Test parse_type_bitmap + assert_equal ['NS', 'A'], validator.send(:parse_type_bitmap, ['NS', 'A']) + assert_equal ['NS'], validator.send(:parse_type_bitmap, 'NS') + assert_equal [], validator.send(:parse_type_bitmap, nil) + + # Test should_auto_remove_nameserver? (default is false) + assert_not validator.send(:should_auto_remove_nameserver?) + + # Test create_resolver + resolver = validator.send(:create_resolver, '192.0.2.1') + assert_instance_of Dnsruby::Resolver, resolver + end + + test 'handles domains without nameservers' do + domain_without_ns = Domain.new( + name: 'empty.test', + registrar: @domain.registrar, + registrant: @domain.registrant + ) + + validator = DNSValidator.new(domain_without_ns) + + assert_nothing_raised do + validator.send(:validate_nameservers) + end + + assert_empty validator.results[:nameservers] + end + + private + + # Simple mock helpers using basic Ruby objects + + def create_mock_resolver + resolver = Object.new + resolver.define_singleton_method(:nameserver=) { |value| } + resolver.define_singleton_method(:query_timeout=) { |value| } + resolver.define_singleton_method(:retry_times=) { |value| } + resolver.define_singleton_method(:recurse=) { |value| } + resolver.define_singleton_method(:do_caching=) { |value| } + resolver + end + + def create_mock_dns_response(records) + response = Object.new + response.define_singleton_method(:answer) { records } + response + end + + def create_mock_soa_record(serial = 123456) + record = Object.new + record.define_singleton_method(:type) { 'SOA' } + record.define_singleton_method(:serial) { serial } + record.define_singleton_method(:instance_variable_defined?) { |var| var == '@serial' } + record + end + + def create_mock_ns_record(hostname) + record = Object.new + record.define_singleton_method(:type) { 'NS' } + + nsdname = Object.new + nsdname.define_singleton_method(:to_s) { hostname } + record.define_singleton_method(:nsdname) { nsdname } + + record + end + + def create_mock_a_record(address) + record = Object.new + record.define_singleton_method(:type) { 'A' } + record.define_singleton_method(:ttl) { 3600 } + + addr = Object.new + addr.define_singleton_method(:to_s) { address } + record.define_singleton_method(:address) { addr } + + record + end + + def create_mock_aaaa_record(address) + record = Object.new + record.define_singleton_method(:type) { 'AAAA' } + record.define_singleton_method(:ttl) { 3600 } + + addr = Object.new + addr.define_singleton_method(:to_s) { address } + record.define_singleton_method(:address) { addr } + + record + end + + def create_mock_cname_record(target) + record = Object.new + record.define_singleton_method(:type) { 'CNAME' } + record.define_singleton_method(:ttl) { 3600 } + + cname = Object.new + cname.define_singleton_method(:to_s) { target } + record.define_singleton_method(:cname) { cname } + + record + end + + def create_mock_cds_record(key_tag, algorithm, digest_type, digest) + record = Object.new + record.define_singleton_method(:type) { 'CDS' } + record.define_singleton_method(:key_tag) { key_tag } + record.define_singleton_method(:algorithm) { algorithm } + record.define_singleton_method(:digest_type) { digest_type } + record.define_singleton_method(:digest) { digest } + record + end +end \ No newline at end of file