Skip to content

Commit 53cb70d

Browse files
committed
Add admin controls for courses.mooc.fi migration status
1 parent a5f2d25 commit 53cb70d

8 files changed

Lines changed: 257 additions & 0 deletions

File tree

app/controllers/participants_controller.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,36 @@ def password_reset_link
131131
@password_reset_link = @user.generate_password_reset_link
132132
end
133133

134+
def check_courses_mooc_fi
135+
@user = User.find(params[:id])
136+
authorize! :view_participant_information, @user
137+
return respond_forbidden('This feature is only available to admins') unless current_user.administrator?
138+
139+
@courses_mooc_fi_status = @user.find_on_courses_mooc_fi
140+
end
141+
142+
def force_migrate_to_courses_mooc_fi
143+
@user = User.find(params[:id])
144+
authorize! :view_participant_information, @user
145+
return respond_forbidden('This feature is only available to admins') unless current_user.administrator?
146+
return respond_forbidden('This feature is disabled for admin accounts') if @user.administrator?
147+
148+
if @user.managed_externally?
149+
return redirect_to participant_path(@user), alert: 'User is already managed by courses.mooc.fi.'
150+
end
151+
152+
if @user.find_on_courses_mooc_fi == true
153+
return redirect_to participant_path(@user), alert: 'User already exists on courses.mooc.fi; refusing to force migrate.'
154+
end
155+
156+
result = @user.force_migrate_to_courses_mooc_fi
157+
if result[:success]
158+
redirect_to participant_path(@user), notice: 'User force-migrated to courses.mooc.fi.'
159+
else
160+
redirect_to participant_path(@user), alert: "Force migration to courses.mooc.fi failed: #{result[:error]}"
161+
end
162+
end
163+
134164
private
135165
def index_json_data
136166
result = []

app/models/user.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,90 @@ def post_new_user_to_courses_mooc_fi(password)
363363
end
364364
end
365365

366+
# Admin-triggered override of the normal login/password-change-triggered migration: creates
367+
# the user on courses.mooc.fi with a throwaway random password nobody needs to know, since the
368+
# user gets a real one later via password reset once courses.mooc.fi confirms the account
369+
# (see set_password_managed_by_courses_mooc_fi). Returns the raw status/body on failure rather
370+
# than a guessed message, since we don't control courses.mooc.fi's error schema.
371+
def force_migrate_to_courses_mooc_fi
372+
create_url = SiteSetting.value('courses_mooc_fi_create_user_url')
373+
password = SecureRandom.hex(24)
374+
375+
conn = Faraday.new(request: { open_timeout: 2, timeout: 10 }) do |f|
376+
f.request :json
377+
f.response :json
378+
end
379+
380+
response = conn.post(create_url) do |req|
381+
req.headers['Content-Type'] = 'application/json'
382+
req.headers['Accept'] = 'application/json'
383+
req.headers['Authorization'] = Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project
384+
req.body = { upstream_id: id, password: normalize_password(password) }
385+
end
386+
387+
data = response.body
388+
if response.status == 200 && data.is_a?(Hash) && data['user'].present?
389+
Rails.logger.info("User #{self.email} force-migrated to courses.mooc.fi by an admin")
390+
{ success: true }
391+
else
392+
Rails.logger.error("Force migration to courses.mooc.fi failed for user #{self.email}: status=#{response.status}, body=#{data.inspect}")
393+
{ success: false, error: "status=#{response.status}, body=#{data.inspect}" }
394+
end
395+
396+
rescue Faraday::ClientError => e
397+
status = e.response&.dig(:status)
398+
body = e.response&.dig(:body)
399+
Rails.logger.error("Force migration to courses.mooc.fi errored for user #{self.email}: status=#{status}, body=#{body.inspect}")
400+
{ success: false, error: "status=#{status}, body=#{body.inspect}" }
401+
402+
rescue => e
403+
Rails.logger.error("Force migration to courses.mooc.fi unexpectedly failed for user #{self.email}: #{e.message}")
404+
{ success: false, error: e.message }
405+
end
406+
407+
# Returns true/false if courses.mooc.fi confirms presence/absence of this user, or nil if the
408+
# lookup itself failed (network error, unexpected status) and presence is therefore unknown.
409+
def find_on_courses_mooc_fi
410+
lookup_url = SiteSetting.value('courses_mooc_fi_user_lookup_url')
411+
return nil if lookup_url.blank?
412+
413+
conn = Faraday.new(request: { open_timeout: 2, timeout: 10 }) do |f|
414+
f.request :json
415+
f.response :json
416+
end
417+
418+
response = conn.get("#{lookup_url}/#{id}") do |req|
419+
req.headers['Accept'] = 'application/json'
420+
req.headers['Authorization'] = Rails.application.secrets.tmc_server_secret_for_communicating_to_secret_project
421+
end
422+
423+
case response.status
424+
when 200
425+
true
426+
when 404
427+
false
428+
else
429+
Rails.logger.error("Looking up user #{self.email} on courses.mooc.fi returned unexpected status: status=#{response.status}, request-id=#{response.headers['request-id']}, body=#{response.body.inspect}")
430+
nil
431+
end
432+
433+
rescue Faraday::ClientError => e
434+
status = e.response&.dig(:status)
435+
request_id = e.response&.dig(:headers, 'request-id')
436+
body = e.response&.dig(:body)
437+
Rails.logger.error("Looking up user #{self.email} on courses.mooc.fi failed: status=#{status}, request-id=#{request_id}, body=#{body.inspect}")
438+
nil
439+
end
440+
441+
def courses_mooc_fi_profile_url
442+
return nil if courses_mooc_fi_user_id.blank?
443+
444+
base_url = SiteSetting.value('courses_mooc_fi_manage_user_url')
445+
return nil if base_url.blank?
446+
447+
"#{base_url}/#{courses_mooc_fi_user_id}"
448+
end
449+
366450
def password_reset_key
367451
action_tokens.find { |t| t.action == 'reset_password' }
368452
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<div>
2+
<p>courses.mooc.fi lookup for <%= link_to @user.email, participant_path(@user) %>:</p>
3+
<% case @courses_mooc_fi_status %>
4+
<% when true %>
5+
<p>Found on courses.mooc.fi.</p>
6+
<% when false %>
7+
<p>Not found on courses.mooc.fi.</p>
8+
<% else %>
9+
<p>Could not determine status &mdash; the lookup failed. Check the logs for details.</p>
10+
<% end %>
11+
</div>

app/views/participants/show.html.erb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@
2020
<% if current_user.administrator? && !@user.administrator? %>
2121
<li><%= link_to 'Generate password reset link', password_reset_link_participant_path, class: 'btn btn-primary' %> (shown to you because you're an admin)</li>
2222
<% end %>
23+
<% if current_user.administrator? %>
24+
<li><%= link_to 'Check courses.mooc.fi status', check_courses_mooc_fi_participant_path, class: 'btn btn-primary' %> (shown to you because you're an admin)</li>
25+
<% end %>
26+
<% if current_user.administrator? && !@user.administrator? && !@user.managed_externally? %>
27+
<li><%= button_to 'Force migrate to courses.mooc.fi', force_migrate_to_courses_mooc_fi_participant_path, method: :post, class: 'btn btn-primary', data: { confirm: "Force migrate #{@user.email} to courses.mooc.fi now?" } %> (shown to you because you're an admin)</li>
28+
<% end %>
29+
<% if current_user.administrator? && @user.courses_mooc_fi_profile_url %>
30+
<li><%= link_to 'View on courses.mooc.fi', @user.courses_mooc_fi_profile_url, target: '_blank', rel: 'noopener', class: 'btn btn-primary' %> (shown to you because you're an admin)</li>
31+
<% end %>
2332
</ul>
2433
</section>
2534
</div>

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,8 @@
276276
end
277277
member do
278278
get 'password_reset_link', to: 'participants#password_reset_link'
279+
get 'check_courses_mooc_fi', to: 'participants#check_courses_mooc_fi'
280+
post 'force_migrate_to_courses_mooc_fi', to: 'participants#force_migrate_to_courses_mooc_fi'
279281
end
280282
end
281283

config/site.defaults.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,3 +143,5 @@ teacher_manual_url: http://testmycode.github.io/tmc-server/usermanual/
143143
courses_mooc_fi_auth_url:
144144
courses_mooc_fi_update_password_url:
145145
courses_mooc_fi_create_user_url:
146+
courses_mooc_fi_user_lookup_url:
147+
courses_mooc_fi_manage_user_url:

spec/controllers/participants_controller_spec.rb

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,102 @@
2727
end
2828
end
2929
end
30+
31+
describe 'GET /check_courses_mooc_fi' do
32+
describe 'when logged in as an admin' do
33+
before :each do
34+
controller.current_user = FactoryBot.create(:admin)
35+
end
36+
37+
it 'shows the user as found when the lookup succeeds' do
38+
expect_any_instance_of(User).to receive(:find_on_courses_mooc_fi).and_return(true)
39+
get :check_courses_mooc_fi, params: { id: @user.id }
40+
expect(response).to be_successful
41+
expect(assigns(:courses_mooc_fi_status)).to eq(true)
42+
end
43+
44+
it 'shows the user as not found when the lookup succeeds' do
45+
expect_any_instance_of(User).to receive(:find_on_courses_mooc_fi).and_return(false)
46+
get :check_courses_mooc_fi, params: { id: @user.id }
47+
expect(response).to be_successful
48+
expect(assigns(:courses_mooc_fi_status)).to eq(false)
49+
end
50+
51+
it 'shows the status as unknown when the lookup fails' do
52+
expect_any_instance_of(User).to receive(:find_on_courses_mooc_fi).and_return(nil)
53+
get :check_courses_mooc_fi, params: { id: @user.id }
54+
expect(response).to be_successful
55+
expect(assigns(:courses_mooc_fi_status)).to be_nil
56+
end
57+
end
58+
59+
describe 'when logged in as a non-admin' do
60+
before :each do
61+
controller.current_user = @user
62+
end
63+
64+
it 'is forbidden' do
65+
get :check_courses_mooc_fi, params: { id: @user.id }
66+
expect(response.code.to_i).to eq(403)
67+
end
68+
end
69+
end
70+
71+
describe 'POST /force_migrate_to_courses_mooc_fi' do
72+
describe 'when logged in as a non-admin' do
73+
before :each do
74+
controller.current_user = @user
75+
end
76+
77+
it 'is forbidden' do
78+
post :force_migrate_to_courses_mooc_fi, params: { id: @user.id }
79+
expect(response.code.to_i).to eq(403)
80+
end
81+
end
82+
83+
describe 'when logged in as an admin' do
84+
before :each do
85+
controller.current_user = FactoryBot.create(:admin)
86+
end
87+
88+
it 'is forbidden when the target user is an admin' do
89+
admin_target = FactoryBot.create(:admin)
90+
expect_any_instance_of(User).not_to receive(:force_migrate_to_courses_mooc_fi)
91+
post :force_migrate_to_courses_mooc_fi, params: { id: admin_target.id }
92+
expect(response.code.to_i).to eq(403)
93+
end
94+
95+
it 'refuses when the user is already managed externally' do
96+
@user.update!(password_managed_by_courses_mooc_fi: true, courses_mooc_fi_user_id: SecureRandom.uuid)
97+
expect_any_instance_of(User).not_to receive(:force_migrate_to_courses_mooc_fi)
98+
post :force_migrate_to_courses_mooc_fi, params: { id: @user.id }
99+
expect(response).to redirect_to(participant_path(@user))
100+
expect(flash[:alert]).to match(/already managed/)
101+
end
102+
103+
it 'refuses when courses.mooc.fi already has the user' do
104+
expect_any_instance_of(User).to receive(:find_on_courses_mooc_fi).and_return(true)
105+
expect_any_instance_of(User).not_to receive(:force_migrate_to_courses_mooc_fi)
106+
post :force_migrate_to_courses_mooc_fi, params: { id: @user.id }
107+
expect(response).to redirect_to(participant_path(@user))
108+
expect(flash[:alert]).to match(/already exists/)
109+
end
110+
111+
it 'migrates the user when the lookup is inconclusive and the migration succeeds' do
112+
expect_any_instance_of(User).to receive(:find_on_courses_mooc_fi).and_return(nil)
113+
expect_any_instance_of(User).to receive(:force_migrate_to_courses_mooc_fi).and_return({ success: true })
114+
post :force_migrate_to_courses_mooc_fi, params: { id: @user.id }
115+
expect(response).to redirect_to(participant_path(@user))
116+
expect(flash[:notice]).to match(/force-migrated/)
117+
end
118+
119+
it 'shows the exact error when the migration fails' do
120+
expect_any_instance_of(User).to receive(:find_on_courses_mooc_fi).and_return(false)
121+
expect_any_instance_of(User).to receive(:force_migrate_to_courses_mooc_fi).and_return({ success: false, error: 'status=422, body={"error"=>"boom"}' })
122+
post :force_migrate_to_courses_mooc_fi, params: { id: @user.id }
123+
expect(response).to redirect_to(participant_path(@user))
124+
expect(flash[:alert]).to match(/status=422/)
125+
end
126+
end
127+
end
30128
end

spec/models/user_spec.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,27 @@
270270
expect(User.authenticate('root', 'ilikecookies')).to be_nil
271271
end
272272

273+
describe 'courses_mooc_fi_profile_url' do
274+
it 'is nil when the user has no courses.mooc.fi id' do
275+
user = User.create!(login: 'localuser', password: 'secret123', email: 'localuser@example.com')
276+
SiteSetting.all_settings['courses_mooc_fi_manage_user_url'] = 'https://courses.mooc.fi/manage/users'
277+
expect(user.courses_mooc_fi_profile_url).to be_nil
278+
end
279+
280+
it 'is nil when courses_mooc_fi_manage_user_url is not configured' do
281+
user = User.create!(login: 'manageduser', password: 'secret123', email: 'managed@example.com', courses_mooc_fi_user_id: SecureRandom.uuid)
282+
SiteSetting.all_settings['courses_mooc_fi_manage_user_url'] = nil
283+
expect(user.courses_mooc_fi_profile_url).to be_nil
284+
end
285+
286+
it 'builds the profile url from the configured base url and the courses.mooc.fi id' do
287+
id = SecureRandom.uuid
288+
user = User.create!(login: 'manageduser', password: 'secret123', email: 'managed@example.com', courses_mooc_fi_user_id: id)
289+
SiteSetting.all_settings['courses_mooc_fi_manage_user_url'] = 'https://courses.mooc.fi/manage/users'
290+
expect(user.courses_mooc_fi_profile_url).to eq("https://courses.mooc.fi/manage/users/#{id}")
291+
end
292+
end
293+
273294
describe 'migrating to courses.mooc.fi on login' do
274295
it 'posts a locally-managed user on successful authentication' do
275296
user = User.create!(login: 'localuser', password: 'secret123', email: 'localuser@example.com')

0 commit comments

Comments
 (0)