From 127165939a3c743a61dcd332e2abc2fe14fabae8 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:11:39 +0100 Subject: [PATCH 01/12] Create school_email_domains table Add a migration to create the school_email_domains table, storing a domain string against a school_id --- .../20260420104937_create_school_email_domains.rb | 14 ++++++++++++++ db/schema.rb | 12 +++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260420104937_create_school_email_domains.rb diff --git a/db/migrate/20260420104937_create_school_email_domains.rb b/db/migrate/20260420104937_create_school_email_domains.rb new file mode 100644 index 000000000..0101e995c --- /dev/null +++ b/db/migrate/20260420104937_create_school_email_domains.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateSchoolEmailDomains < ActiveRecord::Migration[7.2] + def change + create_table :school_email_domains, id: :uuid do |t| + t.references :school, null: false, foreign_key: true, type: :uuid + t.string :domain, null: false + + t.timestamps + end + + add_index :school_email_domains, %i[school_id domain], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index eb22dba99..be55c5e42 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_04_10_110000) do +ActiveRecord::Schema[7.2].define(version: 2026_04_20_104937) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -268,6 +268,15 @@ t.index ["school_id"], name: "index_school_classes_on_school_id" end + create_table "school_email_domains", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "school_id", null: false + t.string "domain", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["school_id", "domain"], name: "index_school_email_domains_on_school_id_and_domain", unique: true + t.index ["school_id"], name: "index_school_email_domains_on_school_id" + end + create_table "school_import_results", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "job_id", null: false t.uuid "user_id", null: false @@ -398,6 +407,7 @@ add_foreign_key "projects", "schools" add_foreign_key "roles", "schools" add_foreign_key "school_classes", "schools" + add_foreign_key "school_email_domains", "schools" add_foreign_key "school_project_transitions", "school_projects" add_foreign_key "school_projects", "projects" add_foreign_key "school_projects", "schools" From 89183cf8db7b47e6eb969323d536b0bea33eb7bc Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:09:35 +0100 Subject: [PATCH 02/12] Add SchoolEmailDomain model Create the model and add initial specs --- app/models/school_email_domain.rb | 3 +++ spec/models/school_email_domain_spec.rb | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 app/models/school_email_domain.rb create mode 100644 spec/models/school_email_domain_spec.rb diff --git a/app/models/school_email_domain.rb b/app/models/school_email_domain.rb new file mode 100644 index 000000000..1ecd08d28 --- /dev/null +++ b/app/models/school_email_domain.rb @@ -0,0 +1,3 @@ +class SchoolEmailDomain < ApplicationRecord + belongs_to :school +end \ No newline at end of file diff --git a/spec/models/school_email_domain_spec.rb b/spec/models/school_email_domain_spec.rb new file mode 100644 index 000000000..e1f0ad1ff --- /dev/null +++ b/spec/models/school_email_domain_spec.rb @@ -0,0 +1,17 @@ +require 'rails_helper' + +RSpec.describe SchoolEmailDomain, type: :model do + let(:school) { create(:school, creator_id: SecureRandom.uuid) } + + let(:school_email_domain) do + described_class.new(school: school, domain: 'example.edu') + end + + it 'has a school' do + expect(school_email_domain.school_id).to eq(school.id) + end + + it 'has a domain' do + expect(school_email_domain.domain).to eq('example.edu') + end +end \ No newline at end of file From ec46038f4e3f81b8db615b6b34013f8fe5dde8c7 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:31:51 +0100 Subject: [PATCH 03/12] Format domains Removing leading @ symbols and lower case domains --- app/models/school_email_domain.rb | 18 ++++++++++++-- spec/models/school_email_domain_spec.rb | 33 ++++++++++++++++--------- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/app/models/school_email_domain.rb b/app/models/school_email_domain.rb index 1ecd08d28..f370e5a95 100644 --- a/app/models/school_email_domain.rb +++ b/app/models/school_email_domain.rb @@ -1,3 +1,17 @@ +# frozen_string_literal: true + class SchoolEmailDomain < ApplicationRecord - belongs_to :school -end \ No newline at end of file + belongs_to :school + + validates :domain, presence: true + + before_validation :format_domain + + private + + def format_domain + return if domain.nil? + + self.domain = domain.to_s.strip.sub(/\A@+/, '').downcase + end +end diff --git a/spec/models/school_email_domain_spec.rb b/spec/models/school_email_domain_spec.rb index e1f0ad1ff..64ac5faac 100644 --- a/spec/models/school_email_domain_spec.rb +++ b/spec/models/school_email_domain_spec.rb @@ -1,17 +1,26 @@ +# frozen_string_literal: true + require 'rails_helper' -RSpec.describe SchoolEmailDomain, type: :model do - let(:school) { create(:school, creator_id: SecureRandom.uuid) } - - let(:school_email_domain) do - described_class.new(school: school, domain: 'example.edu') - end - - it 'has a school' do - expect(school_email_domain.school_id).to eq(school.id) +RSpec.describe SchoolEmailDomain do + let(:school) { create(:school, creator_id: SecureRandom.uuid) } + + it { is_expected.to belong_to(:school) } + it { is_expected.to validate_presence_of(:domain) } + + describe 'domain normalisation' do + it 'downcases the domain' do + record = described_class.new(school:, domain: 'EXAMPLE.EDU') + record.valid? + + expect(record.domain).to eq('example.edu') end - it 'has a domain' do - expect(school_email_domain.domain).to eq('example.edu') + it 'removes a leading @' do + record = described_class.new(school:, domain: '@example.edu') + record.valid? + + expect(record.domain).to eq('example.edu') end -end \ No newline at end of file + end +end From d55bdf489567ac332028f36a40a9cd34420a8c65 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Tue, 21 Apr 2026 11:41:41 +0100 Subject: [PATCH 04/12] Add has_many association on School --- app/models/school.rb | 1 + spec/models/school_spec.rb | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/app/models/school.rb b/app/models/school.rb index fe35f00fa..c256a9379 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -6,6 +6,7 @@ class School < ApplicationRecord has_many :projects, dependent: :nullify has_many :roles, dependent: :nullify has_many :school_projects, dependent: :nullify + has_many :school_email_domains, dependent: :destroy VALID_URL_REGEX = %r{\A(?:https?://)?(?:www.)?[a-z0-9]+([-.]{1}[a-z0-9]+)*\.[a-z]{2,63}(\.[a-z]{2,63})*(/.*)?\z}ix diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 7aec97db1..2e72cea2c 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -35,6 +35,12 @@ expect(school.roles.size).to eq(2) end + it 'has many school email domains' do + SchoolEmailDomain.create!(school:, domain: 'example.edu') + SchoolEmailDomain.create!(school:, domain: 'other.edu') + expect(school.school_email_domains.size).to eq(2) + end + context 'when a school is destroyed' do let!(:school_class) { create(:school_class, school:, teacher_ids: [teacher.id]) } let!(:lesson_1) { create(:lesson, user_id: teacher.id, school_class:) } @@ -88,6 +94,11 @@ school.destroy! expect(role.reload.school_id).to be_nil end + + it 'also destroys school email domains' do + SchoolEmailDomain.create!(school:, domain: 'example.edu') + expect { school.destroy! }.to change(SchoolEmailDomain, :count).by(-1) + end end end From 25e5d20f5cbfe35dec3a482866fcdb98188cf789 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:53:46 +0100 Subject: [PATCH 05/12] Scope domain uniqueness to school_id --- app/models/school_email_domain.rb | 1 + spec/models/school_email_domain_spec.rb | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/app/models/school_email_domain.rb b/app/models/school_email_domain.rb index f370e5a95..a3535bb8c 100644 --- a/app/models/school_email_domain.rb +++ b/app/models/school_email_domain.rb @@ -4,6 +4,7 @@ class SchoolEmailDomain < ApplicationRecord belongs_to :school validates :domain, presence: true + validates :domain, uniqueness: { scope: :school_id } before_validation :format_domain diff --git a/spec/models/school_email_domain_spec.rb b/spec/models/school_email_domain_spec.rb index 64ac5faac..55385a340 100644 --- a/spec/models/school_email_domain_spec.rb +++ b/spec/models/school_email_domain_spec.rb @@ -5,6 +5,8 @@ RSpec.describe SchoolEmailDomain do let(:school) { create(:school, creator_id: SecureRandom.uuid) } + subject { described_class.new(school:, domain: 'example.edu') } + it { is_expected.to belong_to(:school) } it { is_expected.to validate_presence_of(:domain) } @@ -22,5 +24,21 @@ expect(record.domain).to eq('example.edu') end + + it 'rejects a duplicate domain for the same school after normalisation' do + described_class.create!(school:, domain: 'example.edu') + duplicate = described_class.new(school:, domain: 'EXAMPLE.EDU') + duplicate.valid? + + expect(duplicate.errors[:domain]).to include('has already been taken') + end + + it 'allows the same domain for a different school' do + described_class.create!(school:, domain: 'example.edu') + other_school = create(:school, creator_id: SecureRandom.uuid) + other = described_class.new(school: other_school, domain: 'example.edu') + + expect(other).to be_valid + end end end From 7ece12241b5f402cebc1ac8d4e6313b2610880b1 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:59:56 +0100 Subject: [PATCH 06/12] Use type of error instead of string --- spec/models/school_email_domain_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/school_email_domain_spec.rb b/spec/models/school_email_domain_spec.rb index 55385a340..317b89551 100644 --- a/spec/models/school_email_domain_spec.rb +++ b/spec/models/school_email_domain_spec.rb @@ -30,7 +30,7 @@ duplicate = described_class.new(school:, domain: 'EXAMPLE.EDU') duplicate.valid? - expect(duplicate.errors[:domain]).to include('has already been taken') + expect(duplicate.errors.of_kind?(:domain, :taken)).to be(true) end it 'allows the same domain for a different school' do From 92c8194f7dd42f542ab435a251830916a8f81e4b Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:39:18 +0100 Subject: [PATCH 07/12] Declare subject above other let statements --- spec/models/school_email_domain_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/models/school_email_domain_spec.rb b/spec/models/school_email_domain_spec.rb index 317b89551..b9a9f23a1 100644 --- a/spec/models/school_email_domain_spec.rb +++ b/spec/models/school_email_domain_spec.rb @@ -3,10 +3,10 @@ require 'rails_helper' RSpec.describe SchoolEmailDomain do - let(:school) { create(:school, creator_id: SecureRandom.uuid) } - subject { described_class.new(school:, domain: 'example.edu') } + let(:school) { create(:school, creator_id: SecureRandom.uuid) } + it { is_expected.to belong_to(:school) } it { is_expected.to validate_presence_of(:domain) } From 645db9a9f1cd8fa3b46557cfd97022af66d1bd24 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:17:04 +0100 Subject: [PATCH 08/12] Parse and return host when URI provided --- app/models/school_email_domain.rb | 21 +++++++++++++++++++-- spec/models/school_email_domain_spec.rb | 24 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/models/school_email_domain.rb b/app/models/school_email_domain.rb index a3535bb8c..4b9afc91a 100644 --- a/app/models/school_email_domain.rb +++ b/app/models/school_email_domain.rb @@ -1,18 +1,35 @@ # frozen_string_literal: true +require 'uri' + class SchoolEmailDomain < ApplicationRecord belongs_to :school validates :domain, presence: true validates :domain, uniqueness: { scope: :school_id } - before_validation :format_domain + before_validation :validate_domain private + def validate_domain + self.domain = format_domain + end + def format_domain return if domain.nil? - self.domain = domain.to_s.strip.sub(/\A@+/, '').downcase + str = domain.to_s.strip.sub(/\A@+/, '') + str = uri_host_if_http_url(str) || str + return str.downcase + end + + def uri_host_if_http_url(str) + return unless str.match?(/\Ahttps?:\/\//i) + + uri = URI.parse(str) + uri.host if uri.is_a?(URI::HTTP) && uri.host.present? + rescue URI::InvalidURIError + nil end end diff --git a/spec/models/school_email_domain_spec.rb b/spec/models/school_email_domain_spec.rb index b9a9f23a1..8a982caf7 100644 --- a/spec/models/school_email_domain_spec.rb +++ b/spec/models/school_email_domain_spec.rb @@ -10,7 +10,31 @@ it { is_expected.to belong_to(:school) } it { is_expected.to validate_presence_of(:domain) } + describe 'public suffix list validation' do + it 'rejects domains that are not valid under the public suffix list' do + record = described_class.new(school:, domain: 'com') + record.valid? + + expect(record).not_to be_valid + expect(record.errors.of_kind?(:domain, :invalid)).to be(true) + end + end + describe 'domain normalisation' do + it 'takes the host from an http URL before other normalisation' do + record = described_class.new(school:, domain: 'http://mail.school.edu/path?query=1') + record.valid? + + expect(record.domain).to eq('mail.school.edu') + end + + it 'takes the host from an https URL before other normalisation' do + record = described_class.new(school:, domain: 'https://EXAMPLE.EDU/') + record.valid? + + expect(record.domain).to eq('example.edu') + end + it 'downcases the domain' do record = described_class.new(school:, domain: 'EXAMPLE.EDU') record.valid? From 719a8093e315429b9a63488c0016029cc3a1bcb7 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Wed, 22 Apr 2026 11:20:03 +0100 Subject: [PATCH 09/12] Validate domains against Public Suffix List --- Gemfile | 1 + Gemfile.lock | 1 + app/models/school_email_domain.rb | 28 +++++++---- spec/models/school_email_domain_spec.rb | 62 +++++++++++++++++++++---- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/Gemfile b/Gemfile index 1f6f0d1bb..ffe1245b5 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,7 @@ gem 'paper_trail' gem 'pg', '~> 1.6' gem 'postmark-rails' gem 'propshaft' +gem 'public_suffix', '~> 7.0' gem 'puma', '~> 7.2' gem 'rack_content_type_default', '~> 1.1' gem 'rack-cors' diff --git a/Gemfile.lock b/Gemfile.lock index 1a57687fb..728c9924d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -614,6 +614,7 @@ DEPENDENCIES postmark-rails propshaft pry-byebug + public_suffix (~> 7.0) puma (~> 7.2) rack-cors rack_content_type_default (~> 1.1) diff --git a/app/models/school_email_domain.rb b/app/models/school_email_domain.rb index 4b9afc91a..2068557ea 100644 --- a/app/models/school_email_domain.rb +++ b/app/models/school_email_domain.rb @@ -1,31 +1,39 @@ # frozen_string_literal: true -require 'uri' - class SchoolEmailDomain < ApplicationRecord belongs_to :school validates :domain, presence: true validates :domain, uniqueness: { scope: :school_id } - before_validation :validate_domain + before_validation :normalise_domain + validate :validate_public_suffix private - def validate_domain - self.domain = format_domain + def normalise_domain + return if domain.nil? + + self.domain = build_normalised_domain_string(domain) end - def format_domain - return if domain.nil? + # Uses the Public Suffix List via the public_suffix gem: values must be a real + # hostname with a registrable name, not a bare suffix like com or co.uk. + # https://publicsuffix.org + def validate_public_suffix + return if domain.blank? + + errors.add(:domain, :invalid) unless PublicSuffix.valid?(domain) + end - str = domain.to_s.strip.sub(/\A@+/, '') + def build_normalised_domain_string(raw) + str = raw.to_s.strip.sub(/\A@+/, '') str = uri_host_if_http_url(str) || str - return str.downcase + str.downcase end def uri_host_if_http_url(str) - return unless str.match?(/\Ahttps?:\/\//i) + return unless str.match?(%r{\Ahttps?://}i) uri = URI.parse(str) uri.host if uri.is_a?(URI::HTTP) && uri.host.present? diff --git a/spec/models/school_email_domain_spec.rb b/spec/models/school_email_domain_spec.rb index 8a982caf7..dd0c1f6a1 100644 --- a/spec/models/school_email_domain_spec.rb +++ b/spec/models/school_email_domain_spec.rb @@ -10,16 +10,6 @@ it { is_expected.to belong_to(:school) } it { is_expected.to validate_presence_of(:domain) } - describe 'public suffix list validation' do - it 'rejects domains that are not valid under the public suffix list' do - record = described_class.new(school:, domain: 'com') - record.valid? - - expect(record).not_to be_valid - expect(record.errors.of_kind?(:domain, :invalid)).to be(true) - end - end - describe 'domain normalisation' do it 'takes the host from an http URL before other normalisation' do record = described_class.new(school:, domain: 'http://mail.school.edu/path?query=1') @@ -48,7 +38,59 @@ expect(record.domain).to eq('example.edu') end + end + + describe 'public suffix list validation' do + context 'when the hostname is only a suffix' do + it 'rejects com' do + record = described_class.new(school:, domain: 'com') + record.valid? + + expect(record).not_to be_valid + expect(record.errors.of_kind?(:domain, :invalid)).to be(true) + end + + it 'rejects edu' do + record = described_class.new(school:, domain: 'edu') + record.valid? + + expect(record).not_to be_valid + expect(record.errors.of_kind?(:domain, :invalid)).to be(true) + end + + it 'rejects co.uk' do + record = described_class.new(school:, domain: 'co.uk') + record.valid? + + expect(record).not_to be_valid + expect(record.errors.of_kind?(:domain, :invalid)).to be(true) + end + end + + context 'when there is at least one registrable label before the public suffix' do + it 'accepts a typical school-style .edu domain' do + record = described_class.new(school:, domain: 'example.edu') + expect(record).to be_valid + end + + it 'accepts a subdomain' do + record = described_class.new(school:, domain: 'mail.example.edu') + expect(record).to be_valid + end + + it 'accepts a hostname under a multi-part public suffix' do + record = described_class.new(school:, domain: 'school.example.co.uk') + expect(record).to be_valid + end + + it 'accepts district-style hosts' do + record = described_class.new(school:, domain: 'school.k12.tx.us') + expect(record).to be_valid + end + end + end + describe 'domain uniqueness' do it 'rejects a duplicate domain for the same school after normalisation' do described_class.create!(school:, domain: 'example.edu') duplicate = described_class.new(school:, domain: 'EXAMPLE.EDU') From 70c61747aaefa7807e3d3adf8c56697757ea4ead Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:54:30 +0100 Subject: [PATCH 10/12] Update validation and specs --- app/models/school_email_domain.rb | 42 ++---- spec/models/school_email_domain_spec.rb | 183 +++++++++++++++--------- 2 files changed, 127 insertions(+), 98 deletions(-) diff --git a/app/models/school_email_domain.rb b/app/models/school_email_domain.rb index 2068557ea..b78a7efb0 100644 --- a/app/models/school_email_domain.rb +++ b/app/models/school_email_domain.rb @@ -6,38 +6,26 @@ class SchoolEmailDomain < ApplicationRecord validates :domain, presence: true validates :domain, uniqueness: { scope: :school_id } - before_validation :normalise_domain - validate :validate_public_suffix + before_validation :validate_domain private - def normalise_domain - return if domain.nil? - - self.domain = build_normalised_domain_string(domain) - end - - # Uses the Public Suffix List via the public_suffix gem: values must be a real - # hostname with a registrable name, not a bare suffix like com or co.uk. - # https://publicsuffix.org - def validate_public_suffix + def validate_domain return if domain.blank? - errors.add(:domain, :invalid) unless PublicSuffix.valid?(domain) - end - - def build_normalised_domain_string(raw) - str = raw.to_s.strip.sub(/\A@+/, '') - str = uri_host_if_http_url(str) || str - str.downcase - end - - def uri_host_if_http_url(str) - return unless str.match?(%r{\Ahttps?://}i) - - uri = URI.parse(str) - uri.host if uri.is_a?(URI::HTTP) && uri.host.present? + value = domain.strip.downcase + # Add a scheme unless it already has one, so URI can parse it + value = "http://#{value}" unless %r{\A[a-z][a-z0-9+\-.]*://}i.match?(value) + uri = URI.parse(value) + host = uri.host&.delete_suffix('.') + return if host.blank? + + if PublicSuffix.valid?(host) + self.domain = host + else + errors.add(:domain, :invalid) + end rescue URI::InvalidURIError - nil + errors.add(:domain, :invalid) end end diff --git a/spec/models/school_email_domain_spec.rb b/spec/models/school_email_domain_spec.rb index dd0c1f6a1..f5499e7f4 100644 --- a/spec/models/school_email_domain_spec.rb +++ b/spec/models/school_email_domain_spec.rb @@ -3,108 +3,149 @@ require 'rails_helper' RSpec.describe SchoolEmailDomain do - subject { described_class.new(school:, domain: 'example.edu') } + subject(:school_email_domain) { described_class.create!(school:, domain:) } let(:school) { create(:school, creator_id: SecureRandom.uuid) } + let(:domain) { 'example.edu' } - it { is_expected.to belong_to(:school) } - it { is_expected.to validate_presence_of(:domain) } + describe 'associations' do + it { is_expected.to belong_to(:school) } + it { is_expected.to validate_presence_of(:domain) } + it { is_expected.to be_valid } + end - describe 'domain normalisation' do - it 'takes the host from an http URL before other normalisation' do - record = described_class.new(school:, domain: 'http://mail.school.edu/path?query=1') - record.valid? + context 'with a valid domain' do + describe 'domain normalisation' do + context 'when given a full url' do + let(:domain) { 'http://mail.school.edu/path?query=1' } - expect(record.domain).to eq('mail.school.edu') - end + it 'extracts the host' do + expect(school_email_domain.domain).to eq('mail.school.edu') + end + end - it 'takes the host from an https URL before other normalisation' do - record = described_class.new(school:, domain: 'https://EXAMPLE.EDU/') - record.valid? + context 'when given a full https url' do + let(:domain) { 'https://mail.school.edu/path' } - expect(record.domain).to eq('example.edu') - end + it 'extracts the host' do + expect(school_email_domain.domain).to eq('mail.school.edu') + end + end - it 'downcases the domain' do - record = described_class.new(school:, domain: 'EXAMPLE.EDU') - record.valid? + context 'when given a domain with a trailing dot' do + let(:domain) { 'EXAMPLE.EDU.' } - expect(record.domain).to eq('example.edu') - end + it 'stores the host without the trailing dot' do + expect(school_email_domain.domain).to eq('example.edu') + end + end - it 'removes a leading @' do - record = described_class.new(school:, domain: '@example.edu') - record.valid? + context 'when given a capitalised host' do + let(:domain) { 'EXAMPLE.EDU' } - expect(record.domain).to eq('example.edu') - end - end + it 'downcases the host' do + expect(school_email_domain.domain).to eq('example.edu') + end + end - describe 'public suffix list validation' do - context 'when the hostname is only a suffix' do - it 'rejects com' do - record = described_class.new(school:, domain: 'com') - record.valid? + context 'with a leading @' do + let(:domain) { '@example.edu' } - expect(record).not_to be_valid - expect(record.errors.of_kind?(:domain, :invalid)).to be(true) + it 'removes the @' do + expect(school_email_domain.domain).to eq('example.edu') + end end + end - it 'rejects edu' do - record = described_class.new(school:, domain: 'edu') - record.valid? + describe 'public suffix list validation' do + context 'when there is at least one registrable label before the public suffix' do + let(:domain) { 'example.edu' } - expect(record).not_to be_valid - expect(record.errors.of_kind?(:domain, :invalid)).to be(true) + it 'accepts the domain' do + expect(school_email_domain.domain).to eq('example.edu') + end end - it 'rejects co.uk' do - record = described_class.new(school:, domain: 'co.uk') - record.valid? + context 'when there is a subdomain before a valid public suffix' do + let(:domain) { 'mail.example.edu' } - expect(record).not_to be_valid - expect(record.errors.of_kind?(:domain, :invalid)).to be(true) + it 'accepts the domain' do + expect(school_email_domain.domain).to eq('mail.example.edu') + end end - end - context 'when there is at least one registrable label before the public suffix' do - it 'accepts a typical school-style .edu domain' do - record = described_class.new(school:, domain: 'example.edu') - expect(record).to be_valid - end + context 'when there is a hostname under a multi-part public suffix' do + let(:domain) { 'school.example.co.uk' } - it 'accepts a subdomain' do - record = described_class.new(school:, domain: 'mail.example.edu') - expect(record).to be_valid + it 'accepts the domain' do + expect(school_email_domain.domain).to eq('school.example.co.uk') + end end - it 'accepts a hostname under a multi-part public suffix' do - record = described_class.new(school:, domain: 'school.example.co.uk') - expect(record).to be_valid - end + context 'when given a district-style host with a multi-part public suffix' do + let(:domain) { 'school.k12.tx.us' } - it 'accepts district-style hosts' do - record = described_class.new(school:, domain: 'school.k12.tx.us') - expect(record).to be_valid + it 'accepts the domain' do + expect(school_email_domain.domain).to eq('school.k12.tx.us') + end end end - end - describe 'domain uniqueness' do - it 'rejects a duplicate domain for the same school after normalisation' do - described_class.create!(school:, domain: 'example.edu') - duplicate = described_class.new(school:, domain: 'EXAMPLE.EDU') - duplicate.valid? + describe 'domain uniqueness' do + context 'when the proposed domain matches the existing record' do + subject(:school_email_domain) { described_class.new(school:, domain:) } - expect(duplicate.errors.of_kind?(:domain, :taken)).to be(true) - end + let(:domain) { 'example.edu' } + + before do + described_class.create!(school:, domain: 'example.edu') + school_email_domain.valid? + end + + it 'rejects the duplicate' do + expect(school_email_domain).not_to be_valid + end + + it 'records :taken on domain' do + expect(school_email_domain.errors.of_kind?(:domain, :taken)).to be(true) + end + end + + context 'when the proposed domain matches after normalisation' do + subject(:school_email_domain) { described_class.new(school:, domain:) } + + let(:domain) { 'http://EXAMPLE.EDU' } - it 'allows the same domain for a different school' do - described_class.create!(school:, domain: 'example.edu') - other_school = create(:school, creator_id: SecureRandom.uuid) - other = described_class.new(school: other_school, domain: 'example.edu') + before do + described_class.create!(school:, domain: 'example.edu') + school_email_domain.valid? + end - expect(other).to be_valid + it 'rejects the duplicate' do + expect(school_email_domain).not_to be_valid + end + + it 'records :taken on domain' do + expect(school_email_domain.errors.of_kind?(:domain, :taken)).to be(true) + end + end + + it 'allows the same domain for a different school' do + described_class.create!(school:, domain: 'example.edu') + other_school = create(:school, creator_id: SecureRandom.uuid) + other_school_email_domain = described_class.new(school: other_school, domain: 'example.edu') + + expect(other_school_email_domain).to be_valid + end end end + + context 'with an invalid domain' do + it { is_expected.not_to allow_value('').for(:domain) } + it { is_expected.not_to allow_value(' ').for(:domain) } + it { is_expected.not_to allow_value('edu').for(:domain) } + it { is_expected.not_to allow_value('com').for(:domain) } + it { is_expected.not_to allow_value('co.uk').for(:domain) } + it { is_expected.not_to allow_value('http://invalid uri').for(:domain) } + end end From 05bf918a1933f83d03330804dd01ea8e30d9d843 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:14:36 +0100 Subject: [PATCH 11/12] Add email domain validation on School --- app/models/school.rb | 4 ++++ spec/models/school_spec.rb | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/app/models/school.rb b/app/models/school.rb index c256a9379..0a487b586 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -117,6 +117,10 @@ def import_in_progress? .exists?(description: id) end + def valid_domain?(candidate_domain) + school_email_domains.exists?(domain: candidate_domain) + end + private # Ensure the reference is nil, not an empty string diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 2e72cea2c..97f10ffef 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -682,4 +682,21 @@ expect(school.reopen).to be(false) end end + + describe '#valid_domain?' do + let(:valid_domain) { 'valid.edu' } + let(:invalid_domain) { 'invalid.edu' } + + before do + SchoolEmailDomain.create!(school:, domain: valid_domain) + end + + it 'returns true when school has registered the email domain' do + expect(school.valid_domain?(valid_domain)).to be(true) + end + + it 'returns false when school has not registered the email domain' do + expect(school.valid_domain?(invalid_domain)).to be(false) + end + end end From f5eb365ad6826efa80a361ec489f0c82d18db676 Mon Sep 17 00:00:00 2001 From: Pete Simonovic <69108995+PetarSimonovic@users.noreply.github.com> Date: Thu, 30 Apr 2026 17:31:58 +0100 Subject: [PATCH 12/12] Align host validation with Profile --- app/models/school_email_domain.rb | 17 ++++++++++++++--- spec/models/school_email_domain_spec.rb | 2 ++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/app/models/school_email_domain.rb b/app/models/school_email_domain.rb index b78a7efb0..dc4ea8010 100644 --- a/app/models/school_email_domain.rb +++ b/app/models/school_email_domain.rb @@ -18,14 +18,25 @@ def validate_domain value = "http://#{value}" unless %r{\A[a-z][a-z0-9+\-.]*://}i.match?(value) uri = URI.parse(value) host = uri.host&.delete_suffix('.') - return if host.blank? + + validate_host(host) + rescue URI::InvalidURIError + errors.add(:domain, :invalid) + end + + def validate_host(host) + accounts_host_format = + /\A\s*(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z]{2,63}\s*\z/i + + unless host&.match?(accounts_host_format) + errors.add(:domain, :invalid) + return + end if PublicSuffix.valid?(host) self.domain = host else errors.add(:domain, :invalid) end - rescue URI::InvalidURIError - errors.add(:domain, :invalid) end end diff --git a/spec/models/school_email_domain_spec.rb b/spec/models/school_email_domain_spec.rb index f5499e7f4..0ca313e00 100644 --- a/spec/models/school_email_domain_spec.rb +++ b/spec/models/school_email_domain_spec.rb @@ -143,9 +143,11 @@ context 'with an invalid domain' do it { is_expected.not_to allow_value('').for(:domain) } it { is_expected.not_to allow_value(' ').for(:domain) } + it { is_expected.not_to allow_value('http://').for(:domain) } it { is_expected.not_to allow_value('edu').for(:domain) } it { is_expected.not_to allow_value('com').for(:domain) } it { is_expected.not_to allow_value('co.uk').for(:domain) } it { is_expected.not_to allow_value('http://invalid uri').for(:domain) } + it { is_expected.not_to allow_value('-wrong.edu').for(:domain) } end end