panda_pal 5.19.0.beta1 → 5.19.0.beta2

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 +25 -0
  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: b8433b20262277ab9a0e66156b4fb2b865919cbfe73701202c163b1dd4c063c9
4
+ data.tar.gz: fba4d18d40156ccf1799c33b4b2a5aed33b71c6e0e772a65df04c586f207503f
5
5
  SHA512:
6
- metadata.gz: 9d4253dfec794377b88c5efbd6b2167b225373049e0e7b7694ef3f6f4d5e39253e71aa720094b39b336e6b4afc251b00c7f60f35dc1dbe3bb11dde35ce9b966b
7
- data.tar.gz: b5004d4d2f7be4dcfb91d70d81445284084ea24a11f03f8db8513c6794cacdef99bf9c4da96c50b51cd1a9870ae4129ca3d66ac6b91a0065f4ef34e1aac59157
6
+ metadata.gz: 33e70423978afce311e1e0d4a5eecddc17e8814e922bea12e854b1b57135cc336e1c418b6f8d0fcca061c4a25485164dab1468805639d16abd6f1c1951f7b4cb
7
+ data.tar.gz: fa08843f3ad5d268203908c22725c546825102127cce6858c907a84b21e0a58561fab5f1e017b569cb95abd5e42c1715045b699b7790f899e7bbabfbf8f9693c
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
 
@@ -155,9 +176,13 @@ module PandaPal
155
176
  else
156
177
  raise "Unrecognized LTI Version #{version}"
157
178
  end
179
+
180
+ @current_lti_install.installation = @new_lti_installation
158
181
  end
159
182
 
160
183
  save!
184
+ ensure
185
+ @current_lti_install = outer_install
161
186
  end
162
187
 
163
188
  def install_lti(*args, **kwargs)
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = '5.19.0.beta1'
2
+ VERSION = '5.19.0.beta2'
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.beta2
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
@@ -1,144 +0,0 @@
1
- require 'spec_helper'
2
- require 'json/jwt'
3
-
4
- RSpec.describe "PandaPal::LtiV1P3Controller tools", type: :request, isolated_lti_config: true do
5
- let(:organization) { create(:panda_pal_organization, key: '10000000000001') }
6
- let(:issuer) { 'https://canvas.instructure.com' }
7
- let(:platform_key) { OpenSSL::PKey::RSA.new(2048) }
8
- let(:platform_jwk) { JSON::JWK.new(platform_key.public_key) }
9
-
10
- before do
11
- PandaPal.lti_environments = { domain: 'tool.example.com' }
12
- PandaPal.lti_options = { title: 'Shared Title' }
13
- PandaPal.stage_placement(:course_navigation, text: 'Settings')
14
- PandaPal.define_tool(:translate, title: 'Translate Title')
15
- # Only the translate tool routes editor_button anywhere real (the mount
16
- # point) - the default tool would fall back to a missing editor_button_url.
17
- PandaPal.stage_placement(:editor_button, tool: :translate, url: :panda_pal, text: 'Translate')
18
- end
19
-
20
- after { Apartment::Tenant.reset }
21
-
22
- describe 'GET /v1p3/config' do
23
- it "serves only the default tool's placements without a tool param" do
24
- get '/panda_pal/v1p3/config'
25
-
26
- body = JSON.parse(response.body)
27
- placements = body.dig('extensions', 0, 'settings', 'placements').map { |p| p['placement'] }
28
-
29
- expect(body['title']).to eq('Shared Title')
30
- expect(placements).to eq(['course_navigation'])
31
- expect(body['custom_fields']).not_to have_key(PandaPal::TOOL_CUSTOM_FIELD)
32
- end
33
-
34
- it "serves the requested tool's title, placements, and tool custom field" do
35
- get '/panda_pal/v1p3/config', params: { tool: 'translate' }
36
-
37
- body = JSON.parse(response.body)
38
- placements = body.dig('extensions', 0, 'settings', 'placements')
39
-
40
- expect(body['title']).to eq('Translate Title')
41
- expect(placements.map { |p| p['placement'] }).to eq(['editor_button'])
42
- expect(placements[0]['target_link_uri']).to include('/panda_pal/v1p3/resource_link_request?launch_type=editor_button')
43
- expect(body['custom_fields'][PandaPal::TOOL_CUSTOM_FIELD]).to eq('translate')
44
- end
45
-
46
- it 'renders 404 for an unknown tool' do
47
- get '/panda_pal/v1p3/config', params: { tool: 'nope' }
48
-
49
- expect(response).to have_http_status(:not_found)
50
- end
51
- end
52
-
53
- describe 'POST /v1p3/resource_link_request' do
54
- let(:nonce) { SecureRandom.uuid }
55
-
56
- let!(:login_session) do
57
- # What LtiV1P3Controller#login leaves behind in the public schema.
58
- PandaPal::Session.create!(panda_pal_organization_id: 0, data: {
59
- lti_platform: PandaPal::Platform::Canvas.new(iss: issuer).serialize,
60
- lti_oauth_nonce: nonce,
61
- })
62
- end
63
-
64
- before do
65
- allow_any_instance_of(PandaPal::Platform::Canvas).to receive(:public_jwks).and_return(JSON::JWK::Set.new(platform_jwk))
66
- # Launch validation records nonces in Rails.cache - keep them in memory
67
- # rather than in the dummy app's file store.
68
- allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
69
- end
70
-
71
- def id_token(client_id:, deployment_id: 'dep-1', placement: 'editor_button')
72
- claims = {
73
- iss: issuer,
74
- aud: client_id,
75
- sub: 'user-1',
76
- nonce: nonce,
77
- iat: Time.now.to_i,
78
- exp: Time.now.to_i + 300,
79
- PandaPal::LtiConstants::Claims::DEPLOYMENT_ID => deployment_id,
80
- PandaPal::LtiConstants::Claims::MESSAGE_TYPE => 'LtiResourceLinkRequest',
81
- PandaPal::LtiConstants::Canvas::PLACEMENT => placement,
82
- }
83
- jwt = JSON::JWT.new(claims)
84
- jwt.kid = platform_jwk[:kid]
85
- jwt.sign(platform_key, :RS256).to_s
86
- end
87
-
88
- def launch!(**token_opts)
89
- post '/panda_pal/v1p3/resource_link_request', params: { id_token: id_token(**token_opts), state: login_session.session_key }
90
- end
91
-
92
- def launched_session
93
- organization.switch_tenant { PandaPal::Session.order(:id).last }
94
- end
95
-
96
- it "resolves a registered tool's client_id to its organization and routes by that tool" do
97
- organization.lti_registrations.create!(tool: 'translate', client_id: '10000000000002')
98
-
99
- launch!(client_id: '10000000000002')
100
-
101
- expect(response).to have_http_status(:found)
102
- expect(response.headers['Location']).to include('/panda_pal')
103
- expect(launched_session.lti_tool).to eq(:translate)
104
- expect(launched_session.panda_pal_organization).to eq(organization)
105
- end
106
-
107
- it 'prefers a deployment-specific registration' do
108
- other_org = create(:panda_pal_organization)
109
- other_org.lti_registrations.create!(tool: 'translate', client_id: '10000000000002')
110
- organization.lti_registrations.create!(tool: 'translate', client_id: '10000000000002', deployment_id: 'dep-1')
111
-
112
- launch!(client_id: '10000000000002', deployment_id: 'dep-1')
113
-
114
- expect(response).to have_http_status(:found)
115
- expect(launched_session.panda_pal_organization).to eq(organization)
116
- end
117
-
118
- it "still resolves the organization's own key as the default tool" do
119
- PandaPal.stage_placement(:course_navigation, url: :panda_pal)
120
-
121
- launch!(client_id: organization.key, placement: 'course_navigation')
122
-
123
- expect(response).to have_http_status(:found)
124
- expect(launched_session.lti_tool).to eq(PandaPal::DEFAULT_TOOL)
125
- end
126
-
127
- it 'rejects a registration whose tool is no longer defined' do
128
- registration = organization.lti_registrations.create!(tool: 'translate', client_id: '10000000000002')
129
- registration.update_column(:tool, 'removed')
130
-
131
- launch!(client_id: '10000000000002')
132
-
133
- expect(response).to have_http_status(:unauthorized)
134
- expect(response.body).to include('Unrecognized LTI tool')
135
- end
136
-
137
- it 'rejects an unregistered client_id' do
138
- launch!(client_id: '99999')
139
-
140
- expect(response).to have_http_status(:unauthorized)
141
- expect(response.body).to include('Unrecognized Organization')
142
- end
143
- end
144
- end