diff --git a/app/controllers/api/v8/users/request_deletion_controller.rb b/app/controllers/api/v8/users/request_deletion_controller.rb index cf683195c..bb3410ad1 100644 --- a/app/controllers/api/v8/users/request_deletion_controller.rb +++ b/app/controllers/api/v8/users/request_deletion_controller.rb @@ -9,13 +9,11 @@ class RequestDeletionController < Api::V8::BaseController sig { void } def create - user = current_user - user = User.find_by!(id: params[:user_id]) unless params[:user_id] == 'current' - authorize! :destroy, user - UserMailer.destroy_confirmation(user).deliver_now - render json: { - message: 'Verification email sent' - } + unauthorize_guest! + authorize! :destroy, current_user + deletion_url = User.courses_mooc_fi_account_deletion_url + message = deletion_url ? 'Accounts are deleted on courses.mooc.fi.' : 'Please contact support to delete your account.' + render json: errors_json(message).merge(account_deletion_url: deletion_url), status: :gone end end end diff --git a/app/controllers/api/v8/users_controller.rb b/app/controllers/api/v8/users_controller.rb index b1f9d6c82..44badab44 100644 --- a/app/controllers/api/v8/users_controller.rb +++ b/app/controllers/api/v8/users_controller.rb @@ -111,7 +111,40 @@ class UsersController < Api::V8::BaseController end end - skip_authorization_check only: %i[set_password_managed_by_courses_mooc_fi destroy] + swagger_path '/api/v8/users/{user_id}/course_progress' do + operation :get do + key :description, 'Admin only. Lists every course where the user has submissions or awarded points, hidden and disabled courses included, latest submission first.' + key :operationId, 'findUsersCourseProgress' + key :produces, ['application/json'] + key :tags, ['user'] + parameter '$ref': '#/parameters/path_user_id' + response 200 do + key :description, "The user's progress per course" + schema do + key :title, :courses + key :required, [:courses] + property :courses do + key :type, :array + items do + key :required, %i[id name title moocfi_id awarded_points submissions last_submission_at] + property :id, type: :integer, example: 1 + property :name, type: :string, example: 'mooc-java-programming-i' + property :title, type: :string, 'x-nullable': true, example: 'Java Programming I' + property :moocfi_id, type: :string, 'x-nullable': true, example: '5b4a46e2-0ec4-4c4e-8a6c-3f8d9a3c1f2e' + property :awarded_points, type: :integer, example: 12 + property :submissions, type: :integer, example: 34 + property :last_submission_at, type: :string, format: 'date-time', 'x-nullable': true, example: '2026-10-01T12:00:00Z' + end + end + end + end + response 401, '$ref': '#/responses/error' + response 403, '$ref': '#/responses/error' + response 404, '$ref': '#/responses/error' + end + end + + skip_authorization_check only: %i[set_password_managed_by_courses_mooc_fi destroy course_progress] sig { void } def show @@ -248,6 +281,14 @@ def destroy render_destroy_failure('internal_error', 'User deletion failed.', :internal_server_error) end + # courses.mooc.fi lists this to the user before deleting their account; keep the shape stable. + sig { void } + def course_progress + unauthorize_guest! + only_admins! + render json: { courses: User.find_by!(id: params[:id]).course_progress } + end + sig { void } def set_password_managed_by_courses_mooc_fi only_admins! diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 9cefb0d7e..9f6da3a90 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -5,6 +5,8 @@ class UsersController < ApplicationController extend T::Sig skip_authorization_check + # Stale legacy deletion forms must still redirect, whatever the forgery protection strategy. + skip_forgery_protection only: :redirect_to_account_deletion after_action :remove_x_frame_options_header_when_bare_layout, only: %i[new create show] @@ -128,63 +130,16 @@ def send_verification_email end sig { void } - def verify_destroying_user - @user = authenticate_current_user_destroy - VerificationToken.delete_user.find_by!(user: @user, token: params[:token]) - end - - sig { void } - def destroy_user - im_sure = params[:im_sure] - if im_sure != '1' - redirect_to verify_destroying_user_url, notice: 'Please check the checkbox after you have read the instructions.' - return - end - user = authenticate_current_user_destroy - _authenticated_user, status = User.authenticate_with_status(user.login, params[:user][:password]) - unless status == :accepted - redirect_to verify_destroying_user_url, alert: failed_authentication_alert(status) - return + def redirect_to_account_deletion + deletion_url = User.courses_mooc_fi_account_deletion_url + if deletion_url + redirect_to deletion_url, allow_other_host: true + else + redirect_to user_path end - VerificationToken.delete_user.find_by!(user: user, token: params[:token]) - sign_out if current_user == user - email = user.email - username = user.login - id = user.id - user.destroy - RecentlyChangedUserDetail.where(username: username).delete_all - RecentlyChangedUserDetail.deleted.create!(new_value: true, email: email, username: username, user_id: id) - Doorkeeper::AccessToken.where(resource_owner_id: id).delete_all - redirect_to root_url, notice: 'Your account has been permanently destroyed.' - end - - sig { void } - def send_destroy_email - user = authenticate_current_user_destroy - UserMailer.destroy_confirmation(user).deliver_now - redirect_to root_path, notice: "Verification email sent to #{user.email}." end private - sig { returns(User) } - def authenticate_current_user_destroy - user = User.find(params[:user_id]) - authorize! :destroy, user - user - end - - sig { params(status: T.anything).returns(String) } - def failed_authentication_alert(status) - case status - when :unavailable - 'We could not reach the system that holds your password. Please try again in a few minutes, or contact support if this keeps happening.' - when :misconfigured - 'Your account needs manual attention before it can be deleted. Please contact support.' - else - 'The password was incorrect.' - end - end - sig { void } def set_email user = T.must(@user) diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 9e282b9ff..805747a69 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -60,14 +60,6 @@ def email_confirmation(user, origin = nil, language = nil) mail(from: SiteSetting.value('emails')['from'], to: user.email, subject: subject) end - sig { params(user: User).void } - def destroy_confirmation(user) - @user = T.let(user, T.nilable(User)) - token = user.verification_tokens.delete_user.create! - @url = T.let(verify_destroying_user_url(user.id, token.token), T.nilable(String)) - mail(from: SiteSetting.value('emails')['from'], to: user.email, subject: 'Confirm deleting your mooc.fi account') - end - private sig { returns(String) } def base_url diff --git a/app/models/user.rb b/app/models/user.rb index ba6d5f6b4..1c0ce0ca6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -460,7 +460,7 @@ def force_migrate_to_courses_mooc_fi # nil means genuinely unknown (unconfigured, network error, unexpected response), not "not migrated". sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) } def courses_mooc_fi_migration_status - return nil if SiteSetting.value('courses_mooc_fi_base_url').blank? + return nil unless User.courses_mooc_fi_base_url conn = courses_mooc_fi_connection @@ -496,16 +496,55 @@ def courses_mooc_fi_migration_status sig { returns(T.nilable(String)) } def courses_mooc_fi_profile_url return nil if courses_mooc_fi_user_id.blank? - return nil if SiteSetting.value('courses_mooc_fi_base_url').blank? + return nil unless User.courses_mooc_fi_base_url courses_mooc_fi_url("/manage/users/#{courses_mooc_fi_user_id}") end + # courses.mooc.fi's root URL without a trailing slash. Nil when it isn't configured. + sig { returns(T.nilable(String)) } + def self.courses_mooc_fi_base_url + SiteSetting.value('courses_mooc_fi_base_url').to_s.chomp('/').presence + end + + # Where users delete their account, which also deletes it here. Nil when courses.mooc.fi isn't configured. + sig { returns(T.nilable(String)) } + def self.courses_mooc_fi_account_deletion_url + base_url = courses_mooc_fi_base_url + "#{base_url}/user-settings/permissions#delete-account" if base_url + end + sig { returns(T.nilable(ActionToken)) } def password_reset_key action_tokens.find { |t| t.action == 'reset_password' } end + # Submission and awarded point totals for every course the user has any of either in, hidden + # and disabled courses included. Sorted by latest submission (courses with none last), then name. + sig { returns(T::Array[T::Hash[Symbol, T.untyped]]) } + def course_progress + submission_totals = submissions.group(:course_id) + .pluck(:course_id, Arel.sql('COUNT(*)'), Arel.sql('MAX(submissions.created_at)')) + .to_h { |course_id, count, latest| [course_id, { count: count, latest: latest }] } + point_counts = awarded_points.group(:course_id).count + courses = Course.where(id: submission_totals.keys | point_counts.keys).pluck(:id, :name, :title, :moocfi_id) + + courses.sort_by do |course_id, name| + latest_submission_at = submission_totals.dig(course_id, :latest) + [latest_submission_at ? -latest_submission_at.to_r : Float::INFINITY, name.to_s] + end.map do |course_id, name, title, moocfi_id| + { + id: course_id, + name: name, + title: title, + moocfi_id: moocfi_id, + awarded_points: point_counts.fetch(course_id, 0), + submissions: submission_totals.dig(course_id, :count) || 0, + last_submission_at: submission_totals.dig(course_id, :latest)&.utc&.iso8601 + } + end + end + sig { params(course: Course, point_name: String).returns(T::Boolean) } def has_point?(course, point_name) awarded_points.where(course_id: course.id, name: point_name).any? @@ -679,7 +718,7 @@ def courses_mooc_fi_connection sig { params(path: String).returns(String) } def courses_mooc_fi_url(path) - "#{SiteSetting.value('courses_mooc_fi_base_url')}#{path}" + "#{User.courses_mooc_fi_base_url}#{path}" end sig { returns(Arel::SelectManager) } diff --git a/app/views/settings/show.html.erb b/app/views/settings/show.html.erb index 09b18b891..2f84a4a2d 100644 --- a/app/views/settings/show.html.erb +++ b/app/views/settings/show.html.erb @@ -2,9 +2,7 @@ <%= render :partial => 'settings/form', :user => @user, locals: { hide_login: true } %> -<% if can? :destroy, @user %> - <%= button_to 'Request deleting account', send_destroy_email_path(@user.id), method: :post, class: 'btn btn-danger' %> -<% end %> +<%= render 'users/account_deletion', user: @user %>
diff --git a/app/views/settings/user_has_submissions.html.erb b/app/views/settings/user_has_submissions.html.erb index a4f29b943..1cfd7a1e0 100644 --- a/app/views/settings/user_has_submissions.html.erb +++ b/app/views/settings/user_has_submissions.html.erb @@ -1,5 +1,5 @@
- You can't delete this user, because they have submissions. Advice them to delete their own account by pressing "Request deleting account" -button themselves from their User settings. + You can't delete this user, because they have submissions. Advise them to delete their own account themselves with the "Delete account on courses.mooc.fi" button in their User settings.

Make sure that they realize they will lose their progress in all of their courses, and can't ever get any certificates etc. for anything they've done after account deletion. diff --git a/app/views/user_mailer/destroy_confirmation.html.erb b/app/views/user_mailer/destroy_confirmation.html.erb deleted file mode 100644 index 2a9a6c802..000000000 --- a/app/views/user_mailer/destroy_confirmation.html.erb +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - -

Hello!

- -

You have requested deleting your mooc.fi account.

- -

Please proceed to the following link to continue the deletion process.

- - <%= @url %> - -

If you didn't request deleting your account, just ignore this message.

- - diff --git a/app/views/user_mailer/destroy_confirmation.text.erb b/app/views/user_mailer/destroy_confirmation.text.erb deleted file mode 100644 index db40e259f..000000000 --- a/app/views/user_mailer/destroy_confirmation.text.erb +++ /dev/null @@ -1,9 +0,0 @@ -Hello! - -You have requested deleting your mooc.fi account. - -Please proceed to the following link to continue the deletion process. - -<%= @url %> - -If you didn't request deleting your account, just ignore this message. diff --git a/app/views/users/_account_deletion.html.erb b/app/views/users/_account_deletion.html.erb new file mode 100644 index 000000000..e84d3d7c8 --- /dev/null +++ b/app/views/users/_account_deletion.html.erb @@ -0,0 +1,7 @@ +<% if user == current_user %> + <% if (deletion_url = User.courses_mooc_fi_account_deletion_url) %> + <%= link_to 'Delete account on courses.mooc.fi', deletion_url, class: 'btn btn-danger', target: '_top' %> + <% else %> +

To delete your account, please contact support.

+ <% end %> +<% end %> diff --git a/app/views/users/show.html.erb b/app/views/users/show.html.erb index 24be96465..86d0866be 100644 --- a/app/views/users/show.html.erb +++ b/app/views/users/show.html.erb @@ -2,6 +2,4 @@ <%= render :partial => 'users/form', :user => @user, locals: { hide_login: true } %> -<% if can? :destroy, @user %> - <%= button_to 'Request deleting account', send_destroy_email_path(@user.id), method: :post, class: 'btn btn-danger' %> -<% end %> +<%= render 'users/account_deletion', user: @user %> diff --git a/app/views/users/verify_destroying_user.html.erb b/app/views/users/verify_destroying_user.html.erb deleted file mode 100644 index 675cfcfd3..000000000 --- a/app/views/users/verify_destroying_user.html.erb +++ /dev/null @@ -1,32 +0,0 @@ -

Confirm deleting your account

- -
- Deleting your account will have the following consequences for all the courses where you have used this user account (check https://www.mooc.fi/): -
- - -<%= form_tag(destroy_user_path, method: :delete, class: 'form-horizontal') do %> -
- <%= check_box_tag :im_sure %> - <%= label_tag "I've read the above and I'm sure I understand the consequences", nil, class: "control-label", for: :im_sure %> -
-
- <%= bs_labeled_field("Your password", password_field(:user, :password, :autocomplete => 'off', class: 'form-control')) %> -
-

Pressing the button below will permanently destroy your mooc.fi account.

- <%= submit_tag("Destroy my account permanently", class: "btn btn-danger", data: { confirm: "Are you sure you want to delete the account #{@user.login}?"}, disabled: "disabled", id: :destroy_button) %> -<% end %> - - - diff --git a/config/brakeman.ignore b/config/brakeman.ignore index d23d5f4d2..1694a47f2 100644 --- a/config/brakeman.ignore +++ b/config/brakeman.ignore @@ -436,6 +436,29 @@ 77 ], "note": "sh! Shellwords-escapes every word unless escape: false, which no caller passes." + }, + { + "warning_type": "Redirect", + "warning_code": 18, + "fingerprint": "6359af9828749190072deea63d61f76cd4ebf9c9987ef93c154d7da9e902aeaa", + "check_name": "Redirect", + "message": "Possible unprotected redirect", + "file": "app/controllers/users_controller.rb", + "line": 136, + "link": "https://brakemanscanner.org/docs/warning_types/redirect/", + "code": "redirect_to(User.courses_mooc_fi_account_deletion_url, :allow_other_host => true)", + "render_path": null, + "location": { + "type": "method", + "class": "UsersController", + "method": "redirect_to_account_deletion" + }, + "user_input": "User.courses_mooc_fi_account_deletion_url", + "confidence": "Weak", + "cwe_id": [ + 601 + ], + "note": "The URL comes from the courses_mooc_fi_base_url site setting, not from request input." } ], "brakeman_version": "8.1.0" diff --git a/config/routes.rb b/config/routes.rb index 12e706d69..dedcc90a2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -61,6 +61,7 @@ resources :assistantships, module: :users, only: :index resources :teacherships, module: :users, only: :index post :set_password_managed_by_courses_mooc_fi, on: :member + get :course_progress, on: :member get :get_user_with_email, on: :collection end @@ -290,9 +291,9 @@ resources :users, only: [:index] - post '/users/:user_id/send_destroy_email', to: 'users#send_destroy_email', as: 'send_destroy_email' - get '/users/:user_id/destroy/:token', to: 'users#verify_destroying_user', as: 'verify_destroying_user' - delete '/users/:user_id/destroy/:token', to: 'users#destroy_user', as: 'destroy_user' + post '/users/:user_id/send_destroy_email', to: 'users#redirect_to_account_deletion' + # Deletion confirmation links already in users' inboxes land here too. + match '/users/:user_id/destroy/:id', to: 'users#redirect_to_account_deletion', via: %i[get delete] resources :certificates, only: %i[show create] diff --git a/sorbet/rbi/dsl/generated_path_helpers_module.rbi b/sorbet/rbi/dsl/generated_path_helpers_module.rbi index 21d7316b0..d95fdeb9f 100644 --- a/sorbet/rbi/dsl/generated_path_helpers_module.rbi +++ b/sorbet/rbi/dsl/generated_path_helpers_module.rbi @@ -195,6 +195,9 @@ module GeneratedPathHelpersModule sig { params(args: T.untyped).returns(String) } def course_path(*args); end + sig { params(args: T.untyped).returns(String) } + def course_progress_api_v8_user_path(*args); end + sig { params(args: T.untyped).returns(String) } def course_template_path(*args); end @@ -213,9 +216,6 @@ module GeneratedPathHelpersModule sig { params(args: T.untyped).returns(String) } def dangerously_destroy_user_participant_settings_path(*args); end - sig { params(args: T.untyped).returns(String) } - def destroy_user_path(*args); end - sig { params(args: T.untyped).returns(String) } def disable_organization_course_path(*args); end @@ -654,9 +654,6 @@ module GeneratedPathHelpersModule sig { params(args: T.untyped).returns(String) } def save_unlocks_organization_course_path(*args); end - sig { params(args: T.untyped).returns(String) } - def send_destroy_email_path(*args); end - sig { params(args: T.untyped).returns(String) } def send_verification_email_path(*args); end @@ -777,9 +774,6 @@ module GeneratedPathHelpersModule sig { params(args: T.untyped).returns(String) } def verify_dangerously_destroying_user_participant_settings_path(*args); end - sig { params(args: T.untyped).returns(String) } - def verify_destroying_user_path(*args); end - sig { params(args: T.untyped).returns(String) } def verify_organization_path(*args); end end diff --git a/sorbet/rbi/dsl/generated_url_helpers_module.rbi b/sorbet/rbi/dsl/generated_url_helpers_module.rbi index f0166195e..503625b8d 100644 --- a/sorbet/rbi/dsl/generated_url_helpers_module.rbi +++ b/sorbet/rbi/dsl/generated_url_helpers_module.rbi @@ -192,6 +192,9 @@ module GeneratedUrlHelpersModule sig { params(args: T.untyped).returns(String) } def course_migrate_to_other_course_url(*args); end + sig { params(args: T.untyped).returns(String) } + def course_progress_api_v8_user_url(*args); end + sig { params(args: T.untyped).returns(String) } def course_template_url(*args); end @@ -213,9 +216,6 @@ module GeneratedUrlHelpersModule sig { params(args: T.untyped).returns(String) } def dangerously_destroy_user_participant_settings_url(*args); end - sig { params(args: T.untyped).returns(String) } - def destroy_user_url(*args); end - sig { params(args: T.untyped).returns(String) } def disable_organization_course_url(*args); end @@ -654,9 +654,6 @@ module GeneratedUrlHelpersModule sig { params(args: T.untyped).returns(String) } def save_unlocks_organization_course_url(*args); end - sig { params(args: T.untyped).returns(String) } - def send_destroy_email_url(*args); end - sig { params(args: T.untyped).returns(String) } def send_verification_email_url(*args); end @@ -777,9 +774,6 @@ module GeneratedUrlHelpersModule sig { params(args: T.untyped).returns(String) } def verify_dangerously_destroying_user_participant_settings_url(*args); end - sig { params(args: T.untyped).returns(String) } - def verify_destroying_user_url(*args); end - sig { params(args: T.untyped).returns(String) } def verify_organization_url(*args); end end diff --git a/sorbet/rbi/dsl/user_mailer.rbi b/sorbet/rbi/dsl/user_mailer.rbi index a4e2256af..b0447e2d6 100644 --- a/sorbet/rbi/dsl/user_mailer.rbi +++ b/sorbet/rbi/dsl/user_mailer.rbi @@ -10,9 +10,6 @@ class UserMailer include GeneratedMountedHelpers class << self - sig { params(user: ::User).returns(::ActionMailer::MessageDelivery) } - def destroy_confirmation(user); end - sig do params( user: ::User, diff --git a/spec/controllers/api/v8/users/request_deletion_controller_spec.rb b/spec/controllers/api/v8/users/request_deletion_controller_spec.rb index e98cd5754..78a1fbae8 100644 --- a/spec/controllers/api/v8/users/request_deletion_controller_spec.rb +++ b/spec/controllers/api/v8/users/request_deletion_controller_spec.rb @@ -5,6 +5,12 @@ describe Api::V8::Users::RequestDeletionController, type: :controller do let(:user) { FactoryBot.create(:user, courses_mooc_fi_user_id: SecureRandom.uuid) } + let(:application) { Doorkeeper::Application.create!(name: 'client', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob') } + + def authenticate(scopes: 'public') + token = Doorkeeper::AccessToken.create!(application: application, resource_owner_id: user.id, scopes: scopes) + request.headers['Authorization'] = "Bearer #{token.token}" + end around do |example| original = Rails.configuration.x.accept_courses_mooc_fi_tokens @@ -13,13 +19,42 @@ Rails.configuration.x.accept_courses_mooc_fi_tokens = original end + before do + SiteSetting.all_settings['courses_mooc_fi_base_url'] = 'https://courses.mooc.fi' + end + + it 'points the caller to courses.mooc.fi without emailing anything' do + authenticate + + expect { post :create, params: { user_id: 'current' } }.not_to(change { ActionMailer::Base.deliveries.count }) + + expect(response).to have_http_status(410) + expect(response.parsed_body['account_deletion_url']).to eq('https://courses.mooc.fi/user-settings/permissions#delete-account') + expect(User.find_by(id: user.id)).to eq(user) + end + + it "says to contact support when courses.mooc.fi isn't configured" do + SiteSetting.all_settings['courses_mooc_fi_base_url'] = nil + authenticate + + post :create, params: { user_id: 'current' } + + expect(response).to have_http_status(410) + expect(response.parsed_body['errors']).to eq(['Please contact support to delete your account.']) + expect(response.parsed_body['account_deletion_url']).to be_nil + end + + it 'requires authentication' do + post :create, params: { user_id: 'current' } + + expect(response).to have_http_status(401) + end + it 'refuses a deletion request made with a courses.mooc.fi access token' do - allow(controller).to receive(:doorkeeper_token).and_return(nil) request.headers['Authorization'] = 'Bearer sp331-access-token' allow(CoursesMoocFiTokenIntrospector).to receive(:introspect).and_return( CoursesMoocFiTokenIntrospector::Result.new(sub: user.courses_mooc_fi_user_id) ) - expect(UserMailer).not_to receive(:destroy_confirmation) post :create, params: { user_id: 'current' } @@ -27,10 +62,7 @@ end it 'refuses a deletion request made with a handoff token' do - application = Doorkeeper::Application.create!(name: 'mooc.fi frontend', redirect_uri: 'urn:ietf:wg:oauth:2.0:oob') - token = Doorkeeper::AccessToken.create!(application: application, resource_owner_id: user.id, scopes: 'public handoff') - request.headers['Authorization'] = "Bearer #{token.token}" - expect(UserMailer).not_to receive(:destroy_confirmation) + authenticate(scopes: 'public handoff') post :create, params: { user_id: 'current' } diff --git a/spec/controllers/api/v8/users_controller_spec.rb b/spec/controllers/api/v8/users_controller_spec.rb index 9d79aa298..c3be1acf6 100644 --- a/spec/controllers/api/v8/users_controller_spec.rb +++ b/spec/controllers/api/v8/users_controller_spec.rb @@ -289,6 +289,77 @@ def do_update(old_password) end end + describe 'GET course_progress' do + let!(:token) { Doorkeeper::AccessToken.create!(resource_owner_id: admin.id, scopes: 'public') } + + it 'lists each course the user has submissions or points in, latest submission first' do + submitted_course = FactoryBot.create(:course, name: 'submitted', moocfi_id: SecureRandom.uuid, hidden: true) + FactoryBot.create(:submission, user: user, course: submitted_course, created_at: Time.zone.parse('2026-10-01T12:00:00Z')) + FactoryBot.create(:submission, user: user, course: submitted_course, created_at: Time.zone.parse('2026-09-01T12:00:00Z')) + older_course = FactoryBot.create(:course, name: 'older', disabled_status: 1) + FactoryBot.create(:submission, user: user, course: older_course, created_at: Time.zone.parse('2025-01-01T00:00:00Z')) + points_only_course = FactoryBot.create(:course, name: 'points-only') + AwardedPoint.create!(user: user, course: points_only_course, name: 'point1') + AwardedPoint.create!(user: user, course: points_only_course, name: 'point2') + FactoryBot.create(:submission, user: other_user, course: FactoryBot.create(:course)) + + get :course_progress, params: { id: user.id } + + expect(response).to have_http_status(200) + expect(response.parsed_body['courses']).to eq([ + { + 'id' => submitted_course.id, 'name' => 'submitted', 'title' => submitted_course.title, + 'moocfi_id' => submitted_course.moocfi_id, 'awarded_points' => 0, 'submissions' => 2, + 'last_submission_at' => '2026-10-01T12:00:00Z' + }, + { + 'id' => older_course.id, 'name' => 'older', 'title' => older_course.title, 'moocfi_id' => nil, + 'awarded_points' => 0, 'submissions' => 1, 'last_submission_at' => '2025-01-01T00:00:00Z' + }, + { + 'id' => points_only_course.id, 'name' => 'points-only', 'title' => points_only_course.title, + 'moocfi_id' => nil, 'awarded_points' => 2, 'submissions' => 0, 'last_submission_at' => nil + } + ]) + end + + it 'returns an empty list for a user without progress' do + get :course_progress, params: { id: user.id } + + expect(response).to have_http_status(200) + expect(response.parsed_body).to eq('courses' => []) + end + + it 'returns 404 for a missing user' do + get :course_progress, params: { id: User.maximum(:id) + 1 } + + expect(response).to have_http_status(404) + expect(response.parsed_body['errors']).to be_present + end + + context 'as a non-admin' do + let!(:token) { Doorkeeper::AccessToken.create!(resource_owner_id: user.id, scopes: 'public') } + + it 'is forbidden, even for their own progress' do + get :course_progress, params: { id: user.id } + + expect(response).to have_http_status(403) + end + end + + context 'as a guest' do + let!(:token) { nil } + + it 'requires authentication' do + controller.current_user = Guest.new + + get :course_progress, params: { id: user.id } + + expect(response).to have_http_status(401) + end + end + end + describe 'with a courses.mooc.fi access token' do let(:token) { nil } @@ -356,10 +427,15 @@ def authenticate_with_token_as(token_user) before :each do allow(ExtraField).to receive(:by_kind).with(:user).and_return([UserField.new(name: 'first_name'), UserField.new(name: 'last_name')]) + UserField.instance_variable_set(:@all, nil) user.password = 'oldpassword' user.save! end + after :each do + UserField.instance_variable_set(:@all, nil) + end + it 'updates the name when the rest of the profile is sent back unchanged' do put :update, params: { id: user.id, user: { email: user.email.upcase }, user_field: { first_name: 'Ada', last_name: 'Lovelace' } } expect(response).to have_http_status(200) diff --git a/spec/controllers/settings_controller_spec.rb b/spec/controllers/settings_controller_spec.rb new file mode 100644 index 000000000..624c76730 --- /dev/null +++ b/spec/controllers/settings_controller_spec.rb @@ -0,0 +1,32 @@ +# typed: false +# frozen_string_literal: true + +require 'spec_helper' + +describe SettingsController, type: :controller do + render_views + + let!(:user) { FactoryBot.create(:user) } + let(:deletion_url) { 'https://courses.mooc.fi/user-settings/permissions#delete-account' } + + before :each do + SiteSetting.all_settings['courses_mooc_fi_base_url'] = 'https://courses.mooc.fi' + end + + it 'links users to account deletion on courses.mooc.fi' do + controller.current_user = user + + get :show, params: { participant_id: user.id } + + expect(response.body).to include(%(href="#{deletion_url}")) + end + + it "does not offer an admin the courses.mooc.fi link on another user's settings" do + controller.current_user = FactoryBot.create(:admin) + + get :show, params: { participant_id: user.id } + + expect(response.body).not_to include(deletion_url) + expect(response.body).to include('Dangerously destroy account') + end +end diff --git a/spec/controllers/users_controller_spec.rb b/spec/controllers/users_controller_spec.rb index 8e0831575..15aba1b75 100644 --- a/spec/controllers/users_controller_spec.rb +++ b/spec/controllers/users_controller_spec.rb @@ -244,61 +244,30 @@ end end - describe 'DELETE destroy_user' do - let!(:user) { FactoryBot.create(:user, password: 'secret123') } - let!(:verification_token) { VerificationToken.create!(user: user, type: :delete_user) } + describe 'GET show account deletion' do + render_views + + let!(:user) { FactoryBot.create(:user) } + let(:deletion_url) { 'https://courses.mooc.fi/user-settings/permissions#delete-account' } before :each do controller.current_user = user + SiteSetting.all_settings['courses_mooc_fi_base_url'] = 'https://courses.mooc.fi' end - def do_destroy_user(password) - delete :destroy_user, params: { user_id: user.id, token: verification_token.token, im_sure: '1', - user: { password: password } } - end - - it 'should destroy the account when the password is correct' do - allow_any_instance_of(User).to receive(:post_new_user_to_courses_mooc_fi).and_return(true) - - do_destroy_user('secret123') - - expect(User.find_by(id: user.id)).to be_nil - end - - it 'should blame the password when it was actually rejected' do - do_destroy_user('wrongpassword') - - expect(flash[:alert]).to eq('The password was incorrect.') - expect(User.find_by(id: user.id)).not_to be_nil - end - - it 'should not blame the password when courses.mooc.fi could not be reached' do - user.update!(password_managed_by_courses_mooc_fi: true, courses_mooc_fi_user_id: SecureRandom.uuid) - allow_any_instance_of(User).to receive(:courses_mooc_fi_authentication_status).and_return(:unavailable) - - do_destroy_user('secret123') - - expect(flash[:alert]).to include('could not reach') - expect(User.find_by(id: user.id)).not_to be_nil - end - - it 'should tell the user to contact support when the courses.mooc.fi id is missing' do - user.update_columns(password_managed_by_courses_mooc_fi: true, courses_mooc_fi_user_id: nil) - - do_destroy_user('secret123') + it 'links to account deletion on courses.mooc.fi' do + get :show - expect(flash[:alert]).to include('contact support') - expect(flash[:alert]).not_to include('try again') - expect(User.find_by(id: user.id)).not_to be_nil + expect(response.body).to include(%(href="#{deletion_url}")) end - it 'should not destroy the account on a status this controller does not know' do - allow(User).to receive(:authenticate_with_status).and_return([user, :some_future_status]) + it 'tells the user to contact support when courses.mooc.fi is not configured' do + SiteSetting.all_settings['courses_mooc_fi_base_url'] = nil - do_destroy_user('secret123') + get :show - expect(User.find_by(id: user.id)).not_to be_nil - expect(flash[:alert]).to eq('The password was incorrect.') + expect(response.body).to include('contact support') + expect(response.body).not_to include('user-settings/permissions') end end end diff --git a/spec/integration/destroying_user_spec.rb b/spec/integration/destroying_user_spec.rb deleted file mode 100644 index 6dfc16505..000000000 --- a/spec/integration/destroying_user_spec.rb +++ /dev/null @@ -1,130 +0,0 @@ -# typed: false -# frozen_string_literal: true - -require 'spec_helper' - -describe 'Deleting own user', type: :request, integration: true do - include IntegrationTestActions - - it 'sending email should generate token' do - user = User.create!(login: 'user', password: 'password', email: 'theuser@example.com') - - visit '/' - log_in_as('user', 'password') - - visit '/user' - - click_button 'Request deleting account' - - # Yay, you got mail! - - token = user.verification_tokens.delete_user[0] - - expect(token).not_to be_nil - expect(User.find_by(login: user.login)).to eq(user) - end - - it 'verify destroy page should have a verification button and password field' do - user = User.create!(login: 'user', password: 'password', email: 'theuser@example.com') - - visit '/' - log_in_as('user', 'password') - - visit '/user' - - click_button 'Request deleting account' - - # Yay, you got mail! - - token = user.verification_tokens.delete_user[0] - - visit "/users/#{user.id}/destroy/#{token.token}" - - # expect(page).to have_content("Deleting the account #{user.login}") - expect(page).to have_content('Deleting your account will have the following consequences for all the courses where you have used this user account') - - expect(page).to have_button('Destroy my account permanently', disabled: true) - - check "I've read the above and i'm sure i understand the consequences" - - expect(page).to have_button('Destroy my account permanently', disabled: false) - expect(User.find_by(login: user.login)).to eq(user) - expect(page).to have_content('Your password') - end - - it 'pressing verification button destroys user' do - user = User.create!(login: 'user', password: 'password', email: 'theuser@example.com') - username = user.login - - visit '/' - log_in_as('user', 'password') - - visit '/user' - - click_button 'Request deleting account' - - # Yay, you got mail! - - token = user.verification_tokens.delete_user[0] - - visit "/users/#{user.id}/destroy/#{token.token}" - - check "I've read the above and i'm sure i understand the consequences" - - fill_in 'user[password]', with: user.password - - accept_confirm { click_button 'Destroy my account permanently' } - - expect { User.find_by!(login: username) }.to raise_error ActiveRecord::RecordNotFound - expect(page).to have_content('Your account has been permanently destroyed.') - expect(page).to have_content('Log in') - end - - it 'will not destroy user if password incorrect' do - user = User.create!(login: 'user', password: 'password', email: 'theuser@example.com') - - visit '/' - log_in_as('user', 'password') - - visit '/user' - - click_button 'Request deleting account' - - # Yay, you got mail! - - token = user.verification_tokens.delete_user[0] - - visit "/users/#{user.id}/destroy/#{token.token}" - - check "I've read the above and i'm sure i understand the consequences" - - fill_in 'user[password]', with: 'thisiswrongpassword' - - accept_confirm { click_button 'Destroy my account permanently' } - - expect(User.find_by(login: user.login)).to eq(user) - expect(page).to have_content('The password was incorrect') - end - - it 'cannot be verified by another user' do - user1 = User.create!(login: 'user1', password: 'password1', email: 'user1@example.com') - User.create!(login: 'user2', password: 'password2', email: 'user2@example.com') - - visit '/' - log_in_as('user1', 'password1') - - visit '/user' - - click_button 'Request deleting account' - - log_out - - log_in_as('user2', 'password2') - - token = user1.verification_tokens.delete_user[0] - - visit "/users/#{user1.id}/destroy/#{token.token}" - - expect(page).to have_content('Forbidden') - end -end diff --git a/spec/requests/account_deletion_redirect_spec.rb b/spec/requests/account_deletion_redirect_spec.rb new file mode 100644 index 000000000..b9f03b5f5 --- /dev/null +++ b/spec/requests/account_deletion_redirect_spec.rb @@ -0,0 +1,52 @@ +# typed: false +# frozen_string_literal: true + +require 'spec_helper' + +describe 'Self-service account deletion', type: :request do + let!(:user) { FactoryBot.create(:user) } + let!(:token) { VerificationToken.create!(user: user, type: :delete_user) } + let(:deletion_url) { 'https://courses.mooc.fi/user-settings/permissions#delete-account' } + + before :each do + SiteSetting.all_settings['courses_mooc_fi_base_url'] = 'https://courses.mooc.fi' + end + + it 'redirects an emailed deletion link to courses.mooc.fi' do + get "/users/#{user.id}/destroy/#{token.token}" + + expect(response).to redirect_to(deletion_url) + end + + it 'redirects the old deletion form without deleting the user' do + delete "/users/#{user.id}/destroy/#{token.token}", params: { im_sure: '1', user: { password: user.password } } + + expect(response).to redirect_to(deletion_url) + expect(User.find_by(id: user.id)).to eq(user) + end + + it 'redirects a deletion email request without sending email' do + expect { post "/users/#{user.id}/send_destroy_email" }.not_to(change { ActionMailer::Base.deliveries.count }) + + expect(response).to redirect_to(deletion_url) + end + + it 'redirects a stale deletion form when forgery protection is on' do + original = ActionController::Base.allow_forgery_protection + ActionController::Base.allow_forgery_protection = true + + post "/users/#{user.id}/send_destroy_email", params: { authenticity_token: 'stale' } + + expect(response).to redirect_to(deletion_url) + ensure + ActionController::Base.allow_forgery_protection = original + end + + it "sends the user to their account page, which says to contact support, when courses.mooc.fi isn't configured" do + SiteSetting.all_settings['courses_mooc_fi_base_url'] = nil + + get "/users/#{user.id}/destroy/#{token.token}" + + expect(response).to redirect_to(user_path) + end +end diff --git a/spec/requests/secret_path_logging_spec.rb b/spec/requests/secret_path_logging_spec.rb index b3279cd16..9f1bc1f93 100644 --- a/spec/requests/secret_path_logging_spec.rb +++ b/spec/requests/secret_path_logging_spec.rb @@ -39,18 +39,6 @@ def logged_path_and_params(&block) expect(response.body).not_to include("key=#{key.token}") end - it 'keeps the account deletion token out of redirect logs' do - token = VerificationToken.create!(user: user, type: :delete_user) - post '/sessions', params: { session: { login: user.login, password: 'secret123' } } - - payloads = instrumented('redirect_to.action_controller') do - delete "/users/#{user.id}/destroy/#{token.token}", params: { im_sure: '1', user: { password: 'wrong' } } - end - - expect(response).to redirect_to("http://www.example.com/users/#{user.id}/destroy/#{token.token}") - expect(payloads.pluck(:location)).to eq(['[FILTERED]']) - end - it 'filters the token from Rack::Attack log lines' do expect(SecretPathFilter.filter('//users/1/destroy/abc.json')).to eq('//users/1/destroy/[FILTERED]') expect(SecretPathFilter.filter('/reset_password')).to eq('/reset_password')