Skip to content
Merged
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
44 changes: 26 additions & 18 deletions app/controllers/api/ownership_transfer_resolutions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,33 +14,41 @@ def decline
resolve!(:rejected)
end

def cancel
resolve!(:cancelled)
end

private

# Wrapped in a transaction so the row lock below is held across the
# read-and-update, preventing a concurrent accept/decline on the same
# transfer from also finding it pending. Authorizing against the loaded
# transfer (rather than checking cannot? by hand) means an unauthorized
# attempt raises and is rescued below into the same 404 a nonexistent
# transfer gets, instead of leaking that a pending transfer exists.
# The lock is held until update finishes, so two requests can't both act
# on the same pending transfer at once.
#
# Not found and not authorized both raise into the same head :not_found
# below, so the response can't be used to tell whether a transfer exists
# that the user just isn't allowed to touch.
#
# head/render happen after the transaction, not inside it, so a failure
# sending the cancellation email (its after_commit callback) is the only
# thing we respond with.
def resolve!(status)
OwnershipTransfer.transaction do
transfer = pending_ownership_transfer
return head(:not_found) if transfer.blank?

authorize!(action_name.to_sym, transfer)
transfer = OwnershipTransfer.transaction do
loaded = pending_ownership_transfer
authorize!(action_name.to_sym, loaded)
loaded.update(status:)
loaded
end

if transfer.update(status:)
head :ok
else
render json: { error: transfer.errors }, status: :unprocessable_content
end
if transfer.errors.empty?
head :ok
else
render json: { error: transfer.errors }, status: :unprocessable_content
end
rescue CanCan::AccessDenied
rescue CanCan::AccessDenied, ActiveRecord::RecordNotFound
head :not_found
end

def pending_ownership_transfer
@school.ownership_transfers.lock.pending.first
@school.ownership_transfers.lock.pending.first!
end
end
end
6 changes: 3 additions & 3 deletions app/controllers/api/ownership_transfers_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def show

if @ownership_transfer.blank? || cannot?(:read, @ownership_transfer)
head :not_found
elsif current_user_is_requester?
elsif current_user_is_an_owner?
render json: { status: @ownership_transfer.status, you_are: 'owner', nominee_name:, nominee_email: }, status: :ok
else
render json: { status: @ownership_transfer.status, you_are: 'nominee' }, status: :ok
Expand Down Expand Up @@ -42,8 +42,8 @@ def most_recent_ownership_transfer
@school.ownership_transfers.order(created_at: :desc).first
end

def current_user_is_requester?
@ownership_transfer.requested_by_user_id == current_user.id
def current_user_is_an_owner?
current_user.school_owner?(@school)
end

def nominee_name
Expand Down
14 changes: 12 additions & 2 deletions app/mailers/school_ownership_mailer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,25 @@ class SchoolOwnershipMailer < ApplicationMailer
def request_ownership_transfer
@school = ownership_transfer.school

@nominee_name = users_by_id[ownership_transfer.nominated_user_id]&.name
@requested_owner_name = users_by_id[ownership_transfer.requested_by_user_id]&.name
@nominee_name = users_by_id[ownership_transfer.nominated_user_id]&.name.presence || 'there'
@requested_owner_name = users_by_id[ownership_transfer.requested_by_user_id]&.name.presence || 'The school owner'

mail(to: ownership_transfer.email_address,
subject: "You've been nominated to be an owner of #{@school.name}",
track_opens: 'true',
message_stream: 'outbound')
end

def cancel_ownership_transfer
@school = ownership_transfer.school
@nominee_name = users_by_id[ownership_transfer.nominated_user_id]&.name.presence || 'there'

mail(to: ownership_transfer.email_address,
subject: "The ownership nomination for #{@school.name} has been cancelled",
track_opens: 'true',
message_stream: 'outbound')
end

private

def ownership_transfer
Expand Down
7 changes: 4 additions & 3 deletions app/models/ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def initialize(user)
user.schools.active.each do |school|
define_school_student_abilities(user:, school:) if user.school_student?(school)
define_school_teacher_abilities(user:, school:) if user.school_teacher?(school)
define_school_owner_abilities(user:, school:) if user.school_owner?(school)
define_school_owner_abilities(school:) if user.school_owner?(school)
end

define_editor_admin_abilities(user)
Expand Down Expand Up @@ -68,7 +68,7 @@ def define_authenticated_non_student_abilities(user)
can %i[read update destroy], Component, project: { user_id: user.id }
end

def define_school_owner_abilities(user:, school:)
def define_school_owner_abilities(school:)
can(%i[read update], School, id: school.id)
can(%i[read], :school_member)
can(%i[read create import update destroy regenerate_join_code], SchoolClass, school: { id: school.id })
Expand All @@ -83,7 +83,8 @@ def define_school_owner_abilities(user:, school:)
can(%i[read create destroy], :school_owner)
can(%i[read create destroy], :school_teacher)
can(%i[read create], :ownership_transfer)
can(:read, OwnershipTransfer, school_id: school.id, requested_by_user_id: user.id)
can(:read, OwnershipTransfer, school_id: school.id)
can(:cancel, OwnershipTransfer, school_id: school.id, status: 'pending')
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])
Expand Down
6 changes: 6 additions & 0 deletions app/models/ownership_transfer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class OwnershipTransfer < ApplicationRecord
validate :nominee_has_the_school_teacher_role_for_the_school

after_create_commit :send_ownership_transfer_request_email
after_update_commit :send_ownership_transfer_cancelled_email,
if: -> { saved_change_to_status?(from: 'pending', to: 'cancelled') }
encrypts :email_address

private
Expand All @@ -35,4 +37,8 @@ def nominee_has_the_school_teacher_role_for_the_school
def send_ownership_transfer_request_email
SchoolOwnershipMailer.with(ownership_transfer: self).request_ownership_transfer.deliver_later
end

def send_ownership_transfer_cancelled_email
SchoolOwnershipMailer.with(ownership_transfer: self).cancel_ownership_transfer.deliver_later
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Hi <%= @nominee_name %>,

The nomination for you to become the owner of the Code Classroom account for <%= @school.name %> has been cancelled by the school owner.

No action is needed from you, and ownership remains unchanged. If you were expecting to take over this Code Classroom, please reach out to the school owner directly.

If you have any questions, please contact us at websupport@raspberrypi.org.

Kind Regards,
The Code Editor team
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@
resource :ownership_transfer, only: %i[show create], controller: 'ownership_transfers' do
put :accept, to: 'ownership_transfer_resolutions#accept'
put :decline, to: 'ownership_transfer_resolutions#decline'
put :cancel, to: 'ownership_transfer_resolutions#cancel'
end
resources :students, only: %i[index create update destroy], controller: 'school_students' do
post :batch, on: :collection, to: 'school_students#create_batch'
Expand Down
2 changes: 1 addition & 1 deletion lib/user_info_api_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ def fetch_by_ids(user_ids)
r.url '/users'
r.body = { userIds: user_ids }
end
return if response.body.blank?
return [] if response.body.blank?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For what requests does the User Info response return an empty body?

I just wanted to check it was equivalent to no users found, and not a different error state we should handle.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question. Any real failure from the API (like a 500) already gets turned into an error before we reach this line, so an empty body here isn't a hidden failure - it only happens on an otherwise successful response. The case I had in mind is looking up a user who's been deleted since the transfer was created - no match, so nothing comes back.

I also double checked this. I looked at how the JSON library we use handles an empty response, and it looks like it turns a blank body into either nil or an empty string - neither of which works with the code we call it with. So without this line, that case could crash instead of just returning no users.

For anyone who wants to check this themselves:

  • conn (lib/user_info_api_client.rb:51-61) sets f.response :raise_error before f.response :json. raise_error raises on any 4xx/5xx, so by the time we reach line 16, the response is guaranteed 2xx - an empty body here can't be masking a failed request.
  • Faraday's JSON middleware (faraday gem, v2.14.3, lib/faraday/response/json.rb) handles an empty body one of two ways depending on whether the response has a JSON content-type:
    • Content-type matches JSON → its parse method explicitly does return if body.strip.empty? (line 32), so response.body becomes nil.
    • Content-type doesn't match → parse_response? (line 39) returns false, parsing is skipped entirely, and response.body stays whatever the raw HTTP body was - an empty string for a zero-content response.
  • Either way, response.body ends up as nil or "", and .fetch('users', []) isn't defined on either, so line 18 would raise without the guard on line 16.

I confirmed this locally by temporarily removing line 16 and running spec/lib/user_info_api_client_spec.rb - it fails with NoMethodError: undefined method 'fetch' for an instance of String.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I talked to @DNR500 about this to be clear why this change has been made.

It's not actually needed as we know no cases where the UserInfoAPI returns an empty body on successful responses.

Instead the purpose for this change is to prevent errors just in case one day the user info API starts returning blank responses.


transform_result(response.body.fetch('users', []))
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# frozen_string_literal: true

require 'rails_helper'

RSpec.describe 'Cancelling an ownership transfer', type: :request do
include ActionMailer::TestHelper

include_context 'with a school owner and nominated teacher'

it 'responds 401 Unauthorized when no token is given' do
put("/api/schools/#{school.id}/ownership_transfer/cancel")
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)

put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)
expect(response).to have_http_status(:forbidden)
end

it 'responds 403 Forbidden when the user is the owner of a different school' do
other_owner = create(:owner, school: create(:verified_school))
authenticated_in_hydra_as(other_owner)

put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)
expect(response).to have_http_status(:forbidden)
end

context 'when the school has never had an ownership transfer' do
before { authenticated_in_hydra_as(owner) }

it 'responds 404 Not Found' do
put("/api/schools/#{school.id}/ownership_transfer/cancel", 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 owner who requested the transfer' do
before { authenticated_in_hydra_as(owner) }

it 'responds 200 OK' do
put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)
expect(response).to have_http_status(:ok)
end

it 'marks the transfer as cancelled' do
put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)
expect(ownership_transfer.reload.status).to eq('cancelled')
end

it 'sends the cancellation email' do
put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)

assert_enqueued_email_with(
SchoolOwnershipMailer, :cancel_ownership_transfer, params: { ownership_transfer: }
)
end
end

context 'when the current user is a different owner of the same school' do
let(:other_owner) { create(:owner, school:) }

before { authenticated_in_hydra_as(other_owner) }

it 'responds 200 OK, since any current owner can cancel, not only the one who requested it' do
put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)
expect(response).to have_http_status(:ok)
end
end

context 'when the current user is the nominee' do
before { authenticated_in_hydra_as(nominee) }

it 'responds 404 Not Found, since only an owner of the school can cancel' do
put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)
expect(response).to have_http_status(:not_found)
end

it 'does not change the transfer status' do
put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)
expect(ownership_transfer.reload.status).to eq('pending')
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 'responds 404 Not Found' do
put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)
expect(response).to have_http_status(:not_found)
end
end

context 'when the 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
put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)
expect(response).to have_http_status(:not_found)
end
end

context 'when the transfer has already been cancelled' do
before do
authenticated_in_hydra_as(owner)
put("/api/schools/#{school.id}/ownership_transfer/cancel", headers:)
end

it 'allows the owner to start a new transfer' do
other_teacher = create(:teacher, school:)
stub_user_info_api_for(other_teacher)

post(
"/api/schools/#{school.id}/ownership_transfer",
params: { ownership_transfer: { nominated_user_id: other_teacher.id } },
headers:
)

expect(response).to have_http_status(:created)
end
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@
end
end

context 'when the current user is a different owner of the same school' do
let(:other_owner) { create(:owner, school:) }

before do
stub_user_info_api_for(nominee)
authenticated_in_hydra_as(other_owner)
end

it 'responds 200 OK, identifying them as an owner even though they did not request the transfer' 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' => 'pending', 'you_are' => 'owner', 'nominee_name' => nominee.name, 'nominee_email' => nominee.email
)
end
end

context 'when the current user is the nominee' do
before { authenticated_in_hydra_as(nominee) }

Expand Down Expand Up @@ -155,6 +174,22 @@
end
end

context 'when the transfer was cancelled by the owner' do
before do
ownership_transfer.update!(status: :cancelled)
stub_user_info_api_for(nominee)
authenticated_in_hydra_as(owner)
end

it 'responds 200 OK, still visible to the owner who cancelled 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' => 'cancelled', 'you_are' => 'owner', 'nominee_name' => nominee.name)
end
end

context 'when a resolved transfer is viewed by someone who was never involved' do
let(:other_teacher) { create(:teacher, school:) }

Expand Down
Loading
Loading