panda_pal 5.19.0.beta1 → 5.19.0.beta3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +13 -0
  3. data/app/models/panda_pal/platform/canvas.rb +74 -20
  4. data/lib/panda_pal/version.rb +1 -1
  5. data/panda_pal.gemspec +1 -1
  6. metadata +2 -60
  7. data/spec/controllers/panda_pal/api_call_controller_spec.rb +0 -25
  8. data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +0 -194
  9. data/spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb +0 -108
  10. data/spec/controllers/panda_pal/lti_v1_p3_tools_spec.rb +0 -144
  11. data/spec/core/apartment_multidb_spec.rb +0 -166
  12. data/spec/factories/panda_pal_organizations.rb +0 -9
  13. data/spec/factories/panda_pal_sessions.rb +0 -6
  14. data/spec/internal/app/models/account.rb +0 -7
  15. data/spec/internal/app/models/admin.rb +0 -6
  16. data/spec/internal/config/database.yml +0 -18
  17. data/spec/internal/config/routes.rb +0 -5
  18. data/spec/internal/config/storage.yml +0 -3
  19. data/spec/internal/db/schema.rb +0 -30
  20. data/spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb +0 -83
  21. data/spec/lib/panda_pal/ags_client_spec.rb +0 -385
  22. data/spec/lib/panda_pal/helpers/secure_headers_spec.rb +0 -77
  23. data/spec/lib/panda_pal/helpers/session_replacement_spec.rb +0 -72
  24. data/spec/lib/panda_pal/lti_tools_spec.rb +0 -153
  25. data/spec/models/panda_pal/api_call_spec.rb +0 -30
  26. data/spec/models/panda_pal/lti_registration_spec.rb +0 -72
  27. data/spec/models/panda_pal/organization/settings_validation_spec.rb +0 -179
  28. data/spec/models/panda_pal/organization/task_scheduling_spec.rb +0 -145
  29. data/spec/models/panda_pal/organization_spec.rb +0 -50
  30. data/spec/models/panda_pal/platform/canvas_install_spec.rb +0 -115
  31. data/spec/models/panda_pal/platform_spec.rb +0 -349
  32. data/spec/models/panda_pal/session_account_roles_spec.rb +0 -182
  33. data/spec/models/panda_pal/session_spec.rb +0 -202
  34. data/spec/spec_helper.rb +0 -159
  35. data/spec/support/isolated_lti_config.rb +0 -24
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 36f3440bb369f482a622cca1b58dbe3857bd74749d53776be10e6b99e8bf3f79
4
- data.tar.gz: 02c42332159e06ae1778d8155c2a58d230ae24dc82a5f1e0c6027aa7e0a9f274
3
+ metadata.gz: dc0521710ced7088a0342c4af3b5fc5e0435b3578aafe0ae1223ea98024e78ee
4
+ data.tar.gz: 48fb53a3d5522496fc93e8f74f4ceff4c3e510c5a8c74a2a16b4b5a83a7e1294
5
5
  SHA512:
6
- metadata.gz: 9d4253dfec794377b88c5efbd6b2167b225373049e0e7b7694ef3f6f4d5e39253e71aa720094b39b336e6b4afc251b00c7f60f35dc1dbe3bb11dde35ce9b966b
7
- data.tar.gz: b5004d4d2f7be4dcfb91d70d81445284084ea24a11f03f8db8513c6794cacdef99bf9c4da96c50b51cd1a9870ae4129ca3d66ac6b91a0065f4ef34e1aac59157
6
+ metadata.gz: eceb5025a2c539f4ca2c9d6a2757d34595cc8071a30d34b988310ee06cf5e8224d4b514b8cc73bb0f871f2c810d31070c09c4fa3b6d58d61d5f65344aef35d4e
7
+ data.tar.gz: 23402bf99ee96e7ecd37bda8750e5463cf12361c703d38112d464bfa8314ca666ff8bc66cfac3d59205cb3874e7d0613e076be0b55cbb4ecd36674ffda2d285c
data/README.md CHANGED
@@ -56,6 +56,19 @@ lti_nav editor_button: 'translate#editor_button', tool: :translate # Served at <
56
56
 
57
57
  - **Config:** each tool has its own config URL: `/v1p3/config?tool=translate` and `/v1p0/config?tool=translate`. The default tool's URLs are unchanged.
58
58
  - **Install:** `org.install_lti(host: ..., tool: :translate)`. On LTI 1.3 this creates (or reuses) the tool's own Developer Key and records it as a `PandaPal::LtiRegistration` on the Organization. The Organization's `key` keeps pointing at the default tool. On LTI 1.0 both tools share the Organization's consumer key and secret.
59
+ - **Install hooks:** `before_lti_install` / `after_lti_install` callbacks can read `current_lti_install` (`tool`, `host`, `context`, `version`, `exists`, `dedicated_deployment`, and, in `after_lti_install`, the resulting Canvas `installation`). That lets you install a second tool alongside the default one:
60
+ ```ruby
61
+ # app/models/organization_extension.rb
62
+ included do
63
+ after_lti_install :install_translate_tool
64
+ end
65
+
66
+ def install_translate_tool
67
+ install = current_lti_install
68
+ return unless install.tool == PandaPal::DEFAULT_TOOL # the nested :translate install runs these callbacks too
69
+ install_lti(**install.to_h.except(:installation), tool: :translate)
70
+ end
71
+ ```
59
72
  - **Launches:** `current_session.lti_tool` says which tool was launched. On LTI 1.3 the tool comes from the registration matching the launch's client ID (and deployment ID). On LTI 1.0 it comes from the signed `custom_panda_pal_tool` field that every non-default tool config includes. PandaPal routes each launch, and each deep-link redirect, using that tool's placements.
60
73
  - **Registering by hand:** if a tool's Developer Key was set up outside `install_lti`, register it yourself: `org.lti_registrations.create!(tool: 'translate', client_id: '10000000000123', deployment_id: nil)`. A registration with a `deployment_id` takes priority over one without.
61
74
 
@@ -62,10 +62,21 @@ module PandaPal
62
62
  module OrgExtension
63
63
  extend ActiveSupport::Concern
64
64
 
65
+ # What an install_lti call is installing, for :lti_install callbacks to read
66
+ # through #current_lti_install. `version` is normalized ('v1p0'/'v1p3').
67
+ # `installation` is the Canvas external tool, set once the install has run
68
+ # (so it's there for after_lti_install, not before_lti_install).
69
+ LtiInstall = Struct.new(:tool, :host, :context, :version, :exists, :dedicated_deployment, :installation, keyword_init: true)
70
+
65
71
  included do
66
72
  define_model_callbacks :lti_install
67
73
  end
68
74
 
75
+ # The LtiInstall currently running callbacks, or nil outside install_lti.
76
+ # An install started from inside a callback gets its own LtiInstall, and the
77
+ # outer one comes back once it's done.
78
+ attr_reader :current_lti_install
79
+
69
80
  def _parse_lti_context(context)
70
81
  context = context.to_s
71
82
  context = 'account/self' if context == 'root_account'
@@ -79,6 +90,7 @@ module PandaPal
79
90
  # instead of the default one. In LTI 1.3 each tool is its own Developer Key,
80
91
  # tracked by an LtiRegistration rather than by this Organization's `key`.
81
92
  def _install_lti(host: nil, context: :root_account, version: nil, exists: :error, dedicated_deployment: false, tool: PandaPal::DEFAULT_TOOL)
93
+ outer_install = @current_lti_install
82
94
  raise "Automatically installing this LTI requires Bearcat." unless defined?(Bearcat)
83
95
 
84
96
  tool = PandaPal.normalize_lti_tool!(tool)
@@ -95,6 +107,15 @@ module PandaPal
95
107
 
96
108
  # TODO Ensure host is actually this LTI
97
109
 
110
+ @current_lti_install = LtiInstall.new(
111
+ tool: tool,
112
+ host: host,
113
+ context: context,
114
+ version: version,
115
+ exists: exists,
116
+ dedicated_deployment: dedicated_deployment,
117
+ )
118
+
98
119
  run_callbacks :lti_install do
99
120
  ctype, cid = _parse_lti_context(context)
100
121
 
@@ -127,37 +148,29 @@ module PandaPal
127
148
  scope_tool = existing_installs[0]
128
149
 
129
150
  # Don't need to do an update if it already exists - Settings are stored on the LTI Key instead of the installation
130
- if tool != PandaPal::DEFAULT_TOOL
131
- scope_tool ||= bearcat_client.send(:"create_#{ctype}_external_tool", cid, {
132
- client_id: ekey[:id],
133
- })
134
-
135
- lti_registrations.find_or_create_by!(
136
- tool: tool.to_s,
137
- client_id: ekey[:id].to_s,
138
- deployment_id: (scope_tool[:deployment_id].presence&.to_s if dedicated_deployment),
139
- )
140
- elsif !scope_tool
141
- scope_tool = bearcat_client.send(:"create_#{ctype}_external_tool", cid, {
151
+ new_install = !scope_tool
152
+ scope_tool ||= _with_lti_registration_unlocked(ekey) do
153
+ bearcat_client.send(:"create_#{ctype}_external_tool", cid, {
142
154
  client_id: ekey[:id],
143
155
  })
156
+ end
144
157
 
145
- new_client_id = "#{ekey[:id]}"
146
- new_client_id += "/#{scope_tool[:deployment_id]}" if dedicated_deployment
147
-
148
- self.key = new_client_id
149
- self.secret = ekey[:api_key]
150
-
151
- save! if changed?
158
+ # The default tool's key only moves with a new install; other tools always (re)register
159
+ if new_install || tool != PandaPal::DEFAULT_TOOL
160
+ _register_lti_v1p3_key(tool, ekey, deployment_id: (scope_tool[:deployment_id].presence&.to_s if dedicated_deployment))
152
161
  end
153
162
 
154
163
  @new_lti_installation = scope_tool
155
164
  else
156
165
  raise "Unrecognized LTI Version #{version}"
157
166
  end
167
+
168
+ @current_lti_install.installation = @new_lti_installation
158
169
  end
159
170
 
160
171
  save!
172
+ ensure
173
+ @current_lti_install = outer_install
161
174
  end
162
175
 
163
176
  def install_lti(*args, **kwargs)
@@ -219,7 +232,8 @@ module PandaPal
219
232
 
220
233
  valid_redirect_uris.uniq!
221
234
 
222
- if !ekey
235
+ created = !ekey
236
+ if created
223
237
  # Create New
224
238
  ekey = bearcat_client.post("api/lti/accounts/self/developer_keys/tool_configuration", {
225
239
  developer_key: {
@@ -245,6 +259,10 @@ module PandaPal
245
259
 
246
260
  ekey = ekey[:developer_key] || ekey
247
261
 
262
+ # Record a new key right away, so a failed install is retried against
263
+ # this key instead of creating yet another one.
264
+ _register_lti_v1p3_key(tool, ekey) if created
265
+
248
266
  if ekey[:developer_key_account_binding][:workflow_state] == "off"
249
267
  bearcat_client.post("api/v1/accounts/self/developer_keys/#{ekey[:id]}/developer_key_account_bindings", {
250
268
  developer_key_account_binding: {
@@ -256,6 +274,42 @@ module PandaPal
256
274
  ekey
257
275
  end
258
276
 
277
+ # Points this Organization (default tool) or an LtiRegistration (other
278
+ # tools) at a v1.3 Developer Key.
279
+ def _register_lti_v1p3_key(tool, ekey, deployment_id: nil)
280
+ client_id = ekey[:id].to_s
281
+
282
+ if tool == PandaPal::DEFAULT_TOOL
283
+ self.key = deployment_id ? "#{client_id}/#{deployment_id}" : client_id
284
+ self.secret = ekey[:api_key] if ekey[:api_key].present?
285
+ save! if changed?
286
+ else
287
+ lti_registrations.find_or_create_by!(tool: tool.to_s, client_id: client_id, deployment_id: deployment_id)
288
+ # Drop the deployment-less placeholder recorded when the key was created
289
+ lti_registrations.where(tool: tool.to_s, client_id: client_id, deployment_id: nil).delete_all if deployment_id
290
+ end
291
+ end
292
+
293
+ # Canvas creates LTI Registrations with lock_deploying on, which blocks
294
+ # installing by client_id (when lock_lti_registrations is enabled). Unlock
295
+ # for the duration of the block, then put the lock back as it was.
296
+ def _with_lti_registration_unlocked(ekey)
297
+ registration_id = ekey[:lti_registration_id]
298
+ return yield unless registration_id.present?
299
+
300
+ registration_url = "api/v1/accounts/self/lti_registrations/#{registration_id}"
301
+ registration = bearcat_client.get(registration_url)
302
+ # Inherited registrations can only be changed by their owning account
303
+ return yield unless registration[:lock_deploying] && !registration[:inherited]
304
+
305
+ bearcat_client.put(registration_url, { lock_deploying: false })
306
+ begin
307
+ yield
308
+ ensure
309
+ bearcat_client.put(registration_url, { lock_deploying: true })
310
+ end
311
+ end
312
+
259
313
  def _find_existing_installs(context, exists: nil, &matcher)
260
314
  ctype, cid = _parse_lti_context(context)
261
315
 
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = '5.19.0.beta1'
2
+ VERSION = '5.19.0.beta3'
3
3
  end
data/panda_pal.gemspec CHANGED
@@ -18,7 +18,7 @@ Gem::Specification.new do |s|
18
18
  s.license = "MIT"
19
19
 
20
20
  s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "AGS.md", "panda_pal.gemspec"]
21
- s.test_files = Dir["spec/**/*"].reject{|f| f =~ /\/log\//}
21
+ # s.test_files = Dir["spec/**/*"].reject{|f| f =~ /\/log\//}
22
22
 
23
23
  s.add_dependency "rails", ">= 4.2"
24
24
  s.add_dependency 'ros-apartment', '~> 3.0'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: panda_pal
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.19.0.beta1
4
+ version: 5.19.0.beta3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure CustomDev
@@ -216,35 +216,6 @@ files:
216
216
  - lib/panda_pal/version.rb
217
217
  - lib/tasks/panda_pal_tasks.rake
218
218
  - panda_pal.gemspec
219
- - spec/controllers/panda_pal/api_call_controller_spec.rb
220
- - spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb
221
- - spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb
222
- - spec/controllers/panda_pal/lti_v1_p3_tools_spec.rb
223
- - spec/core/apartment_multidb_spec.rb
224
- - spec/factories/panda_pal_organizations.rb
225
- - spec/factories/panda_pal_sessions.rb
226
- - spec/internal/app/models/account.rb
227
- - spec/internal/app/models/admin.rb
228
- - spec/internal/config/database.yml
229
- - spec/internal/config/routes.rb
230
- - spec/internal/config/storage.yml
231
- - spec/internal/db/schema.rb
232
- - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
233
- - spec/lib/panda_pal/ags_client_spec.rb
234
- - spec/lib/panda_pal/helpers/secure_headers_spec.rb
235
- - spec/lib/panda_pal/helpers/session_replacement_spec.rb
236
- - spec/lib/panda_pal/lti_tools_spec.rb
237
- - spec/models/panda_pal/api_call_spec.rb
238
- - spec/models/panda_pal/lti_registration_spec.rb
239
- - spec/models/panda_pal/organization/settings_validation_spec.rb
240
- - spec/models/panda_pal/organization/task_scheduling_spec.rb
241
- - spec/models/panda_pal/organization_spec.rb
242
- - spec/models/panda_pal/platform/canvas_install_spec.rb
243
- - spec/models/panda_pal/platform_spec.rb
244
- - spec/models/panda_pal/session_account_roles_spec.rb
245
- - spec/models/panda_pal/session_spec.rb
246
- - spec/spec_helper.rb
247
- - spec/support/isolated_lti_config.rb
248
219
  homepage: http://instructure.com
249
220
  licenses:
250
221
  - MIT
@@ -266,33 +237,4 @@ requirements: []
266
237
  rubygems_version: 3.6.9
267
238
  specification_version: 4
268
239
  summary: LTI mountable engine
269
- test_files:
270
- - spec/controllers/panda_pal/api_call_controller_spec.rb
271
- - spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb
272
- - spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb
273
- - spec/controllers/panda_pal/lti_v1_p3_tools_spec.rb
274
- - spec/core/apartment_multidb_spec.rb
275
- - spec/factories/panda_pal_organizations.rb
276
- - spec/factories/panda_pal_sessions.rb
277
- - spec/internal/app/models/account.rb
278
- - spec/internal/app/models/admin.rb
279
- - spec/internal/config/database.yml
280
- - spec/internal/config/routes.rb
281
- - spec/internal/config/storage.yml
282
- - spec/internal/db/schema.rb
283
- - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
284
- - spec/lib/panda_pal/ags_client_spec.rb
285
- - spec/lib/panda_pal/helpers/secure_headers_spec.rb
286
- - spec/lib/panda_pal/helpers/session_replacement_spec.rb
287
- - spec/lib/panda_pal/lti_tools_spec.rb
288
- - spec/models/panda_pal/api_call_spec.rb
289
- - spec/models/panda_pal/lti_registration_spec.rb
290
- - spec/models/panda_pal/organization/settings_validation_spec.rb
291
- - spec/models/panda_pal/organization/task_scheduling_spec.rb
292
- - spec/models/panda_pal/organization_spec.rb
293
- - spec/models/panda_pal/platform/canvas_install_spec.rb
294
- - spec/models/panda_pal/platform_spec.rb
295
- - spec/models/panda_pal/session_account_roles_spec.rb
296
- - spec/models/panda_pal/session_spec.rb
297
- - spec/spec_helper.rb
298
- - spec/support/isolated_lti_config.rb
240
+ test_files: []
@@ -1,25 +0,0 @@
1
- require 'spec_helper'
2
-
3
- RSpec.describe PandaPal::ApiCallController, type: :controller do
4
- let!(:api_call) { PandaPal::ApiCall.create!(logic: '"#{p[:foo]}_bar"') }
5
-
6
- def json_body
7
- JSON.parse(response.body)
8
- end
9
-
10
- describe "#call" do
11
- it "calls the function with parameters" do
12
- get :call, params: { token: api_call.jwt_token, foo: 'bar', use_route: 'panda_pal' }
13
- expect(response).to be_successful
14
- expect(json_body["status"]).to eq 'ok'
15
- expect(json_body["result"]).to eq 'bar_bar'
16
- end
17
-
18
- it "fails given a bad JWT" do
19
- get :call, params: { token: "bad.jwt.token", foo: 'bar', use_route: 'panda_pal' }
20
- expect(response).not_to be_successful
21
- expect(json_body["status"]).to eq 'error'
22
- expect(json_body["error"]).to eq 'Invalid JWT'
23
- end
24
- end
25
- end
@@ -1,194 +0,0 @@
1
- require 'spec_helper'
2
-
3
- RSpec.describe "PandaPal::LtiV1P0Controller", type: :request do
4
- let(:organization) { create(:panda_pal_organization) }
5
- let(:launch_url) { panda_pal.v1p0_launch_url(host: 'www.example.com') }
6
-
7
- # Signs a full LTI v1p0 launch params hash the same way an LMS (Tool Consumer)
8
- # would, using the platform-side half of the very same `ims-lti` gem that
9
- # PandaPal uses on the tool-provider side to validate incoming launches.
10
- def signed_launch_params(secret:, timestamp: Time.now.to_i, launch_type: 'panda_pal', custom: {})
11
- consumer = IMS::LTI::ToolConsumer.new(organization.key, secret, {
12
- 'resource_link_id' => 'test-resource-link-id',
13
- })
14
- consumer.launch_url = launch_url
15
- consumer.timestamp = timestamp
16
- # `launch_type` isn't a spec'd LTI param, but the controller reads it from
17
- # params, so it must be included in the signed payload or the signature
18
- # PandaPal recomputes on the real request body won't match.
19
- consumer.set_non_spec_param('launch_type', launch_type)
20
- custom.each { |k, v| consumer.set_custom_param(k.to_s, v) }
21
- consumer.generate_launch_data
22
- end
23
-
24
- def launched_session
25
- organization.switch_tenant { PandaPal::Session.order(:id).last }
26
- end
27
-
28
- after do
29
- # `validate_v1p0_launch` permanently switches the Apartment tenant to the
30
- # organization's schema on a successful launch (PandaPal::Organization
31
- # #switch_tenant with no block => Apartment::Tenant.switch!). Reset it so
32
- # this doesn't leak into other spec files run later in the suite.
33
- Apartment::Tenant.reset
34
- end
35
-
36
- describe 'POST /v1p0/launch' do
37
- context 'with a validly-signed launch' do
38
- it 'passes signature validation, starts a session, and redirects instead of rendering the 401 body' do
39
- params = signed_launch_params(secret: organization.secret)
40
-
41
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
42
-
43
- expect(response).not_to have_http_status(:unauthorized)
44
- expect(response.body).not_to include('Failed to validate LTI v1p0 launch')
45
- expect(response).to have_http_status(:found)
46
- expect(response.headers['Location']).to include('/panda_pal')
47
-
48
- # The session record is created after PandaPal switches Apartment tenants
49
- # to the organization's own schema, so look it up there.
50
- session_count = organization.switch_tenant { PandaPal::Session.count }
51
- expect(session_count).to eq(1)
52
- end
53
- end
54
-
55
- context 'tools', :isolated_lti_config do
56
- before do
57
- # Only the translate tool routes editor_button anywhere real (the mount
58
- # point) - the default tool would fall back to a missing editor_button_url.
59
- PandaPal.stage_placement(:editor_button, tool: :translate, url: :panda_pal)
60
- end
61
-
62
- it 'records the default tool for a launch with no tool custom field' do
63
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: signed_launch_params(secret: organization.secret)
64
-
65
- expect(response).to have_http_status(:found)
66
- expect(launched_session.lti_tool).to eq(PandaPal::DEFAULT_TOOL)
67
- end
68
-
69
- it "routes by the signed tool custom field and records the tool on the session" do
70
- params = signed_launch_params(secret: organization.secret, launch_type: 'editor_button', custom: { PandaPal::TOOL_CUSTOM_FIELD => 'translate' })
71
-
72
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
73
-
74
- expect(response).to have_http_status(:found)
75
- expect(response.headers['Location']).to include('/panda_pal')
76
- expect(launched_session.lti_tool).to eq(:translate)
77
- end
78
-
79
- it 'rejects a launch naming a tool that was never defined' do
80
- params = signed_launch_params(secret: organization.secret, custom: { PandaPal::TOOL_CUSTOM_FIELD => 'nope' })
81
-
82
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
83
-
84
- expect(response).to have_http_status(:unauthorized)
85
- expect(response.body).to eq('Unrecognized LTI tool')
86
- end
87
- end
88
-
89
- context 'with an invalid signature (wrong secret used to sign)' do
90
- it 'renders 401 with the failure body' do
91
- params = signed_launch_params(secret: 'not-the-real-secret')
92
-
93
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
94
-
95
- expect(response).to have_http_status(:unauthorized)
96
- expect(response.body).to eq('Failed to validate LTI v1p0 launch')
97
- end
98
- end
99
-
100
- context 'with a tampered parameter after signing' do
101
- it 'renders 401 with the failure body' do
102
- params = signed_launch_params(secret: organization.secret)
103
- params['resource_link_id'] = 'a-different-resource-link-id'
104
-
105
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
106
-
107
- expect(response).to have_http_status(:unauthorized)
108
- expect(response.body).to eq('Failed to validate LTI v1p0 launch')
109
- end
110
- end
111
-
112
- context 'with a stale oauth_timestamp' do
113
- it 'renders 401 without ever consulting IMS::LTI::ToolProvider' do
114
- stale_timestamp = 400.seconds.ago.to_i
115
- params = signed_launch_params(secret: organization.secret, timestamp: stale_timestamp)
116
-
117
- expect(IMS::LTI::ToolProvider).not_to receive(:new)
118
-
119
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
120
-
121
- expect(response).to have_http_status(:unauthorized)
122
- expect(response.body).to eq('Failed to validate LTI v1p0 launch')
123
- end
124
- end
125
- end
126
-
127
- describe 'safari_override (browser gem regression coverage)' do
128
- let(:safari_user_agent) do
129
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
130
- end
131
- let(:chrome_user_agent) do
132
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
133
- end
134
-
135
- # safari_override is the very first thing validate_launch! does, before any
136
- # OAuth/org checks, so an otherwise-invalid launch is sufficient to exercise it.
137
- it 'applies the secure_headers safari override when the User-Agent is Safari' do
138
- expect_any_instance_of(PandaPal::LtiV1P0Controller).to receive(:use_secure_headers_override).with(:safari_override)
139
-
140
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: {}, headers: { 'User-Agent' => safari_user_agent }
141
- end
142
-
143
- it 'does not apply the override for a non-Safari User-Agent (Chrome)' do
144
- expect_any_instance_of(PandaPal::LtiV1P0Controller).not_to receive(:use_secure_headers_override)
145
-
146
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: {}, headers: { 'User-Agent' => chrome_user_agent }
147
- end
148
- end
149
-
150
- describe 'GET /v1p0/config', :isolated_lti_config do
151
- before do
152
- PandaPal.lti_environments = { domain: 'tool.example.com' }
153
- PandaPal.lti_options = { title: 'Shared Title' }
154
- PandaPal.stage_placement(:course_navigation, url: :panda_pal, text: 'Settings')
155
- PandaPal.define_tool(:translate, title: 'Translate Title')
156
- PandaPal.stage_placement(:editor_button, tool: :translate, url: :panda_pal, text: 'Translate')
157
- end
158
-
159
- def ext_param(doc, name)
160
- doc.at_xpath("//lticm:property[@name='#{name}'] | //lticm:options[@name='#{name}']", 'lticm' => 'http://www.imsglobal.org/xsd/imslticm_v1p0')
161
- end
162
-
163
- it "serves only the default tool's placements without a tool param" do
164
- get '/panda_pal/v1p0/config'
165
-
166
- doc = Nokogiri::XML(response.body)
167
- expect(response).to have_http_status(:ok)
168
- expect(doc.at_xpath('//blti:title', 'blti' => 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0').text).to eq('Shared Title')
169
- expect(ext_param(doc, 'course_navigation')).to be_present
170
- expect(ext_param(doc, 'editor_button')).to be_nil
171
- expect(response.body).not_to include(PandaPal::TOOL_CUSTOM_FIELD)
172
- end
173
-
174
- it "serves the requested tool's title, placements, and tool custom field" do
175
- get '/panda_pal/v1p0/config', params: { tool: 'translate' }
176
-
177
- doc = Nokogiri::XML(response.body)
178
- expect(response).to have_http_status(:ok)
179
- expect(doc.at_xpath('//blti:title', 'blti' => 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0').text).to eq('Translate Title')
180
- expect(ext_param(doc, 'editor_button')).to be_present
181
- expect(ext_param(doc, 'course_navigation')).to be_nil
182
-
183
- custom = doc.at_xpath("//blti:custom/lticm:property[@name='#{PandaPal::TOOL_CUSTOM_FIELD}']",
184
- 'blti' => 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0', 'lticm' => 'http://www.imsglobal.org/xsd/imslticm_v1p0')
185
- expect(custom&.text).to eq('translate')
186
- end
187
-
188
- it 'renders 404 for an unknown tool' do
189
- get '/panda_pal/v1p0/config', params: { tool: 'nope' }
190
-
191
- expect(response).to have_http_status(:not_found)
192
- end
193
- end
194
- end
@@ -1,108 +0,0 @@
1
- require 'spec_helper'
2
- require 'nokogiri'
3
-
4
- RSpec.describe "PandaPal::LtiV1P3Controller", type: :request do
5
- describe 'GET /v1p3/public_jwks' do
6
- let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
7
- let(:test_jwk) { JWT::JWK.new(rsa_key, { use: 'sig', alg: 'RS256' }) }
8
-
9
- before { PandaPal.jwk = test_jwk }
10
-
11
- after do
12
- # PandaPal.jwk memoizes into a class variable (`@@jwk ||= ...`). Reset it
13
- # so our test key doesn't leak into other spec files run later in the suite.
14
- PandaPal.jwk = nil
15
- end
16
-
17
- it 'renders a 200 JSON JWK Set whose key matches PandaPal.jwk, with no private key material' do
18
- get "/panda_pal/v1p3/public_jwks"
19
-
20
- expect(response).to have_http_status(:ok)
21
-
22
- expected_body = JSON.parse(JWT::JWK::Set.new(test_jwk).export.to_json)
23
- body = JSON.parse(response.body)
24
-
25
- expect(body).to eq(expected_body)
26
- expect(body['keys']).to be_an(Array)
27
- expect(body['keys'].length).to eq(1)
28
-
29
- returned_key = body['keys'].first
30
- expect(returned_key['kty']).to eq('RSA')
31
- expect(returned_key['use']).to eq('sig')
32
- expect(returned_key['alg']).to eq('RS256')
33
- expect(returned_key['n']).to eq(test_jwk[:n])
34
- expect(returned_key['e']).to eq(test_jwk[:e])
35
- # Only the public modulus/exponent should ever be exposed - never private key material.
36
- expect(returned_key.keys).not_to include('d', 'p', 'q', 'dp', 'dq', 'qi')
37
- end
38
- end
39
-
40
- describe 'GET /v1p3/oidc_login' do
41
- let(:login_params) do
42
- {
43
- client_id: 'test-client-id',
44
- login_hint: 'test-login-hint',
45
- lti_message_hint: 'test-lti-message-hint',
46
- iss: 'https://canvas.instructure.com',
47
- }
48
- end
49
-
50
- it 'renders the auto-submit form targeting the platform auth redirect URL, with the expected hidden fields' do
51
- get "/panda_pal/v1p3/oidc_login", params: login_params
52
-
53
- expect(response).to have_http_status(:ok)
54
-
55
- doc = Nokogiri::HTML(response.body)
56
- form = doc.at_css('form#redirect-form')
57
- expect(form).not_to be_nil
58
-
59
- created_session = PandaPal::Session.order(:id).last
60
- expect(created_session).not_to be_nil
61
-
62
- expect(form['action']).to eq(PandaPal::Platform::Canvas.new(iss: 'https://canvas.instructure.com').authentication_redirect_url)
63
-
64
- hidden_fields = form.css('input[type=hidden]').each_with_object({}) do |input, h|
65
- h[input['name']] = input['value']
66
- end
67
-
68
- expect(hidden_fields['client_id']).to eq('test-client-id')
69
- expect(hidden_fields['login_hint']).to eq('test-login-hint')
70
- expect(hidden_fields['lti_message_hint']).to eq('test-lti-message-hint')
71
- expect(hidden_fields['scope']).to eq('openid')
72
- expect(hidden_fields['response_type']).to eq('id_token')
73
- expect(hidden_fields['state']).to eq(created_session.session_key)
74
- expect(hidden_fields['nonce']).to eq(created_session[:lti_oauth_nonce])
75
- expect(hidden_fields['nonce']).not_to be_blank
76
- end
77
- end
78
-
79
- describe 'GET /v1p3/config' do
80
- before { PandaPal.lti_environments = { domain: 'tool.example.com' } }
81
-
82
- after do
83
- PandaPal.lti_environments = {}
84
- PandaPal.lti_scopes = []
85
- end
86
-
87
- it 'publishes no scopes by default' do
88
- get "/panda_pal/v1p3/config"
89
-
90
- expect(response).to have_http_status(:ok)
91
- expect(JSON.parse(response.body)['scopes']).to eq([])
92
- end
93
-
94
- it 'publishes the configured PandaPal.lti_scopes' do
95
- PandaPal.lti_scopes = [
96
- PandaPal::LtiConstants::AgsScopes::SCORE,
97
- PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY,
98
- ]
99
-
100
- get "/panda_pal/v1p3/config"
101
-
102
- expect(JSON.parse(response.body)['scopes']).to eq([
103
- 'https://purl.imsglobal.org/spec/lti-ags/scope/score',
104
- 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
105
- ])
106
- end
107
- end
108
- end