Skip to content

Commit fce88d3

Browse files
Add SchoolEmailDomainValidator
Extract domain validation from school_email_domain model to module Call it from SchoolEmailDomain and School
1 parent afaf941 commit fce88d3

6 files changed

Lines changed: 159 additions & 100 deletions

File tree

app/models/school.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,10 @@ def import_in_progress?
118118
end
119119

120120
def valid_domain?(candidate_domain)
121-
school_email_domains.exists?(domain: candidate_domain)
121+
validated_domain = SchoolEmailDomainValidator.call(candidate_domain)
122+
school_email_domains.exists?(domain: validated_domain)
123+
rescue ::SchoolEmailDomainValidator::Error
124+
false
122125
end
123126

124127
private

app/models/school_email_domain.rb

Lines changed: 3 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,32 +11,8 @@ class SchoolEmailDomain < ApplicationRecord
1111
private
1212

1313
def validate_domain
14-
return if domain.blank?
15-
16-
value = domain.strip.downcase
17-
# Add a scheme unless it already has one, so URI can parse it
18-
value = "http://#{value}" unless %r{\A[a-z][a-z0-9+\-.]*://}i.match?(value)
19-
uri = URI.parse(value)
20-
host = uri.host&.delete_suffix('.')
21-
22-
validate_host(host)
23-
rescue URI::InvalidURIError
24-
errors.add(:domain, :invalid)
25-
end
26-
27-
def validate_host(host)
28-
accounts_host_format =
29-
/\A\s*(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}\s*\z/i
30-
31-
unless host&.match?(accounts_host_format)
32-
errors.add(:domain, :invalid)
33-
return
34-
end
35-
36-
if PublicSuffix.valid?(host)
37-
self.domain = host
38-
else
39-
errors.add(:domain, :invalid)
40-
end
14+
self.domain = SchoolEmailDomainValidator.call(domain)
15+
rescue ::SchoolEmailDomainValidator::Error => e
16+
errors.add(:domain, e.error_code)
4117
end
4218
end
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
module SchoolEmailDomainValidator
4+
class Error < StandardError
5+
def error_code
6+
:invalid
7+
end
8+
end
9+
10+
class BlankDomainError < Error
11+
def error_code
12+
:blank
13+
end
14+
end
15+
16+
class InvalidURIError < Error
17+
def error_code
18+
:invalid_uri
19+
end
20+
end
21+
22+
class InvalidHostError < Error
23+
def error_code
24+
:invalid_host
25+
end
26+
end
27+
28+
class PublicSuffixError < Error
29+
def error_code
30+
:invalid_public_suffix
31+
end
32+
end
33+
34+
def self.call(domain)
35+
raise BlankDomainError if domain.blank?
36+
37+
validate_domain(domain)
38+
end
39+
40+
def self.validate_domain(domain)
41+
value = domain.strip.downcase
42+
# Add a scheme unless it already has one, so URI can parse it
43+
value = "http://#{value}" unless %r{\A[a-z][a-z0-9+\-.]*://}i.match?(value)
44+
uri = URI.parse(value)
45+
host = uri.host&.delete_suffix('.')
46+
47+
validate_host(host)
48+
rescue URI::InvalidURIError
49+
raise InvalidURIError
50+
end
51+
52+
def self.validate_host(host)
53+
accounts_host_format =
54+
/\A\s*(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}\s*\z/i
55+
56+
raise InvalidHostError unless host&.match?(accounts_host_format)
57+
58+
raise PublicSuffixError, 'domain has no registered public suffix' unless PublicSuffix.valid?(host)
59+
60+
host
61+
end
62+
end
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# frozen_string_literal: true
2+
3+
require 'rails_helper'
4+
5+
RSpec.describe SchoolEmailDomainValidator do
6+
describe '.call' do
7+
it 'returns success with a normalised host for a valid domain' do
8+
result = described_class.call('example.com')
9+
10+
expect(result).to eq('example.com')
11+
end
12+
13+
context 'with a valid domain' do
14+
it 'extracts the host for http' do
15+
result = described_class.call('http://mail.school.edu/path?query=1')
16+
expect(result).to eq('mail.school.edu')
17+
end
18+
19+
it 'extracts the host for https' do
20+
result = described_class.call('https://mail.school.edu/path?query=1')
21+
expect(result).to eq('mail.school.edu')
22+
end
23+
24+
it 'removes a trailing dot' do
25+
result = described_class.call('example.edu.')
26+
expect(result).to eq('example.edu')
27+
end
28+
29+
it 'lowercases the host' do
30+
result = described_class.call('EXAMPLE.EDU')
31+
expect(result).to eq('example.edu')
32+
end
33+
34+
it 'removes a leading @' do
35+
result = described_class.call('@example.edu')
36+
expect(result).to eq('example.edu')
37+
end
38+
39+
it 'accepts a subdomain' do
40+
result = described_class.call('mail.example.edu')
41+
expect(result).to eq('mail.example.edu')
42+
end
43+
44+
it 'accepts a hostname er a multi-part public suffix' do
45+
result = described_class.call('school.example.co.uk')
46+
expect(result).to eq('school.example.co.uk')
47+
end
48+
49+
it 'accepts a district-style with a multi-part public suffix' do
50+
result = described_class.call('school.k12.tx.us')
51+
expect(result).to eq('school.k12.tx.us')
52+
end
53+
end
54+
55+
context 'when the domain is blank' do
56+
it 'raises BlankDomain' do
57+
expect { described_class.call('') }.to raise_error(SchoolEmailDomainValidator::BlankDomainError)
58+
end
59+
end
60+
61+
context 'when the domain has an invalid URI' do
62+
it 'raises InvalidURIError' do
63+
expect { described_class.call('https://exa mple.com') }.to raise_error(SchoolEmailDomainValidator::InvalidURIError)
64+
end
65+
end
66+
67+
context 'when the host does not match accounts_host_format' do
68+
it 'raises InvalidHostError' do
69+
expect { described_class.call('school_domain.edu') }
70+
.to raise_error(SchoolEmailDomainValidator::InvalidHostError)
71+
end
72+
end
73+
74+
context 'when the host fails PublicSuffix validation' do
75+
it 'raises PublicSuffixError' do
76+
expect { described_class.call('co.uk') }
77+
.to raise_error(SchoolEmailDomainValidator::PublicSuffixError)
78+
end
79+
end
80+
end
81+
end

spec/models/school_email_domain_spec.rb

Lines changed: 6 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -15,79 +15,15 @@
1515
end
1616

1717
context 'with a valid domain' do
18-
describe 'domain normalisation' do
19-
context 'when given a full url' do
20-
let(:domain) { 'http://mail.school.edu/path?query=1' }
21-
22-
it 'extracts the host' do
23-
expect(school_email_domain.domain).to eq('mail.school.edu')
24-
end
25-
end
26-
27-
context 'when given a full https url' do
28-
let(:domain) { 'https://mail.school.edu/path' }
29-
30-
it 'extracts the host' do
31-
expect(school_email_domain.domain).to eq('mail.school.edu')
32-
end
33-
end
34-
35-
context 'when given a domain with a trailing dot' do
36-
let(:domain) { 'EXAMPLE.EDU.' }
37-
38-
it 'stores the host without the trailing dot' do
39-
expect(school_email_domain.domain).to eq('example.edu')
40-
end
41-
end
42-
43-
context 'when given a capitalised host' do
44-
let(:domain) { 'EXAMPLE.EDU' }
45-
46-
it 'downcases the host' do
47-
expect(school_email_domain.domain).to eq('example.edu')
48-
end
49-
end
50-
51-
context 'with a leading @' do
52-
let(:domain) { '@example.edu' }
53-
54-
it 'removes the @' do
55-
expect(school_email_domain.domain).to eq('example.edu')
56-
end
57-
end
18+
it 'accepts the domain' do
19+
expect(school_email_domain.domain).to eq('example.edu')
5820
end
5921

60-
describe 'public suffix list validation' do
61-
context 'when there is at least one registrable label before the public suffix' do
62-
let(:domain) { 'example.edu' }
63-
64-
it 'accepts the domain' do
65-
expect(school_email_domain.domain).to eq('example.edu')
66-
end
67-
end
68-
69-
context 'when there is a subdomain before a valid public suffix' do
70-
let(:domain) { 'mail.example.edu' }
71-
72-
it 'accepts the domain' do
73-
expect(school_email_domain.domain).to eq('mail.example.edu')
74-
end
75-
end
76-
77-
context 'when there is a hostname under a multi-part public suffix' do
78-
let(:domain) { 'school.example.co.uk' }
79-
80-
it 'accepts the domain' do
81-
expect(school_email_domain.domain).to eq('school.example.co.uk')
82-
end
83-
end
84-
85-
context 'when given a district-style host with a multi-part public suffix' do
86-
let(:domain) { 'school.k12.tx.us' }
22+
describe 'domain normalisation' do
23+
let(:domain) { '@EXAMPLE.EDU.' }
8724

88-
it 'accepts the domain' do
89-
expect(school_email_domain.domain).to eq('school.k12.tx.us')
90-
end
25+
it 'normalises a domain' do
26+
expect(school_email_domain.domain).to eq('example.edu')
9127
end
9228
end
9329

spec/models/school_spec.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,8 @@
685685

686686
describe '#valid_domain?' do
687687
let(:valid_domain) { 'valid.edu' }
688-
let(:invalid_domain) { 'invalid.edu' }
688+
let(:unregistered_domain) { 'invalid.edu' }
689+
let(:invalid_domain) { 'not a domain' }
689690

690691
before do
691692
SchoolEmailDomain.create!(school:, domain: valid_domain)
@@ -696,7 +697,7 @@
696697
end
697698

698699
it 'returns false when school has not registered the email domain' do
699-
expect(school.valid_domain?(invalid_domain)).to be(false)
700+
expect(school.valid_domain?(unregistered_domain)).to be(false)
700701
end
701702
end
702703
end

0 commit comments

Comments
 (0)