Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
1 change: 1 addition & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/models/school.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions app/models/school_email_domain.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true

class SchoolEmailDomain < ApplicationRecord
belongs_to :school

validates :domain, presence: true
Comment thread
PetarSimonovic marked this conversation as resolved.
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
Comment thread
PetarSimonovic marked this conversation as resolved.
end
14 changes: 14 additions & 0 deletions db/migrate/20260420104937_create_school_email_domains.rb
Original file line number Diff line number Diff line change
@@ -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|
Comment thread
PetarSimonovic marked this conversation as resolved.
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
12 changes: 11 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

110 changes: 110 additions & 0 deletions spec/models/school_email_domain_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe SchoolEmailDomain do
Comment thread
PetarSimonovic marked this conversation as resolved.
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) }

Comment thread
PetarSimonovic marked this conversation as resolved.
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
Comment thread
PetarSimonovic marked this conversation as resolved.
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
11 changes: 11 additions & 0 deletions spec/models/school_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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:) }
Expand Down Expand Up @@ -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

Expand Down
Loading