diff --git a/app/interactions/actions/domain_create.rb b/app/interactions/actions/domain_create.rb index bd4f032d23..00af6f8565 100644 --- a/app/interactions/actions/domain_create.rb +++ b/app/interactions/actions/domain_create.rb @@ -18,6 +18,8 @@ def call # domain.attach_default_contacts assign_expiry_time maybe_attach_legal_doc + validate_ns_records + validate_dns_records commit end @@ -181,6 +183,24 @@ def maybe_attach_legal_doc ::Actions::BaseAction.attach_legal_doc_to_new(domain, params[:legal_document], domain: true) end + def validate_ns_records + return unless domain.nameservers.any? + + result = DNSValidator.validate(domain: domain, name: domain.name, record_type: 'NS') + return if result[:errors].blank? + + assign_dns_validation_error(result[:errors]) + end + + def validate_dns_records + return unless domain.dnskeys.any? + + result = DNSValidator.validate(domain: domain, name: domain.name, record_type: 'DNSKEY') + return if result[:errors].blank? + + assign_dns_validation_error(result[:errors]) + end + def process_auction_and_disputes dn = DNS::DomainName.new(domain.name) Dispute.close_by_domain(domain.name) @@ -200,6 +220,12 @@ def commit domain.save end + def assign_dns_validation_error(errors) + errors.each do |error| + domain.add_epp_error('2306', nil, nil, error) + end + end + def validation_process_errored? return if domain.valid? diff --git a/app/interactions/actions/domain_transfer.rb b/app/interactions/actions/domain_transfer.rb index f72f7b5d73..fdb8043622 100644 --- a/app/interactions/actions/domain_transfer.rb +++ b/app/interactions/actions/domain_transfer.rb @@ -17,9 +17,10 @@ def call # return domain.pending_transfer if domain.pending_transfer # attach_legal_document(::Deserializers::Xml::LegalDocument.new(frame).call) - return if domain.errors[:epp_errors].any? + return false if domain.errors[:epp_errors].any? commit + true end def domain_exists? @@ -34,6 +35,8 @@ def run_validations validate_registrar validate_eligilibty validate_not_discarded + validate_ns_records + validate_dns_records end def valid_transfer_code? @@ -62,6 +65,30 @@ def validate_not_discarded domain.add_epp_error('2106', nil, nil, 'Object is not eligible for transfer') end + def validate_ns_records + return unless domain.nameservers.any? + + result = DNSValidator.validate(domain: domain, name: domain.name, record_type: 'NS') + return if result[:errors].blank? + + assign_dns_validation_error(result[:errors]) + end + + def validate_dns_records + return unless domain.dnskeys.any? + + result = DNSValidator.validate(domain: domain, name: domain.name, record_type: 'DNSKEY') + return if result[:errors].blank? + + assign_dns_validation_error(result[:errors]) + end + + def assign_dns_validation_error(errors) + errors.each do |error| + domain.add_epp_error('2306', nil, nil, error) + end + end + def commit bare_domain = Domain.find(domain.id) ::DomainTransfer.request(bare_domain, user) diff --git a/app/interactions/actions/domain_update.rb b/app/interactions/actions/domain_update.rb index 36752b2a0b..282f2c2ac8 100644 --- a/app/interactions/actions/domain_update.rb +++ b/app/interactions/actions/domain_update.rb @@ -13,6 +13,8 @@ def call validate_domain_integrity assign_new_registrant if params[:registrant] assign_relational_modifications + validate_ns_records + validate_dns_records assign_requested_statuses ::Actions::BaseAction.maybe_attach_legal_doc(domain, params[:legal_document]) @@ -97,6 +99,30 @@ def validate_ns_integrity(ns_attr) end end + def validate_ns_records + return unless domain.nameservers.any? + + result = DNSValidator.validate(domain: domain, name: domain.name, record_type: 'NS') + return if result[:errors].blank? + + assign_dns_validation_error(result[:errors]) + end + + def validate_dns_records + return unless domain.dnskeys.any? + + result = DNSValidator.validate(domain: domain, name: domain.name, record_type: 'DNSKEY') + return if result[:errors].blank? + + assign_dns_validation_error(result[:errors]) + end + + def assign_dns_validation_error(errors) + errors.each do |error| + domain.add_epp_error('2306', nil, nil, error) + end + end + def assign_dnssec_modifications @dnskeys = [] params[:dns_keys].each do |key| diff --git a/app/jobs/dns_validation_job.rb b/app/jobs/dns_validation_job.rb new file mode 100644 index 0000000000..13410f3393 --- /dev/null +++ b/app/jobs/dns_validation_job.rb @@ -0,0 +1,8 @@ +class DNSValidationJob < ApplicationJob + queue_as :default + + def perform(domain_id) + domain = Domain.find(domain_id) + DNSValidator.validate(domain: domain, name: domain.name, record_type: 'all') + end +end diff --git a/app/services/dns_validator.rb b/app/services/dns_validator.rb new file mode 100644 index 0000000000..9ae861bc32 --- /dev/null +++ b/app/services/dns_validator.rb @@ -0,0 +1,911 @@ +require 'resolv' +require 'dnsruby' + +class DNSValidator + include Dnsruby + + attr_reader :domain, :results, :record_type, :apply_changes + + def initialize(domain:, name:, record_type:, apply_changes: true) + @domain = domain.present? ? domain : Domain.find_by_name(name) + raise "Domain not found" if @domain.blank? + @record_type = record_type + @apply_changes = apply_changes + + @results = { + nameservers: {}, + dns_records: {}, + dnssec: {}, + csync: {}, + errors: [], + warnings: [] + } + end + + def self.validate(domain:, name:, record_type:, apply_changes: true) + new(domain: domain, name: name, record_type: record_type, apply_changes: apply_changes).validate + end + + def validate + Rails.logger.info "Starting DNS validation for domain: #{domain.name}" + + case record_type + when 'NS' + validate_nameservers + when 'A', 'AAAA' + validate_dns_records + when 'DNSKEY' + check_dnssec_sync_records + when 'CSYNC' + check_csync_records + when 'all' + validate_nameservers + validate_dns_records + check_dnssec_sync_records + check_csync_records + else + raise "Invalid record type: #{record_type}" + end + + 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.upcase, + nameserver: nameserver.hostname, + validated: false + } + + # Validate CDS record if DNSSEC is enabled + if domain.dnskeys.any? && validate_dnssec_chain(nameserver) + cds_data[:validated] = true + end + + @results[:dnssec][:cds_records] << cds_data + + # Only process validated CDS records or if no DNSSEC validation required + if cds_data[:validated] || domain.dnskeys.empty? + # 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', + validated: cds_data[:validated] + } + else + # Check if we need to update DS records + existing_ds = domain.dnskeys.find_by(ds_key_tag: record.key_tag.to_s) + if !existing_ds || + existing_ds.ds_digest != record.digest.upcase || + existing_ds.ds_alg != record.algorithm || + existing_ds.ds_digest_type != record.digest_type + @results[:dnssec][:ds_updates_needed] << { + action: 'update_ds', + cds_data: cds_data, + reason: 'CDS record indicates DS record update needed', + validated: cds_data[:validated] + } + end + end + else + @results[:warnings] << "CDS record from #{nameserver.hostname} not validated - skipping" + 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' + + # Handle the special delete CDNSKEY (algorithm 0) + if record.algorithm == 0 + @results[:dnssec][:cdnskey_records] << { + flags: record.flags, + protocol: record.protocol, + algorithm: 0, + public_key: nil, + nameserver: nameserver.hostname, + validated: true, + action: 'delete' + } + + @results[:dnssec][:ds_updates_needed] << { + action: 'remove_all_dnskeys', + reason: 'CDNSKEY with algorithm 0 indicates removal of all DNSSEC keys', + validated: true + } + next + end + + cdnskey_data = { + flags: record.flags, + protocol: record.protocol, + algorithm: record.algorithm, + public_key: Base64.strict_encode64(record.key), + nameserver: nameserver.hostname, + validated: false + } + + # Validate CDNSKEY record if DNSSEC is enabled + if domain.dnskeys.any? && validate_dnssec_chain(nameserver) + cdnskey_data[:validated] = true + end + + @results[:dnssec][:cdnskey_records] << cdnskey_data + + # Only process validated CDNSKEY records or if no DNSSEC validation required + if cdnskey_data[:validated] || domain.dnskeys.empty? + # 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', + validated: cdnskey_data[:validated] + } + end + + # Check for key rotation - if CDNSKEY exists but old keys should be removed + if cdnskey_data[:flags] == 257 # KSK + old_keys = domain.dnskeys.where(flags: 257).where.not( + public_key: cdnskey_data[:public_key] + ) + if old_keys.any? + @results[:dnssec][:ds_updates_needed] << { + action: 'rotate_ksk', + cdnskey_data: cdnskey_data, + old_keys: old_keys.pluck(:id), + reason: 'CDNSKEY indicates KSK rotation', + validated: cdnskey_data[:validated] + } + end + end + else + @results[:warnings] << "CDNSKEY record from #{nameserver.hostname} not validated - skipping" + 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) + + # Since Dnsruby doesn't support CSYNC, we need to query TYPE62 + # CSYNC is RFC 7477, type 62 + message = Dnsruby::Message.new(domain.name, 'TYPE62', 'IN') + response = resolver.send_message(message) + + response.answer.each do |record| + # Parse CSYNC record manually + csync_data = parse_csync_record(record, nameserver) + next unless csync_data + + @results[:csync][:csync_records] << csync_data + + # Check what needs to be synchronized based on type bitmap + 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 parse_csync_record(record, nameserver) + return nil unless record.type_string == 'TYPE62' || record.type == 62 + + # CSYNC record format: SOA serial (4 bytes) + flags (2 bytes) + type bitmap + rdata = record.rdata + return nil if rdata.nil? || rdata.length < 6 + + # Parse binary data + data = rdata.unpack('C*') + + # Extract SOA serial (32 bits) + serial = (data[0] << 24) | (data[1] << 16) | (data[2] << 8) | data[3] + + # Extract flags (16 bits) + flags = (data[4] << 8) | data[5] + + # Parse type bitmap (remaining bytes) + type_bitmap = parse_dns_type_bitmap(data[6..-1]) + + { + serial: serial, + flags: flags, + type_bitmap: type_bitmap, + nameserver: nameserver.hostname, + immediate: (flags & 0x01) == 1, # Bit 0: immediate flag + soaminimum: (flags & 0x02) == 2 # Bit 1: soaminimum flag + } + rescue StandardError => e + Rails.logger.error "Failed to parse CSYNC record: #{e.message}" + nil + end + + def parse_dns_type_bitmap(bitmap_data) + return [] if bitmap_data.nil? || bitmap_data.empty? + + types = [] + i = 0 + + while i < bitmap_data.length + window_number = bitmap_data[i] + bitmap_length = bitmap_data[i + 1] + + break if i + 2 + bitmap_length > bitmap_data.length + + bitmap = bitmap_data[(i + 2)...(i + 2 + bitmap_length)] + + bitmap.each_with_index do |byte, byte_index| + 8.times do |bit| + if (byte & (0x80 >> bit)) != 0 + type_number = (window_number * 256) + (byte_index * 8) + bit + types << dns_type_name(type_number) + end + end + end + + i += 2 + bitmap_length + end + + types + end + + def dns_type_name(type_number) + type_map = { + 1 => 'A', + 2 => 'NS', + 5 => 'CNAME', + 6 => 'SOA', + 12 => 'PTR', + 15 => 'MX', + 16 => 'TXT', + 28 => 'AAAA', + 33 => 'SRV', + 43 => 'DS', + 46 => 'RRSIG', + 47 => 'NSEC', + 48 => 'DNSKEY', + 50 => 'NSEC3', + 51 => 'NSEC3PARAM', + 59 => 'CDS', + 60 => 'CDNSKEY', + 62 => 'CSYNC' + } + + type_map[type_number] || "TYPE#{type_number}" + 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} (apply_changes: #{@apply_changes})" + + # 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 @apply_changes && 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 (only if apply_changes is true) + if @apply_changes + @results[:dnssec][:ds_updates_needed].each do |update| + # Skip non-validated updates unless explicitly allowed + next if update[:validated] == false && require_dnssec_validation? + + case update[:action] + when 'update_ds' + update_ds_record(update[:cds_data]) + when 'remove_ds' + remove_ds_records + when 'remove_all_dnskeys' + remove_all_dnskeys + when 'add_dnskey' + add_dnskey(update[:cdnskey_data]) + when 'rotate_ksk' + rotate_ksk(update[:cdnskey_data], update[:old_keys]) + 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 + else + Rails.logger.info "Skipping enforcement actions (apply_changes is false)" + + # Log what would have been done + if @results[:dnssec][:ds_updates_needed].any? + @results[:warnings] << "DNSSEC updates detected but not applied (validation mode only):" + @results[:dnssec][:ds_updates_needed].each do |update| + @results[:warnings] << " - #{update[:action]}: #{update[:reason]}" + end + end + + if @results[:csync][:delegation_updates_needed].any? + @results[:warnings] << "Delegation updates detected but not applied (validation mode only):" + @results[:csync][:delegation_updates_needed].each do |update| + @results[:warnings] << " - #{update[:type]}: #{update[:reason] || 'Update needed'}" + end + end + end + + # Send notifications if there are errors + if @results[:errors].any? && @apply_changes + 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 + + # DNSSEC validation methods + def validate_dnssec_chain(nameserver) + begin + resolver = create_resolver(nameserver.hostname) + resolver.dnssec = true + + # Try to get DNSKEY records with validation + response = resolver.query(domain.name, 'DNSKEY') + + # Check if response is authenticated + return response.security_level == Dnsruby::Message::SecurityLevel.SECURE + rescue StandardError => e + Rails.logger.warn "DNSSEC validation failed for #{domain.name} via #{nameserver.hostname}: #{e.message}" + false + end + end + + def require_dnssec_validation? + # Require validation if domain already has DNSSEC enabled + # Can be configured via settings if needed + domain.dnskeys.any? + end + + def dnssec_validates? + # Simple DNSSEC validation check + return true unless domain.dnskeys.any? + + valid_nameservers = domain.nameservers.select do |ns| + validate_dnssec_chain(ns) + end + + valid_nameservers.any? + 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}" + create_notification("DS records removed for domain #{domain.name} based on CDS record") + end + + def remove_all_dnskeys + count = domain.dnskeys.count + domain.dnskeys.destroy_all + Rails.logger.info "Removed all #{count} DNSKEY records for #{domain.name}" + create_notification("All DNSSEC keys removed for domain #{domain.name} based on CDNSKEY record") + end + + def rotate_ksk(cdnskey_data, old_key_ids) + # Add new KSK + new_key = domain.dnskeys.build( + flags: cdnskey_data[:flags], + protocol: cdnskey_data[:protocol], + alg: cdnskey_data[:algorithm], + public_key: cdnskey_data[:public_key] + ) + + if new_key.save + # Remove old KSKs + domain.dnskeys.where(id: old_key_ids).destroy_all + Rails.logger.info "Rotated KSK for #{domain.name}: added new key, removed #{old_key_ids.size} old keys" + create_notification("KSK rotation completed for domain #{domain.name}") + else + Rails.logger.error "Failed to rotate KSK: #{new_key.errors.full_messages.join(', ')}" + @results[:errors] << "Failed to rotate KSK: #{new_key.errors.full_messages.join(', ')}" + end + 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, record_type: 'all', apply_changes: true) + validator = new(domain: domain, name: domain.name, record_type: record_type, apply_changes: apply_changes) + validator.validate + end + + def check_only(domain:, name: nil, record_type: 'all') + new(domain: domain, name: name || domain&.name, record_type: record_type, apply_changes: false).validate + end + + def apply_dnssec_updates(domain:, name: nil) + new(domain: domain, name: name || domain&.name, record_type: 'DNSKEY', apply_changes: true).validate + end + + def apply_delegation_updates(domain:, name: nil) + new(domain: domain, name: name || domain&.name, record_type: 'CSYNC', apply_changes: true).validate + end + end +end \ No newline at end of file diff --git a/test/integration/api/domain_admin_contacts_test.rb b/test/integration/api/domain_admin_contacts_test.rb index 8064483bc6..34dd608ea9 100644 --- a/test/integration/api/domain_admin_contacts_test.rb +++ b/test/integration/api/domain_admin_contacts_test.rb @@ -12,6 +12,15 @@ class APIDomainAdminContactsTest < ApplicationIntegrationTest ident_country_code: @admin_current.ident_country_code) adapter = ENV["shunter_default_adapter"].constantize.new adapter&.clear! + + # Mock DNSValidator to return success + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } + end + + def teardown + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) end def test_replace_all_admin_contacts_when_ident_data_doesnt_match diff --git a/test/integration/api/domain_contacts_test.rb b/test/integration/api/domain_contacts_test.rb index 3301326d07..721c8a6fe4 100644 --- a/test/integration/api/domain_contacts_test.rb +++ b/test/integration/api/domain_contacts_test.rb @@ -1,6 +1,17 @@ require 'test_helper' class APIDomainContactsTest < ApplicationIntegrationTest + def setup + # Mock DNSValidator to return success + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } + end + + def teardown + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) + end + def test_replace_all_tech_contacts_of_the_current_registrar patch '/repp/v1/domains/contacts', params: { current_contact_id: 'william-001', new_contact_id: 'john-001' }, diff --git a/test/integration/api/domain_transfers_test.rb b/test/integration/api/domain_transfers_test.rb index c56417f4dc..f242bf62f7 100644 --- a/test/integration/api/domain_transfers_test.rb +++ b/test/integration/api/domain_transfers_test.rb @@ -6,10 +6,16 @@ class APIDomainTransfersTest < ApplicationIntegrationTest @new_registrar = registrars(:goodnames) @original_transfer_wait_time = Setting.transfer_wait_time Setting.transfer_wait_time = 0 # Auto-approval + + # Mock DNSValidator to return success + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } end teardown do Setting.transfer_wait_time = @original_transfer_wait_time + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) end def test_creates_new_domain_transfer diff --git a/test/integration/api/nameservers/put_test.rb b/test/integration/api/nameservers/put_test.rb index 1c1c16a014..cad5b98ef4 100644 --- a/test/integration/api/nameservers/put_test.rb +++ b/test/integration/api/nameservers/put_test.rb @@ -1,6 +1,17 @@ require 'test_helper' class APINameserversPutTest < ApplicationIntegrationTest + def setup + # Mock DNSValidator to return success + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } + end + + def teardown + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) + end + def test_replaces_registrar_nameservers old_nameserver_ids = [nameservers(:shop_ns1).id, nameservers(:airport_ns1).id, diff --git a/test/integration/epp/domain/update/base_test.rb b/test/integration/epp/domain/update/base_test.rb index a8c2045c9a..22a044324f 100644 --- a/test/integration/epp/domain/update/base_test.rb +++ b/test/integration/epp/domain/update/base_test.rb @@ -13,11 +13,17 @@ class EppDomainUpdateBaseTest < EppTestCase adapter = ENV["shunter_default_adapter"].constantize.new adapter&.clear! + + # Mock DNSValidator to return success + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } end teardown do Setting.request_confirmation_on_registrant_change_enabled = @original_registrant_change_verification + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) end def test_update_dnskey_with_invalid_alg diff --git a/test/integration/epp/domain/update/rem_dns_test.rb b/test/integration/epp/domain/update/rem_dns_test.rb index 6e079b1260..64559a0545 100644 --- a/test/integration/epp/domain/update/rem_dns_test.rb +++ b/test/integration/epp/domain/update/rem_dns_test.rb @@ -12,11 +12,17 @@ class EppDomainUpdateRemDnsTest < EppTestCase @original_registrant_change_verification = Setting.request_confirmation_on_registrant_change_enabled ActionMailer::Base.deliveries.clear + + # Mock DNSValidator to return success + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } end teardown do Setting.request_confirmation_on_registrant_change_enabled = @original_registrant_change_verification + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) end def test_remove_dnskey_if_explicitly_set diff --git a/test/integration/epp/domain/update/replace_dns_test.rb b/test/integration/epp/domain/update/replace_dns_test.rb index 62dcbc299f..0a15398a81 100644 --- a/test/integration/epp/domain/update/replace_dns_test.rb +++ b/test/integration/epp/domain/update/replace_dns_test.rb @@ -1,6 +1,17 @@ require 'test_helper' class EppDomainUpdateReplaceDnsTest < EppTestCase + def setup + # Mock DNSValidator to return success + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } + end + + def teardown + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) + end + def test_parsed_response_for_dnskey_with_spaces_in_request doc = Nokogiri::XML::Document.parse(schema_update) params = { parsed_frame: doc } diff --git a/test/integration/repp/v1/domains/contacts_test.rb b/test/integration/repp/v1/domains/contacts_test.rb index df472bad43..2cff548804 100644 --- a/test/integration/repp/v1/domains/contacts_test.rb +++ b/test/integration/repp/v1/domains/contacts_test.rb @@ -41,7 +41,8 @@ def test_returns_error_response_if_throttled end def test_can_add_new_admin_contacts - new_contact = contacts(:john) + DNSValidator.stub :validate, { errors: [] } do + new_contact = contacts(:john) refute @domain.admin_contacts.find_by(code: new_contact.code).present? payload = { contacts: [ { code: new_contact.code, type: 'admin' } ] } @@ -52,10 +53,12 @@ def test_can_add_new_admin_contacts assert_equal 1000, json[:code] assert @domain.admin_contacts.find_by(code: new_contact.code).present? + end end def test_can_add_new_tech_contacts - new_contact = contacts(:john) + DNSValidator.stub :validate, { errors: [] } do + new_contact = contacts(:john) refute @domain.tech_contacts.find_by(code: new_contact.code).present? payload = { contacts: [ { code: new_contact.code, type: 'tech' } ] } @@ -67,10 +70,12 @@ def test_can_add_new_tech_contacts @domain.reload assert @domain.tech_contacts.find_by(code: new_contact.code).present? + end end def test_can_remove_admin_contacts - Spy.on_instance_method(Actions::DomainUpdate, :validate_email).and_return(true) + DNSValidator.stub :validate, { errors: [] } do + Spy.on_instance_method(Actions::DomainUpdate, :validate_email).and_return(true) contact = contacts(:john) payload = { contacts: [ { code: contact.code, type: 'admin' } ] } @@ -85,10 +90,12 @@ def test_can_remove_admin_contacts assert_equal 1000, json[:code] refute @domain.admin_contacts.find_by(code: contact.code).present? + end end def test_can_remove_tech_contacts - Spy.on_instance_method(Actions::DomainUpdate, :validate_email).and_return(true) + DNSValidator.stub :validate, { errors: [] } do + Spy.on_instance_method(Actions::DomainUpdate, :validate_email).and_return(true) contact = contacts(:john) payload = { contacts: [ { code: contact.code, type: 'tech' } ] } @@ -106,10 +113,12 @@ def test_can_remove_tech_contacts assert_equal 1000, json[:code] refute @domain.tech_contacts.find_by(code: contact.code).present? + end end def test_can_remove_all_admin_contacts_for_private_registrant - Spy.on_instance_method(Actions::DomainUpdate, :validate_email).and_return(true) + DNSValidator.stub :validate, { errors: [] } do + Spy.on_instance_method(Actions::DomainUpdate, :validate_email).and_return(true) @domain.registrant.update!(ident_type: 'priv') @domain.reload @@ -126,6 +135,7 @@ def test_can_remove_all_admin_contacts_for_private_registrant assert_equal 1000, json[:code] assert_empty @domain.admin_contacts + end end def test_can_not_remove_one_and_only_contact @@ -183,10 +193,11 @@ def test_cannot_remove_admin_contact_for_underage_private_registrant end def test_can_remove_admin_contact_for_adult_private_registrant - @domain.registrant.update!( - ident_type: 'birthday', - ident: (Time.zone.now - 20.years).strftime('%Y-%m-%d') - ) + DNSValidator.stub :validate, { errors: [] } do + @domain.registrant.update!( + ident_type: 'birthday', + ident: (Time.zone.now - 20.years).strftime('%Y-%m-%d') + ) @domain.reload assert @domain.registrant.priv? @@ -199,6 +210,7 @@ def test_can_remove_admin_contact_for_adult_private_registrant assert_response :ok assert_equal 1000, json[:code] assert_empty @domain.admin_contacts + end end def test_cannot_remove_admin_contact_for_underage_estonian_id @@ -222,11 +234,12 @@ def test_cannot_remove_admin_contact_for_underage_estonian_id end def test_can_remove_admin_contact_for_adult_estonian_id - @domain.registrant.update!( - ident_type: 'priv', - ident: '38903111310', - ident_country_code: 'EE' - ) + DNSValidator.stub :validate, { errors: [] } do + @domain.registrant.update!( + ident_type: 'priv', + ident: '38903111310', + ident_country_code: 'EE' + ) @domain.reload assert @domain.registrant.priv? @@ -239,5 +252,6 @@ def test_can_remove_admin_contact_for_adult_estonian_id assert_response :ok assert_equal 1000, json[:code] assert_empty @domain.admin_contacts + end end end diff --git a/test/integration/repp/v1/domains/create_test.rb b/test/integration/repp/v1/domains/create_test.rb index 7907e709ec..aa1fb45edd 100644 --- a/test/integration/repp/v1/domains/create_test.rb +++ b/test/integration/repp/v1/domains/create_test.rb @@ -8,6 +8,16 @@ def setup token = "Basic #{token}" @auth_headers = { 'Authorization' => token } + + # Mock DNSValidator to return success by default + # Individual tests can override this if they need to test DNS validation + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } + end + + def teardown + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) end def test_creates_new_domain_successfully @@ -134,4 +144,148 @@ def test_creates_new_domain_with_desired_transfer_code assert @user.registrar.domains.find_by(name: 'domeener.test').present? assert_equal 'ABADIATS', @user.registrar.domains.find_by(name: 'domeener.test').transfer_code end + + def test_creates_domain_with_nameservers_validates_dns + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + @auth_headers['Content-Type'] = 'application/json' + contact = contacts(:john) + + # Mock successful DNS validation + DNSValidator.stub :validate, { errors: [] } do + payload = { + domain: { + name: 'domeener.test', + registrant: contact.code, + period: 1, + period_unit: 'y', + nameservers_attributes: [ + { hostname: 'ns1.example.com' }, + { hostname: 'ns2.example.com' } + ] + } + } + + post "/repp/v1/domains", headers: @auth_headers, params: payload.to_json + json = JSON.parse(response.body, symbolize_names: true) + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + + domain = @user.registrar.domains.find_by(name: 'domeener.test') + assert domain.present? + assert_equal 2, domain.nameservers.count + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end + + def test_fails_to_create_domain_with_invalid_nameservers + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + @auth_headers['Content-Type'] = 'application/json' + contact = contacts(:john) + + # Mock DNS validation failure + DNSValidator.stub :validate, { errors: ['Nameserver ns1.example.com is not authoritative for domain'] } do + payload = { + domain: { + name: 'domeener.test', + registrant: contact.code, + period: 1, + period_unit: 'y', + nameservers_attributes: [ + { hostname: 'ns1.example.com' }, + { hostname: 'ns2.example.com' } + ] + } + } + + post "/repp/v1/domains", headers: @auth_headers, params: payload.to_json + json = JSON.parse(response.body, symbolize_names: true) + assert_response :bad_request + assert_equal 2306, json[:code] + assert json[:message].include?('Nameserver ns1.example.com is not authoritative') + + refute @user.registrar.domains.find_by(name: 'domeener.test').present? + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end + + def test_creates_domain_with_dnssec_validates_dnskey + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + @auth_headers['Content-Type'] = 'application/json' + contact = contacts(:john) + + # Mock successful DNSSEC validation + DNSValidator.stub :validate, { errors: [] } do + payload = { + domain: { + name: 'domeener.test', + registrant: contact.code, + period: 1, + period_unit: 'y', + dnskeys_attributes: [ + { + flags: '257', + protocol: '3', + alg: '8', + public_key: 'AwEAAddt2AkLfYGKgiEZB5SmIF8EvrjxNMH6HtxWEA4RJ9Ao6LCWheg8' + } + ] + } + } + + post "/repp/v1/domains", headers: @auth_headers, params: payload.to_json + json = JSON.parse(response.body, symbolize_names: true) + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + + domain = @user.registrar.domains.find_by(name: 'domeener.test') + assert domain.present? + assert_equal 1, domain.dnskeys.count + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end + + def test_fails_to_create_domain_with_invalid_dnskey + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + @auth_headers['Content-Type'] = 'application/json' + contact = contacts(:john) + + # Mock DNSSEC validation failure + DNSValidator.stub :validate, { errors: ['DNSKEY record not found in DNS'] } do + payload = { + domain: { + name: 'domeener.test', + registrant: contact.code, + period: 1, + period_unit: 'y', + dnskeys_attributes: [ + { + flags: '257', + protocol: '3', + alg: '8', + public_key: 'AwEAAddt2AkLfYGKgiEZB5SmIF8EvrjxNMH6HtxWEA4RJ9Ao6LCWheg8' + } + ] + } + } + + post "/repp/v1/domains", headers: @auth_headers, params: payload.to_json + json = JSON.parse(response.body, symbolize_names: true) + assert_response :bad_request + assert_equal 2306, json[:code] + assert json[:message].include?('DNSKEY record not found') + + refute @user.registrar.domains.find_by(name: 'domeener.test').present? + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end end diff --git a/test/integration/repp/v1/domains/dnssec_test.rb b/test/integration/repp/v1/domains/dnssec_test.rb index 46e239fbf0..57ec39598a 100644 --- a/test/integration/repp/v1/domains/dnssec_test.rb +++ b/test/integration/repp/v1/domains/dnssec_test.rb @@ -11,6 +11,15 @@ def setup adapter = ENV["shunter_default_adapter"].constantize.new adapter&.clear! + + # Mock DNSValidator to return success + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } + end + + def teardown + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) end def test_shows_dnssec_keys_associated_with_domain @@ -138,4 +147,63 @@ def test_returns_error_response_if_throttled ENV["shunter_default_threshold"] = '10000' ENV["shunter_enabled"] = 'false' end + + def test_validates_dns_when_adding_dnskey + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + # Mock successful DNS validation + DNSValidator.stub :validate, { errors: [] } do + payload = { + dns_keys: [ + { flags: '257', + alg: '8', + protocol: '3', + public_key: 'AwEAAddt2AkLfYGKgiEZB5SmIF8EvrjxNMH6HtxWEA4RJ9Ao6LCWheg8' }, + ], + } + + post "/repp/v1/domains/#{@domain.name}/dnssec", params: payload, headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + + @domain.reload + assert @domain.dnskeys.present? + dnssec_key = @domain.dnskeys.last + assert_equal 257, dnssec_key.flags + assert_equal 8, dnssec_key.alg + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end + + def test_fails_to_add_dnskey_with_invalid_dns + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + # Mock DNS validation failure + DNSValidator.stub :validate, { errors: ['DNSKEY record not found in DNS'] } do + payload = { + dns_keys: [ + { flags: '257', + alg: '8', + protocol: '3', + public_key: 'AwEAAddt2AkLfYGKgiEZB5SmIF8EvrjxNMH6HtxWEA4RJ9Ao6LCWheg8' }, + ], + } + + post "/repp/v1/domains/#{@domain.name}/dnssec", params: payload, headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert_equal 2306, json[:code] + assert json[:message].include?('DNSKEY record not found') + + @domain.reload + assert @domain.dnskeys.empty? + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end end diff --git a/test/integration/repp/v1/domains/nameservers_test.rb b/test/integration/repp/v1/domains/nameservers_test.rb index 3ff85260ee..4fe453156a 100644 --- a/test/integration/repp/v1/domains/nameservers_test.rb +++ b/test/integration/repp/v1/domains/nameservers_test.rb @@ -11,6 +11,15 @@ def setup adapter = ENV["shunter_default_adapter"].constantize.new adapter&.clear! + + # Mock DNSValidator to return success + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } + end + + def teardown + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) end def test_can_add_new_nameserver @@ -109,4 +118,58 @@ def test_returns_error_when_ns_count_too_low assert_equal 'Data management policy violation; Nameserver count must be between 2-11 for active ' \ 'domains [nameservers]', json[:message] end + + def test_validates_dns_when_adding_nameserver + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + # Mock successful DNS validation + DNSValidator.stub :validate, { errors: [] } do + payload = { + nameservers: [ + { hostname: "ns3.example.com", + ipv4: ["192.168.1.1"], + ipv6: ["FE80::AEDE:48FF:FE00:1122"]} + ] + } + + post "/repp/v1/domains/#{@domain.name}/nameservers", params: payload, headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + + @domain.reload + assert @domain.nameservers.where(hostname: 'ns3.example.com').any? + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end + + def test_fails_to_add_nameserver_with_invalid_dns + ENV['DNS_VALIDATION_ENABLED'] = 'true' + + # Mock DNS validation failure + DNSValidator.stub :validate, { errors: ['Nameserver ns3.example.com is not authoritative for domain'] } do + payload = { + nameservers: [ + { hostname: "ns3.example.com", + ipv4: ["192.168.1.1"], + ipv6: ["FE80::AEDE:48FF:FE00:1122"]} + ] + } + + post "/repp/v1/domains/#{@domain.name}/nameservers", params: payload, headers: @auth_headers + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert_equal 2306, json[:code] + assert json[:message].include?('Nameserver ns3.example.com is not authoritative') + + @domain.reload + refute @domain.nameservers.where(hostname: 'ns3.example.com').any? + end + ensure + ENV.delete('DNS_VALIDATION_ENABLED') + end end diff --git a/test/integration/repp/v1/domains/statuses_test.rb b/test/integration/repp/v1/domains/statuses_test.rb index ee2cb445b7..d44dbf2d44 100644 --- a/test/integration/repp/v1/domains/statuses_test.rb +++ b/test/integration/repp/v1/domains/statuses_test.rb @@ -11,6 +11,15 @@ def setup adapter = ENV["shunter_default_adapter"].constantize.new adapter&.clear! + + # Mock DNSValidator to return success + @original_validate = DNSValidator.method(:validate) + DNSValidator.define_singleton_method(:validate) { |**args| { errors: [] } } + end + + def teardown + # Restore original validate method + DNSValidator.define_singleton_method(:validate, @original_validate) end def test_client_hold_can_be_added diff --git a/test/integration/repp/v1/domains/transfer_test.rb b/test/integration/repp/v1/domains/transfer_test.rb index fdcbe41d77..153b310d37 100644 --- a/test/integration/repp/v1/domains/transfer_test.rb +++ b/test/integration/repp/v1/domains/transfer_test.rb @@ -171,4 +171,218 @@ def test_returns_error_response_if_throttled ENV["shunter_default_threshold"] = '10000' ENV["shunter_enabled"] = 'false' end + + def test_transfers_domain_with_valid_dns_records + # Add nameservers to the domain + @domain.nameservers.create!(hostname: 'ns1.example.com', ipv4: ['192.0.2.1']) + @domain.nameservers.create!(hostname: 'ns2.example.com', ipv4: ['192.0.2.2']) + + # Mock successful DNS validation for NS records + DNSValidator.stub :validate, { errors: [] } do + payload = { transfer: { transfer_code: @domain.transfer_code } } + post "/repp/v1/domains/#{@domain.name}/transfer", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + @domain.reload + + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + assert_equal @domain.registrar, @user.registrar + end + end + + def test_fails_transfer_with_invalid_nameserver_records + # Add nameservers to the domain + @domain.nameservers.create!(hostname: 'ns1.example.com', ipv4: ['192.0.2.1']) + @domain.nameservers.create!(hostname: 'ns2.example.com', ipv4: ['192.0.2.2']) + + # Mock DNS validation failure for NS records + dns_error = 'Nameserver ns1.example.com is not authoritative for domain' + DNSValidator.stub :validate, { errors: [dns_error] } do + payload = { transfer: { transfer_code: @domain.transfer_code } } + post "/repp/v1/domains/#{@domain.name}/transfer", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + @domain.reload + + assert_response :bad_request + assert_equal 2306, json[:code] + assert_equal dns_error, json[:message] + + # Domain should not be transferred + refute @domain.registrar == @user.registrar + end + end + + def test_transfers_domain_with_valid_dnssec_records + # Add DNSSEC keys to the domain + @domain.dnskeys.create!( + flags: 257, + protocol: 3, + alg: 8, + public_key: 'AwEAAddt2AkLfYGKgiEZB5SmIF8EvrjxNMH6HtxWEA4RJ9Ao6LCRHzfK' + ) + + # Mock successful DNS validation for DNSKEY records + DNSValidator.stub :validate, { errors: [] } do + payload = { transfer: { transfer_code: @domain.transfer_code } } + post "/repp/v1/domains/#{@domain.name}/transfer", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + @domain.reload + + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + assert_equal @domain.registrar, @user.registrar + end + end + + def test_fails_transfer_with_invalid_dnssec_records + # Add DNSSEC keys to the domain + @domain.dnskeys.create!( + flags: 257, + protocol: 3, + alg: 8, + public_key: 'AwEAAddt2AkLfYGKgiEZB5SmIF8EvrjxNMH6HtxWEA4RJ9Ao6LCRHzfK' + ) + + # Mock DNS validation failure for DNSKEY records + dns_error = 'DNSKEY record not found in DNS' + DNSValidator.stub :validate, { errors: [dns_error] } do + payload = { transfer: { transfer_code: @domain.transfer_code } } + post "/repp/v1/domains/#{@domain.name}/transfer", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + @domain.reload + + assert_response :bad_request + assert_equal 2306, json[:code] + assert_equal dns_error, json[:message] + + # Domain should not be transferred + refute @domain.registrar == @user.registrar + end + end + + def test_transfers_domain_without_nameservers + # Ensure domain has no nameservers + @domain.nameservers.destroy_all + + # Should transfer successfully without DNS validation + payload = { transfer: { transfer_code: @domain.transfer_code } } + post "/repp/v1/domains/#{@domain.name}/transfer", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + @domain.reload + + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + assert_equal @domain.registrar, @user.registrar + end + + def test_transfers_domain_without_dnssec + # Ensure domain has no DNSSEC keys + @domain.dnskeys.destroy_all + + # Should transfer successfully without DNSSEC validation + payload = { transfer: { transfer_code: @domain.transfer_code } } + post "/repp/v1/domains/#{@domain.name}/transfer", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + @domain.reload + + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + assert_equal @domain.registrar, @user.registrar + end + + def test_bulk_transfer_with_dns_validation + domain2 = domains(:metro) + + # Add minimum required nameservers to both domains (2 nameservers required) + @domain.nameservers.create!(hostname: 'ns1.example.com', ipv4: ['192.0.2.1']) + @domain.nameservers.create!(hostname: 'ns2.example.com', ipv4: ['192.0.2.2']) + + domain2.nameservers.create!(hostname: 'ns1.example.org', ipv4: ['192.0.2.10']) + domain2.nameservers.create!(hostname: 'ns2.example.org', ipv4: ['192.0.2.11']) + + # Mock DNS validation - success for both domains + DNSValidator.stub :validate, { errors: [] } do + payload = { + "data": { + "domain_transfers": [ + { "domain_name": @domain.name, "transfer_code": @domain.transfer_code }, + { "domain_name": domain2.name, "transfer_code": domain2.transfer_code } + ] + } + } + + post "/repp/v1/domains/transfer", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 1000, json[:code] + assert_equal 'Command completed successfully', json[:message] + + # Both domains should be in success list + assert_equal 2, json[:data][:success].length + assert json[:data][:success].any? { |d| d[:domain_name] == @domain.name } + assert json[:data][:success].any? { |d| d[:domain_name] == domain2.name } + + @domain.reload + domain2.reload + + assert @domain.registrar == @user.registrar + assert domain2.registrar == @user.registrar + end + end + + def test_bulk_transfer_with_mixed_dns_validation_results + domain2 = domains(:metro) + + # Add minimum required nameservers to both domains (2 nameservers required) + @domain.nameservers.create!(hostname: 'ns1.example.com', ipv4: ['192.0.2.1']) + @domain.nameservers.create!(hostname: 'ns2.example.com', ipv4: ['192.0.2.2']) + + domain2.nameservers.create!(hostname: 'ns1.example.org', ipv4: ['192.0.2.10']) + domain2.nameservers.create!(hostname: 'ns2.example.org', ipv4: ['192.0.2.11']) + + # Mock DNS validation - fail for first domain, succeed for second + validation_results = { + @domain.name => { errors: ['Nameserver ns1.example.com is not authoritative'] }, + domain2.name => { errors: [] } + } + + DNSValidator.stub :validate, ->(domain:, **) { + validation_results[domain.name] || { errors: [] } + } do + payload = { + "data": { + "domain_transfers": [ + { "domain_name": @domain.name, "transfer_code": @domain.transfer_code }, + { "domain_name": domain2.name, "transfer_code": domain2.transfer_code } + ] + } + } + + post "/repp/v1/domains/transfer", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 1000, json[:code] + + # First domain should fail, second should succeed + assert_equal 1, json[:data][:success].length + assert_equal domain2.name, json[:data][:success][0][:domain_name] + + assert_equal 1, json[:data][:failed].length + assert_equal @domain.name, json[:data][:failed][0][:domain_name] + assert json[:data][:failed][0][:errors][:msg].include?('not authoritative') + + @domain.reload + domain2.reload + + # Only domain2 should be transferred + refute @domain.registrar == @user.registrar + assert domain2.registrar == @user.registrar + end + end end diff --git a/test/integration/repp/v1/domains/update_test.rb b/test/integration/repp/v1/domains/update_test.rb index a91639c7ac..491cad1173 100644 --- a/test/integration/repp/v1/domains/update_test.rb +++ b/test/integration/repp/v1/domains/update_test.rb @@ -11,6 +11,7 @@ def setup end def test_updates_transfer_code_for_domain + DNSValidator.stub :validate, { errors: [] } do @auth_headers['Content-Type'] = 'application/json' new_auth_code = 'aisdcbkabcsdnc' @@ -28,12 +29,14 @@ def test_updates_transfer_code_for_domain assert_equal 'Command completed successfully', json[:message] assert new_auth_code, @domain.auth_info + end end def test_domain_pending_update_on_registrant_change - Setting.request_confirmation_on_registrant_change_enabled = true + DNSValidator.stub :validate, { errors: [] } do + Setting.request_confirmation_on_registrant_change_enabled = true - @auth_headers['Content-Type'] = 'application/json' + @auth_headers['Content-Type'] = 'application/json' new_registrant = contacts(:william) refute @domain.registrant == new_registrant @@ -54,12 +57,14 @@ def test_domain_pending_update_on_registrant_change refute @domain.registrant.code == new_registrant.code assert @domain.statuses.include? DomainStatus::PENDING_UPDATE + end end def test_replaces_registrant_when_verified - Setting.request_confirmation_on_registrant_change_enabled = true + DNSValidator.stub :validate, { errors: [] } do + Setting.request_confirmation_on_registrant_change_enabled = true - @auth_headers['Content-Type'] = 'application/json' + @auth_headers['Content-Type'] = 'application/json' new_registrant = contacts(:william) refute @domain.registrant == new_registrant old_transfer_code = @domain.transfer_code @@ -84,12 +89,14 @@ def test_replaces_registrant_when_verified refute_equal old_transfer_code, @domain.transfer_code assert @domain.registrant.code == new_registrant.code refute @domain.statuses.include? DomainStatus::PENDING_UPDATE + end end def test_adds_epp_error_when_reserved_pw_is_missing_for_disputed_domain - Dispute.create!(domain_name: @domain.name, password: '1234567890', starts_at: Time.zone.now, expires_at: Time.zone.now + 5.days) + DNSValidator.stub :validate, { errors: [] } do + Dispute.create!(domain_name: @domain.name, password: '1234567890', starts_at: Time.zone.now, expires_at: Time.zone.now + 5.days) - @auth_headers['Content-Type'] = 'application/json' + @auth_headers['Content-Type'] = 'application/json' payload = { domain: { reserved_pw: nil, @@ -102,12 +109,14 @@ def test_adds_epp_error_when_reserved_pw_is_missing_for_disputed_domain assert_response :bad_request assert_equal 2304, json[:code] assert_equal 'Required parameter missing; reservedpw element required for dispute domains', json[:message] + end end def test_adds_epp_error_when_reserved_pw_is_invalid_for_disputed_domain - Dispute.create!(domain_name: @domain.name, password: '1234567890', starts_at: Time.zone.now, expires_at: Time.zone.now + 5.days) + DNSValidator.stub :validate, { errors: [] } do + Dispute.create!(domain_name: @domain.name, password: '1234567890', starts_at: Time.zone.now, expires_at: Time.zone.now + 5.days) - @auth_headers['Content-Type'] = 'application/json' + @auth_headers['Content-Type'] = 'application/json' payload = { domain: { reserved_pw: 'invalid', @@ -120,5 +129,7 @@ def test_adds_epp_error_when_reserved_pw_is_invalid_for_disputed_domain assert_response :bad_request assert_equal 2202, json[:code] assert_equal 'Invalid authorization information; invalid reserved>pw value', json[:message] + end end + end diff --git a/test/services/dns_validator_test.rb b/test/services/dns_validator_test.rb new file mode 100644 index 0000000000..adfe51c704 --- /dev/null +++ b/test/services/dns_validator_test.rb @@ -0,0 +1,948 @@ +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: @domain, name: @domain.name, record_type: 'NS') + + 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 + result = { test: 'result' } + + # Mock the class method directly + DNSValidator.stub :validate, result do + actual_result = DNSValidator.validate(domain: @domain, name: @domain.name, record_type: 'NS') + assert_equal result, actual_result + end + end + + # Story 1: Nameserver Validation Tests + + test 'validates nameservers successfully' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') + + # 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: @domain, name: @domain.name, record_type: 'NS') + + # 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: @domain, name: @domain.name, record_type: 'NS') + + # 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: @domain, name: @domain.name, record_type: 'NS') + 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: @domain, name: @domain.name, record_type: 'NS') + 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: @domain, name: @domain.name, record_type: 'A') + + # 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: @domain, name: @domain.name, record_type: 'A') + + # 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: @domain, name: @domain.name, record_type: 'DNSKEY') + 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: @domain, name: @domain.name, record_type: 'NS') + + # 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: @domain, name: @domain.name, record_type: 'NS') + + 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: @domain, name: @domain.name, record_type: 'all') + + # 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: @domain, name: @domain.name, record_type: 'NS') + + 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: @domain, name: @domain.name, record_type: 'NS') + + # 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: domain_without_ns, name: domain_without_ns.name, record_type: 'NS') + + assert_nothing_raised do + validator.send(:validate_nameservers) + end + + assert_empty validator.results[:nameservers] + end + + # New tests for apply_changes flag functionality + + test 'initializes with apply_changes flag defaulting to true' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS') + assert_equal true, validator.apply_changes + end + + test 'respects apply_changes flag when false' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'NS', apply_changes: false) + assert_equal false, validator.apply_changes + end + + test 'does not apply changes when apply_changes is false' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY', apply_changes: false) + + # Set up DNSSEC updates needed + validator.instance_variable_set(:@results, { + nameservers: {}, + dns_records: {}, + dnssec: { + ds_updates_needed: [ + { action: 'update_ds', cds_data: { key_tag: 12345 }, reason: 'Test', validated: true } + ], + cds_records: [], + cdnskey_records: [] + }, + csync: { delegation_updates_needed: [] }, + errors: [], + warnings: [] + }) + + # Mock methods to track if they're called + update_called = false + validator.define_singleton_method(:update_ds_record) { |data| update_called = true } + validator.define_singleton_method(:create_notification) { |text| nil } + + validator.send(:apply_enforcement_actions) + + assert_not update_called, "Update should not be called when apply_changes is false" + assert_includes validator.results[:warnings], "DNSSEC updates detected but not applied (validation mode only):" + end + + # CDS record tests + + test 'processes CDS records with validation' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + + # Initialize results structure first + validator.instance_variable_set(:@results, { + nameservers: {}, + dns_records: {}, + dnssec: { cds_records: [], cdnskey_records: [], ds_updates_needed: [] }, + csync: { csync_records: [], delegation_updates_needed: [] }, + errors: [], + warnings: [] + }) + + resolver = create_mock_resolver + + # Pre-create CDS response with proper structure + cds_response = create_mock_dns_response([ + create_mock_cds_record(12345, 7, 2, 'ABCDEF123456') + ]) + 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 + + resolver.define_singleton_method(:dnssec=) { |value| } + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + validator.define_singleton_method(:validate_dnssec_chain) { |ns| false } # Not validated + + validator.send(:check_cds_records, @nameserver1) + + assert_not_empty validator.results[:dnssec][:cds_records] + cds = validator.results[:dnssec][:cds_records].first + assert_equal 12345, cds[:key_tag] + assert_equal 7, cds[:algorithm] + assert_equal 2, cds[:digest_type] + assert_equal 'ABCDEF123456', cds[:digest] + assert_equal false, cds[:validated] + end + + test 'handles CDS record with algorithm 0 for removal' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + + # Initialize results structure + validator.instance_variable_set(:@results, { + nameservers: {}, + dns_records: {}, + dnssec: { cds_records: [], cdnskey_records: [], ds_updates_needed: [] }, + csync: { csync_records: [], delegation_updates_needed: [] }, + errors: [], + warnings: [] + }) + + resolver = create_mock_resolver + + # CDS with algorithm 0 means remove all DS records + cds_response = create_mock_dns_response([ + create_mock_cds_record(0, 0, 0, 'AA==') + ]) + + resolver.define_singleton_method(:query) do |domain, type| + type == 'CDS' ? cds_response : create_mock_dns_response([]) + end + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + validator.define_singleton_method(:validate_dnssec_chain) { |ns| true } + + validator.send(:check_cds_records, @nameserver1) + + ds_updates = validator.results[:dnssec][:ds_updates_needed] + assert_not_empty ds_updates + assert_equal 'remove_ds', ds_updates.first[:action] + end + + # CDNSKEY record tests + + test 'processes CDNSKEY records correctly' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + + # Initialize results structure + validator.instance_variable_set(:@results, { + nameservers: {}, + dns_records: {}, + dnssec: { cds_records: [], cdnskey_records: [], ds_updates_needed: [] }, + csync: { csync_records: [], delegation_updates_needed: [] }, + errors: [], + warnings: [] + }) + + resolver = create_mock_resolver + + # Mock CDNSKEY response + cdnskey_response = create_mock_dns_response([ + create_mock_cdnskey_record(257, 3, 13, 'mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==') + ]) + + resolver.define_singleton_method(:query) do |domain, type| + type == 'CDNSKEY' ? cdnskey_response : create_mock_dns_response([]) + end + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + validator.define_singleton_method(:validate_dnssec_chain) { |ns| false } + + validator.send(:check_cdnskey_records, @nameserver1) + + assert_not_empty validator.results[:dnssec][:cdnskey_records] + cdnskey = validator.results[:dnssec][:cdnskey_records].first + assert_equal 257, cdnskey[:flags] + assert_equal 3, cdnskey[:protocol] + assert_equal 13, cdnskey[:algorithm] + end + + test 'handles CDNSKEY with algorithm 0 for deletion' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + + # Initialize results structure + validator.instance_variable_set(:@results, { + nameservers: {}, + dns_records: {}, + dnssec: { cds_records: [], cdnskey_records: [], ds_updates_needed: [] }, + csync: { csync_records: [], delegation_updates_needed: [] }, + errors: [], + warnings: [] + }) + + resolver = create_mock_resolver + + # CDNSKEY with algorithm 0 means delete all keys + cdnskey_response = create_mock_dns_response([ + create_mock_cdnskey_record(0, 3, 0, 'AA==') + ]) + + resolver.define_singleton_method(:query) do |domain, type| + type == 'CDNSKEY' ? cdnskey_response : create_mock_dns_response([]) + end + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + + validator.send(:check_cdnskey_records, @nameserver1) + + assert_not_empty validator.results[:dnssec][:ds_updates_needed] + assert_equal 'remove_all_dnskeys', validator.results[:dnssec][:ds_updates_needed].first[:action] + end + + test 'detects KSK rotation from CDNSKEY' do + # Set up domain with existing KSK - use valid base64 string from fixtures + @dnskey.update!(flags: 257, protocol: 3, alg: 13, public_key: 'mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==') + + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + resolver = create_mock_resolver + + # Initialize results structure + validator.instance_variable_set(:@results, { + nameservers: {}, + dns_records: {}, + dnssec: { cds_records: [], cdnskey_records: [], ds_updates_needed: [] }, + csync: { csync_records: [], delegation_updates_needed: [] }, + errors: [], + warnings: [] + }) + + # New KSK in CDNSKEY - different valid base64 key + cdnskey_response = create_mock_dns_response([ + create_mock_cdnskey_record(257, 3, 13, 'AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU=') + ]) + + resolver.define_singleton_method(:query) do |domain, type| + type == 'CDNSKEY' ? cdnskey_response : create_mock_dns_response([]) + end + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + validator.define_singleton_method(:validate_dnssec_chain) { |ns| true } + + validator.send(:check_cdnskey_records, @nameserver1) + + ds_updates = validator.results[:dnssec][:ds_updates_needed] + rotation_update = ds_updates.find { |u| u[:action] == 'rotate_ksk' } + assert_not_nil rotation_update + assert_includes rotation_update[:old_keys], @dnskey.id + end + + # CSYNC record tests + + test 'parses CSYNC records correctly' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'CSYNC') + + # Initialize results structure + validator.instance_variable_set(:@results, { + nameservers: {}, + dns_records: {}, + dnssec: { cds_records: [], cdnskey_records: [], ds_updates_needed: [] }, + csync: { csync_records: [], delegation_updates_needed: [] }, + errors: [], + warnings: [] + }) + + resolver = create_mock_resolver + + # Mock CSYNC (TYPE62) response + csync_response = create_mock_dns_response([ + create_mock_csync_record(123456789, 3, ['NS', 'A', 'AAAA']) + ]) + + # Mock send_message for TYPE62 query + resolver.define_singleton_method(:send_message) do |message| + csync_response + end + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + validator.define_singleton_method(:check_ns_sync_needed) { |ns| } + validator.define_singleton_method(:check_a_sync_needed) { |ns| } + validator.define_singleton_method(:check_aaaa_sync_needed) { |ns| } + + validator.send(:check_single_csync_record, @nameserver1) + + assert_not_empty validator.results[:csync][:csync_records] + csync = validator.results[:csync][:csync_records].first + assert_equal 123456789, csync[:serial] + assert_equal 3, csync[:flags] + assert csync[:immediate] + assert csync[:soaminimum] + assert_includes csync[:type_bitmap], 'NS' + assert_includes csync[:type_bitmap], 'A' + assert_includes csync[:type_bitmap], 'AAAA' + end + + test 'detects NS synchronization needed from CSYNC' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'CSYNC') + + # Set up nameserver validation results + validator.instance_variable_set(:@results, { + nameservers: { + @nameserver1.hostname => { valid: true, ns_records: ['ns1.other.com', 'ns2.other.com'] }, + @nameserver2.hostname => { valid: true, ns_records: ['ns1.other.com', 'ns2.other.com'] } + }, + dns_records: {}, + dnssec: { ds_updates_needed: [] }, + csync: { csync_records: [], delegation_updates_needed: [] }, + errors: [], + warnings: [] + }) + + validator.send(:check_ns_sync_needed, @nameserver1) + + updates = validator.results[:csync][:delegation_updates_needed] + assert_not_empty updates + + ns_update = updates.find { |u| u[:type] == 'ns_records' } + assert_not_nil ns_update + assert_not_empty ns_update[:add] + assert_includes ns_update[:add], 'ns1.other.com' + end + + # Update methods tests + + test 'update_ds_record creates or updates DS record' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + + # First create a dnskey with required fields that we'll update + existing_key = @domain.dnskeys.create!( + flags: 257, + protocol: 3, + alg: 8, + public_key: 'AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU=', + ds_key_tag: '54321' + ) + + cds_data = { + key_tag: 54321, + algorithm: 8, + digest_type: 2, + digest: 'FEDCBA987654' + } + + validator.define_singleton_method(:create_notification) { |text| nil } + + # update_ds_record should update the existing record + validator.send(:update_ds_record, cds_data) + + # Reload and verify + existing_key.reload + assert_equal '54321', existing_key.ds_key_tag + assert_equal 8, existing_key.ds_alg + assert_equal 2, existing_key.ds_digest_type + assert_equal 'FEDCBA987654', existing_key.ds_digest + end + + test 'remove_all_dnskeys removes all DNSSEC keys' do + # Create additional keys with valid base64 public key + @domain.dnskeys.create!(flags: 256, protocol: 3, alg: 13, public_key: 'AwEAAYCMDMDqoEKPbuW7qPxTvdeWOZsSe8D6v3G9O7cLnWbwFe2yUW6eVG2BRLbo8fIxu0V3u8hHPqnFqzLgV/cHqlIhfcLVgFJLSYVBPqTRh8j1TEL0Rbz6GTzTDVnLO2F8DnudqPmNM1eSjUPmUto3ti7A9z2mfqiEGhtC0YT9Nne3') + + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + validator.define_singleton_method(:create_notification) { |text| nil } + + initial_count = @domain.dnskeys.count + assert initial_count > 0 + + validator.send(:remove_all_dnskeys) + + assert_equal 0, @domain.dnskeys.count + end + + test 'rotate_ksk replaces old KSK with new one' do + old_ksk = @domain.dnskeys.create!(flags: 257, protocol: 3, alg: 13, public_key: 'AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU=') + + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + validator.define_singleton_method(:create_notification) { |text| nil } + + cdnskey_data = { + flags: 257, + protocol: 3, + algorithm: 13, + public_key: 'mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==' + } + + validator.send(:rotate_ksk, cdnskey_data, [old_ksk.id]) + + assert_not @domain.dnskeys.exists?(old_ksk.id) + assert @domain.dnskeys.exists?(public_key: 'mdsswUyr3DPW132mOi8V9xESWE8jTo0dxCjjnopKl+GqJxpVXckHAeF+KkxLbxILfDLUT0rAK9iUzy1L53eKGQ==') + end + + # DNSSEC validation tests + + test 'validate_dnssec_chain checks DNSSEC' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + resolver = create_mock_resolver + + # Mock secure response + secure_response = create_mock_dns_response([]) + secure_response.define_singleton_method(:security_level) { Dnsruby::Message::SecurityLevel.const_get(:SECURE) } + + resolver.define_singleton_method(:dnssec=) { |value| } + resolver.define_singleton_method(:query) { |domain, type| secure_response } + + validator.define_singleton_method(:create_resolver) { |ip| resolver } + + result = validator.send(:validate_dnssec_chain, @nameserver1) + assert result + end + + test 'require_dnssec_validation returns true when domain has keys' do + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + assert validator.send(:require_dnssec_validation?) + end + + test 'require_dnssec_validation returns false when domain has no keys' do + @domain.dnskeys.destroy_all + validator = DNSValidator.new(domain: @domain, name: @domain.name, record_type: 'DNSKEY') + assert_not validator.send(:require_dnssec_validation?) + end + + # Class method tests + + test 'check_only class method creates validator with apply_changes false' do + DNSValidator.stub :new, ->(args) { + assert_equal false, args[:apply_changes] + validator = Object.new + validator.define_singleton_method(:validate) { { test: 'result' } } + validator + } do + result = DNSValidator.check_only(domain: @domain) + assert_equal({ test: 'result' }, result) + end + end + + test 'apply_dnssec_updates class method uses DNSKEY record type' do + DNSValidator.stub :new, ->(args) { + assert_equal 'DNSKEY', args[:record_type] + assert_equal true, args[:apply_changes] + validator = Object.new + validator.define_singleton_method(:validate) { { test: 'result' } } + validator + } do + result = DNSValidator.apply_dnssec_updates(domain: @domain) + assert_equal({ test: 'result' }, result) + end + end + + test 'apply_delegation_updates class method uses CSYNC record type' do + DNSValidator.stub :new, ->(args) { + assert_equal 'CSYNC', args[:record_type] + assert_equal true, args[:apply_changes] + validator = Object.new + validator.define_singleton_method(:validate) { { test: 'result' } } + validator + } do + result = DNSValidator.apply_delegation_updates(domain: @domain) + assert_equal({ test: 'result' }, result) + end + 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 + + def create_mock_cdnskey_record(flags, protocol, algorithm, key) + record = Object.new + record.define_singleton_method(:type) { 'CDNSKEY' } + record.define_singleton_method(:flags) { flags } + record.define_singleton_method(:protocol) { protocol } + record.define_singleton_method(:algorithm) { algorithm } + record.define_singleton_method(:key) { Base64.strict_decode64(key) } + record + end + + def create_mock_csync_record(serial, flags, types) + record = Object.new + record.define_singleton_method(:type) { 'TYPE62' } + record.define_singleton_method(:type_string) { 'TYPE62' } + + # Create binary data for CSYNC record + serial_bytes = [serial].pack('N') + flags_bytes = [flags].pack('n') + + # Create type bitmap for NS (2), A (1), AAAA (28) + # Window 0: types 0-255 + # Types 1 (A) and 2 (NS) are in byte 0: 01100000 = 0x60 + # Type 28 (AAAA) is in byte 3, bit 4: 00001000 = 0x08 + window_0 = "\x00" # Window number 0 + bitmap_len = "\x04" # 4 bytes of bitmap + bitmap = "\x60\x00\x00\x08" # Bits for types 1, 2, and 28 + + type_bitmap = window_0 + bitmap_len + bitmap + + rdata = serial_bytes + flags_bytes + type_bitmap + record.define_singleton_method(:rdata) { rdata } + record + end + + def create_mock_message(name, type_str, class_str) + message = Object.new + message + end +end \ No newline at end of file