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.
@@ -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.beta1'
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
@@ -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