panda_pal 5.18.2 → 5.19.0.beta1
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 +26 -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 +63 -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 +1 -0
- data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +85 -1
- data/spec/controllers/panda_pal/lti_v1_p3_tools_spec.rb +144 -0
- data/spec/lib/panda_pal/lti_tools_spec.rb +153 -0
- data/spec/models/panda_pal/lti_registration_spec.rb +72 -0
- data/spec/models/panda_pal/platform/canvas_install_spec.rb +115 -0
- data/spec/spec_helper.rb +1 -0
- data/spec/support/isolated_lti_config.rb +24 -0
- metadata +28 -1
|
@@ -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
|
@@ -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
|
|
@@ -7,7 +7,7 @@ RSpec.describe "PandaPal::LtiV1P0Controller", type: :request do
|
|
|
7
7
|
# Signs a full LTI v1p0 launch params hash the same way an LMS (Tool Consumer)
|
|
8
8
|
# would, using the platform-side half of the very same `ims-lti` gem that
|
|
9
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')
|
|
10
|
+
def signed_launch_params(secret:, timestamp: Time.now.to_i, launch_type: 'panda_pal', custom: {})
|
|
11
11
|
consumer = IMS::LTI::ToolConsumer.new(organization.key, secret, {
|
|
12
12
|
'resource_link_id' => 'test-resource-link-id',
|
|
13
13
|
})
|
|
@@ -17,9 +17,14 @@ RSpec.describe "PandaPal::LtiV1P0Controller", type: :request do
|
|
|
17
17
|
# params, so it must be included in the signed payload or the signature
|
|
18
18
|
# PandaPal recomputes on the real request body won't match.
|
|
19
19
|
consumer.set_non_spec_param('launch_type', launch_type)
|
|
20
|
+
custom.each { |k, v| consumer.set_custom_param(k.to_s, v) }
|
|
20
21
|
consumer.generate_launch_data
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
def launched_session
|
|
25
|
+
organization.switch_tenant { PandaPal::Session.order(:id).last }
|
|
26
|
+
end
|
|
27
|
+
|
|
23
28
|
after do
|
|
24
29
|
# `validate_v1p0_launch` permanently switches the Apartment tenant to the
|
|
25
30
|
# organization's schema on a successful launch (PandaPal::Organization
|
|
@@ -47,6 +52,40 @@ RSpec.describe "PandaPal::LtiV1P0Controller", type: :request do
|
|
|
47
52
|
end
|
|
48
53
|
end
|
|
49
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
|
+
|
|
50
89
|
context 'with an invalid signature (wrong secret used to sign)' do
|
|
51
90
|
it 'renders 401 with the failure body' do
|
|
52
91
|
params = signed_launch_params(secret: 'not-the-real-secret')
|
|
@@ -107,4 +146,49 @@ RSpec.describe "PandaPal::LtiV1P0Controller", type: :request do
|
|
|
107
146
|
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: {}, headers: { 'User-Agent' => chrome_user_agent }
|
|
108
147
|
end
|
|
109
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
|
|
110
194
|
end
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe 'PandaPal LTI tools', :isolated_lti_config do
|
|
4
|
+
before do
|
|
5
|
+
PandaPal.lti_options = { title: 'Shared Title', description: 'Shared description' }
|
|
6
|
+
PandaPal.lti_custom_params = { custom_canvas_user_id: '$Canvas.user.id' }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
describe '.stage_placement' do
|
|
10
|
+
it 'stages placements on the default tool when no tool is given' do
|
|
11
|
+
PandaPal.stage_placement(:course_navigation, text: 'Default')
|
|
12
|
+
|
|
13
|
+
expect(PandaPal.lti_paths.keys).to eq([:course_navigation])
|
|
14
|
+
expect(PandaPal.lti_paths(PandaPal::DEFAULT_TOOL)[:course_navigation]).to eq(text: 'Default')
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'keeps the same placement separate per tool' do
|
|
18
|
+
PandaPal.stage_placement(:editor_button, text: 'Default')
|
|
19
|
+
PandaPal.stage_placement(:editor_button, tool: :translate, text: 'Translate')
|
|
20
|
+
|
|
21
|
+
expect(PandaPal.lti_paths[:editor_button]).to eq(text: 'Default')
|
|
22
|
+
expect(PandaPal.lti_paths(:translate)[:editor_button]).to eq(text: 'Translate')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'implicitly defines a tool it has not seen yet' do
|
|
26
|
+
PandaPal.stage_placement(:editor_button, tool: :translate)
|
|
27
|
+
|
|
28
|
+
expect(PandaPal.lti_tools).to eq([PandaPal::DEFAULT_TOOL, :translate])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'does not mutate the options hash it is given' do
|
|
32
|
+
options = { tool: :translate, deep_linking: true }
|
|
33
|
+
PandaPal.stage_placement(:editor_button, options)
|
|
34
|
+
|
|
35
|
+
expect(options).to eq(tool: :translate, deep_linking: true)
|
|
36
|
+
expect(PandaPal.lti_paths(:translate)[:editor_button][:route_helper_key]).to eq(:deep_link_request)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe '.define_tool' do
|
|
41
|
+
it 'refuses to redefine the default tool' do
|
|
42
|
+
expect { PandaPal.define_tool(PandaPal::DEFAULT_TOOL, title: 'x') }.to raise_error(ArgumentError)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'merges its options over lti_options' do
|
|
46
|
+
PandaPal.define_tool(:translate, title: 'Translate')
|
|
47
|
+
|
|
48
|
+
options = PandaPal.lti_tool_options(:translate)
|
|
49
|
+
expect(options[:title]).to eq('Translate')
|
|
50
|
+
expect(options[:description]).to eq('Shared description')
|
|
51
|
+
expect(PandaPal.lti_tool_options[:title]).to eq('Shared Title')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'merges its custom params over lti_custom_params and tags them with the tool' do
|
|
55
|
+
PandaPal.define_tool(:translate, custom_params: { mode: 'translate' })
|
|
56
|
+
|
|
57
|
+
expect(PandaPal.lti_custom_params(:translate)).to eq(
|
|
58
|
+
'custom_canvas_user_id' => '$Canvas.user.id',
|
|
59
|
+
'mode' => 'translate',
|
|
60
|
+
PandaPal::TOOL_CUSTOM_FIELD => 'translate',
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'leaves the default tool custom params untouched' do
|
|
65
|
+
PandaPal.define_tool(:translate, custom_params: { mode: 'translate' })
|
|
66
|
+
|
|
67
|
+
expect(PandaPal.lti_custom_params).to eq('custom_canvas_user_id' => '$Canvas.user.id')
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
describe '.normalize_lti_tool!' do
|
|
72
|
+
it 'treats blank as the default tool' do
|
|
73
|
+
expect(PandaPal.normalize_lti_tool!(nil)).to eq(PandaPal::DEFAULT_TOOL)
|
|
74
|
+
expect(PandaPal.normalize_lti_tool!('')).to eq(PandaPal::DEFAULT_TOOL)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'accepts defined tools by string or symbol' do
|
|
78
|
+
PandaPal.define_tool(:translate)
|
|
79
|
+
|
|
80
|
+
expect(PandaPal.normalize_lti_tool!('translate')).to eq(:translate)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'raises for tools that were never defined' do
|
|
84
|
+
expect { PandaPal.normalize_lti_tool!(:nope) }.to raise_error(PandaPal::UnknownLtiTool)
|
|
85
|
+
expect { PandaPal.lti_paths(:nope) }.to raise_error(PandaPal::UnknownLtiTool)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe PandaPal::LaunchUrlHelpers do
|
|
90
|
+
it 'resolves launch routes from the given tool' do
|
|
91
|
+
PandaPal.stage_placement(:editor_button, url: :default_editor)
|
|
92
|
+
PandaPal.stage_placement(:editor_button, tool: :translate, url: :translate_editor)
|
|
93
|
+
|
|
94
|
+
expect(described_class.launch_route(:editor_button)).to eq(:default_editor)
|
|
95
|
+
expect(described_class.launch_route(:editor_button, tool: :translate)).to eq(:translate_editor)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'resolves deep link redirects from the given tool' do
|
|
99
|
+
PandaPal.stage_placement(:editor_button, deep_linking: true, deep_link_redirect_url: :default_picker)
|
|
100
|
+
PandaPal.stage_placement(:editor_button, tool: :translate, deep_linking: true, deep_link_redirect_url: :translate_picker)
|
|
101
|
+
|
|
102
|
+
request = double(query_parameters: { 'launch_type' => 'editor_button' })
|
|
103
|
+
|
|
104
|
+
expect(described_class.deep_link_redirect_url(request)).to eq(:default_picker)
|
|
105
|
+
expect(described_class.deep_link_redirect_url(request, tool: :translate)).to eq(:translate_picker)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "falls back to the tool's own deep_link_redirect_url option" do
|
|
109
|
+
PandaPal.lti_options = { deep_link_redirect_url: :shared_picker }
|
|
110
|
+
PandaPal.define_tool(:translate, deep_link_redirect_url: :translate_picker)
|
|
111
|
+
|
|
112
|
+
request = double(query_parameters: {})
|
|
113
|
+
|
|
114
|
+
expect(described_class.deep_link_redirect_url(request)).to eq(:shared_picker)
|
|
115
|
+
expect(described_class.deep_link_redirect_url(request, tool: :translate)).to eq(:translate_picker)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'only adds a tool query param for non-default tools' do
|
|
119
|
+
PandaPal.define_tool(:translate)
|
|
120
|
+
|
|
121
|
+
expect(described_class.tool_config_params(nil)).to eq({})
|
|
122
|
+
expect(described_class.tool_config_params(:translate)).to eq(tool: 'translate')
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
describe 'lti_nav' do
|
|
127
|
+
# A throwaway route set, so the dummy app's own routes stay untouched.
|
|
128
|
+
let(:routes) { ActionDispatch::Routing::RouteSet.new }
|
|
129
|
+
|
|
130
|
+
def paths
|
|
131
|
+
routes.routes.map { |r| r.path.spec.to_s.sub('(.:format)', '') }.uniq
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it "nests a non-default tool's placement under the tool name" do
|
|
135
|
+
routes.draw do
|
|
136
|
+
lti_nav editor_button: 'lti#editor_button'
|
|
137
|
+
lti_nav editor_button: 'lti#translate', tool: :translate
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
expect(paths).to contain_exactly('/panda_pal/editor_button', '/panda_pal/translate/editor_button')
|
|
141
|
+
expect(PandaPal.lti_paths[:editor_button][:route_helper_key]).to eq('panda_pal_editor_button')
|
|
142
|
+
expect(PandaPal.lti_paths(:translate)[:editor_button][:route_helper_key]).to eq('panda_pal_translate_editor_button')
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
describe '.validate_lti_navigation' do
|
|
147
|
+
it "reports a non-default tool's placement that has no route" do
|
|
148
|
+
PandaPal.stage_placement(:editor_button, tool: :translate, url: :no_such_route)
|
|
149
|
+
|
|
150
|
+
expect(PandaPal.validate_lti_navigation).to include("lti navigation 'editor_button' (tool 'translate') does not have a Route!")
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|