From 863dcce647cc4366037cb8abc520881d85ed2b7d Mon Sep 17 00:00:00 2001 From: oleghasjanov Date: Fri, 31 Jan 2025 12:49:10 +0200 Subject: [PATCH] Add admin contact validation rules based on registrant type and age - Add validation requiring admin contacts for legal entity registrants - Add validation requiring admin contacts for underage private registrants: - Under 18 years old for birthday-based identification - Under 18 years old for Estonian ID numbers - Make admin contacts optional for: - Adult private registrants (18+ years) - Adult Estonian ID holders - Non-Estonian private registrants - Add tests covering all new validation scenarios - Add helper methods to calculate age and parse Estonian ID birth dates --- app/models/domain.rb | 51 +++- .../epp/domain/create/base_test.rb | 217 ++++++++++++++---- .../repp/v1/domains/contacts_test.rb | 94 ++++++++ test/models/domain_test.rb | 100 +++++++- 4 files changed, 405 insertions(+), 57 deletions(-) diff --git a/app/models/domain.rb b/app/models/domain.rb index 93e45e55b..2da7c25b3 100644 --- a/app/models/domain.rb +++ b/app/models/domain.rb @@ -206,6 +206,8 @@ class Domain < ApplicationRecord validate :statuses_uniqueness + validate :validate_admin_contact_type + def security_level_resolver resolver = Dnsruby::Resolver.new(nameserver: Dnskey::RESOLVERS) resolver.do_validation = true @@ -857,10 +859,57 @@ class Domain < ApplicationRecord end def require_admin_contacts? - registrant.present? && registrant.org? + return true if registrant.org? + return false unless registrant.priv? + + case registrant.ident_type + when 'birthday' + birth_date = Date.parse(registrant.ident) + calculate_age(birth_date) < 18 + when 'priv' + if registrant.ident_country_code == 'EE' && registrant.ident.match?(/^\d{11}$/) + birth_date = parse_estonian_id_birth_date(registrant.ident) + calculate_age(birth_date) < 18 + else + false + end + else + false + end end def require_tech_contacts? registrant.present? && registrant.org? end + + private + + def calculate_age(birth_date) + ((Time.zone.now - birth_date.to_time) / 1.year.seconds).floor + end + + def parse_estonian_id_birth_date(id_code) + century_number = id_code[0].to_i + year_digits = id_code[1..2] + month = id_code[3..4] + day = id_code[5..6] + + birth_year = case century_number + when 1, 2 then "18#{year_digits}" + when 3, 4 then "19#{year_digits}" + when 5, 6 then "20#{year_digits}" + else + raise ArgumentError, "Invalid century number in Estonian ID" + end + + Date.parse("#{birth_year}-#{month}-#{day}") + end + + def validate_admin_contact_type + admin_contacts.each do |contact| + if contact.org? + errors.add(:admin_contacts, 'Admin contact must be a private person') + end + end + end end diff --git a/test/integration/epp/domain/create/base_test.rb b/test/integration/epp/domain/create/base_test.rb index 71cfa1d42..ec2c967e0 100644 --- a/test/integration/epp/domain/create/base_test.rb +++ b/test/integration/epp/domain/create/base_test.rb @@ -429,6 +429,173 @@ class EppDomainCreateBaseTest < EppTestCase assert_epp_response :parameter_value_policy_error end + def test_registers_new_domain_with_private_registrant_without_admin_contacts + now = Time.zone.parse('2010-07-05') + travel_to now + name = "new.#{dns_zones(:one).origin}" + contact = contacts(:john) + registrant = contact.becomes(Registrant) + + registrant.update!(ident_type: 'priv') + registrant.reload + assert_not registrant.org? + + request_xml = <<-XML + + + + + + #{name} + #{registrant.code} + + + + + #{'test' * 2000} + + + + + XML + + assert_difference 'Domain.count' do + post epp_create_path, params: { frame: request_xml }, + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + end + response_xml = Nokogiri::XML(response.body) + assert_correct_against_schema response_xml + + assert_epp_response :completed_successfully + + domain = Domain.find_by(name: name) + assert_equal name, domain.name + assert_equal registrant, domain.registrant + assert_empty domain.admin_contacts + assert_empty domain.tech_contacts + assert_not_empty domain.transfer_code + + default_registration_period = 1.year + 1.day + assert_equal now + default_registration_period, domain.expire_time + end + + def test_does_not_register_domain_for_legal_entity_without_admin_contact + name = "new.#{dns_zones(:one).origin}" + contact = contacts(:john) + registrant = contact.becomes(Registrant) + + # Устанавливаем регистранта как юр.лицо + registrant.update!(ident_type: 'org') + registrant.reload + assert registrant.org? + + request_xml = <<-XML + + + + + + #{name} + #{registrant.code} + + + + + #{'test' * 2000} + + + + + XML + + assert_no_difference 'Domain.count' do + post epp_create_path, params: { frame: request_xml }, + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + end + + assert_epp_response :parameter_value_range_error + end + + def test_does_not_register_domain_for_underage_estonian_id_without_admin_contact + name = "new.#{dns_zones(:one).origin}" + contact = contacts(:john) + registrant = contact.becomes(Registrant) + + registrant.update!( + ident_type: 'priv', + ident: '61203150222', + ident_country_code: 'EE' + ) + registrant.reload + assert registrant.priv? + + request_xml = <<-XML + + + + + + #{name} + #{registrant.code} + + + + + #{'test' * 2000} + + + + + XML + + assert_no_difference 'Domain.count' do + post epp_create_path, params: { frame: request_xml }, + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + end + + assert_epp_response :parameter_value_range_error + end + + def test_registers_domain_for_adult_estonian_id_without_admin_contact + name = "new.#{dns_zones(:one).origin}" + contact = contacts(:john) + registrant = contact.becomes(Registrant) + + registrant.update!( + ident_type: 'priv', + ident: '38903111310', + ident_country_code: 'EE' + ) + registrant.reload + assert registrant.priv? + + request_xml = <<-XML + + + + + + #{name} + #{registrant.code} + + + + + #{'test' * 2000} + + + + + XML + + assert_difference 'Domain.count' do + post epp_create_path, params: { frame: request_xml }, + headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } + end + + assert_epp_response :completed_successfully + end + def test_registers_new_domain_with_required_attributes now = Time.zone.parse('2010-07-05') travel_to now @@ -941,54 +1108,4 @@ class EppDomainCreateBaseTest < EppTestCase ENV["shunter_default_threshold"] = '10000' ENV["shunter_enabled"] = 'false' end - - def test_registers_new_domain_with_private_registrant_without_admin_contacts - now = Time.zone.parse('2010-07-05') - travel_to now - name = "new.#{dns_zones(:one).origin}" - contact = contacts(:john) - registrant = contact.becomes(Registrant) - - registrant.update!(ident_type: 'priv') - registrant.reload - assert_not registrant.org? - - request_xml = <<-XML - - - - - - #{name} - #{registrant.code} - - - - - #{'test' * 2000} - - - - - XML - - assert_difference 'Domain.count' do - post epp_create_path, params: { frame: request_xml }, - headers: { 'HTTP_COOKIE' => 'session=api_bestnames' } - end - response_xml = Nokogiri::XML(response.body) - assert_correct_against_schema response_xml - - assert_epp_response :completed_successfully - - domain = Domain.find_by(name: name) - assert_equal name, domain.name - assert_equal registrant, domain.registrant - assert_empty domain.admin_contacts - assert_empty domain.tech_contacts - assert_not_empty domain.transfer_code - - default_registration_period = 1.year + 1.day - assert_equal now + default_registration_period, domain.expire_time - end end diff --git a/test/integration/repp/v1/domains/contacts_test.rb b/test/integration/repp/v1/domains/contacts_test.rb index a40cb3d14..c473ac763 100644 --- a/test/integration/repp/v1/domains/contacts_test.rb +++ b/test/integration/repp/v1/domains/contacts_test.rb @@ -146,4 +146,98 @@ class ReppV1DomainsContactsTest < ActionDispatch::IntegrationTest assert @domain.admin_contacts.any? end + + def test_cannot_remove_admin_contact_for_legal_entity + @domain.registrant.update!(ident_type: 'org') + @domain.reload + assert @domain.registrant.org? + + contact = @domain.admin_contacts.last + payload = { contacts: [ { code: contact.code, type: 'admin' } ] } + + delete "/repp/v1/domains/#{@domain.name}/contacts", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert_equal 2004, json[:code] + assert @domain.admin_contacts.any? + end + + def test_cannot_remove_admin_contact_for_underage_private_registrant + @domain.registrant.update!( + ident_type: 'birthday', + ident: (Time.zone.now - 16.years).strftime('%Y-%m-%d') + ) + @domain.reload + assert @domain.registrant.priv? + + contact = @domain.admin_contacts.last + payload = { contacts: [ { code: contact.code, type: 'admin' } ] } + + delete "/repp/v1/domains/#{@domain.name}/contacts", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert_equal 2004, json[:code] + assert @domain.admin_contacts.any? + 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') + ) + @domain.reload + assert @domain.registrant.priv? + + contact = @domain.admin_contacts.last + payload = { contacts: [ { code: contact.code, type: 'admin' } ] } + + delete "/repp/v1/domains/#{@domain.name}/contacts", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 1000, json[:code] + assert_empty @domain.admin_contacts + end + + def test_cannot_remove_admin_contact_for_underage_estonian_id + @domain.registrant.update!( + ident_type: 'priv', + ident: '61203150222', + ident_country_code: 'EE' + ) + @domain.reload + assert @domain.registrant.priv? + + contact = @domain.admin_contacts.last + payload = { contacts: [ { code: contact.code, type: 'admin' } ] } + + delete "/repp/v1/domains/#{@domain.name}/contacts", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :bad_request + assert_equal 2004, json[:code] + assert @domain.admin_contacts.any? + end + + def test_can_remove_admin_contact_for_adult_estonian_id + @domain.registrant.update!( + ident_type: 'priv', + ident: '38903111310', + ident_country_code: 'EE' + ) + @domain.reload + assert @domain.registrant.priv? + + contact = @domain.admin_contacts.last + payload = { contacts: [ { code: contact.code, type: 'admin' } ] } + + delete "/repp/v1/domains/#{@domain.name}/contacts", headers: @auth_headers, params: payload + json = JSON.parse(response.body, symbolize_names: true) + + assert_response :ok + assert_equal 1000, json[:code] + assert_empty @domain.admin_contacts + end end diff --git a/test/models/domain_test.rb b/test/models/domain_test.rb index b9c9056b2..6c56cb928 100644 --- a/test/models/domain_test.rb +++ b/test/models/domain_test.rb @@ -493,16 +493,13 @@ class DomainTest < ActiveSupport::TestCase domain.reload assert_not domain.registrant.org? - # Valid without any admin contacts domain.admin_domain_contacts.clear assert domain.valid?, proc { domain.errors.full_messages } - # Valid with some admin contacts domain.admin_domain_contacts.clear max_count.pred.times { domain.admin_domain_contacts.build(domain_contact_attributes) } assert domain.valid?, proc { domain.errors.full_messages } - # Invalid when exceeding max contacts domain.admin_domain_contacts.clear max_count.next.times { domain.admin_domain_contacts.build(domain_contact_attributes) } assert domain.invalid? @@ -518,21 +515,112 @@ class DomainTest < ActiveSupport::TestCase domain.reload assert_not domain.registrant.org? - # Valid without any tech contacts domain.tech_domain_contacts.clear assert domain.valid?, proc { domain.errors.full_messages } - # Valid with some tech contacts domain.tech_domain_contacts.clear max_count.pred.times { domain.tech_domain_contacts.build(domain_contact_attributes) } assert domain.valid?, proc { domain.errors.full_messages } - # Invalid when exceeding max contacts domain.tech_domain_contacts.clear max_count.next.times { domain.tech_domain_contacts.build(domain_contact_attributes) } assert domain.invalid? end + def test_validates_admin_contact_required_for_legal_entity_registrant + domain = valid_domain + registrant = domain.registrant + + registrant.update!(ident_type: 'org') + domain.reload + assert registrant.org? + + domain.admin_domain_contacts.clear + assert domain.invalid? + assert_includes domain.errors.full_messages, 'Admin domain contacts Admin contacts count must be between 1-10' + + domain.admin_domain_contacts.build(contact: contacts(:john)) + assert domain.valid? + end + + def test_validates_admin_contact_requirements_by_registrant_type + domain = valid_domain + registrant = domain.registrant + + registrant.update!(ident_type: 'org') + domain.reload + assert registrant.org? + domain.admin_domain_contacts.clear + assert domain.invalid? + + registrant.update!( + ident_type: 'birthday', + ident: (Time.zone.now - 20.years).strftime('%Y-%m-%d') + ) + domain.reload + assert registrant.priv? + domain.admin_domain_contacts.clear + assert domain.valid? + + registrant.update!( + ident_type: 'birthday', + ident: (Time.zone.now - 16.years).strftime('%Y-%m-%d') + ) + domain.reload + assert registrant.priv? + domain.admin_domain_contacts.clear + assert domain.invalid? + end + + def test_validates_admin_contact_required_for_underage_estonian_id + domain = valid_domain + registrant = domain.registrant + + registrant.update!( + ident_type: 'priv', + ident: '61203150222', + ident_country_code: 'EE' + ) + domain.reload + + domain.admin_domain_contacts.clear + assert domain.invalid? + assert_includes domain.errors.full_messages, 'Admin domain contacts Admin contacts count must be between 1-10' + + domain.admin_domain_contacts.build(contact: contacts(:john)) + assert domain.valid? + end + + def test_validates_admin_contact_optional_for_adult_estonian_id + domain = valid_domain + registrant = domain.registrant + + registrant.update!( + ident_type: 'priv', + ident: '38903111310', + ident_country_code: 'EE' + ) + domain.reload + + domain.admin_domain_contacts.clear + assert domain.valid? + end + + def test_validates_admin_contact_optional_for_non_estonian_private_id + domain = valid_domain + registrant = domain.registrant + + registrant.update!( + ident_type: 'priv', + ident: '12345678', + ident_country_code: 'LV' + ) + domain.reload + + domain.admin_domain_contacts.clear + assert domain.valid? + end + private def valid_domain