feat: add age validation for admin contacts

- Add AgeValidation module for consistent age checks
- Validate admin contacts must be at least 18 years old
- Move age validation logic from Domain to shared module
- Add tests for admin contact age validation
- Fix JSON format for admin_contacts_allowed_ident_type setting

This change ensures that administrative contacts must be adults (18+),
using the same age validation logic as for registrants. The validation
works with both birthday and Estonian ID formats. Settings are now
properly stored as JSON strings for consistent parsing.
This commit is contained in:
oleghasjanov 2025-02-11 12:22:05 +02:00
parent 66619f12fe
commit 7799727867
10 changed files with 181 additions and 51 deletions

View file

@ -1,4 +1,8 @@
class AdminDomainContact < DomainContact
include AgeValidation
validate :validate_contact_age
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
def self.replace(current_contact, new_contact)
@ -23,4 +27,14 @@ class AdminDomainContact < DomainContact
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
private
def validate_contact_age
return unless contact&.underage?
errors.add(:contact, I18n.t(
'activerecord.errors.models.admin_domain_contact.contact_too_young'
))
end
end

View file

@ -0,0 +1,53 @@
module AgeValidation
extend ActiveSupport::Concern
def underage?
case ident_type
when 'birthday'
underage_by_birthday?
when 'priv'
underage_by_estonian_id?
else
false
end
end
private
def underage_by_birthday?
birth_date = Date.parse(ident)
calculate_age(birth_date) < 18
end
def underage_by_estonian_id?
return false unless estonian_id?
birth_date = parse_estonian_id_birth_date(ident)
calculate_age(birth_date) < 18
end
def estonian_id?
ident_country_code == 'EE' && ident.match?(/^\d{11}$/)
end
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
end

View file

@ -10,6 +10,7 @@ class Contact < ApplicationRecord
include Contact::Archivable
include Contact::CompanyRegister
include EmailVerifable
include AgeValidation
belongs_to :original, class_name: 'Contact'
belongs_to :registrar, required: true

View file

@ -12,6 +12,7 @@ class Domain < ApplicationRecord
include Domain::Releasable
include Domain::Disputable
include Domain::BulkUpdatable
include AgeValidation
PERIODS = [
['3 months', '3m'],
@ -866,7 +867,7 @@ class Domain < ApplicationRecord
return true if registrant.org? && Setting.admin_contacts_required_for_org
return false unless registrant.priv?
underage_registrant? && Setting.admin_contacts_required_for_minors
registrant.underage? && Setting.admin_contacts_required_for_minors
end
def require_tech_contacts?
@ -876,51 +877,7 @@ class Domain < ApplicationRecord
private
def underage_registrant?
case registrant.ident_type
when 'birthday'
underage_by_birthday?
when 'priv'
underage_by_estonian_id?
else
false
end
end
def underage_by_birthday?
birth_date = Date.parse(registrant.ident)
calculate_age(birth_date) < 18
end
def underage_by_estonian_id?
return false unless estonian_id?
birth_date = parse_estonian_id_birth_date(registrant.ident)
calculate_age(birth_date) < 18
end
def estonian_id?
registrant.ident_country_code == 'EE' && registrant.ident.match?(/^\d{11}$/)
end
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}")
registrant.underage?
end
def validate_admin_contacts_ident_type

View file

@ -160,6 +160,8 @@ en:
ipv6:
taken: 'has already been taken'
admin_domain_contact:
contact_too_young: "Administrative contact must be at least 18 years old"
attributes:
epp_domain: &epp_domain_attributes

View file

@ -597,7 +597,7 @@ class EppDomainCreateBaseTest < EppTestCase
end
def test_registers_new_domain_with_required_attributes
Setting.admin_contacts_allowed_ident_type = { 'org' => true, 'priv' => true, 'birthday' => true }
Setting.admin_contacts_allowed_ident_type = { 'org' => true, 'priv' => true, 'birthday' => true }.to_json
now = Time.zone.parse('2010-07-05')
travel_to now
@ -647,7 +647,7 @@ class EppDomainCreateBaseTest < EppTestCase
default_registration_period = 1.year + 1.day
assert_equal now + default_registration_period, domain.expire_time
Setting.admin_contacts_allowed_ident_type = { 'org' => false, 'priv' => true, 'birthday' => true }
Setting.admin_contacts_allowed_ident_type = { 'org' => false, 'priv' => true, 'birthday' => true }.to_json
end
def test_registers_domain_without_legaldoc_if_optout

View file

@ -1032,7 +1032,7 @@ class EppDomainUpdateBaseTest < EppTestCase
@domain.save!
# Change allowed types after domain is created
Setting.admin_contacts_allowed_ident_type = { 'birthday' => true, 'priv' => true, 'org' => false }
Setting.admin_contacts_allowed_ident_type = { 'birthday' => true, 'priv' => true, 'org' => false }.to_json
# Try to update domain with some other changes
request_xml = <<-XML
@ -1061,6 +1061,36 @@ class EppDomainUpdateBaseTest < EppTestCase
assert_epp_response :completed_successfully
end
def test_does_not_allow_underage_admin_contact
admin_contact = contacts(:william)
admin_contact.update!(
ident_type: 'priv',
ident: '61203150222',
ident_country_code: 'EE'
)
request_xml = <<-XML
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<epp xmlns="#{Xsd::Schema.filename(for_prefix: 'epp-ee', for_version: '1.0')}">
<command>
<update>
<domain:update xmlns:domain="#{Xsd::Schema.filename(for_prefix: 'domain-ee', for_version: '1.2')}">
<domain:name>#{@domain.name}</domain:name>
<domain:add>
<domain:contact type="admin">#{admin_contact.code}</domain:contact>
</domain:add>
</domain:update>
</update>
</command>
</epp>
XML
post epp_update_path, params: { frame: request_xml },
headers: { 'HTTP_COOKIE' => 'session=api_bestnames' }
assert_epp_response :object_status_prohibits_operation
end
private
def assert_verification_and_notification_emails

View file

@ -14,7 +14,7 @@ class DomainVersionTest < ActiveSupport::TestCase
end
def test_assigns_creator_to_paper_trail_whodunnit
Setting.admin_contacts_allowed_ident_type = { 'org' => true, 'priv' => true, 'birthday' => true }
Setting.admin_contacts_allowed_ident_type = { 'org' => true, 'priv' => true, 'birthday' => true }.to_json
duplicate_domain = prepare_duplicate_domain
PaperTrail.request.whodunnit = @user.id_role_username

View file

@ -14,4 +14,70 @@ class DomainContactTest < ActiveSupport::TestCase
assert @domain_contact.value_typeahead, 'Jane'
end
def test_validates_admin_contact_age_with_birthday
admin_contact = contacts(:john)
admin_contact.update!(
ident_type: 'birthday',
ident: (Time.zone.now - 16.years).strftime('%Y-%m-%d')
)
domain_contact = AdminDomainContact.new(
domain: domains(:shop),
contact: admin_contact
)
assert_not domain_contact.valid?
assert_includes domain_contact.errors.full_messages,
'Contact Administrative contact must be at least 18 years old'
end
def test_validates_admin_contact_age_with_estonian_id
admin_contact = contacts(:john)
admin_contact.update!(
ident_type: 'priv',
ident: '61203150222',
ident_country_code: 'EE'
)
domain_contact = AdminDomainContact.new(
domain: domains(:shop),
contact: admin_contact
)
assert_not domain_contact.valid?
assert_includes domain_contact.errors.full_messages,
'Contact Administrative contact must be at least 18 years old'
end
def test_allows_adult_admin_contact_with_birthday
admin_contact = contacts(:john)
admin_contact.update!(
ident_type: 'birthday',
ident: (Time.zone.now - 20.years).strftime('%Y-%m-%d')
)
domain_contact = AdminDomainContact.new(
domain: domains(:shop),
contact: admin_contact
)
assert domain_contact.valid?
end
def test_allows_adult_admin_contact_with_estonian_id
admin_contact = contacts(:john)
admin_contact.update!(
ident_type: 'priv',
ident: '38903111310',
ident_country_code: 'EE'
)
domain_contact = AdminDomainContact.new(
domain: domains(:shop),
contact: admin_contact
)
assert domain_contact.valid?
end
end

View file

@ -587,7 +587,14 @@ class DomainTest < ActiveSupport::TestCase
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))
admin_contact = contacts(:john)
admin_contact.update!(
ident_type: 'priv',
ident: '37810166020',
ident_country_code: 'EE'
)
domain.admin_domain_contacts.build(contact: admin_contact)
assert domain.valid?
end