panda_pal 5.18.2 → 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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +39 -0
  3. data/app/controllers/panda_pal/lti_v1_p0_controller.rb +11 -4
  4. data/app/controllers/panda_pal/lti_v1_p3_controller.rb +17 -8
  5. data/app/lib/lti_xml/base_platform.rb +32 -18
  6. data/app/lib/lti_xml/bridge_platform.rb +2 -2
  7. data/app/lib/panda_pal/launch_url_helpers.rb +21 -14
  8. data/app/models/panda_pal/lti_registration.rb +41 -0
  9. data/app/models/panda_pal/organization.rb +2 -0
  10. data/app/models/panda_pal/platform/canvas.rb +88 -28
  11. data/app/models/panda_pal/session.rb +19 -1
  12. data/config/initializers/apartment.rb +1 -1
  13. data/db/migrate/20261006000000_create_panda_pal_lti_registrations.rb +14 -0
  14. data/db/migrate/20261006000001_add_tool_to_panda_pal_sessions.rb +5 -0
  15. data/lib/panda_pal/helpers/controller_helper.rb +20 -1
  16. data/lib/panda_pal/helpers/route_helper.rb +8 -1
  17. data/lib/panda_pal/helpers/session_replacement.rb +2 -2
  18. data/lib/panda_pal/version.rb +1 -1
  19. data/lib/panda_pal.rb +82 -10
  20. data/panda_pal.gemspec +2 -1
  21. metadata +19 -50
  22. data/spec/controllers/panda_pal/api_call_controller_spec.rb +0 -25
  23. data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +0 -110
  24. data/spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb +0 -108
  25. data/spec/core/apartment_multidb_spec.rb +0 -166
  26. data/spec/factories/panda_pal_organizations.rb +0 -9
  27. data/spec/factories/panda_pal_sessions.rb +0 -6
  28. data/spec/internal/app/models/account.rb +0 -7
  29. data/spec/internal/app/models/admin.rb +0 -6
  30. data/spec/internal/config/database.yml +0 -18
  31. data/spec/internal/config/routes.rb +0 -5
  32. data/spec/internal/config/storage.yml +0 -3
  33. data/spec/internal/db/schema.rb +0 -30
  34. data/spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb +0 -83
  35. data/spec/lib/panda_pal/ags_client_spec.rb +0 -385
  36. data/spec/lib/panda_pal/helpers/secure_headers_spec.rb +0 -77
  37. data/spec/lib/panda_pal/helpers/session_replacement_spec.rb +0 -72
  38. data/spec/models/panda_pal/api_call_spec.rb +0 -30
  39. data/spec/models/panda_pal/organization/settings_validation_spec.rb +0 -179
  40. data/spec/models/panda_pal/organization/task_scheduling_spec.rb +0 -145
  41. data/spec/models/panda_pal/organization_spec.rb +0 -50
  42. data/spec/models/panda_pal/platform_spec.rb +0 -349
  43. data/spec/models/panda_pal/session_account_roles_spec.rb +0 -182
  44. data/spec/models/panda_pal/session_spec.rb +0 -202
  45. data/spec/spec_helper.rb +0 -158
@@ -211,6 +211,20 @@ module PandaPal
211
211
  launch_params['https://www.instructure.com/placement'] || launch_params[:launch_type]
212
212
  end
213
213
 
214
+ # The PandaPal tool (see PandaPal.define_tool) this session was launched through.
215
+ def lti_tool
216
+ (tool.presence || PandaPal::DEFAULT_TOOL).to_sym
217
+ end
218
+
219
+ # The main_app route a +launch_type+ launch lands on, per this session's tool.
220
+ def lti_launch_route(launch_type)
221
+ LaunchUrlHelpers.launch_route(launch_type, tool: lti_tool)
222
+ end
223
+
224
+ def deep_link_redirect_url(request)
225
+ LaunchUrlHelpers.deep_link_redirect_url(request, tool: lti_tool)
226
+ end
227
+
214
228
  def custom_lti_params
215
229
  @custom_lti_params ||= begin
216
230
  # LT 1.3
@@ -230,7 +244,11 @@ module PandaPal
230
244
 
231
245
  def get_lti_cust_param(key, default: :if_not_var)
232
246
  nkey = key.to_s.gsub(/^custom_/, '')
233
- default_value = ->() { PandaPal.lti_custom_params[nkey] || PandaPal.lti_custom_params["custom_#{nkey}"] }
247
+ default_value = ->() do
248
+ tool = PandaPal.lti_tool?(lti_tool) ? lti_tool : PandaPal::DEFAULT_TOOL
249
+ params = PandaPal.lti_custom_params(tool)
250
+ params[nkey] || params["custom_#{nkey}"]
251
+ end
234
252
 
235
253
  val = launch_params.dig("https://purl.imsglobal.org/spec/lti/claim/custom", nkey) || launch_params[nkey] || launch_params["custom_#{nkey}"]
236
254
 
@@ -253,7 +253,7 @@ end
253
253
 
254
254
  Apartment.configure do |config|
255
255
  config.excluded_models ||= []
256
- config.excluded_models |= ['PandaPal::Organization']
256
+ config.excluded_models |= ['PandaPal::Organization', 'PandaPal::LtiRegistration']
257
257
 
258
258
  config.with_multi_server_setup = true unless Rails.env.test?
259
259
 
@@ -0,0 +1,14 @@
1
+ class CreatePandaPalLtiRegistrations < ActiveRecord::Migration[6.1]
2
+ def change
3
+ create_table :panda_pal_lti_registrations do |t|
4
+ t.references :panda_pal_organization, null: false, index: true
5
+ t.string :tool, null: false
6
+ t.string :client_id, null: false
7
+ t.string :deployment_id
8
+
9
+ t.timestamps null: false
10
+ end
11
+
12
+ add_index :panda_pal_lti_registrations, [:client_id, :deployment_id], unique: true, name: 'index_panda_pal_lti_registrations_on_client_and_deployment'
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ class AddToolToPandaPalSessions < ActiveRecord::Migration[6.1]
2
+ def change
3
+ add_column :panda_pal_sessions, :tool, :string
4
+ end
5
+ end
@@ -63,9 +63,17 @@ module PandaPal::Helpers
63
63
  return false
64
64
  end
65
65
 
66
+ # LTI 1.0 tools share the Organization's consumer key, so the tool comes
67
+ # from its (OAuth-signed) custom field instead of the key.
68
+ lti_tool = params[:"custom_#{PandaPal::TOOL_CUSTOM_FIELD}"]
69
+ unless PandaPal.lti_tool?(lti_tool)
70
+ render plain: 'Unrecognized LTI tool', status: :unauthorized
71
+ return false
72
+ end
73
+
66
74
  @organization.switch_tenant
67
75
 
68
- start_panda_session!
76
+ start_panda_session!(tool: PandaPal.normalize_lti_tool!(lti_tool).to_s)
69
77
  end
70
78
 
71
79
  def validate_v1p3_launch
@@ -78,6 +86,15 @@ module PandaPal::Helpers
78
86
 
79
87
  client_id = decoded_jwt['aud']
80
88
  deployment_id = decoded_jwt['https://purl.imsglobal.org/spec/lti/claim/deployment_id']
89
+ lti_tool = PandaPal::DEFAULT_TOOL
90
+
91
+ # Each tool is its own Developer Key, so the client_id (signed, as `aud`)
92
+ # identifies both the Organization and the tool.
93
+ registration = PandaPal::LtiRegistration.for_launch(decoded_jwt['azp'].presence || Array(client_id).first, deployment_id)
94
+ if registration
95
+ @organization = registration.panda_pal_organization
96
+ lti_tool = registration.lti_tool
97
+ end
81
98
 
82
99
  if deployment_id.present?
83
100
  @organization ||= PandaPal::Organization.find_by(key: "#{client_id}/#{deployment_id}")
@@ -89,6 +106,7 @@ module PandaPal::Helpers
89
106
  params[:session_key] = params[:state]
90
107
 
91
108
  raise JSON::JWT::VerificationFailed, 'Unrecognized Organization' unless @organization.present?
109
+ raise JSON::JWT::VerificationFailed, 'Unrecognized LTI tool' unless PandaPal.lti_tool?(lti_tool)
92
110
  raise JSON::JWT::VerificationFailed, 'Organization does not trust platform' unless @organization.trusted_platform?(current_lti_platform)
93
111
 
94
112
  begin
@@ -111,6 +129,7 @@ module PandaPal::Helpers
111
129
 
112
130
  @current_session = login_session.dup
113
131
  @current_session.panda_pal_organization = @organization
132
+ @current_session.tool = lti_tool.to_s
114
133
  @current_session.save!
115
134
 
116
135
  @decoded_lti_jwt = decoded_jwt
@@ -6,10 +6,17 @@ module PandaPal::Helpers::RouteHelper
6
6
  nav, to = options.first
7
7
  options[:to] = to
8
8
  options.delete nav
9
- path = "#{base_path}/#{nav.to_s}"
10
9
 
11
10
  lti_options = options.delete(:lti_options) || {}
12
11
 
12
+ # Non-default tools get their own path segment, so the same placement can be
13
+ # declared once per tool without the routes colliding.
14
+ tool = options.delete(:tool) || lti_options[:tool]
15
+ tool = tool.presence&.to_sym || PandaPal::DEFAULT_TOOL
16
+ lti_options[:tool] = tool
17
+
18
+ path = tool == PandaPal::DEFAULT_TOOL ? "#{base_path}/#{nav.to_s}" : "#{base_path}/#{tool}/#{nav.to_s}"
19
+
13
20
  lti_options[:auto_launch] = options.delete(:auto_launch)
14
21
  # NB if lti_nav is outside an :organization_id scope, auto_launch defaults differently between 1.1 and 1.3 - 1.1 defaults off, 1.3 defaults on
15
22
  lti_options[:auto_launch] = true if lti_options[:auto_launch].nil? && (@scope[:path] || '').include?(':organization_id')
@@ -31,9 +31,9 @@ module PandaPal::Helpers
31
31
  current_session.try(:save)
32
32
  end
33
33
 
34
- def start_panda_session!
34
+ def start_panda_session!(**attributes)
35
35
  raise "Session already started" if @current_session.present?
36
- @current_session = PandaPal::Session.new(panda_pal_organization_id: current_organization&.id).tap do |session|
36
+ @current_session = PandaPal::Session.new(panda_pal_organization_id: current_organization&.id, **attributes).tap do |session|
37
37
  session.save!
38
38
  end
39
39
  end
@@ -1,3 +1,3 @@
1
1
  module PandaPal
2
- VERSION = '5.18.2'
2
+ VERSION = '5.19.0.beta2'
3
3
  end
data/lib/panda_pal.rb CHANGED
@@ -22,7 +22,20 @@ module PandaPal
22
22
  end
23
23
  end
24
24
 
25
- @@lti_navigation = {}
25
+ # A single host app can publish more than one LTI tool (each its own Canvas
26
+ # Developer Key / tool config, with its own placements). Everything that
27
+ # isn't given an explicit tool belongs to DEFAULT_TOOL, so single-tool apps
28
+ # never need to know tools exist.
29
+ DEFAULT_TOOL = :default
30
+
31
+ # Custom field added to every non-default tool's config, so a launch (and an
32
+ # installed tool in Canvas) can be traced back to the tool it came from.
33
+ TOOL_CUSTOM_FIELD = 'panda_pal_tool'.freeze
34
+
35
+ class UnknownLtiTool < StandardError; end
36
+
37
+ @@lti_navigation = { DEFAULT_TOOL => {} }
38
+ @@lti_tools = {}
26
39
  @@lti_options = {}
27
40
  @@lti_properties = {}
28
41
  @@lti_environments = {}
@@ -59,8 +72,58 @@ module PandaPal
59
72
  @@lti_custom_params = custom_params.with_indifferent_access
60
73
  end
61
74
 
62
- def self.lti_custom_params
63
- @@lti_custom_params.deep_dup
75
+ def self.lti_custom_params(tool = DEFAULT_TOOL)
76
+ tool = normalize_lti_tool!(tool)
77
+ params = @@lti_custom_params.deep_dup
78
+ return params if tool == DEFAULT_TOOL
79
+
80
+ params.merge!(@@lti_tools[tool][:custom_params] || {})
81
+ params[TOOL_CUSTOM_FIELD] = tool.to_s
82
+ params
83
+ end
84
+
85
+ # Registers an additional LTI tool. `options` are merged over lti_options
86
+ # when building that tool's config (title, description, deep_link_redirect_url,
87
+ # ...), and `custom_params:` are merged over lti_custom_params.
88
+ #
89
+ # PandaPal.define_tool(:translate, title: 'My LTI - Translate', custom_params: { ... })
90
+ # PandaPal.stage_placement(:editor_button, tool: :translate, ...)
91
+ def self.define_tool(name, options = {})
92
+ name = name.to_sym
93
+ raise ArgumentError, "#{DEFAULT_TOOL} is configured through lti_options, not define_tool" if name == DEFAULT_TOOL
94
+
95
+ options = options.with_indifferent_access
96
+ tool = (@@lti_tools[name] ||= {})
97
+ tool[:custom_params] = (tool[:custom_params] || {}).merge(options.delete(:custom_params) || {}).with_indifferent_access
98
+ tool[:options] = (tool[:options] || {}).merge(options)
99
+ @@lti_navigation[name] ||= {}
100
+ name
101
+ end
102
+
103
+ def self.lti_tools
104
+ [DEFAULT_TOOL, *@@lti_tools.keys]
105
+ end
106
+
107
+ def self.lti_tool?(name)
108
+ return true if name.blank?
109
+ lti_tools.include?(name.to_sym)
110
+ end
111
+
112
+ # Blank means the default tool. Anything else must have been defined.
113
+ def self.normalize_lti_tool!(name)
114
+ return DEFAULT_TOOL if name.blank?
115
+ name = name.to_sym
116
+ raise UnknownLtiTool, "Unknown LTI tool '#{name}'" unless lti_tool?(name)
117
+ name
118
+ end
119
+
120
+ # lti_options as seen by one tool - its own options merged over the shared ones.
121
+ def self.lti_tool_options(tool = DEFAULT_TOOL)
122
+ tool = normalize_lti_tool!(tool)
123
+ opts = lti_options
124
+ return opts if tool == DEFAULT_TOOL
125
+
126
+ opts.with_indifferent_access.merge(@@lti_tools[tool][:options].deep_dup)
64
127
  end
65
128
 
66
129
  def self.lti_scopes=(scopes)
@@ -77,7 +140,12 @@ module PandaPal
77
140
  end
78
141
 
79
142
  def self.stage_placement(placement, options = {})
80
- @@lti_navigation[placement] ||= {}
143
+ options = options.dup
144
+ tool = options.delete(:tool).presence&.to_sym || DEFAULT_TOOL
145
+ define_tool(tool) unless tool == DEFAULT_TOOL || @@lti_tools.key?(tool)
146
+
147
+ placements = (@@lti_navigation[tool] ||= {})
148
+ placements[placement] ||= {}
81
149
 
82
150
  if options.delete(:deep_linking)
83
151
  options = options.merge({
@@ -86,11 +154,12 @@ module PandaPal
86
154
  })
87
155
  end
88
156
 
89
- @@lti_navigation[placement].merge!(options)
157
+ placements[placement].merge!(options)
90
158
  end
91
159
 
92
- def self.lti_paths
93
- @@lti_navigation.deep_dup
160
+ def self.lti_paths(tool = DEFAULT_TOOL)
161
+ tool = normalize_lti_tool!(tool)
162
+ (@@lti_navigation[tool] || {}).deep_dup
94
163
  end
95
164
 
96
165
  def self.lti_private_key
@@ -157,9 +226,12 @@ module PandaPal
157
226
  end
158
227
 
159
228
  def self.validate_lti_navigation(errors = [])
160
- @@lti_navigation.each do |k, v|
161
- next if v[:route_helper_key]
162
- errors << "lti navigation '#{k}' does not have a Route!" unless (LaunchUrlHelpers.launch_url(k) rescue nil)
229
+ @@lti_navigation.each do |tool, placements|
230
+ placements.each do |k, v|
231
+ next if v[:route_helper_key]
232
+ label = tool == DEFAULT_TOOL ? "'#{k}'" : "'#{k}' (tool '#{tool}')"
233
+ errors << "lti navigation #{label} does not have a Route!" unless (LaunchUrlHelpers.launch_url(k, tool: tool) rescue nil)
234
+ end
163
235
  end
164
236
  errors
165
237
  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'
@@ -27,5 +27,6 @@ Gem::Specification.new do |s|
27
27
  s.add_dependency 'attr_encrypted', '~> 4.2'
28
28
  s.add_dependency 'secure_headers', '>= 6.1', '< 8'
29
29
  s.add_dependency 'jwt'
30
+ s.add_dependency 'json-jwt', '>= 1.15' # LTI 1.3 id_token decoding/verification (validate_v1p3_launch)
30
31
  s.add_dependency 'httparty'
31
32
  end
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.18.2
4
+ version: 5.19.0.beta2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Instructure CustomDev
@@ -113,6 +113,20 @@ dependencies:
113
113
  - - ">="
114
114
  - !ruby/object:Gem::Version
115
115
  version: '0'
116
+ - !ruby/object:Gem::Dependency
117
+ name: json-jwt
118
+ requirement: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '1.15'
123
+ type: :runtime
124
+ prerelease: false
125
+ version_requirements: !ruby/object:Gem::Requirement
126
+ requirements:
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: '1.15'
116
130
  - !ruby/object:Gem::Dependency
117
131
  name: httparty
118
132
  requirement: !ruby/object:Gem::Requirement
@@ -158,6 +172,7 @@ files:
158
172
  - app/lib/panda_pal/launch_url_helpers.rb
159
173
  - app/lib/panda_pal/lti_jwt_validator.rb
160
174
  - app/models/panda_pal/api_call.rb
175
+ - app/models/panda_pal/lti_registration.rb
161
176
  - app/models/panda_pal/organization.rb
162
177
  - app/models/panda_pal/organization_concerns/multi_database_sharding.rb
163
178
  - app/models/panda_pal/organization_concerns/organization_builder.rb
@@ -183,6 +198,8 @@ files:
183
198
  - db/migrate/20171205194657_remove_old_organization_settings.rb
184
199
  - db/migrate/20220721095653_create_panda_pal_api_calls.rb
185
200
  - db/migrate/20250401214421_rename_session_session_key.rb
201
+ - db/migrate/20261006000000_create_panda_pal_lti_registrations.rb
202
+ - db/migrate/20261006000001_add_tool_to_panda_pal_sessions.rb
186
203
  - lib/panda_pal.rb
187
204
  - lib/panda_pal/concerns/ability_helper.rb
188
205
  - lib/panda_pal/engine.rb
@@ -199,30 +216,6 @@ files:
199
216
  - lib/panda_pal/version.rb
200
217
  - lib/tasks/panda_pal_tasks.rake
201
218
  - panda_pal.gemspec
202
- - spec/controllers/panda_pal/api_call_controller_spec.rb
203
- - spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb
204
- - spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb
205
- - spec/core/apartment_multidb_spec.rb
206
- - spec/factories/panda_pal_organizations.rb
207
- - spec/factories/panda_pal_sessions.rb
208
- - spec/internal/app/models/account.rb
209
- - spec/internal/app/models/admin.rb
210
- - spec/internal/config/database.yml
211
- - spec/internal/config/routes.rb
212
- - spec/internal/config/storage.yml
213
- - spec/internal/db/schema.rb
214
- - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
215
- - spec/lib/panda_pal/ags_client_spec.rb
216
- - spec/lib/panda_pal/helpers/secure_headers_spec.rb
217
- - spec/lib/panda_pal/helpers/session_replacement_spec.rb
218
- - spec/models/panda_pal/api_call_spec.rb
219
- - spec/models/panda_pal/organization/settings_validation_spec.rb
220
- - spec/models/panda_pal/organization/task_scheduling_spec.rb
221
- - spec/models/panda_pal/organization_spec.rb
222
- - spec/models/panda_pal/platform_spec.rb
223
- - spec/models/panda_pal/session_account_roles_spec.rb
224
- - spec/models/panda_pal/session_spec.rb
225
- - spec/spec_helper.rb
226
219
  homepage: http://instructure.com
227
220
  licenses:
228
221
  - MIT
@@ -244,28 +237,4 @@ requirements: []
244
237
  rubygems_version: 3.6.9
245
238
  specification_version: 4
246
239
  summary: LTI mountable engine
247
- test_files:
248
- - spec/controllers/panda_pal/api_call_controller_spec.rb
249
- - spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb
250
- - spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb
251
- - spec/core/apartment_multidb_spec.rb
252
- - spec/factories/panda_pal_organizations.rb
253
- - spec/factories/panda_pal_sessions.rb
254
- - spec/internal/app/models/account.rb
255
- - spec/internal/app/models/admin.rb
256
- - spec/internal/config/database.yml
257
- - spec/internal/config/routes.rb
258
- - spec/internal/config/storage.yml
259
- - spec/internal/db/schema.rb
260
- - spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
261
- - spec/lib/panda_pal/ags_client_spec.rb
262
- - spec/lib/panda_pal/helpers/secure_headers_spec.rb
263
- - spec/lib/panda_pal/helpers/session_replacement_spec.rb
264
- - spec/models/panda_pal/api_call_spec.rb
265
- - spec/models/panda_pal/organization/settings_validation_spec.rb
266
- - spec/models/panda_pal/organization/task_scheduling_spec.rb
267
- - spec/models/panda_pal/organization_spec.rb
268
- - spec/models/panda_pal/platform_spec.rb
269
- - spec/models/panda_pal/session_account_roles_spec.rb
270
- - spec/models/panda_pal/session_spec.rb
271
- - spec/spec_helper.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,110 +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')
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
- consumer.generate_launch_data
21
- end
22
-
23
- after do
24
- # `validate_v1p0_launch` permanently switches the Apartment tenant to the
25
- # organization's schema on a successful launch (PandaPal::Organization
26
- # #switch_tenant with no block => Apartment::Tenant.switch!). Reset it so
27
- # this doesn't leak into other spec files run later in the suite.
28
- Apartment::Tenant.reset
29
- end
30
-
31
- describe 'POST /v1p0/launch' do
32
- context 'with a validly-signed launch' do
33
- it 'passes signature validation, starts a session, and redirects instead of rendering the 401 body' do
34
- params = signed_launch_params(secret: organization.secret)
35
-
36
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
37
-
38
- expect(response).not_to have_http_status(:unauthorized)
39
- expect(response.body).not_to include('Failed to validate LTI v1p0 launch')
40
- expect(response).to have_http_status(:found)
41
- expect(response.headers['Location']).to include('/panda_pal')
42
-
43
- # The session record is created after PandaPal switches Apartment tenants
44
- # to the organization's own schema, so look it up there.
45
- session_count = organization.switch_tenant { PandaPal::Session.count }
46
- expect(session_count).to eq(1)
47
- end
48
- end
49
-
50
- context 'with an invalid signature (wrong secret used to sign)' do
51
- it 'renders 401 with the failure body' do
52
- params = signed_launch_params(secret: 'not-the-real-secret')
53
-
54
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
55
-
56
- expect(response).to have_http_status(:unauthorized)
57
- expect(response.body).to eq('Failed to validate LTI v1p0 launch')
58
- end
59
- end
60
-
61
- context 'with a tampered parameter after signing' do
62
- it 'renders 401 with the failure body' do
63
- params = signed_launch_params(secret: organization.secret)
64
- params['resource_link_id'] = 'a-different-resource-link-id'
65
-
66
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
67
-
68
- expect(response).to have_http_status(:unauthorized)
69
- expect(response.body).to eq('Failed to validate LTI v1p0 launch')
70
- end
71
- end
72
-
73
- context 'with a stale oauth_timestamp' do
74
- it 'renders 401 without ever consulting IMS::LTI::ToolProvider' do
75
- stale_timestamp = 400.seconds.ago.to_i
76
- params = signed_launch_params(secret: organization.secret, timestamp: stale_timestamp)
77
-
78
- expect(IMS::LTI::ToolProvider).not_to receive(:new)
79
-
80
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
81
-
82
- expect(response).to have_http_status(:unauthorized)
83
- expect(response.body).to eq('Failed to validate LTI v1p0 launch')
84
- end
85
- end
86
- end
87
-
88
- describe 'safari_override (browser gem regression coverage)' do
89
- let(:safari_user_agent) do
90
- "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"
91
- end
92
- let(:chrome_user_agent) do
93
- "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"
94
- end
95
-
96
- # safari_override is the very first thing validate_launch! does, before any
97
- # OAuth/org checks, so an otherwise-invalid launch is sufficient to exercise it.
98
- it 'applies the secure_headers safari override when the User-Agent is Safari' do
99
- expect_any_instance_of(PandaPal::LtiV1P0Controller).to receive(:use_secure_headers_override).with(:safari_override)
100
-
101
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: {}, headers: { 'User-Agent' => safari_user_agent }
102
- end
103
-
104
- it 'does not apply the override for a non-Safari User-Agent (Chrome)' do
105
- expect_any_instance_of(PandaPal::LtiV1P0Controller).not_to receive(:use_secure_headers_override)
106
-
107
- post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: {}, headers: { 'User-Agent' => chrome_user_agent }
108
- end
109
- end
110
- 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