diff --git a/app/controllers/api/join_controller.rb b/app/controllers/api/join_controller.rb index bd56e04c2..2ad41fd06 100644 --- a/app/controllers/api/join_controller.rb +++ b/app/controllers/api/join_controller.rb @@ -14,12 +14,12 @@ def show def create case action_status - when :wrong_school, :domain_mismatch, :not_a_student + when :wrong_school, :not_a_student render json: { error: action_status.to_s }, status: :forbidden when :already_member render json: {}, status: :ok when :joinable - add_student_to_school_and_class + add_student_to_class render json: {}, status: :ok else raise "Unexpected join action_status: #{action_status.inspect}" @@ -43,13 +43,8 @@ def action_status @action_status ||= JoinStatusService.new(school: @school, school_class: @school_class, user: current_user).call end - def add_student_to_school_and_class - ActiveRecord::Base.transaction do - Role.find_or_create_by!(school: @school, user_id: current_user.id, role: :student) - ClassStudent.find_or_create_by!(school_class: @school_class, student_id: current_user.id) do |class_student| - class_student.student = current_user - end - end + def add_student_to_class + @school_class.students.find_or_create_by!(student_id: current_user.id) rescue ActiveRecord::RecordNotUnique # Concurrent join request for the same user/class — DB unique index # caught a race we couldn't catch at validation time. Already enrolled. diff --git a/app/controllers/concerns/identifiable.rb b/app/controllers/concerns/identifiable.rb index af43b0b3e..956cd6d80 100644 --- a/app/controllers/concerns/identifiable.rb +++ b/app/controllers/concerns/identifiable.rb @@ -16,6 +16,8 @@ def load_current_user @current_user = User.from_token(token:) return if @current_user.blank? + + StudentRoleService.ensure_student_role(@current_user) return unless RequestStore.respond_to?(:active?) && RequestStore.active? RequestStore.store[:safeguarding_flag_users_by_token] ||= {} diff --git a/app/models/school.rb b/app/models/school.rb index 3ddb8c92d..b224422c6 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -156,15 +156,6 @@ def valid_domain?(candidate_domain) false end - def email_domain_in_school_domains?(email) - return false if email.blank? - - local, separator, domain = email.to_s.rpartition('@') - return false if separator.empty? || local.blank? || domain.blank? - - valid_domain?(domain.strip.downcase) - end - private # Ensure the reference is nil, not an empty string diff --git a/app/models/user.rb b/app/models/user.rb index a3fcc0870..0c8957b1e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,6 +17,7 @@ class User picture postcode profile + school_id token username roles @@ -54,6 +55,10 @@ def student? Role.student.exists?(user_id: id) end + def school_id + @school_id if student_account_type? + end + def student_account_type? sub.to_s.starts_with?('student:') end diff --git a/app/services/join_status_service.rb b/app/services/join_status_service.rb index 0e03ee2b4..47e5c8187 100644 --- a/app/services/join_status_service.rb +++ b/app/services/join_status_service.rb @@ -9,7 +9,6 @@ # :joinable — user can be enrolled as a student of this class # :not_a_student — user has a non-student role # :wrong_school — user is a student of a different school -# :domain_mismatch — user's email domain isn't registered for the school class JoinStatusService def initialize(school:, school_class:, user:) @school = school @@ -30,7 +29,6 @@ def call def new_user_join_status return :not_a_student if user_has_non_student_account_type? return :wrong_school if user_in_different_school? - return :domain_mismatch unless @school.email_domain_in_school_domains?(@user.email) :joinable end diff --git a/app/services/student_role_service.rb b/app/services/student_role_service.rb new file mode 100644 index 000000000..ca892568f --- /dev/null +++ b/app/services/student_role_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class StudentRoleService + class << self + def ensure_student_role(user) + return unless user.student_account_type? && user.school_id.present? + + Role.student.find_or_create_by!(school_id: user.school_id, user_id: user.id) + end + end +end diff --git a/spec/factories/user.rb b/spec/factories/user.rb index e3240bd69..f0fc71afd 100644 --- a/spec/factories/user.rb +++ b/spec/factories/user.rb @@ -18,10 +18,15 @@ end factory :student do + transient do + school { nil } + end + email { nil } username { Faker::Internet.username } sso_providers { [] } # standard students have no SSO providers sub { "student:#{id}" } + school_id { school&.id || create(:school).id } trait :sso do email { Faker::Internet.email } @@ -29,12 +34,8 @@ sso_providers { ['google'] } # SSO students have SSO providers end - transient do - school { nil } - end - - after(:create) do |user, context| - create(:student_role, user_id: user.id, school: context.school) + after(:create) do |user| + create(:student_role, user_id: user.id, school_id: user.school_id) end end diff --git a/spec/features/school/listing_schools_spec.rb b/spec/features/school/listing_schools_spec.rb index 6b45e7384..46dc3a8f6 100644 --- a/spec/features/school/listing_schools_spec.rb +++ b/spec/features/school/listing_schools_spec.rb @@ -3,37 +3,56 @@ require 'rails_helper' RSpec.describe 'Listing schools', type: :request do - before do - school = create(:school, name: 'Test School') - owner = create(:owner, school:) - authenticated_in_hydra_as(owner) - end - let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school, name: 'Test School') } - it 'responds 200 OK' do - get('/api/schools', headers:) - expect(response).to have_http_status(:ok) + it 'responds 401 Unauthorized when no token is given' do + get '/api/schools' + expect(response).to have_http_status(:unauthorized) end - it 'responds with the schools JSON' do - get('/api/schools', headers:) - data = JSON.parse(response.body, symbolize_names: true) + context 'when the user is a school owner' do + let(:user) { create(:owner, school:) } - expect(data.first[:name]).to eq('Test School') - end + before do + authenticated_in_hydra_as(user) + end - it 'only includes schools the user belongs to' do - create(:school, id: SecureRandom.uuid) + it 'responds 200 OK' do + get('/api/schools', headers:) + expect(response).to have_http_status(:ok) + end - get('/api/schools', headers:) - data = JSON.parse(response.body, symbolize_names: true) + it 'responds with the schools JSON' do + get('/api/schools', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('Test School') + end - expect(data.size).to eq(1) + it 'only includes schools the user belongs to' do + create(:school, id: SecureRandom.uuid) + + get('/api/schools', headers:) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.size).to eq(1) + end + + it 'responds 401 Unauthorized when no token is given' do + get '/api/schools' + expect(response).to have_http_status(:unauthorized) + end end - it 'responds 401 Unauthorized when no token is given' do - get '/api/schools' - expect(response).to have_http_status(:unauthorized) + it 'creates a student role when the students token includes a school claim but they have no school already' do + user = build(:student, school_id: school.id) + authenticated_in_hydra_as(user, :student) + + expect { get('/api/schools', headers:) } + .to change { Role.student.where(school:, user_id: user.id).count }.by(1) + data = JSON.parse(response.body, symbolize_names: true) + + expect(data.first[:name]).to eq('Test School') end end diff --git a/spec/lib/profile_api_client_spec.rb b/spec/lib/profile_api_client_spec.rb index 0b9dfb446..1033baab1 100644 --- a/spec/lib/profile_api_client_spec.rb +++ b/spec/lib/profile_api_client_spec.rb @@ -410,8 +410,8 @@ def list_school_students let(:username) { 'username' } let(:password) { 'password' } let(:name) { 'name' } - let(:school) { build(:school, id: SecureRandom.uuid) } - let(:student) { create(:student, school:) } + let(:school) { create(:school, id: SecureRandom.uuid) } + let(:student) { create(:student, school_id: school.id) } let(:update_student_url) { "#{api_url}/api/v1/schools/#{school.id}/students/#{student.id}" } before do diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index cca6f977c..20cffcc64 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -775,36 +775,4 @@ expect(school.valid_domain?(unregistered_domain)).to be(false) end end - - describe '#email_domain_in_school_domains?' do - before do - SchoolEmailDomain.create!(school:, domain: 'valid.edu') - end - - it 'returns true when the email domain is registered for the school' do - expect(school.email_domain_in_school_domains?('user@valid.edu')).to be(true) - end - - it 'returns false when the email domain is not registered for the school' do - expect(school.email_domain_in_school_domains?('user@other.edu')).to be(false) - end - - it 'normalizes case and surrounding whitespace on the domain' do - expect(school.email_domain_in_school_domains?('User@VALID.EDU ')).to be(true) - end - - it 'returns false when the email is blank' do - expect(school.email_domain_in_school_domains?('')).to be(false) - expect(school.email_domain_in_school_domains?(nil)).to be(false) - end - - it 'returns false when the email has no @ separator' do - expect(school.email_domain_in_school_domains?('not-an-email')).to be(false) - end - - it 'returns false when the local part or domain is missing' do - expect(school.email_domain_in_school_domains?('@valid.edu')).to be(false) - expect(school.email_domain_in_school_domains?('user@')).to be(false) - end - end end diff --git a/spec/requests/join_controller_spec.rb b/spec/requests/join_controller_spec.rb index ed6817390..03281452f 100644 --- a/spec/requests/join_controller_spec.rb +++ b/spec/requests/join_controller_spec.rb @@ -5,7 +5,7 @@ RSpec.describe 'Join endpoint' do let(:school) { create(:school, code: '12-34-56') } let(:school_class) { create(:school_class, school:, join_code: 'B123-C456') } - let(:student) { build(:student, email: 'student@example.edu') } + let(:student) { build(:student, email: 'student@example.edu', school_id: school.id) } let(:teacher) { build(:teacher, email: 'teacher@example.edu') } let(:owner) { build(:owner, email: 'owner@example.edu') } let(:headers) { { Authorization: UserProfileMock::TOKEN } } @@ -43,9 +43,8 @@ end context 'when the user is authenticated as a student' do - before { authenticated_in_hydra_as(student, :student) } - it 'returns status: joinable when the user can join' do + authenticated_in_hydra_as(student, :student) get "/api/join/#{school_class.join_code}", headers: headers data = JSON.parse(response.body, symbolize_names: true) @@ -53,6 +52,7 @@ end it 'returns status: already_member when the user is already in the class' do + authenticated_in_hydra_as(student, :student) create(:student_role, school:, user_id: student.id) ClassStudent.create!(school_class:, student_id: student.id) @@ -64,33 +64,14 @@ it 'returns status: wrong_school when the user belongs to a different school' do other_school = create(:school) - create(:student_role, school: other_school, user_id: student.id) + student = build(:student, school_id: other_school.id) + authenticated_in_hydra_as(student, :student) get "/api/join/#{school_class.join_code}", headers: headers data = JSON.parse(response.body, symbolize_names: true) expect(data[:status]).to eq('wrong_school') end - - context 'when the email domain is not registered for the school' do - let(:student) { build(:student, email: 'student@other.edu') } - - it 'returns status: domain_mismatch' do - get "/api/join/#{school_class.join_code}", headers: headers - - data = JSON.parse(response.body, symbolize_names: true) - expect(data[:status]).to eq('domain_mismatch') - end - - it 'returns status: joinable when the user is already a student of the school' do - create(:student_role, school:, user_id: student.id) - - get "/api/join/#{school_class.join_code}", headers: headers - - data = JSON.parse(response.body, symbolize_names: true) - expect(data[:status]).to eq('joinable') - end - end end context 'when the user is authenticated as a teacher' do @@ -149,9 +130,8 @@ end context 'when the user is authenticated as a student' do - before { authenticated_in_hydra_as(student, :student) } - it 'adds the user to the school' do + authenticated_in_hydra_as(student, :student) expect do post "/api/join/#{school_class.join_code}", headers: headers end.to change(ClassStudent, :count).by(1).and change(Role, :count).by(1) @@ -163,6 +143,7 @@ end it 'is idempotent when the user is already in the class' do + authenticated_in_hydra_as(student, :student) create(:student_role, school:, user_id: student.id) ClassStudent.create!(school_class:, student_id: student.id) @@ -174,6 +155,7 @@ end it 'does not duplicate the school role if the user is already in the school' do + authenticated_in_hydra_as(student, :student) create(:student_role, school:, user_id: student.id) expect do @@ -185,7 +167,8 @@ it 'responds with 403 wrong_school when the user belongs to a different school' do other_school = create(:school) - create(:student_role, school: other_school, user_id: student.id) + student = build(:student, school_id: other_school.id) + authenticated_in_hydra_as(student, :student) post "/api/join/#{school_class.join_code}", headers: headers @@ -195,12 +178,14 @@ end it 'responds with 404 when the join code does not exist' do + authenticated_in_hydra_as(student, :student) post '/api/join/INVALID123', headers: headers expect(response).to have_http_status(:not_found) end # rubocop:disable-next RSpec/AnyInstance it 'responds with 500 when action_status returns an unexpected value' do + authenticated_in_hydra_as(student, :student) allow_any_instance_of(Api::JoinController).to receive(:action_status).and_return(:something_unexpected) post "/api/join/#{school_class.join_code}", headers: headers @@ -208,30 +193,6 @@ expect(response).to have_http_status(:internal_server_error) expect(response.body).to include('Unexpected join action_status') end - - context 'when the email domain is not registered for the school' do - let(:student) { build(:student, email: 'student@other.edu') } - - it 'responds with 403 domain_mismatch and does not enroll the user' do - expect do - post "/api/join/#{school_class.join_code}", headers: headers - end.not_to change(ClassStudent, :count) - - expect(response).to have_http_status(:forbidden) - data = JSON.parse(response.body, symbolize_names: true) - expect(data[:error]).to eq('domain_mismatch') - end - - it 'enrolls the user when they are already a student of the school' do - create(:student_role, school:, user_id: student.id) - - expect do - post "/api/join/#{school_class.join_code}", headers: headers - end.to change(ClassStudent, :count).by(1) - - expect(response).to have_http_status(:ok) - end - end end context 'when the user is authenticated as a teacher' do diff --git a/spec/services/join_status_service_spec.rb b/spec/services/join_status_service_spec.rb index 3b2bcfea3..e555f267c 100644 --- a/spec/services/join_status_service_spec.rb +++ b/spec/services/join_status_service_spec.rb @@ -86,14 +86,6 @@ end end - context "when the user's email domain is not registered for the school" do - let(:user) { build(:student, email: 'user@other.edu') } - - it 'returns :domain_mismatch' do - expect(service.call).to eq(:domain_mismatch) - end - end - context 'when the user has no prior role and their email domain matches the school' do it 'returns :joinable' do expect(service.call).to eq(:joinable) diff --git a/spec/services/student_role_service_spec.rb b/spec/services/student_role_service_spec.rb new file mode 100644 index 000000000..2659a980b --- /dev/null +++ b/spec/services/student_role_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe StudentRoleService do + let(:school) { create(:school) } + + describe '.ensure_student_role' do + it 'does not create a role for a user without a student account' do + user = build(:user, school_id: school.id) + + expect { described_class.ensure_student_role(user) }.not_to change(Role, :count) + end + + it 'does not create a role when the school_id is not set' do + user = build(:student, school_id: nil) + + expect { described_class.ensure_student_role(user) }.not_to change(Role, :count) + end + + it 'creates a student role in the school of a student account' do + user = build(:student, school_id: school.id) + + described_class.ensure_student_role(user) + + expect(Role.student.find_by(user_id: user.id, school_id: school.id)).to be_present + end + + it 'does not create another role when the student role already exists' do + user = build(:student, school_id: school.id) + create(:student_role, user_id: user.id, school:) + + expect { described_class.ensure_student_role(user) }.not_to change(Role, :count) + end + end +end diff --git a/spec/support/user_profile_mock.rb b/spec/support/user_profile_mock.rb index c694d158c..3a7232dbc 100644 --- a/spec/support/user_profile_mock.rb +++ b/spec/support/user_profile_mock.rb @@ -32,7 +32,8 @@ def user_to_hash(user, user_type, id_field = :id) name: user.name, email: user.email, username: user.username, - roles: user.roles + roles: user.roles, + school_id: user.school_id } end