From 3b9af1b021999fbb7032108e92e23875f237cabc Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 09:25:08 +0100 Subject: [PATCH 01/16] add nominee, requester and status columns to ownership transfers --- ...0506_add_nominee_and_status_to_ownership_transfers.rb | 9 +++++++++ db/schema.rb | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb diff --git a/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb b/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb new file mode 100644 index 000000000..e3d1a540b --- /dev/null +++ b/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddNomineeAndStatusToOwnershipTransfers < ActiveRecord::Migration[8.1] + def change + add_column :ownership_transfers, :nominated_user_id, :uuid, null: false + add_column :ownership_transfers, :requested_by_user_id, :uuid, null: false + add_column :ownership_transfers, :status, :integer, null: false, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index b05ab35a8..3167c57f8 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[8.1].define(version: 2026_09_11_104254) do +ActiveRecord::Schema[8.1].define(version: 2026_09_15_080506) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -226,7 +226,10 @@ t.datetime "accepted_at" t.datetime "created_at", null: false t.string "email_address" + t.uuid "nominated_user_id", null: false + t.uuid "requested_by_user_id", null: false t.uuid "school_id", null: false + t.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.index ["school_id"], name: "index_ownership_transfers_on_school_id" end From 768f1331b012147d0f5834f3efaa71c8bdefaa0c Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 10:37:19 +0100 Subject: [PATCH 02/16] Revert "add nominee, requester and status columns to ownership transfers" This reverts commit 3b9af1b021999fbb7032108e92e23875f237cabc. --- ...0506_add_nominee_and_status_to_ownership_transfers.rb | 9 --------- db/schema.rb | 5 +---- 2 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb diff --git a/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb b/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb deleted file mode 100644 index e3d1a540b..000000000 --- a/db/migrate/20260915080506_add_nominee_and_status_to_ownership_transfers.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class AddNomineeAndStatusToOwnershipTransfers < ActiveRecord::Migration[8.1] - def change - add_column :ownership_transfers, :nominated_user_id, :uuid, null: false - add_column :ownership_transfers, :requested_by_user_id, :uuid, null: false - add_column :ownership_transfers, :status, :integer, null: false, default: 0 - end -end diff --git a/db/schema.rb b/db/schema.rb index 3167c57f8..b05ab35a8 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[8.1].define(version: 2026_09_15_080506) do +ActiveRecord::Schema[8.1].define(version: 2026_09_11_104254) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -226,10 +226,7 @@ t.datetime "accepted_at" t.datetime "created_at", null: false t.string "email_address" - t.uuid "nominated_user_id", null: false - t.uuid "requested_by_user_id", null: false t.uuid "school_id", null: false - t.integer "status", default: 0, null: false t.datetime "updated_at", null: false t.index ["school_id"], name: "index_ownership_transfers_on_school_id" end From e8eceae0a02f1541f58597d00654f3503a7fef09 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 11:13:15 +0100 Subject: [PATCH 03/16] add string enum status, nominee and requester to ownership_transfers table --- ...3824_add_nominee_and_status_to_ownership_transfers.rb | 9 +++++++++ db/schema.rb | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb diff --git a/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb b/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb new file mode 100644 index 000000000..35675e8dc --- /dev/null +++ b/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddNomineeAndStatusToOwnershipTransfers < ActiveRecord::Migration[8.1] + def change + add_column :ownership_transfers, :nominated_user_id, :uuid, null: false + add_column :ownership_transfers, :requested_by_user_id, :uuid, null: false + add_column :ownership_transfers, :status, :string, null: false, default: 'pre_pending' + end +end diff --git a/db/schema.rb b/db/schema.rb index b05ab35a8..b293d4fba 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[8.1].define(version: 2026_09_11_104254) do +ActiveRecord::Schema[8.1].define(version: 2026_09_15_093824) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -226,7 +226,10 @@ t.datetime "accepted_at" t.datetime "created_at", null: false t.string "email_address" + t.uuid "nominated_user_id", null: false + t.uuid "requested_by_user_id", null: false t.uuid "school_id", null: false + t.string "status", default: "pre_pending", null: false t.datetime "updated_at", null: false t.index ["school_id"], name: "index_ownership_transfers_on_school_id" end From 3aa9ec62345df38a1a928320aa25c867373f1c4d Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 12:44:43 +0100 Subject: [PATCH 04/16] update owner transfer model, factory and test --- app/models/ownership_transfer.rb | 24 ++++ spec/factories/ownership_transfer.rb | 7 +- spec/models/ownership_transfer_spec.rb | 151 ++++++++++++++++++++++--- 3 files changed, 162 insertions(+), 20 deletions(-) diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb index 1b65ca227..427cd6288 100644 --- a/app/models/ownership_transfer.rb +++ b/app/models/ownership_transfer.rb @@ -4,13 +4,37 @@ class OwnershipTransfer < ApplicationRecord delegate :name, to: :school, prefix: true belongs_to :school + + enum :status, { + pre_pending: 'pre_pending', pending: 'pending', + pre_completion: 'pre_completion', completed: 'completed', + pre_rejected: 'pre_rejected', rejected: 'rejected', + pre_cancelled: 'pre_cancelled', cancelled: 'cancelled' + }, default: :pre_pending, validate: true + + validates :nominated_user_id, presence: true + validates :requested_by_user_id, presence: true validates :email_address, format: { with: EmailValidator.regexp, message: I18n.t('validations.invitation.email_address') } + validate :nominee_has_the_school_owner_or_school_teacher_role_for_the_school + after_create_commit :send_ownership_transfer_request_email encrypts :email_address private + def nominee_has_the_school_owner_or_school_teacher_role_for_the_school + return unless nominated_user_id_changed? && errors.blank? && school + + nominated_user = User.from_userinfo(ids: [nominated_user_id]).first + + return if nominated_user.school_owner?(school) + return if nominated_user.school_teacher?(school) + + msg = "'#{nominated_user_id}' does not have the 'owner' or 'teacher' role for school '#{school.id}'" + errors.add(:nominated_user_id, msg) + end + def send_ownership_transfer_request_email SchoolOwnershipMailer.with(ownership_transfer: self).request_ownership_transfer.deliver_later end diff --git a/spec/factories/ownership_transfer.rb b/spec/factories/ownership_transfer.rb index 33c89a03a..c5b3f458e 100644 --- a/spec/factories/ownership_transfer.rb +++ b/spec/factories/ownership_transfer.rb @@ -2,7 +2,10 @@ FactoryBot.define do factory :ownership_transfer do - email_address { 'new-owner@example.com' } - school factory: :verified_school + school + email_address { Faker::Internet.email } + nominated_user_id { SecureRandom.uuid } + requested_by_user_id { SecureRandom.uuid } + status { 'pre_pending' } end end diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb index 618ef0057..c96685252 100644 --- a/spec/models/ownership_transfer_spec.rb +++ b/spec/models/ownership_transfer_spec.rb @@ -5,37 +5,152 @@ RSpec.describe OwnershipTransfer do include ActionMailer::TestHelper - it 'has a valid factory' do - ownership_transfer = build(:ownership_transfer) + subject(:ownership_transfer) { build(:ownership_transfer, school:, nominated_user_id: nominee.id) } - expect(ownership_transfer).to be_valid + let(:school) { create(:verified_school) } + let(:nominee) { create(:teacher, school:) } + + before do + stub_user_info_api_fetch_by_ids(user_ids: [nominee.id]) end - it 'is invalid with an incorrectly formatted email address' do - ownership_transfer = build(:ownership_transfer, email_address: 'not-an-email-address') + describe 'validations' do + it 'has a valid factory' do + expect(ownership_transfer).to be_valid + end + + it 'requires a school' do + ownership_transfer.school = nil + + expect(ownership_transfer).not_to be_valid + end + + it 'requires a nominated_user_id' do + ownership_transfer.nominated_user_id = nil + + expect(ownership_transfer).not_to be_valid + end + + it 'requires a requested_by_user_id' do + ownership_transfer.requested_by_user_id = nil + + expect(ownership_transfer).not_to be_valid + end - expect(ownership_transfer).not_to be_valid + it 'requires an email_address' do + ownership_transfer.email_address = nil + + expect(ownership_transfer).not_to be_valid + end + + it 'is invalid with an incorrectly formatted email address' do + ownership_transfer.email_address = 'not-an-email-address' + + expect(ownership_transfer).not_to be_valid + end + + it 'non-deterministically encrypts the email_address' do + ownership_transfer.save! + + expect(described_class.find_by(email_address: ownership_transfer.email_address)).to be_nil + end end - it 'sends an ownership transfer request email after create' do - school = create(:verified_school) + describe 'status' do + it 'defaults to pre_pending on a new record' do + expect(ownership_transfer.status).to eq('pre_pending') + end + + it 'is valid for every declared status' do + described_class.statuses.each_key do |status| + ownership_transfer.status = status + + expect(ownership_transfer).to be_valid + end + end - ownership_transfer = described_class.create!(email_address: 'new-owner@example.com', school:) + it 'is invalid when set to a status outside the enum' do + ownership_transfer.status = 'made-up-status' - assert_enqueued_email_with SchoolOwnershipMailer, :request_ownership_transfer, params: { ownership_transfer: } + expect(ownership_transfer).not_to be_valid + end + + it 'exposes a predicate for the current status' do + described_class.statuses.each_key do |status| + ownership_transfer.status = status + + expect(ownership_transfer.public_send("#{status}?")).to be true + end + end + + it 'exposes a scope per status' do + described_class.statuses.each_key do |status| + ownership_transfer.status = status + ownership_transfer.save! + + expect(described_class.public_send(status)).to include(ownership_transfer) + end + end end - it 'delegates #school_name to School#name' do - school = build(:school, name: 'school-name') - ownership_transfer = build(:ownership_transfer, school:) + describe 'nominee role validation' do + it 'does not run when nominated_user_id is unchanged' do + ownership_transfer.save! + ownership_transfer.update!(status: :pending) + + # only the save! above should have looked up the nominee; the role check + # is skipped on update since nominated_user_id isn't changing + expect(UserInfoApiClient).to have_received(:fetch_by_ids).once + end + + it 'is valid when the nominee has the teacher role for the school' do + expect(ownership_transfer).to be_valid + end + + it 'is valid when the nominee has the owner role for the school' do + owner = create(:owner, school:) + stub_user_info_api_fetch_by_ids(user_ids: [owner.id]) + ownership_transfer.nominated_user_id = owner.id + + expect(ownership_transfer).to be_valid + end + + it 'is invalid when the nominee has only the student role for the school' do + student = create(:student, school:) + stub_user_info_api_fetch_by_ids(user_ids: [student.id]) + ownership_transfer.nominated_user_id = student.id + + expect(ownership_transfer).not_to be_valid + end + + it 'is invalid when the nominee has a teacher role for a different school' do + other_school = create(:verified_school) + other_teacher = create(:teacher, school: other_school) + stub_user_info_api_fetch_by_ids(user_ids: [other_teacher.id]) + ownership_transfer.nominated_user_id = other_teacher.id + + expect(ownership_transfer).not_to be_valid + end + + it 'adds an error naming the nominated_user_id and the school id' do + student = create(:student, school:) + stub_user_info_api_fetch_by_ids(user_ids: [student.id]) + ownership_transfer.nominated_user_id = student.id + + ownership_transfer.valid? - expect(ownership_transfer.school_name).to eq('school-name') + expect(ownership_transfer.errors[:nominated_user_id].first).to include(student.id) + expect(ownership_transfer.errors[:nominated_user_id].first).to include(school.id) + end end - it 'non-deterministically encrypts the email_address' do - school = create(:verified_school) - described_class.create!(email_address: 'new-owner@example.com', school:) + describe 'the request email' do + it 'is enqueued with the transfer as the mailer param' do + ownership_transfer.save! - expect(described_class.find_by(email_address: 'new-owner@example.com')).to be_nil + assert_enqueued_email_with( + SchoolOwnershipMailer, :request_ownership_transfer, params: { ownership_transfer: } + ) + end end end From 08ace9621b352944bc0bb9fc92fea41e810f006a Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 13:01:58 +0100 Subject: [PATCH 05/16] add has many association to 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 3ddb8c92d..db4d790c1 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -7,6 +7,7 @@ class School < ApplicationRecord has_many :roles, dependent: :nullify has_many :school_projects, dependent: :nullify has_many :school_email_domains, dependent: :destroy + has_many :ownership_transfers, 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 cca6f977c..af610a072 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -41,6 +41,17 @@ expect(school.school_email_domains.size).to eq(2) end + it 'has many ownership transfers' do + owner_one = create(:owner_role, school:) + owner_two = create(:owner_role, school:) + stub_user_info_api_fetch_by_ids(user_ids: [owner_one.user_id]) + create(:ownership_transfer, school:, requested_by_user_id: owner_one.user_id, nominated_user_id: owner_one.user_id) + stub_user_info_api_fetch_by_ids(user_ids: [owner_two.user_id]) + create(:ownership_transfer, school:, requested_by_user_id: owner_two.user_id, nominated_user_id: owner_two.user_id) + + expect(school.ownership_transfers.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:) } From 521a896c02f626a5802971f066d0bd4c79cb1334 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 15:18:32 +0100 Subject: [PATCH 06/16] redo migration with updated status default --- ...60915093824_add_nominee_and_status_to_ownership_transfers.rb | 2 +- db/schema.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb b/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb index 35675e8dc..6db23a243 100644 --- a/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb +++ b/db/migrate/20260915093824_add_nominee_and_status_to_ownership_transfers.rb @@ -4,6 +4,6 @@ class AddNomineeAndStatusToOwnershipTransfers < ActiveRecord::Migration[8.1] def change add_column :ownership_transfers, :nominated_user_id, :uuid, null: false add_column :ownership_transfers, :requested_by_user_id, :uuid, null: false - add_column :ownership_transfers, :status, :string, null: false, default: 'pre_pending' + add_column :ownership_transfers, :status, :string, null: false, default: 'pending' end end diff --git a/db/schema.rb b/db/schema.rb index b293d4fba..fa1861ef6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -229,7 +229,7 @@ t.uuid "nominated_user_id", null: false t.uuid "requested_by_user_id", null: false t.uuid "school_id", null: false - t.string "status", default: "pre_pending", null: false + t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["school_id"], name: "index_ownership_transfers_on_school_id" end From 9649cac66a2fe51df3a26790d74af5c68135a3f4 Mon Sep 17 00:00:00 2001 From: cocomarine Date: Tue, 15 Sep 2026 15:33:46 +0100 Subject: [PATCH 07/16] update models and test with status default pending and simplify nominated_user logic --- app/models/ownership_transfer.rb | 12 +++--------- spec/factories/ownership_transfer.rb | 2 +- spec/models/ownership_transfer_spec.rb | 22 ++-------------------- spec/models/school_spec.rb | 4 +--- 4 files changed, 7 insertions(+), 33 deletions(-) diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb index 427cd6288..d364b8234 100644 --- a/app/models/ownership_transfer.rb +++ b/app/models/ownership_transfer.rb @@ -6,11 +6,8 @@ class OwnershipTransfer < ApplicationRecord belongs_to :school enum :status, { - pre_pending: 'pre_pending', pending: 'pending', - pre_completion: 'pre_completion', completed: 'completed', - pre_rejected: 'pre_rejected', rejected: 'rejected', - pre_cancelled: 'pre_cancelled', cancelled: 'cancelled' - }, default: :pre_pending, validate: true + pending: 'pending', completed: 'completed', rejected: 'rejected', cancelled: 'cancelled' + }, default: :pending, validate: true validates :nominated_user_id, presence: true validates :requested_by_user_id, presence: true @@ -26,10 +23,7 @@ class OwnershipTransfer < ApplicationRecord def nominee_has_the_school_owner_or_school_teacher_role_for_the_school return unless nominated_user_id_changed? && errors.blank? && school - nominated_user = User.from_userinfo(ids: [nominated_user_id]).first - - return if nominated_user.school_owner?(school) - return if nominated_user.school_teacher?(school) + return if school.roles.exists?(user_id: nominated_user_id, role: %i[owner teacher]) msg = "'#{nominated_user_id}' does not have the 'owner' or 'teacher' role for school '#{school.id}'" errors.add(:nominated_user_id, msg) diff --git a/spec/factories/ownership_transfer.rb b/spec/factories/ownership_transfer.rb index c5b3f458e..3e630476b 100644 --- a/spec/factories/ownership_transfer.rb +++ b/spec/factories/ownership_transfer.rb @@ -6,6 +6,6 @@ email_address { Faker::Internet.email } nominated_user_id { SecureRandom.uuid } requested_by_user_id { SecureRandom.uuid } - status { 'pre_pending' } + status { 'pending' } end end diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb index c96685252..e9dede049 100644 --- a/spec/models/ownership_transfer_spec.rb +++ b/spec/models/ownership_transfer_spec.rb @@ -10,10 +10,6 @@ let(:school) { create(:verified_school) } let(:nominee) { create(:teacher, school:) } - before do - stub_user_info_api_fetch_by_ids(user_ids: [nominee.id]) - end - describe 'validations' do it 'has a valid factory' do expect(ownership_transfer).to be_valid @@ -57,8 +53,8 @@ end describe 'status' do - it 'defaults to pre_pending on a new record' do - expect(ownership_transfer.status).to eq('pre_pending') + it 'defaults to pending on a new record' do + expect(ownership_transfer.status).to eq('pending') end it 'is valid for every declared status' do @@ -94,22 +90,12 @@ end describe 'nominee role validation' do - it 'does not run when nominated_user_id is unchanged' do - ownership_transfer.save! - ownership_transfer.update!(status: :pending) - - # only the save! above should have looked up the nominee; the role check - # is skipped on update since nominated_user_id isn't changing - expect(UserInfoApiClient).to have_received(:fetch_by_ids).once - end - it 'is valid when the nominee has the teacher role for the school' do expect(ownership_transfer).to be_valid end it 'is valid when the nominee has the owner role for the school' do owner = create(:owner, school:) - stub_user_info_api_fetch_by_ids(user_ids: [owner.id]) ownership_transfer.nominated_user_id = owner.id expect(ownership_transfer).to be_valid @@ -117,7 +103,6 @@ it 'is invalid when the nominee has only the student role for the school' do student = create(:student, school:) - stub_user_info_api_fetch_by_ids(user_ids: [student.id]) ownership_transfer.nominated_user_id = student.id expect(ownership_transfer).not_to be_valid @@ -126,7 +111,6 @@ it 'is invalid when the nominee has a teacher role for a different school' do other_school = create(:verified_school) other_teacher = create(:teacher, school: other_school) - stub_user_info_api_fetch_by_ids(user_ids: [other_teacher.id]) ownership_transfer.nominated_user_id = other_teacher.id expect(ownership_transfer).not_to be_valid @@ -134,9 +118,7 @@ it 'adds an error naming the nominated_user_id and the school id' do student = create(:student, school:) - stub_user_info_api_fetch_by_ids(user_ids: [student.id]) ownership_transfer.nominated_user_id = student.id - ownership_transfer.valid? expect(ownership_transfer.errors[:nominated_user_id].first).to include(student.id) diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index af610a072..50bd17ee3 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -44,11 +44,9 @@ it 'has many ownership transfers' do owner_one = create(:owner_role, school:) owner_two = create(:owner_role, school:) - stub_user_info_api_fetch_by_ids(user_ids: [owner_one.user_id]) create(:ownership_transfer, school:, requested_by_user_id: owner_one.user_id, nominated_user_id: owner_one.user_id) - stub_user_info_api_fetch_by_ids(user_ids: [owner_two.user_id]) create(:ownership_transfer, school:, requested_by_user_id: owner_two.user_id, nominated_user_id: owner_two.user_id) - + expect(school.ownership_transfers.size).to eq(2) end From be71d4be2875499faed11d70da37d6d0e83dba6a Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 15 Sep 2026 17:05:19 +0200 Subject: [PATCH 08/16] fix: prevent concurrent pending ownership transfers for a school --- app/models/ownership_transfer.rb | 3 ++ config/locales/en.yml | 2 + ...ue_pending_index_to_ownership_transfers.rb | 13 +++++++ db/schema.rb | 3 +- spec/models/ownership_transfer_spec.rb | 38 +++++++++++++++++++ spec/models/school_spec.rb | 8 +++- 6 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20260915100000_add_unique_pending_index_to_ownership_transfers.rb diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb index d364b8234..1c4c6d9fa 100644 --- a/app/models/ownership_transfer.rb +++ b/app/models/ownership_transfer.rb @@ -13,6 +13,9 @@ class OwnershipTransfer < ApplicationRecord validates :requested_by_user_id, presence: true validates :email_address, format: { with: EmailValidator.regexp, message: I18n.t('validations.invitation.email_address') } + validates :school_id, + uniqueness: { conditions: -> { where(status: :pending) }, message: I18n.t('validations.ownership_transfer.school_pending') }, + on: :create validate :nominee_has_the_school_owner_or_school_teacher_role_for_the_school after_create_commit :send_ownership_transfer_request_email diff --git a/config/locales/en.yml b/config/locales/en.yml index 7c9c88e49..efd4f0193 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -23,6 +23,8 @@ en: school_roll_number_exists: "School roll number already exists" invitation: email_address: "'%s' is invalid" + ownership_transfer: + school_pending: "already has a pending ownership transfer" activerecord: attributes: school_class: diff --git a/db/migrate/20260915100000_add_unique_pending_index_to_ownership_transfers.rb b/db/migrate/20260915100000_add_unique_pending_index_to_ownership_transfers.rb new file mode 100644 index 000000000..10360484b --- /dev/null +++ b/db/migrate/20260915100000_add_unique_pending_index_to_ownership_transfers.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddUniquePendingIndexToOwnershipTransfers < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + def change + add_index :ownership_transfers, :school_id, + unique: true, + where: "status = 'pending'", + name: 'index_ownership_transfers_on_school_id_when_pending', + algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index fa1861ef6..aabf04a02 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[8.1].define(version: 2026_09_15_093824) do +ActiveRecord::Schema[8.1].define(version: 2026_09_15_100000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pgcrypto" @@ -232,6 +232,7 @@ t.string "status", default: "pending", null: false t.datetime "updated_at", null: false t.index ["school_id"], name: "index_ownership_transfers_on_school_id" + t.index ["school_id"], name: "index_ownership_transfers_on_school_id_when_pending", unique: true, where: "((status)::text = 'pending'::text)" end create_table "project_errors", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| diff --git a/spec/models/ownership_transfer_spec.rb b/spec/models/ownership_transfer_spec.rb index e9dede049..814496ac2 100644 --- a/spec/models/ownership_transfer_spec.rb +++ b/spec/models/ownership_transfer_spec.rb @@ -52,6 +52,44 @@ end end + describe 'pending transfer uniqueness' do + it 'is invalid when the school already has a pending transfer' do + create(:ownership_transfer, school:, nominated_user_id: nominee.id) + + second_transfer = build(:ownership_transfer, school:, nominated_user_id: nominee.id) + + expect(second_transfer).not_to be_valid + expect(second_transfer.errors[:school_id]).to include('already has a pending ownership transfer') + end + + it 'is valid for a second school even when another school has a pending transfer' do + create(:ownership_transfer, school:, nominated_user_id: nominee.id) + + other_school = create(:verified_school) + other_nominee = create(:teacher, school: other_school) + second_transfer = build(:ownership_transfer, school: other_school, nominated_user_id: other_nominee.id) + + expect(second_transfer).to be_valid + end + + it "is valid when the school's only existing transfer is no longer pending" do + create(:ownership_transfer, school:, nominated_user_id: nominee.id, status: :completed) + + second_transfer = build(:ownership_transfer, school:, nominated_user_id: nominee.id) + + expect(second_transfer).to be_valid + end + + it 'rejects a duplicate pending transfer created concurrently, bypassing application-level validation' do + first_transfer = build(:ownership_transfer, school:, nominated_user_id: nominee.id) + second_transfer = build(:ownership_transfer, school:, nominated_user_id: nominee.id) + + first_transfer.save!(validate: false) + + expect { second_transfer.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique) + end + end + describe 'status' do it 'defaults to pending on a new record' do expect(ownership_transfer.status).to eq('pending') diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 50bd17ee3..d3fe3fa82 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -44,7 +44,13 @@ it 'has many ownership transfers' do owner_one = create(:owner_role, school:) owner_two = create(:owner_role, school:) - create(:ownership_transfer, school:, requested_by_user_id: owner_one.user_id, nominated_user_id: owner_one.user_id) + create( + :ownership_transfer, + school:, + requested_by_user_id: owner_one.user_id, + nominated_user_id: owner_one.user_id, + status: :completed + ) create(:ownership_transfer, school:, requested_by_user_id: owner_two.user_id, nominated_user_id: owner_two.user_id) expect(school.ownership_transfers.size).to eq(2) From 3d7abd23d29f8872483eb6484af8ab53666fb41f Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 15 Sep 2026 17:06:35 +0200 Subject: [PATCH 09/16] feat: add endpoint to create a school ownership transfer --- .../api/ownership_transfers_controller.rb | 29 ++++ app/models/ability.rb | 1 + config/routes.rb | 1 + lib/concepts/ownership_transfer/create.rb | 37 +++++ .../creating_an_ownership_transfer_spec.rb | 127 ++++++++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 app/controllers/api/ownership_transfers_controller.rb create mode 100644 lib/concepts/ownership_transfer/create.rb create mode 100644 spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb diff --git a/app/controllers/api/ownership_transfers_controller.rb b/app/controllers/api/ownership_transfers_controller.rb new file mode 100644 index 000000000..aa791119a --- /dev/null +++ b/app/controllers/api/ownership_transfers_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Api + class OwnershipTransfersController < ApiController + before_action :authorize_user + load_and_authorize_resource :school + authorize_resource :ownership_transfer, class: false + + def create + result = OwnershipTransfer::Create.call(school: @school, nominated_user_id:, requested_by_user_id: current_user.id) + + if result.success? + head :created + else + render json: { error: result[:error] }, status: :unprocessable_content + end + end + + private + + def ownership_transfer_params + params.expect(ownership_transfer: [:nominated_user_id]) + end + + def nominated_user_id + ownership_transfer_params[:nominated_user_id] + end + end +end diff --git a/app/models/ability.rb b/app/models/ability.rb index c5c096990..d30240713 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -82,6 +82,7 @@ def define_school_owner_abilities(school:) can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id } }) can(%i[read create destroy], :school_owner) can(%i[read create destroy], :school_teacher) + can(:create, :ownership_transfer) can(%i[read create create_batch update destroy destroy_batch], :school_student) can(%i[create create_copy], Lesson, school_id: school.id) can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public]) diff --git a/config/routes.rb b/config/routes.rb index 56c04b11b..753cc24ef 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -87,6 +87,7 @@ resources :owners, only: %i[index], controller: 'school_owners' resources :teachers, only: %i[index create], controller: 'school_teachers' + resource :ownership_transfer, only: %i[create], controller: 'ownership_transfers' resources :students, only: %i[index create update destroy], controller: 'school_students' do post :batch, on: :collection, to: 'school_students#create_batch' delete :batch, on: :collection, to: 'school_students#destroy_batch' diff --git a/lib/concepts/ownership_transfer/create.rb b/lib/concepts/ownership_transfer/create.rb new file mode 100644 index 000000000..2b77210eb --- /dev/null +++ b/lib/concepts/ownership_transfer/create.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class OwnershipTransfer + class Create + class << self + def call(school:, nominated_user_id:, requested_by_user_id:) + response = OperationResponse.new + ownership_transfer = build_ownership_transfer(school:, nominated_user_id:, requested_by_user_id:) + + if ownership_transfer.save + response[:ownership_transfer] = ownership_transfer + else + response[:error] = ownership_transfer.errors + end + + response + rescue StandardError => e + Sentry.capture_exception(e) + response[:error] = "Error creating ownership transfer: #{e}" + response + end + + private + + def build_ownership_transfer(school:, nominated_user_id:, requested_by_user_id:) + email_address = nominee_email(school:, nominated_user_id:) + OwnershipTransfer.new(school:, nominated_user_id:, requested_by_user_id:, email_address:) + end + + def nominee_email(school:, nominated_user_id:) + return unless school.roles.exists?(user_id: nominated_user_id, role: %i[owner teacher]) + + User.from_userinfo(ids: nominated_user_id).first&.email + end + end + end +end diff --git a/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb new file mode 100644 index 000000000..d38643601 --- /dev/null +++ b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Creating an ownership transfer', type: :request do + include ActionMailer::TestHelper + + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:nominee) { create(:teacher, school:) } + let(:params) { { ownership_transfer: { nominated_user_id: nominee.id } } } + + before do + stub_user_info_api_for(nominee) + end + + it 'responds 401 Unauthorized when no token is given' do + post("/api/schools/#{school.id}/ownership_transfer", params:) + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-teacher' do + authenticated_in_hydra_as(nominee) + + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:forbidden) + end + + it 'responds 403 Forbidden when the user is the owner of a different school' do + authenticated_in_hydra_as(owner) + Role.owner.find_by(user_id: owner.id, school:).delete + Role.teacher.find_by(user_id: nominee.id, school:).delete + school.update!(id: SecureRandom.uuid) + + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:forbidden) + end + + context 'when the current user is the school owner' do + before { authenticated_in_hydra_as(owner) } + + context 'when the nominee has the teacher role at the school' do + it 'responds 201 Created' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:created) + end + + it 'creates an ownership transfer for the nominee' do + expect do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + end.to change(OwnershipTransfer, :count).by(1) + + expect(OwnershipTransfer.last).to have_attributes( + school:, + nominated_user_id: nominee.id, + requested_by_user_id: owner.id, + email_address: nominee.email, + status: 'pending' + ) + end + + it 'sends the ownership transfer request email' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + + assert_enqueued_email_with( + SchoolOwnershipMailer, + :request_ownership_transfer, + params: { ownership_transfer: OwnershipTransfer.last } + ) + end + end + + context 'when the nominee does not have the owner or teacher role at the school' do + let(:params) { { ownership_transfer: { nominated_user_id: SecureRandom.uuid } } } + + it 'responds 422 Unprocessable entity' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:unprocessable_content) + end + + it 'does not create an ownership transfer' do + expect do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + end.not_to change(OwnershipTransfer, :count) + end + end + + context 'when a transfer is already pending for the school' do + before { create(:ownership_transfer, school:, nominated_user_id: nominee.id) } + + it 'responds 422 Unprocessable entity' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:unprocessable_content) + end + + it 'does not create a second ownership transfer' do + expect do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + end.not_to change(OwnershipTransfer, :count) + end + end + + context 'when a transfer for the school is no longer pending' do + before { create(:ownership_transfer, school:, nominated_user_id: nominee.id, status: :completed) } + + it 'responds 201 Created' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + expect(response).to have_http_status(:created) + end + + it 'creates a new ownership transfer' do + expect do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + end.to change(OwnershipTransfer, :count).by(1) + end + end + end +end From dc715085e27a708a529b4116faec413eb5cb47fa Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 15 Sep 2026 17:08:13 +0200 Subject: [PATCH 10/16] feat: add endpoint to view a school's ownership transfer status --- .../api/ownership_transfers_controller.rb | 24 ++++ app/models/ability.rb | 6 +- config/routes.rb | 2 +- .../creating_an_ownership_transfer_spec.rb | 6 +- .../viewing_ownership_transfer_status_spec.rb | 111 ++++++++++++++++++ .../ownership_transfer_context.rb | 8 ++ .../ownership_transfer_examples.rb | 8 ++ 7 files changed, 159 insertions(+), 6 deletions(-) create mode 100644 spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb create mode 100644 spec/support/shared_contexts/ownership_transfer_context.rb create mode 100644 spec/support/shared_examples/ownership_transfer_examples.rb diff --git a/app/controllers/api/ownership_transfers_controller.rb b/app/controllers/api/ownership_transfers_controller.rb index aa791119a..7a95b0adf 100644 --- a/app/controllers/api/ownership_transfers_controller.rb +++ b/app/controllers/api/ownership_transfers_controller.rb @@ -6,6 +6,18 @@ class OwnershipTransfersController < ApiController load_and_authorize_resource :school authorize_resource :ownership_transfer, class: false + def show + @ownership_transfer = pending_ownership_transfer + + if @ownership_transfer.blank? || cannot?(:read, @ownership_transfer) + head :not_found + elsif current_user_is_requester? + render json: { you_are: 'owner', nominee_name: nominee_name }, status: :ok + else + render json: { you_are: 'nominee' }, status: :ok + end + end + def create result = OwnershipTransfer::Create.call(school: @school, nominated_user_id:, requested_by_user_id: current_user.id) @@ -25,5 +37,17 @@ def ownership_transfer_params def nominated_user_id ownership_transfer_params[:nominated_user_id] end + + def pending_ownership_transfer + @school.ownership_transfers.pending.order(created_at: :desc).first + end + + def current_user_is_requester? + @ownership_transfer.requested_by_user_id == current_user.id + end + + def nominee_name + User.from_userinfo(ids: @ownership_transfer.nominated_user_id).first&.name + end end end diff --git a/app/models/ability.rb b/app/models/ability.rb index d30240713..ac365cee3 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -39,6 +39,9 @@ def define_authenticated_abilities(user) invitation.email_address.present? && invitation.email_address.casecmp?(user.email) end + can :read, OwnershipTransfer do |transfer| + user.id == transfer.requested_by_user_id || user.id == transfer.nominated_user_id + end end def define_authenticated_non_student_abilities(user) @@ -82,7 +85,7 @@ def define_school_owner_abilities(school:) can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id } }) can(%i[read create destroy], :school_owner) can(%i[read create destroy], :school_teacher) - can(:create, :ownership_transfer) + can(%i[read create], :ownership_transfer) can(%i[read create create_batch update destroy destroy_batch], :school_student) can(%i[create create_copy], Lesson, school_id: school.id) can(%i[read update destroy], Lesson, school_id: school.id, visibility: %w[teachers students public]) @@ -99,6 +102,7 @@ def define_school_teacher_abilities(user:, school:) can(%i[read create create_batch destroy], ClassStudent, school_class: { school: { id: school.id }, teachers: { teacher_id: user.id } }) can(%i[read], :school_owner) can(%i[read], :school_teacher) + can(:read, :ownership_transfer) can(%i[read create create_batch update], :school_student) can(%i[create update destroy], Lesson) do |lesson| school_teacher_can_manage_lesson?(user:, school:, lesson:) diff --git a/config/routes.rb b/config/routes.rb index 753cc24ef..ba8945c18 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -87,7 +87,7 @@ resources :owners, only: %i[index], controller: 'school_owners' resources :teachers, only: %i[index create], controller: 'school_teachers' - resource :ownership_transfer, only: %i[create], controller: 'ownership_transfers' + resource :ownership_transfer, only: %i[show create], controller: 'ownership_transfers' resources :students, only: %i[index create update destroy], controller: 'school_students' do post :batch, on: :collection, to: 'school_students#create_batch' delete :batch, on: :collection, to: 'school_students#destroy_batch' diff --git a/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb index d38643601..5710043af 100644 --- a/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb +++ b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb @@ -5,10 +5,8 @@ RSpec.describe 'Creating an ownership transfer', type: :request do include ActionMailer::TestHelper - let(:headers) { { Authorization: UserProfileMock::TOKEN } } - let(:school) { create(:school) } - let(:owner) { create(:owner, school:) } - let(:nominee) { create(:teacher, school:) } + include_context 'with a school owner and nominated teacher' + let(:params) { { ownership_transfer: { nominated_user_id: nominee.id } } } before do diff --git a/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb b/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb new file mode 100644 index 000000000..4a79c5382 --- /dev/null +++ b/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Viewing ownership transfer status', type: :request do + include_context 'with a school owner and nominated teacher' + + it 'responds 401 Unauthorized when no token is given' do + get("/api/schools/#{school.id}/ownership_transfer") + expect(response).to have_http_status(:unauthorized) + end + + it 'responds 403 Forbidden when the user is a school-student' do + student = create(:student, school:) + authenticated_in_hydra_as(student) + + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:forbidden) + end + + context 'when there is no pending transfer for the school' do + before { authenticated_in_hydra_as(owner) } + + it 'responds 404 Not Found' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:not_found) + end + end + + context 'when there is a pending transfer for the school' do + let!(:ownership_transfer) do + create( + :ownership_transfer, + school:, + nominated_user_id: nominee.id, + requested_by_user_id: owner.id, + email_address: nominee.email + ) + end + + context 'when the current user is the school owner' do + before do + stub_user_info_api_for(nominee) + authenticated_in_hydra_as(owner) + end + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:ok) + end + + it 'identifies the current user as the owner' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + json = JSON.parse(response.body) + expect(json['you_are']).to eq('owner') + end + + it 'includes the nominated teacher\'s name' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + json = JSON.parse(response.body) + expect(json['nominee_name']).to eq(nominee.name) + end + end + + context 'when the current user is the nominee' do + before { authenticated_in_hydra_as(nominee) } + + it 'responds 200 OK' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:ok) + end + + it 'identifies the current user as the nominee' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + json = JSON.parse(response.body) + expect(json['you_are']).to eq('nominee') + end + end + + context 'when the current user is a different teacher at the school' do + let(:other_teacher) { create(:teacher, school:) } + + before { authenticated_in_hydra_as(other_teacher) } + + it_behaves_like 'a hidden ownership transfer' + end + + context 'when the current user is a different owner of the school who did not request the transfer' do + let(:other_owner) { create(:owner, school:) } + + before { authenticated_in_hydra_as(other_owner) } + + it_behaves_like 'a hidden ownership transfer' + end + + context 'when the pending transfer is no longer pending' do + before do + ownership_transfer.update!(status: :completed) + authenticated_in_hydra_as(owner) + end + + it 'responds 404 Not Found' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/support/shared_contexts/ownership_transfer_context.rb b/spec/support/shared_contexts/ownership_transfer_context.rb new file mode 100644 index 000000000..6f5d8fb20 --- /dev/null +++ b/spec/support/shared_contexts/ownership_transfer_context.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +RSpec.shared_context 'with a school owner and nominated teacher' do + let(:headers) { { Authorization: UserProfileMock::TOKEN } } + let(:school) { create(:school) } + let(:owner) { create(:owner, school:) } + let(:nominee) { create(:teacher, school:) } +end diff --git a/spec/support/shared_examples/ownership_transfer_examples.rb b/spec/support/shared_examples/ownership_transfer_examples.rb new file mode 100644 index 000000000..c1ad78d32 --- /dev/null +++ b/spec/support/shared_examples/ownership_transfer_examples.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'a hidden ownership transfer' do + it 'responds 404 Not Found, without revealing that a transfer exists' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + expect(response).to have_http_status(:not_found) + end +end From b611bf530bc901ec1520f5d3294ac1070bc6f852 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 15 Sep 2026 17:15:22 +0200 Subject: [PATCH 11/16] fix: use a nominee with a real role in the ownership mailer spec --- spec/mailers/school_ownership_mailer_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/mailers/school_ownership_mailer_spec.rb b/spec/mailers/school_ownership_mailer_spec.rb index e910221be..e15f1b790 100644 --- a/spec/mailers/school_ownership_mailer_spec.rb +++ b/spec/mailers/school_ownership_mailer_spec.rb @@ -6,7 +6,9 @@ describe 'request_ownership_transfer' do subject(:email) { described_class.with(ownership_transfer:).request_ownership_transfer } - let(:ownership_transfer) { create(:ownership_transfer) } + let(:school) { create(:school) } + let(:nominee) { create(:teacher, school:) } + let(:ownership_transfer) { create(:ownership_transfer, school:, nominated_user_id: nominee.id) } before do allow(ENV).to receive(:fetch).with('EDITOR_PUBLIC_URL').and_return('http://example.com') From 69a8eed9aa51c4cccd448cdbeb75000e59347bc0 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Tue, 15 Sep 2026 18:35:36 +0200 Subject: [PATCH 12/16] feat: show the transfer's actual status instead of hiding non-pending ones --- .../api/ownership_transfers_controller.rb | 10 +-- .../viewing_ownership_transfer_status_spec.rb | 67 +++++++++++++++++-- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/app/controllers/api/ownership_transfers_controller.rb b/app/controllers/api/ownership_transfers_controller.rb index 7a95b0adf..5c4775585 100644 --- a/app/controllers/api/ownership_transfers_controller.rb +++ b/app/controllers/api/ownership_transfers_controller.rb @@ -7,14 +7,14 @@ class OwnershipTransfersController < ApiController authorize_resource :ownership_transfer, class: false def show - @ownership_transfer = pending_ownership_transfer + @ownership_transfer = most_recent_ownership_transfer if @ownership_transfer.blank? || cannot?(:read, @ownership_transfer) head :not_found elsif current_user_is_requester? - render json: { you_are: 'owner', nominee_name: nominee_name }, status: :ok + render json: { status: @ownership_transfer.status, you_are: 'owner', nominee_name: nominee_name }, status: :ok else - render json: { you_are: 'nominee' }, status: :ok + render json: { status: @ownership_transfer.status, you_are: 'nominee' }, status: :ok end end @@ -38,8 +38,8 @@ def nominated_user_id ownership_transfer_params[:nominated_user_id] end - def pending_ownership_transfer - @school.ownership_transfers.pending.order(created_at: :desc).first + def most_recent_ownership_transfer + @school.ownership_transfers.order(created_at: :desc).first end def current_user_is_requester? diff --git a/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb b/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb index 4a79c5382..31d1bce02 100644 --- a/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb +++ b/spec/features/ownership_transfer/viewing_ownership_transfer_status_spec.rb @@ -18,7 +18,7 @@ expect(response).to have_http_status(:forbidden) end - context 'when there is no pending transfer for the school' do + context 'when the school has never had an ownership transfer' do before { authenticated_in_hydra_as(owner) } it 'responds 404 Not Found' do @@ -62,6 +62,13 @@ json = JSON.parse(response.body) expect(json['nominee_name']).to eq(nominee.name) end + + it 'includes the transfer status' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + json = JSON.parse(response.body) + expect(json['status']).to eq('pending') + end end context 'when the current user is the nominee' do @@ -78,6 +85,13 @@ json = JSON.parse(response.body) expect(json['you_are']).to eq('nominee') end + + it 'includes the transfer status' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + json = JSON.parse(response.body) + expect(json['status']).to eq('pending') + end end context 'when the current user is a different teacher at the school' do @@ -96,16 +110,61 @@ it_behaves_like 'a hidden ownership transfer' end - context 'when the pending transfer is no longer pending' do + context 'when the transfer has completed' do before do ownership_transfer.update!(status: :completed) + stub_user_info_api_for(nominee) authenticated_in_hydra_as(owner) end - it 'responds 404 Not Found' do + it 'responds 200 OK, still visible to the requester' do get("/api/schools/#{school.id}/ownership_transfer", headers:) - expect(response).to have_http_status(:not_found) + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json).to include('status' => 'completed', 'you_are' => 'owner', 'nominee_name' => nominee.name) end end + + context 'when the transfer was rejected' do + before do + ownership_transfer.update!(status: :rejected) + authenticated_in_hydra_as(nominee) + end + + it 'responds 200 OK, still visible to the nominee who rejected it' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json).to include('status' => 'rejected', 'you_are' => 'nominee') + end + end + + context 'when the transfer was cancelled' do + before do + ownership_transfer.update!(status: :cancelled) + authenticated_in_hydra_as(nominee) + end + + it 'responds 200 OK, still visible to the nominee' do + get("/api/schools/#{school.id}/ownership_transfer", headers:) + + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json).to include('status' => 'cancelled', 'you_are' => 'nominee') + end + end + + context 'when a resolved transfer is viewed by someone who was never involved' do + let(:other_teacher) { create(:teacher, school:) } + + before do + ownership_transfer.update!(status: :completed) + authenticated_in_hydra_as(other_teacher) + end + + it_behaves_like 'a hidden ownership transfer' + end end end From f9361f28b423d300867ff4fa3efe48b18e5a7c2f Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Fri, 18 Sep 2026 14:11:20 +0200 Subject: [PATCH 13/16] fix: guard against an uninitialized response in OwnershipTransfer::Create The rescue clause is attached to the whole method body (no explicit begin), so if an exception were raised before `response = OperationResponse.new` finished executing, response would still be nil inside the rescue block, turning response[:error] = ... into a second, uncaught NoMethodError instead of a graceful error response. --- lib/concepts/ownership_transfer/create.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/concepts/ownership_transfer/create.rb b/lib/concepts/ownership_transfer/create.rb index 2b77210eb..e42208108 100644 --- a/lib/concepts/ownership_transfer/create.rb +++ b/lib/concepts/ownership_transfer/create.rb @@ -15,6 +15,7 @@ def call(school:, nominated_user_id:, requested_by_user_id:) response rescue StandardError => e + response ||= OperationResponse.new Sentry.capture_exception(e) response[:error] = "Error creating ownership transfer: #{e}" response From f1864801c371cc0c70629d800dfd29812e9dede0 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Fri, 18 Sep 2026 14:14:11 +0200 Subject: [PATCH 14/16] fix: return the friendly duplicate-transfer message on a concurrent race The DB's partial unique index exists specifically to catch the race the application-level uniqueness validation can't (two requests creating a pending transfer for the same school at once). When it fires, .save raises ActiveRecord::RecordNotUnique, which the previous blanket rescue StandardError caught and surfaced as a raw exception string - including the Postgres constraint name - instead of the same "already has a pending ownership transfer" message a non-concurrent duplicate gets. Rescuing RecordNotUnique specifically lets us add the same validation message to the record's own errors, so callers see one consistent error shape regardless of which path caught the duplicate. Also stopped reporting this specific, expected, already-handled outcome to Sentry - it's not the kind of exception that needs alerting on. --- lib/concepts/ownership_transfer/create.rb | 5 ++ .../ownership_transfer/create_spec.rb | 70 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 spec/concepts/ownership_transfer/create_spec.rb diff --git a/lib/concepts/ownership_transfer/create.rb b/lib/concepts/ownership_transfer/create.rb index e42208108..325ba54bb 100644 --- a/lib/concepts/ownership_transfer/create.rb +++ b/lib/concepts/ownership_transfer/create.rb @@ -13,6 +13,11 @@ def call(school:, nominated_user_id:, requested_by_user_id:) response[:error] = ownership_transfer.errors end + response + rescue ActiveRecord::RecordNotUnique + response ||= OperationResponse.new + ownership_transfer.errors.add(:school_id, I18n.t('validations.ownership_transfer.school_pending')) + response[:error] = ownership_transfer.errors response rescue StandardError => e response ||= OperationResponse.new diff --git a/spec/concepts/ownership_transfer/create_spec.rb b/spec/concepts/ownership_transfer/create_spec.rb new file mode 100644 index 000000000..d4d6677e1 --- /dev/null +++ b/spec/concepts/ownership_transfer/create_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe OwnershipTransfer::Create, type: :unit do + let(:school) { create(:verified_school) } + let(:owner) { create(:owner, school:) } + let(:nominee) { create(:teacher, school:) } + + before { stub_user_info_api_for(nominee) } + + it 'returns a successful response and creates the transfer' do + response = described_class.call(school:, nominated_user_id: nominee.id, requested_by_user_id: owner.id) + + expect(response.success?).to be(true) + expect(response[:ownership_transfer]).to have_attributes( + school:, + nominated_user_id: nominee.id, + requested_by_user_id: owner.id, + email_address: nominee.email + ) + end + + it 'returns a failure response when the nominee has no role at the school' do + response = described_class.call(school:, nominated_user_id: SecureRandom.uuid, requested_by_user_id: owner.id) + + expect(response.failure?).to be(true) + expect(response[:error]).to be_present + end + + context 'when a duplicate pending transfer is created concurrently' do + before do + allow(OwnershipTransfer).to receive(:new).and_wrap_original do |method, *args| + method.call(*args).tap do |ownership_transfer| + allow(ownership_transfer).to receive(:save).and_raise(ActiveRecord::RecordNotUnique) + end + end + end + + it 'returns the same friendly error a non-concurrent duplicate would get, not the raw exception' do + response = described_class.call(school:, nominated_user_id: nominee.id, requested_by_user_id: owner.id) + + expect(response.failure?).to be(true) + expect(response[:error][:school_id]).to include('already has a pending ownership transfer') + end + + it 'does not report the race to Sentry, since it is an expected, handled outcome' do + allow(Sentry).to receive(:capture_exception) + + described_class.call(school:, nominated_user_id: nominee.id, requested_by_user_id: owner.id) + + expect(Sentry).not_to have_received(:capture_exception) + end + end + + context 'when an unexpected error occurs' do + before do + allow(OwnershipTransfer).to receive(:new).and_raise(StandardError, 'boom') + allow(Sentry).to receive(:capture_exception) + end + + it 'reports it to Sentry and returns a generic error' do + response = described_class.call(school:, nominated_user_id: nominee.id, requested_by_user_id: owner.id) + + expect(Sentry).to have_received(:capture_exception) + expect(response.failure?).to be(true) + expect(response[:error]).to include('Error creating ownership transfer') + end + end +end From bd2893f56f283fde48d86c26e1b618362f3a5a40 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Fri, 18 Sep 2026 14:15:58 +0200 Subject: [PATCH 15/16] refactor: extract the nominee role-eligibility check onto School school.roles.exists?(user_id:, role: %i[owner teacher]) was written independently in both OwnershipTransfer::Create#nominee_email and the model's own nominee_has_the_school_owner_or_school_teacher_role_for_the_school validation. If the eligible-role rule ever changes, it's easy to update one call site and miss the other. --- app/models/ownership_transfer.rb | 2 +- app/models/school.rb | 4 ++++ lib/concepts/ownership_transfer/create.rb | 2 +- spec/models/school_spec.rb | 19 +++++++++++++++++++ 4 files changed, 25 insertions(+), 2 deletions(-) diff --git a/app/models/ownership_transfer.rb b/app/models/ownership_transfer.rb index 1c4c6d9fa..b74102682 100644 --- a/app/models/ownership_transfer.rb +++ b/app/models/ownership_transfer.rb @@ -26,7 +26,7 @@ class OwnershipTransfer < ApplicationRecord def nominee_has_the_school_owner_or_school_teacher_role_for_the_school return unless nominated_user_id_changed? && errors.blank? && school - return if school.roles.exists?(user_id: nominated_user_id, role: %i[owner teacher]) + return if school.owner_or_teacher?(nominated_user_id) msg = "'#{nominated_user_id}' does not have the 'owner' or 'teacher' role for school '#{school.id}'" errors.add(:nominated_user_id, msg) diff --git a/app/models/school.rb b/app/models/school.rb index 479d9bd96..abf010d36 100644 --- a/app/models/school.rb +++ b/app/models/school.rb @@ -133,6 +133,10 @@ def student_count roles.student.count end + def owner_or_teacher?(user_id) + roles.exists?(user_id:, role: %i[owner teacher]) + end + def postal_code=(str) super(str.to_s.upcase) end diff --git a/lib/concepts/ownership_transfer/create.rb b/lib/concepts/ownership_transfer/create.rb index 325ba54bb..c8d323819 100644 --- a/lib/concepts/ownership_transfer/create.rb +++ b/lib/concepts/ownership_transfer/create.rb @@ -34,7 +34,7 @@ def build_ownership_transfer(school:, nominated_user_id:, requested_by_user_id:) end def nominee_email(school:, nominated_user_id:) - return unless school.roles.exists?(user_id: nominated_user_id, role: %i[owner teacher]) + return unless school.owner_or_teacher?(nominated_user_id) User.from_userinfo(ids: nominated_user_id).first&.email end diff --git a/spec/models/school_spec.rb b/spec/models/school_spec.rb index 5e3dc307a..3fcb0b829 100644 --- a/spec/models/school_spec.rb +++ b/spec/models/school_spec.rb @@ -117,6 +117,25 @@ end end + describe '#owner_or_teacher?' do + it 'is true for a user with the owner role at the school' do + owner = create(:owner, school:) + expect(school.owner_or_teacher?(owner.id)).to be(true) + end + + it 'is true for a user with the teacher role at the school' do + expect(school.owner_or_teacher?(teacher.id)).to be(true) + end + + it 'is false for a user with only the student role at the school' do + expect(school.owner_or_teacher?(student.id)).to be(false) + end + + it 'is false for a user with no role at the school' do + expect(school.owner_or_teacher?(SecureRandom.uuid)).to be(false) + end + end + describe 'validations' do subject(:school) { create(:school) } From e9ecd300183bd810d6f9c1d3b7fad6b1a559f3fa Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Fri, 18 Sep 2026 14:17:01 +0200 Subject: [PATCH 16/16] test: assert the create endpoint's 422 error response body Both 422 cases (invalid nominee role, already-pending transfer) only checked the HTTP status, never the response body's shape or content. A regression to the error body - including the exact leaked-exception bug fixed two commits ago - would have passed CI unnoticed. --- .../creating_an_ownership_transfer_spec.rb | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb index 5710043af..646f16713 100644 --- a/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb +++ b/spec/features/ownership_transfer/creating_an_ownership_transfer_spec.rb @@ -85,6 +85,13 @@ expect(response).to have_http_status(:unprocessable_content) end + it 'includes a validation error in the response body' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + + json = JSON.parse(response.body) + expect(json['error']).to be_present + end + it 'does not create an ownership transfer' do expect do post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) @@ -100,6 +107,13 @@ expect(response).to have_http_status(:unprocessable_content) end + it 'includes the pending-transfer error in the response body' do + post("/api/schools/#{school.id}/ownership_transfer", params:, headers:) + + json = JSON.parse(response.body) + expect(json['error']['school_id']).to include('already has a pending ownership transfer') + end + it 'does not create a second ownership transfer' do expect do post("/api/schools/#{school.id}/ownership_transfer", params:, headers:)