From 8261e61cb04ad6a24dc239b8d4bb716af7058045 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 1/9] 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 1699e359f..d625a9f69 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_03_23_152328) 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 @@ -394,6 +403,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 49bb35e54a569a48398ef5d4ba1f93640cfa6c91 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 2/9] 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 8391fbb08881c99beb0eefa20cfa538798c4c107 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 3/9] 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 aff003abbfd9dd3d7577ba38408e3d9726d9c0b6 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 4/9] 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 b528185d0615397e45512fbd1fdf25dbd57ff3ef 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 5/9] 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 cb94318ec88d07d956c0129b0a0f343537b9601d 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 6/9] 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 ce7c580f5b91660e892761e8f922d01c92c83812 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 7/9] 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 142142c9dd8041e17866f4d253bb58e8786838d7 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 8/9] 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 80f2296747c1a644dcdfb23c07073ebbef8ec536 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 9/9] 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 322112698..6382772ab 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 0cc789d5b..e95e70b90 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')