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.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/app/models/school_email_domain.rb b/app/models/school_email_domain.rb new file mode 100644 index 000000000..2068557ea --- /dev/null +++ b/app/models/school_email_domain.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class SchoolEmailDomain < ApplicationRecord + belongs_to :school + + validates :domain, presence: true + validates :domain, uniqueness: { scope: :school_id } + + before_validation :normalise_domain + validate :validate_public_suffix + + 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 + 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? + rescue URI::InvalidURIError + nil + end +end 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" diff --git a/spec/models/school_email_domain_spec.rb b/spec/models/school_email_domain_spec.rb new file mode 100644 index 000000000..dd0c1f6a1 --- /dev/null +++ b/spec/models/school_email_domain_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SchoolEmailDomain do + 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) } + + 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? + + expect(record.domain).to eq('example.edu') + end + + it 'removes a leading @' do + record = described_class.new(school:, domain: '@example.edu') + record.valid? + + 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') + duplicate.valid? + + expect(duplicate.errors.of_kind?(:domain, :taken)).to be(true) + 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 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