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.
- checksums.yaml +4 -4
- data/README.md +39 -0
- data/app/controllers/panda_pal/lti_v1_p0_controller.rb +11 -4
- data/app/controllers/panda_pal/lti_v1_p3_controller.rb +17 -8
- data/app/lib/lti_xml/base_platform.rb +32 -18
- data/app/lib/lti_xml/bridge_platform.rb +2 -2
- data/app/lib/panda_pal/launch_url_helpers.rb +21 -14
- data/app/models/panda_pal/lti_registration.rb +41 -0
- data/app/models/panda_pal/organization.rb +2 -0
- data/app/models/panda_pal/platform/canvas.rb +88 -28
- data/app/models/panda_pal/session.rb +19 -1
- data/config/initializers/apartment.rb +1 -1
- data/db/migrate/20261006000000_create_panda_pal_lti_registrations.rb +14 -0
- data/db/migrate/20261006000001_add_tool_to_panda_pal_sessions.rb +5 -0
- data/lib/panda_pal/helpers/controller_helper.rb +20 -1
- data/lib/panda_pal/helpers/route_helper.rb +8 -1
- data/lib/panda_pal/helpers/session_replacement.rb +2 -2
- data/lib/panda_pal/version.rb +1 -1
- data/lib/panda_pal.rb +82 -10
- data/panda_pal.gemspec +2 -1
- metadata +19 -50
- data/spec/controllers/panda_pal/api_call_controller_spec.rb +0 -25
- data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +0 -110
- data/spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb +0 -108
- data/spec/core/apartment_multidb_spec.rb +0 -166
- data/spec/factories/panda_pal_organizations.rb +0 -9
- data/spec/factories/panda_pal_sessions.rb +0 -6
- data/spec/internal/app/models/account.rb +0 -7
- data/spec/internal/app/models/admin.rb +0 -6
- data/spec/internal/config/database.yml +0 -18
- data/spec/internal/config/routes.rb +0 -5
- data/spec/internal/config/storage.yml +0 -3
- data/spec/internal/db/schema.rb +0 -30
- data/spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb +0 -83
- data/spec/lib/panda_pal/ags_client_spec.rb +0 -385
- data/spec/lib/panda_pal/helpers/secure_headers_spec.rb +0 -77
- data/spec/lib/panda_pal/helpers/session_replacement_spec.rb +0 -72
- data/spec/models/panda_pal/api_call_spec.rb +0 -30
- data/spec/models/panda_pal/organization/settings_validation_spec.rb +0 -179
- data/spec/models/panda_pal/organization/task_scheduling_spec.rb +0 -145
- data/spec/models/panda_pal/organization_spec.rb +0 -50
- data/spec/models/panda_pal/platform_spec.rb +0 -349
- data/spec/models/panda_pal/session_account_roles_spec.rb +0 -182
- data/spec/models/panda_pal/session_spec.rb +0 -202
- 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 = ->()
|
|
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
|
|
@@ -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
|
data/lib/panda_pal/version.rb
CHANGED
data/lib/panda_pal.rb
CHANGED
|
@@ -22,7 +22,20 @@ module PandaPal
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
placements[placement].merge!(options)
|
|
90
158
|
end
|
|
91
159
|
|
|
92
|
-
def self.lti_paths
|
|
93
|
-
|
|
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 |
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
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
|