panda_pal 5.19.0.beta1 → 5.19.0.beta2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +13 -0
- data/app/models/panda_pal/platform/canvas.rb +25 -0
- data/lib/panda_pal/version.rb +1 -1
- data/panda_pal.gemspec +1 -1
- metadata +2 -60
- data/spec/controllers/panda_pal/api_call_controller_spec.rb +0 -25
- data/spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb +0 -194
- data/spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb +0 -108
- data/spec/controllers/panda_pal/lti_v1_p3_tools_spec.rb +0 -144
- 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/lib/panda_pal/lti_tools_spec.rb +0 -153
- data/spec/models/panda_pal/api_call_spec.rb +0 -30
- data/spec/models/panda_pal/lti_registration_spec.rb +0 -72
- 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/canvas_install_spec.rb +0 -115
- 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 -159
- data/spec/support/isolated_lti_config.rb +0 -24
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b8433b20262277ab9a0e66156b4fb2b865919cbfe73701202c163b1dd4c063c9
|
|
4
|
+
data.tar.gz: fba4d18d40156ccf1799c33b4b2a5aed33b71c6e0e772a65df04c586f207503f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 33e70423978afce311e1e0d4a5eecddc17e8814e922bea12e854b1b57135cc336e1c418b6f8d0fcca061c4a25485164dab1468805639d16abd6f1c1951f7b4cb
|
|
7
|
+
data.tar.gz: fa08843f3ad5d268203908c22725c546825102127cce6858c907a84b21e0a58561fab5f1e017b569cb95abd5e42c1715045b699b7790f899e7bbabfbf8f9693c
|
data/README.md
CHANGED
|
@@ -56,6 +56,19 @@ lti_nav editor_button: 'translate#editor_button', tool: :translate # Served at <
|
|
|
56
56
|
|
|
57
57
|
- **Config:** each tool has its own config URL: `/v1p3/config?tool=translate` and `/v1p0/config?tool=translate`. The default tool's URLs are unchanged.
|
|
58
58
|
- **Install:** `org.install_lti(host: ..., tool: :translate)`. On LTI 1.3 this creates (or reuses) the tool's own Developer Key and records it as a `PandaPal::LtiRegistration` on the Organization. The Organization's `key` keeps pointing at the default tool. On LTI 1.0 both tools share the Organization's consumer key and secret.
|
|
59
|
+
- **Install hooks:** `before_lti_install` / `after_lti_install` callbacks can read `current_lti_install` (`tool`, `host`, `context`, `version`, `exists`, `dedicated_deployment`, and, in `after_lti_install`, the resulting Canvas `installation`). That lets you install a second tool alongside the default one:
|
|
60
|
+
```ruby
|
|
61
|
+
# app/models/organization_extension.rb
|
|
62
|
+
included do
|
|
63
|
+
after_lti_install :install_translate_tool
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def install_translate_tool
|
|
67
|
+
install = current_lti_install
|
|
68
|
+
return unless install.tool == PandaPal::DEFAULT_TOOL # the nested :translate install runs these callbacks too
|
|
69
|
+
install_lti(**install.to_h.except(:installation), tool: :translate)
|
|
70
|
+
end
|
|
71
|
+
```
|
|
59
72
|
- **Launches:** `current_session.lti_tool` says which tool was launched. On LTI 1.3 the tool comes from the registration matching the launch's client ID (and deployment ID). On LTI 1.0 it comes from the signed `custom_panda_pal_tool` field that every non-default tool config includes. PandaPal routes each launch, and each deep-link redirect, using that tool's placements.
|
|
60
73
|
- **Registering by hand:** if a tool's Developer Key was set up outside `install_lti`, register it yourself: `org.lti_registrations.create!(tool: 'translate', client_id: '10000000000123', deployment_id: nil)`. A registration with a `deployment_id` takes priority over one without.
|
|
61
74
|
|
|
@@ -62,10 +62,21 @@ module PandaPal
|
|
|
62
62
|
module OrgExtension
|
|
63
63
|
extend ActiveSupport::Concern
|
|
64
64
|
|
|
65
|
+
# What an install_lti call is installing, for :lti_install callbacks to read
|
|
66
|
+
# through #current_lti_install. `version` is normalized ('v1p0'/'v1p3').
|
|
67
|
+
# `installation` is the Canvas external tool, set once the install has run
|
|
68
|
+
# (so it's there for after_lti_install, not before_lti_install).
|
|
69
|
+
LtiInstall = Struct.new(:tool, :host, :context, :version, :exists, :dedicated_deployment, :installation, keyword_init: true)
|
|
70
|
+
|
|
65
71
|
included do
|
|
66
72
|
define_model_callbacks :lti_install
|
|
67
73
|
end
|
|
68
74
|
|
|
75
|
+
# The LtiInstall currently running callbacks, or nil outside install_lti.
|
|
76
|
+
# An install started from inside a callback gets its own LtiInstall, and the
|
|
77
|
+
# outer one comes back once it's done.
|
|
78
|
+
attr_reader :current_lti_install
|
|
79
|
+
|
|
69
80
|
def _parse_lti_context(context)
|
|
70
81
|
context = context.to_s
|
|
71
82
|
context = 'account/self' if context == 'root_account'
|
|
@@ -79,6 +90,7 @@ module PandaPal
|
|
|
79
90
|
# instead of the default one. In LTI 1.3 each tool is its own Developer Key,
|
|
80
91
|
# tracked by an LtiRegistration rather than by this Organization's `key`.
|
|
81
92
|
def _install_lti(host: nil, context: :root_account, version: nil, exists: :error, dedicated_deployment: false, tool: PandaPal::DEFAULT_TOOL)
|
|
93
|
+
outer_install = @current_lti_install
|
|
82
94
|
raise "Automatically installing this LTI requires Bearcat." unless defined?(Bearcat)
|
|
83
95
|
|
|
84
96
|
tool = PandaPal.normalize_lti_tool!(tool)
|
|
@@ -95,6 +107,15 @@ module PandaPal
|
|
|
95
107
|
|
|
96
108
|
# TODO Ensure host is actually this LTI
|
|
97
109
|
|
|
110
|
+
@current_lti_install = LtiInstall.new(
|
|
111
|
+
tool: tool,
|
|
112
|
+
host: host,
|
|
113
|
+
context: context,
|
|
114
|
+
version: version,
|
|
115
|
+
exists: exists,
|
|
116
|
+
dedicated_deployment: dedicated_deployment,
|
|
117
|
+
)
|
|
118
|
+
|
|
98
119
|
run_callbacks :lti_install do
|
|
99
120
|
ctype, cid = _parse_lti_context(context)
|
|
100
121
|
|
|
@@ -155,9 +176,13 @@ module PandaPal
|
|
|
155
176
|
else
|
|
156
177
|
raise "Unrecognized LTI Version #{version}"
|
|
157
178
|
end
|
|
179
|
+
|
|
180
|
+
@current_lti_install.installation = @new_lti_installation
|
|
158
181
|
end
|
|
159
182
|
|
|
160
183
|
save!
|
|
184
|
+
ensure
|
|
185
|
+
@current_lti_install = outer_install
|
|
161
186
|
end
|
|
162
187
|
|
|
163
188
|
def install_lti(*args, **kwargs)
|
data/lib/panda_pal/version.rb
CHANGED
data/panda_pal.gemspec
CHANGED
|
@@ -18,7 +18,7 @@ Gem::Specification.new do |s|
|
|
|
18
18
|
s.license = "MIT"
|
|
19
19
|
|
|
20
20
|
s.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md", "AGS.md", "panda_pal.gemspec"]
|
|
21
|
-
s.test_files = Dir["spec/**/*"].reject{|f| f =~ /\/log\//}
|
|
21
|
+
# s.test_files = Dir["spec/**/*"].reject{|f| f =~ /\/log\//}
|
|
22
22
|
|
|
23
23
|
s.add_dependency "rails", ">= 4.2"
|
|
24
24
|
s.add_dependency 'ros-apartment', '~> 3.0'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: panda_pal
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.19.0.
|
|
4
|
+
version: 5.19.0.beta2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Instructure CustomDev
|
|
@@ -216,35 +216,6 @@ files:
|
|
|
216
216
|
- lib/panda_pal/version.rb
|
|
217
217
|
- lib/tasks/panda_pal_tasks.rake
|
|
218
218
|
- panda_pal.gemspec
|
|
219
|
-
- spec/controllers/panda_pal/api_call_controller_spec.rb
|
|
220
|
-
- spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb
|
|
221
|
-
- spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb
|
|
222
|
-
- spec/controllers/panda_pal/lti_v1_p3_tools_spec.rb
|
|
223
|
-
- spec/core/apartment_multidb_spec.rb
|
|
224
|
-
- spec/factories/panda_pal_organizations.rb
|
|
225
|
-
- spec/factories/panda_pal_sessions.rb
|
|
226
|
-
- spec/internal/app/models/account.rb
|
|
227
|
-
- spec/internal/app/models/admin.rb
|
|
228
|
-
- spec/internal/config/database.yml
|
|
229
|
-
- spec/internal/config/routes.rb
|
|
230
|
-
- spec/internal/config/storage.yml
|
|
231
|
-
- spec/internal/db/schema.rb
|
|
232
|
-
- spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
|
|
233
|
-
- spec/lib/panda_pal/ags_client_spec.rb
|
|
234
|
-
- spec/lib/panda_pal/helpers/secure_headers_spec.rb
|
|
235
|
-
- spec/lib/panda_pal/helpers/session_replacement_spec.rb
|
|
236
|
-
- spec/lib/panda_pal/lti_tools_spec.rb
|
|
237
|
-
- spec/models/panda_pal/api_call_spec.rb
|
|
238
|
-
- spec/models/panda_pal/lti_registration_spec.rb
|
|
239
|
-
- spec/models/panda_pal/organization/settings_validation_spec.rb
|
|
240
|
-
- spec/models/panda_pal/organization/task_scheduling_spec.rb
|
|
241
|
-
- spec/models/panda_pal/organization_spec.rb
|
|
242
|
-
- spec/models/panda_pal/platform/canvas_install_spec.rb
|
|
243
|
-
- spec/models/panda_pal/platform_spec.rb
|
|
244
|
-
- spec/models/panda_pal/session_account_roles_spec.rb
|
|
245
|
-
- spec/models/panda_pal/session_spec.rb
|
|
246
|
-
- spec/spec_helper.rb
|
|
247
|
-
- spec/support/isolated_lti_config.rb
|
|
248
219
|
homepage: http://instructure.com
|
|
249
220
|
licenses:
|
|
250
221
|
- MIT
|
|
@@ -266,33 +237,4 @@ requirements: []
|
|
|
266
237
|
rubygems_version: 3.6.9
|
|
267
238
|
specification_version: 4
|
|
268
239
|
summary: LTI mountable engine
|
|
269
|
-
test_files:
|
|
270
|
-
- spec/controllers/panda_pal/api_call_controller_spec.rb
|
|
271
|
-
- spec/controllers/panda_pal/lti_v1_p0_controller_spec.rb
|
|
272
|
-
- spec/controllers/panda_pal/lti_v1_p3_controller_spec.rb
|
|
273
|
-
- spec/controllers/panda_pal/lti_v1_p3_tools_spec.rb
|
|
274
|
-
- spec/core/apartment_multidb_spec.rb
|
|
275
|
-
- spec/factories/panda_pal_organizations.rb
|
|
276
|
-
- spec/factories/panda_pal_sessions.rb
|
|
277
|
-
- spec/internal/app/models/account.rb
|
|
278
|
-
- spec/internal/app/models/admin.rb
|
|
279
|
-
- spec/internal/config/database.yml
|
|
280
|
-
- spec/internal/config/routes.rb
|
|
281
|
-
- spec/internal/config/storage.yml
|
|
282
|
-
- spec/internal/db/schema.rb
|
|
283
|
-
- spec/jobs/panda_pal/jobs/grade_passback_job_spec.rb
|
|
284
|
-
- spec/lib/panda_pal/ags_client_spec.rb
|
|
285
|
-
- spec/lib/panda_pal/helpers/secure_headers_spec.rb
|
|
286
|
-
- spec/lib/panda_pal/helpers/session_replacement_spec.rb
|
|
287
|
-
- spec/lib/panda_pal/lti_tools_spec.rb
|
|
288
|
-
- spec/models/panda_pal/api_call_spec.rb
|
|
289
|
-
- spec/models/panda_pal/lti_registration_spec.rb
|
|
290
|
-
- spec/models/panda_pal/organization/settings_validation_spec.rb
|
|
291
|
-
- spec/models/panda_pal/organization/task_scheduling_spec.rb
|
|
292
|
-
- spec/models/panda_pal/organization_spec.rb
|
|
293
|
-
- spec/models/panda_pal/platform/canvas_install_spec.rb
|
|
294
|
-
- spec/models/panda_pal/platform_spec.rb
|
|
295
|
-
- spec/models/panda_pal/session_account_roles_spec.rb
|
|
296
|
-
- spec/models/panda_pal/session_spec.rb
|
|
297
|
-
- spec/spec_helper.rb
|
|
298
|
-
- spec/support/isolated_lti_config.rb
|
|
240
|
+
test_files: []
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
require 'spec_helper'
|
|
2
|
-
|
|
3
|
-
RSpec.describe PandaPal::ApiCallController, type: :controller do
|
|
4
|
-
let!(:api_call) { PandaPal::ApiCall.create!(logic: '"#{p[:foo]}_bar"') }
|
|
5
|
-
|
|
6
|
-
def json_body
|
|
7
|
-
JSON.parse(response.body)
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
describe "#call" do
|
|
11
|
-
it "calls the function with parameters" do
|
|
12
|
-
get :call, params: { token: api_call.jwt_token, foo: 'bar', use_route: 'panda_pal' }
|
|
13
|
-
expect(response).to be_successful
|
|
14
|
-
expect(json_body["status"]).to eq 'ok'
|
|
15
|
-
expect(json_body["result"]).to eq 'bar_bar'
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
it "fails given a bad JWT" do
|
|
19
|
-
get :call, params: { token: "bad.jwt.token", foo: 'bar', use_route: 'panda_pal' }
|
|
20
|
-
expect(response).not_to be_successful
|
|
21
|
-
expect(json_body["status"]).to eq 'error'
|
|
22
|
-
expect(json_body["error"]).to eq 'Invalid JWT'
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
require 'spec_helper'
|
|
2
|
-
|
|
3
|
-
RSpec.describe "PandaPal::LtiV1P0Controller", type: :request do
|
|
4
|
-
let(:organization) { create(:panda_pal_organization) }
|
|
5
|
-
let(:launch_url) { panda_pal.v1p0_launch_url(host: 'www.example.com') }
|
|
6
|
-
|
|
7
|
-
# Signs a full LTI v1p0 launch params hash the same way an LMS (Tool Consumer)
|
|
8
|
-
# would, using the platform-side half of the very same `ims-lti` gem that
|
|
9
|
-
# PandaPal uses on the tool-provider side to validate incoming launches.
|
|
10
|
-
def signed_launch_params(secret:, timestamp: Time.now.to_i, launch_type: 'panda_pal', custom: {})
|
|
11
|
-
consumer = IMS::LTI::ToolConsumer.new(organization.key, secret, {
|
|
12
|
-
'resource_link_id' => 'test-resource-link-id',
|
|
13
|
-
})
|
|
14
|
-
consumer.launch_url = launch_url
|
|
15
|
-
consumer.timestamp = timestamp
|
|
16
|
-
# `launch_type` isn't a spec'd LTI param, but the controller reads it from
|
|
17
|
-
# params, so it must be included in the signed payload or the signature
|
|
18
|
-
# PandaPal recomputes on the real request body won't match.
|
|
19
|
-
consumer.set_non_spec_param('launch_type', launch_type)
|
|
20
|
-
custom.each { |k, v| consumer.set_custom_param(k.to_s, v) }
|
|
21
|
-
consumer.generate_launch_data
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def launched_session
|
|
25
|
-
organization.switch_tenant { PandaPal::Session.order(:id).last }
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
after do
|
|
29
|
-
# `validate_v1p0_launch` permanently switches the Apartment tenant to the
|
|
30
|
-
# organization's schema on a successful launch (PandaPal::Organization
|
|
31
|
-
# #switch_tenant with no block => Apartment::Tenant.switch!). Reset it so
|
|
32
|
-
# this doesn't leak into other spec files run later in the suite.
|
|
33
|
-
Apartment::Tenant.reset
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
describe 'POST /v1p0/launch' do
|
|
37
|
-
context 'with a validly-signed launch' do
|
|
38
|
-
it 'passes signature validation, starts a session, and redirects instead of rendering the 401 body' do
|
|
39
|
-
params = signed_launch_params(secret: organization.secret)
|
|
40
|
-
|
|
41
|
-
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
|
|
42
|
-
|
|
43
|
-
expect(response).not_to have_http_status(:unauthorized)
|
|
44
|
-
expect(response.body).not_to include('Failed to validate LTI v1p0 launch')
|
|
45
|
-
expect(response).to have_http_status(:found)
|
|
46
|
-
expect(response.headers['Location']).to include('/panda_pal')
|
|
47
|
-
|
|
48
|
-
# The session record is created after PandaPal switches Apartment tenants
|
|
49
|
-
# to the organization's own schema, so look it up there.
|
|
50
|
-
session_count = organization.switch_tenant { PandaPal::Session.count }
|
|
51
|
-
expect(session_count).to eq(1)
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
context 'tools', :isolated_lti_config do
|
|
56
|
-
before do
|
|
57
|
-
# Only the translate tool routes editor_button anywhere real (the mount
|
|
58
|
-
# point) - the default tool would fall back to a missing editor_button_url.
|
|
59
|
-
PandaPal.stage_placement(:editor_button, tool: :translate, url: :panda_pal)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
it 'records the default tool for a launch with no tool custom field' do
|
|
63
|
-
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: signed_launch_params(secret: organization.secret)
|
|
64
|
-
|
|
65
|
-
expect(response).to have_http_status(:found)
|
|
66
|
-
expect(launched_session.lti_tool).to eq(PandaPal::DEFAULT_TOOL)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
it "routes by the signed tool custom field and records the tool on the session" do
|
|
70
|
-
params = signed_launch_params(secret: organization.secret, launch_type: 'editor_button', custom: { PandaPal::TOOL_CUSTOM_FIELD => 'translate' })
|
|
71
|
-
|
|
72
|
-
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
|
|
73
|
-
|
|
74
|
-
expect(response).to have_http_status(:found)
|
|
75
|
-
expect(response.headers['Location']).to include('/panda_pal')
|
|
76
|
-
expect(launched_session.lti_tool).to eq(:translate)
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
it 'rejects a launch naming a tool that was never defined' do
|
|
80
|
-
params = signed_launch_params(secret: organization.secret, custom: { PandaPal::TOOL_CUSTOM_FIELD => 'nope' })
|
|
81
|
-
|
|
82
|
-
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
|
|
83
|
-
|
|
84
|
-
expect(response).to have_http_status(:unauthorized)
|
|
85
|
-
expect(response.body).to eq('Unrecognized LTI tool')
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
context 'with an invalid signature (wrong secret used to sign)' do
|
|
90
|
-
it 'renders 401 with the failure body' do
|
|
91
|
-
params = signed_launch_params(secret: 'not-the-real-secret')
|
|
92
|
-
|
|
93
|
-
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
|
|
94
|
-
|
|
95
|
-
expect(response).to have_http_status(:unauthorized)
|
|
96
|
-
expect(response.body).to eq('Failed to validate LTI v1p0 launch')
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
context 'with a tampered parameter after signing' do
|
|
101
|
-
it 'renders 401 with the failure body' do
|
|
102
|
-
params = signed_launch_params(secret: organization.secret)
|
|
103
|
-
params['resource_link_id'] = 'a-different-resource-link-id'
|
|
104
|
-
|
|
105
|
-
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
|
|
106
|
-
|
|
107
|
-
expect(response).to have_http_status(:unauthorized)
|
|
108
|
-
expect(response.body).to eq('Failed to validate LTI v1p0 launch')
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
context 'with a stale oauth_timestamp' do
|
|
113
|
-
it 'renders 401 without ever consulting IMS::LTI::ToolProvider' do
|
|
114
|
-
stale_timestamp = 400.seconds.ago.to_i
|
|
115
|
-
params = signed_launch_params(secret: organization.secret, timestamp: stale_timestamp)
|
|
116
|
-
|
|
117
|
-
expect(IMS::LTI::ToolProvider).not_to receive(:new)
|
|
118
|
-
|
|
119
|
-
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: params
|
|
120
|
-
|
|
121
|
-
expect(response).to have_http_status(:unauthorized)
|
|
122
|
-
expect(response.body).to eq('Failed to validate LTI v1p0 launch')
|
|
123
|
-
end
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
describe 'safari_override (browser gem regression coverage)' do
|
|
128
|
-
let(:safari_user_agent) do
|
|
129
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
|
|
130
|
-
end
|
|
131
|
-
let(:chrome_user_agent) do
|
|
132
|
-
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36"
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
# safari_override is the very first thing validate_launch! does, before any
|
|
136
|
-
# OAuth/org checks, so an otherwise-invalid launch is sufficient to exercise it.
|
|
137
|
-
it 'applies the secure_headers safari override when the User-Agent is Safari' do
|
|
138
|
-
expect_any_instance_of(PandaPal::LtiV1P0Controller).to receive(:use_secure_headers_override).with(:safari_override)
|
|
139
|
-
|
|
140
|
-
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: {}, headers: { 'User-Agent' => safari_user_agent }
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
it 'does not apply the override for a non-Safari User-Agent (Chrome)' do
|
|
144
|
-
expect_any_instance_of(PandaPal::LtiV1P0Controller).not_to receive(:use_secure_headers_override)
|
|
145
|
-
|
|
146
|
-
post panda_pal.v1p0_launch_path(host: 'www.example.com'), params: {}, headers: { 'User-Agent' => chrome_user_agent }
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
describe 'GET /v1p0/config', :isolated_lti_config do
|
|
151
|
-
before do
|
|
152
|
-
PandaPal.lti_environments = { domain: 'tool.example.com' }
|
|
153
|
-
PandaPal.lti_options = { title: 'Shared Title' }
|
|
154
|
-
PandaPal.stage_placement(:course_navigation, url: :panda_pal, text: 'Settings')
|
|
155
|
-
PandaPal.define_tool(:translate, title: 'Translate Title')
|
|
156
|
-
PandaPal.stage_placement(:editor_button, tool: :translate, url: :panda_pal, text: 'Translate')
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def ext_param(doc, name)
|
|
160
|
-
doc.at_xpath("//lticm:property[@name='#{name}'] | //lticm:options[@name='#{name}']", 'lticm' => 'http://www.imsglobal.org/xsd/imslticm_v1p0')
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
it "serves only the default tool's placements without a tool param" do
|
|
164
|
-
get '/panda_pal/v1p0/config'
|
|
165
|
-
|
|
166
|
-
doc = Nokogiri::XML(response.body)
|
|
167
|
-
expect(response).to have_http_status(:ok)
|
|
168
|
-
expect(doc.at_xpath('//blti:title', 'blti' => 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0').text).to eq('Shared Title')
|
|
169
|
-
expect(ext_param(doc, 'course_navigation')).to be_present
|
|
170
|
-
expect(ext_param(doc, 'editor_button')).to be_nil
|
|
171
|
-
expect(response.body).not_to include(PandaPal::TOOL_CUSTOM_FIELD)
|
|
172
|
-
end
|
|
173
|
-
|
|
174
|
-
it "serves the requested tool's title, placements, and tool custom field" do
|
|
175
|
-
get '/panda_pal/v1p0/config', params: { tool: 'translate' }
|
|
176
|
-
|
|
177
|
-
doc = Nokogiri::XML(response.body)
|
|
178
|
-
expect(response).to have_http_status(:ok)
|
|
179
|
-
expect(doc.at_xpath('//blti:title', 'blti' => 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0').text).to eq('Translate Title')
|
|
180
|
-
expect(ext_param(doc, 'editor_button')).to be_present
|
|
181
|
-
expect(ext_param(doc, 'course_navigation')).to be_nil
|
|
182
|
-
|
|
183
|
-
custom = doc.at_xpath("//blti:custom/lticm:property[@name='#{PandaPal::TOOL_CUSTOM_FIELD}']",
|
|
184
|
-
'blti' => 'http://www.imsglobal.org/xsd/imsbasiclti_v1p0', 'lticm' => 'http://www.imsglobal.org/xsd/imslticm_v1p0')
|
|
185
|
-
expect(custom&.text).to eq('translate')
|
|
186
|
-
end
|
|
187
|
-
|
|
188
|
-
it 'renders 404 for an unknown tool' do
|
|
189
|
-
get '/panda_pal/v1p0/config', params: { tool: 'nope' }
|
|
190
|
-
|
|
191
|
-
expect(response).to have_http_status(:not_found)
|
|
192
|
-
end
|
|
193
|
-
end
|
|
194
|
-
end
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
require 'spec_helper'
|
|
2
|
-
require 'nokogiri'
|
|
3
|
-
|
|
4
|
-
RSpec.describe "PandaPal::LtiV1P3Controller", type: :request do
|
|
5
|
-
describe 'GET /v1p3/public_jwks' do
|
|
6
|
-
let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
|
|
7
|
-
let(:test_jwk) { JWT::JWK.new(rsa_key, { use: 'sig', alg: 'RS256' }) }
|
|
8
|
-
|
|
9
|
-
before { PandaPal.jwk = test_jwk }
|
|
10
|
-
|
|
11
|
-
after do
|
|
12
|
-
# PandaPal.jwk memoizes into a class variable (`@@jwk ||= ...`). Reset it
|
|
13
|
-
# so our test key doesn't leak into other spec files run later in the suite.
|
|
14
|
-
PandaPal.jwk = nil
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
it 'renders a 200 JSON JWK Set whose key matches PandaPal.jwk, with no private key material' do
|
|
18
|
-
get "/panda_pal/v1p3/public_jwks"
|
|
19
|
-
|
|
20
|
-
expect(response).to have_http_status(:ok)
|
|
21
|
-
|
|
22
|
-
expected_body = JSON.parse(JWT::JWK::Set.new(test_jwk).export.to_json)
|
|
23
|
-
body = JSON.parse(response.body)
|
|
24
|
-
|
|
25
|
-
expect(body).to eq(expected_body)
|
|
26
|
-
expect(body['keys']).to be_an(Array)
|
|
27
|
-
expect(body['keys'].length).to eq(1)
|
|
28
|
-
|
|
29
|
-
returned_key = body['keys'].first
|
|
30
|
-
expect(returned_key['kty']).to eq('RSA')
|
|
31
|
-
expect(returned_key['use']).to eq('sig')
|
|
32
|
-
expect(returned_key['alg']).to eq('RS256')
|
|
33
|
-
expect(returned_key['n']).to eq(test_jwk[:n])
|
|
34
|
-
expect(returned_key['e']).to eq(test_jwk[:e])
|
|
35
|
-
# Only the public modulus/exponent should ever be exposed - never private key material.
|
|
36
|
-
expect(returned_key.keys).not_to include('d', 'p', 'q', 'dp', 'dq', 'qi')
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
describe 'GET /v1p3/oidc_login' do
|
|
41
|
-
let(:login_params) do
|
|
42
|
-
{
|
|
43
|
-
client_id: 'test-client-id',
|
|
44
|
-
login_hint: 'test-login-hint',
|
|
45
|
-
lti_message_hint: 'test-lti-message-hint',
|
|
46
|
-
iss: 'https://canvas.instructure.com',
|
|
47
|
-
}
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
it 'renders the auto-submit form targeting the platform auth redirect URL, with the expected hidden fields' do
|
|
51
|
-
get "/panda_pal/v1p3/oidc_login", params: login_params
|
|
52
|
-
|
|
53
|
-
expect(response).to have_http_status(:ok)
|
|
54
|
-
|
|
55
|
-
doc = Nokogiri::HTML(response.body)
|
|
56
|
-
form = doc.at_css('form#redirect-form')
|
|
57
|
-
expect(form).not_to be_nil
|
|
58
|
-
|
|
59
|
-
created_session = PandaPal::Session.order(:id).last
|
|
60
|
-
expect(created_session).not_to be_nil
|
|
61
|
-
|
|
62
|
-
expect(form['action']).to eq(PandaPal::Platform::Canvas.new(iss: 'https://canvas.instructure.com').authentication_redirect_url)
|
|
63
|
-
|
|
64
|
-
hidden_fields = form.css('input[type=hidden]').each_with_object({}) do |input, h|
|
|
65
|
-
h[input['name']] = input['value']
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
expect(hidden_fields['client_id']).to eq('test-client-id')
|
|
69
|
-
expect(hidden_fields['login_hint']).to eq('test-login-hint')
|
|
70
|
-
expect(hidden_fields['lti_message_hint']).to eq('test-lti-message-hint')
|
|
71
|
-
expect(hidden_fields['scope']).to eq('openid')
|
|
72
|
-
expect(hidden_fields['response_type']).to eq('id_token')
|
|
73
|
-
expect(hidden_fields['state']).to eq(created_session.session_key)
|
|
74
|
-
expect(hidden_fields['nonce']).to eq(created_session[:lti_oauth_nonce])
|
|
75
|
-
expect(hidden_fields['nonce']).not_to be_blank
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
describe 'GET /v1p3/config' do
|
|
80
|
-
before { PandaPal.lti_environments = { domain: 'tool.example.com' } }
|
|
81
|
-
|
|
82
|
-
after do
|
|
83
|
-
PandaPal.lti_environments = {}
|
|
84
|
-
PandaPal.lti_scopes = []
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
it 'publishes no scopes by default' do
|
|
88
|
-
get "/panda_pal/v1p3/config"
|
|
89
|
-
|
|
90
|
-
expect(response).to have_http_status(:ok)
|
|
91
|
-
expect(JSON.parse(response.body)['scopes']).to eq([])
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
it 'publishes the configured PandaPal.lti_scopes' do
|
|
95
|
-
PandaPal.lti_scopes = [
|
|
96
|
-
PandaPal::LtiConstants::AgsScopes::SCORE,
|
|
97
|
-
PandaPal::LtiConstants::AgsScopes::LINEITEM_READONLY,
|
|
98
|
-
]
|
|
99
|
-
|
|
100
|
-
get "/panda_pal/v1p3/config"
|
|
101
|
-
|
|
102
|
-
expect(JSON.parse(response.body)['scopes']).to eq([
|
|
103
|
-
'https://purl.imsglobal.org/spec/lti-ags/scope/score',
|
|
104
|
-
'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly',
|
|
105
|
-
])
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
end
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
require 'spec_helper'
|
|
2
|
-
require 'json/jwt'
|
|
3
|
-
|
|
4
|
-
RSpec.describe "PandaPal::LtiV1P3Controller tools", type: :request, isolated_lti_config: true do
|
|
5
|
-
let(:organization) { create(:panda_pal_organization, key: '10000000000001') }
|
|
6
|
-
let(:issuer) { 'https://canvas.instructure.com' }
|
|
7
|
-
let(:platform_key) { OpenSSL::PKey::RSA.new(2048) }
|
|
8
|
-
let(:platform_jwk) { JSON::JWK.new(platform_key.public_key) }
|
|
9
|
-
|
|
10
|
-
before do
|
|
11
|
-
PandaPal.lti_environments = { domain: 'tool.example.com' }
|
|
12
|
-
PandaPal.lti_options = { title: 'Shared Title' }
|
|
13
|
-
PandaPal.stage_placement(:course_navigation, text: 'Settings')
|
|
14
|
-
PandaPal.define_tool(:translate, title: 'Translate Title')
|
|
15
|
-
# Only the translate tool routes editor_button anywhere real (the mount
|
|
16
|
-
# point) - the default tool would fall back to a missing editor_button_url.
|
|
17
|
-
PandaPal.stage_placement(:editor_button, tool: :translate, url: :panda_pal, text: 'Translate')
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
after { Apartment::Tenant.reset }
|
|
21
|
-
|
|
22
|
-
describe 'GET /v1p3/config' do
|
|
23
|
-
it "serves only the default tool's placements without a tool param" do
|
|
24
|
-
get '/panda_pal/v1p3/config'
|
|
25
|
-
|
|
26
|
-
body = JSON.parse(response.body)
|
|
27
|
-
placements = body.dig('extensions', 0, 'settings', 'placements').map { |p| p['placement'] }
|
|
28
|
-
|
|
29
|
-
expect(body['title']).to eq('Shared Title')
|
|
30
|
-
expect(placements).to eq(['course_navigation'])
|
|
31
|
-
expect(body['custom_fields']).not_to have_key(PandaPal::TOOL_CUSTOM_FIELD)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
it "serves the requested tool's title, placements, and tool custom field" do
|
|
35
|
-
get '/panda_pal/v1p3/config', params: { tool: 'translate' }
|
|
36
|
-
|
|
37
|
-
body = JSON.parse(response.body)
|
|
38
|
-
placements = body.dig('extensions', 0, 'settings', 'placements')
|
|
39
|
-
|
|
40
|
-
expect(body['title']).to eq('Translate Title')
|
|
41
|
-
expect(placements.map { |p| p['placement'] }).to eq(['editor_button'])
|
|
42
|
-
expect(placements[0]['target_link_uri']).to include('/panda_pal/v1p3/resource_link_request?launch_type=editor_button')
|
|
43
|
-
expect(body['custom_fields'][PandaPal::TOOL_CUSTOM_FIELD]).to eq('translate')
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
it 'renders 404 for an unknown tool' do
|
|
47
|
-
get '/panda_pal/v1p3/config', params: { tool: 'nope' }
|
|
48
|
-
|
|
49
|
-
expect(response).to have_http_status(:not_found)
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
describe 'POST /v1p3/resource_link_request' do
|
|
54
|
-
let(:nonce) { SecureRandom.uuid }
|
|
55
|
-
|
|
56
|
-
let!(:login_session) do
|
|
57
|
-
# What LtiV1P3Controller#login leaves behind in the public schema.
|
|
58
|
-
PandaPal::Session.create!(panda_pal_organization_id: 0, data: {
|
|
59
|
-
lti_platform: PandaPal::Platform::Canvas.new(iss: issuer).serialize,
|
|
60
|
-
lti_oauth_nonce: nonce,
|
|
61
|
-
})
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
before do
|
|
65
|
-
allow_any_instance_of(PandaPal::Platform::Canvas).to receive(:public_jwks).and_return(JSON::JWK::Set.new(platform_jwk))
|
|
66
|
-
# Launch validation records nonces in Rails.cache - keep them in memory
|
|
67
|
-
# rather than in the dummy app's file store.
|
|
68
|
-
allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def id_token(client_id:, deployment_id: 'dep-1', placement: 'editor_button')
|
|
72
|
-
claims = {
|
|
73
|
-
iss: issuer,
|
|
74
|
-
aud: client_id,
|
|
75
|
-
sub: 'user-1',
|
|
76
|
-
nonce: nonce,
|
|
77
|
-
iat: Time.now.to_i,
|
|
78
|
-
exp: Time.now.to_i + 300,
|
|
79
|
-
PandaPal::LtiConstants::Claims::DEPLOYMENT_ID => deployment_id,
|
|
80
|
-
PandaPal::LtiConstants::Claims::MESSAGE_TYPE => 'LtiResourceLinkRequest',
|
|
81
|
-
PandaPal::LtiConstants::Canvas::PLACEMENT => placement,
|
|
82
|
-
}
|
|
83
|
-
jwt = JSON::JWT.new(claims)
|
|
84
|
-
jwt.kid = platform_jwk[:kid]
|
|
85
|
-
jwt.sign(platform_key, :RS256).to_s
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def launch!(**token_opts)
|
|
89
|
-
post '/panda_pal/v1p3/resource_link_request', params: { id_token: id_token(**token_opts), state: login_session.session_key }
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
def launched_session
|
|
93
|
-
organization.switch_tenant { PandaPal::Session.order(:id).last }
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
it "resolves a registered tool's client_id to its organization and routes by that tool" do
|
|
97
|
-
organization.lti_registrations.create!(tool: 'translate', client_id: '10000000000002')
|
|
98
|
-
|
|
99
|
-
launch!(client_id: '10000000000002')
|
|
100
|
-
|
|
101
|
-
expect(response).to have_http_status(:found)
|
|
102
|
-
expect(response.headers['Location']).to include('/panda_pal')
|
|
103
|
-
expect(launched_session.lti_tool).to eq(:translate)
|
|
104
|
-
expect(launched_session.panda_pal_organization).to eq(organization)
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
it 'prefers a deployment-specific registration' do
|
|
108
|
-
other_org = create(:panda_pal_organization)
|
|
109
|
-
other_org.lti_registrations.create!(tool: 'translate', client_id: '10000000000002')
|
|
110
|
-
organization.lti_registrations.create!(tool: 'translate', client_id: '10000000000002', deployment_id: 'dep-1')
|
|
111
|
-
|
|
112
|
-
launch!(client_id: '10000000000002', deployment_id: 'dep-1')
|
|
113
|
-
|
|
114
|
-
expect(response).to have_http_status(:found)
|
|
115
|
-
expect(launched_session.panda_pal_organization).to eq(organization)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
it "still resolves the organization's own key as the default tool" do
|
|
119
|
-
PandaPal.stage_placement(:course_navigation, url: :panda_pal)
|
|
120
|
-
|
|
121
|
-
launch!(client_id: organization.key, placement: 'course_navigation')
|
|
122
|
-
|
|
123
|
-
expect(response).to have_http_status(:found)
|
|
124
|
-
expect(launched_session.lti_tool).to eq(PandaPal::DEFAULT_TOOL)
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
it 'rejects a registration whose tool is no longer defined' do
|
|
128
|
-
registration = organization.lti_registrations.create!(tool: 'translate', client_id: '10000000000002')
|
|
129
|
-
registration.update_column(:tool, 'removed')
|
|
130
|
-
|
|
131
|
-
launch!(client_id: '10000000000002')
|
|
132
|
-
|
|
133
|
-
expect(response).to have_http_status(:unauthorized)
|
|
134
|
-
expect(response.body).to include('Unrecognized LTI tool')
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
it 'rejects an unregistered client_id' do
|
|
138
|
-
launch!(client_id: '99999')
|
|
139
|
-
|
|
140
|
-
expect(response).to have_http_status(:unauthorized)
|
|
141
|
-
expect(response.body).to include('Unrecognized Organization')
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
end
|